Compare commits

...
Author SHA1 Message Date
timmy c04cba29d9 feat: batch-plan mobile search results into Today (Closes #809)
CI / lint (pull_request) Successful in 1m37s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 08:06:03 +00:00
timmy cacecac865 Merge pull request 'Make partial mobile Search pagination retryable and lossless' (#808) from timmy/807-lossless-partial-search into main
CI / lint (push) Successful in 1m34s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-14 07:35:02 +00:00
timmy 10f7cc832a fix: make partial search pagination lossless (Closes #807)
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 07:32:45 +00:00
timmy e5f37a866c Merge pull request 'Reply and continue from mobile Search' (#806) from timmy/805-reply-continue-mobile-search into main
CI / lint (push) Successful in 1m39s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-14 06:44:29 +00:00
timmy 5a4fbd1564 feat: reply and continue from mobile search (Closes #805)
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 06:41:34 +00:00
timmy 7bcab03b8a Merge pull request 'Read current conversations inside mobile Search preview' (#804)
CI / lint (push) Successful in 1m37s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Closes #803
2026-08-14 06:07:12 +00:00
timmy 51fe9e5af2 feat: show conversations in mobile search preview (Closes #803)
CI / lint (pull_request) Successful in 1m45s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 06:04:25 +00:00
timmy 2406d24cb8 Merge pull request 'Continue through mobile Search results while planning work' (#802) from timmy/801-sequential-mobile-search into main
CI / lint (push) Successful in 1m33s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-14 05:13:09 +00:00
timmy 745d2529e5 feat: continue through mobile search results (Closes #801)
CI / lint (pull_request) Successful in 1m39s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 05:08:56 +00:00
timmy af9699e362 Merge pull request 'Schedule mobile Search results into Later' (#800)
CI / lint (push) Successful in 1m38s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
Closes #799
2026-08-14 04:32:45 +00:00
timmy 71b38687c4 feat: schedule search results into Later (Closes #799)
CI / lint (pull_request) Successful in 1m42s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 04:30:02 +00:00
timmy 0a8f33dc83 Merge pull request 'Queue mobile Search results into Today' (#798) from timmy/797-queue-mobile-search-results into main
CI / lint (push) Successful in 1m45s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
2026-08-14 04:08:20 +00:00
timmy 0f45b7aead feat: queue mobile search results into Today (Closes #797)
CI / lint (pull_request) Successful in 1m41s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 04:05:49 +00:00
timmy a44bf46bef Merge pull request 'Scope mobile Search to an accessible repository' (#796) from timmy/795-repository-scoped-mobile-search into main
CI / lint (push) Successful in 1m38s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
2026-08-14 03:14:05 +00:00
timmy ec86bf55a8 Scope mobile Search by repository
CI / lint (pull_request) Successful in 1m37s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 03:11:40 +00:00
timmy 7636e30482 Merge pull request 'Scope mobile Search by type and status' (#794) from timmy/793-scope-mobile-search into main
CI / lint (push) Successful in 1m42s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
Merge pull request 'Scope mobile Search by type and status' (#794) from timmy/793-scope-mobile-search into main
2026-08-14 02:37:38 +00:00
timmy bd054998b2 feat: scope mobile search by type and status (Closes #793)
CI / lint (pull_request) Successful in 1m45s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 02:34:44 +00:00
timmy 3c65bc1b31 Merge pull request 'Open requested pull reviews from mobile Search' (#792) from timmy/791-open-requested-reviews-from-search into main
CI / lint (push) Successful in 1m36s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
2026-08-14 01:47:39 +00:00
timmy bc082f2bd9 feat: open requested reviews from mobile search (Closes #791)
CI / lint (pull_request) Successful in 1m45s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 01:44:51 +00:00
timmy 9ae0a89970 Merge pull request 'Share resumable mobile Search previews' (#790) from timmy/787-share-resumable-search-previews into main
CI / lint (push) Successful in 1m37s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-14 01:07:05 +00:00
timmy 43163ed926 feat: share resumable mobile search previews (Closes #787)
CI / lint (pull_request) Successful in 1m41s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 01:04:20 +00:00
timmy 66a2b78860 Merge pull request 'Open assigned pull requests from mobile Search' (#786) from timmy/785-open-assigned-pulls-from-search into main
CI / lint (push) Successful in 1m31s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-14 00:27:40 +00:00
timmy 20d4f1ef7e feat: open assigned pulls from mobile search (Closes #785)
CI / lint (pull_request) Successful in 1m31s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 00:25:07 +00:00
timmy 70281bb56a Merge pull request 'Load complete, balanced results in mobile Search' (#784) from timmy/783-paginated-balanced-mobile-search into main
CI / lint (push) Successful in 1m32s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
2026-08-14 00:06:24 +00:00
timmy 7b736bc4c6 feat: paginate balanced mobile search results (Closes #783)
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 00:04:06 +00:00
timmy fa3e5e3bf0 Merge pull request 'Resume addressable mobile Search sessions' (#782) from timmy/781-resumable-mobile-search into main
CI / lint (push) Successful in 1m32s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Merge pull request Resume addressable mobile Search sessions (#782)
2026-08-13 23:27:44 +00:00
timmy bc0d47e8e7 feat: resume addressable mobile search sessions (Closes #781)
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 23:25:35 +00:00
timmy 741c27eacf Merge pull request 'Authorize and audit permanent comment deletion' (#780) from timmy/779-authorize-audit-comment-deletion into main
CI / lint (push) Successful in 1m26s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
2026-08-13 22:58:01 +00:00
timmy 3896f17a58 feat: authorize and audit comment deletion (Closes #779)
CI / lint (pull_request) Successful in 1m28s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 22:55:47 +00:00
timmy 9d14fa5ffb Merge pull request 'Reset mobile work details when advancing to another item' (#778) from timmy/777-reset-mobile-work-detail-position into main
CI / lint (push) Successful in 1m34s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-13 22:30:11 +00:00
timmy 83be8fcdbe fix: reset mobile work detail position (Closes #777)
CI / lint (pull_request) Successful in 1m25s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 22:28:08 +00:00
timmy 35f3999787 Merge pull request 'Complete review requests without leaving mobile Updates triage' (#776) from timmy/775-update-review-handoff into main
CI / lint (push) Successful in 1m35s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-13 22:01:54 +00:00
timmy 378291d3c8 feat: review pull requests from update triage (Closes #775)
CI / lint (pull_request) Successful in 1m42s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 21:59:35 +00:00
timmy 584752ed24 Merge pull request 'Prioritize actionable work in mobile Updates triage' (#774) from timmy/773-actionable-update-triage into main
CI / lint (push) Successful in 1m31s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
2026-08-13 21:26:58 +00:00
timmy d54f58bf36 feat: prioritize actionable update triage (Closes #773)
CI / lint (pull_request) Successful in 1m37s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 21:24:34 +00:00
timmy 84302d6f1e Merge pull request 'Make mobile Update decisions transaction-safe' (#772) from timmy/771-transaction-safe-update-decisions into main
CI / lint (push) Successful in 1m39s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-13 21:01:00 +00:00
timmy 2e0d64907b fix: make update decisions transaction-safe (Closes #771)
CI / lint (pull_request) Successful in 1m36s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 20:58:43 +00:00
timmy 80042ce561 Merge pull request 'Add one-handed swipe decisions to mobile Updates triage' (#770) from timmy/769-mobile-update-swipe-triage into main
CI / lint (push) Successful in 1m34s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-13 20:32:44 +00:00
timmy 931f656f78 feat: add mobile update swipe triage (Closes #769)
CI / lint (pull_request) Successful in 1m41s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 20:30:15 +00:00
timmy 18f56583ba Merge pull request 'Recover mobile sign-in from stalled requests' (#768) from timmy/767-stalled-login-recovery into main
CI / lint (push) Successful in 1m35s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-13 19:54:59 +00:00
timmy 6e2f5d1120 fix: recover stalled mobile sign-in (Closes #767)
CI / lint (pull_request) Successful in 1m32s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 19:52:44 +00:00
timmy 9bd755f521 Merge pull request 'Review kept-unread outcomes after mobile Updates triage' (#766) from timmy/765-update-outcome-review into main
CI / lint (push) Successful in 1m33s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-13 19:23:52 +00:00
timmy fd2f801f3e feat: review mobile update triage outcomes (Closes #765)
CI / lint (pull_request) Successful in 1m30s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 19:21:36 +00:00
timmy e21cc58cca Merge pull request 'Keep mobile Update decisions within thumb reach' (#764) from timmy/763-mobile-update-decision-bar into main
CI / lint (push) Successful in 1m31s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
2026-08-13 18:56:13 +00:00
timmy d6212b2812 feat: keep mobile update decisions within reach (Closes #763)
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 18:53:50 +00:00
timmy 6528b2e73f Merge pull request 'Load mobile update conversations without blocking core detail' (#762) from timmy/761-progressive-update-conversation into main
CI / lint (push) Successful in 1m36s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-13 18:27:41 +00:00
timmy a9d586e689 feat: load update conversations progressively (Closes #761)
CI / lint (pull_request) Successful in 1m38s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 18:25:21 +00:00
timmy 7c86f28346 Merge pull request 'Open each mobile update at its newest unread activity' (#760) from timmy/759-newest-update-activity into main
CI / lint (push) Successful in 1m33s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
2026-08-13 17:58:03 +00:00
timmy 707ee4c1bc feat: open updates at newest activity (Closes #759)
CI / lint (pull_request) Successful in 1m41s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 17:55:33 +00:00
timmy 82982e5741 Merge pull request 'Prefetch the next mobile update during triage' (#758) from timmy/757-prefetch-next-mobile-update into main
CI / lint (push) Successful in 1m32s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-13 17:33:37 +00:00
timmy db08c7e75f feat: prefetch the next mobile update (Closes #757)
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 17:31:20 +00:00
timmy 1a3df5d7a7 Merge pull request 'Make the mobile Work action launch the highest-priority queue' (#756) from timmy/755-priority-mobile-work into main
CI / lint (push) Successful in 1m31s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
2026-08-13 17:02:12 +00:00
53 changed files with 4628 additions and 255 deletions

No files matched your search

+27 -4
View File
@@ -62,7 +62,17 @@ and durable delivery flow. A different or unconfirmed account can only copy or d
the private content. Issue capture and authored mobile actions (issue
comments, pull-request comments, notification replies, and reviews) persist per-draft
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
another worker replays a confirmed result instead of posting duplicate content. The ordered,
another worker replays a confirmed result instead of posting duplicate content. Mobile Search previews
let operators assign an eligible issue directly into Later at an exact local return time without filling
Today or interrupting active work. Cancel and browser Back preserve the Search preview without assigning;
confirmation claims only when needed, syncs the Later plan across devices, and returns to the preserved
query, filters, results, and scroll position. If assignment succeeds but Later storage fails, the issue
remains recoverable in My Work and the dashboard reports the partial outcome instead of claiming success.
Search previews also
let operators assign an eligible issue and add it to Today without starting or replacing active work.
The queue action keeps the Search query, filters, results, and scroll position available for continued
planning, reports an existing Today item without duplicating it, and uses the same capacity, sync, and
offline-warm path as other Today admission flows. The ordered,
five-item Today plan syncs across the operator's devices. **Plan Today** also stores available minutes
and a per-item estimate with the account-scoped plan, continuously showing planned/free or over-capacity
time. An over-capacity plan requires a second explicit save, legacy plans migrate with unestimated work,
@@ -81,7 +91,11 @@ an open, unassigned issue update also offers **Take ownership & start**, which c
before assignment, preserves the unread update, adds and syncs the owned issue to Today, checkpoints
the session, and opens the issue. The adjacent **Take ownership** action remains available for
claim-only triage, and a local start failure opens the now-owned issue with truthful recovery guidance.
**Mark read & next** remains the explicit acknowledgement path. Delivery or local-admission
**Mark read & next** remains the explicit acknowledgement path. During an online Updates pass,
Stackchain preloads at most the next surviving conversation from the fixed snapshot while the
current one is being read. Advancing consumes that account-bound result without another detail
request; failures fall back to the normal foreground retry path, and offline triage never speculates.
Delivery or local-admission
failure preserves both the reply draft and checkpoint. Finishing or choosing **End session** clears only the checkpoint and leaves the Today plan
unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
@@ -285,8 +299,9 @@ remain compatible. The bounded **Passkey counter anomaly** Security activity ent
only the device label and attempted action; repeated alerts should prompt removal and
re-enrollment of that passkey.
High-impact actions—merging a pull request, closing an assigned issue, revoking a
remote device, or signing out every device—require a passkey assertion or the
High-impact actions—merging a pull request, closing an assigned issue, permanently
deleting an authored comment, revoking a remote device, or signing out every device—
require a passkey assertion or the
operator access token again.
The server issues a random 90-second grant bound to the active session, exact action,
and exact target. Only its digest is stored, and the grant is consumed atomically on
@@ -403,6 +418,14 @@ filter, preserves the selected release lane, shows the current draft count, resp
the device safe area, and moves out of the way while a full-screen task is open.
Desktop layout is unchanged.
Mobile **Search** provides touch-sized **Repository**, **Type**, and **Status** controls.
Operators can find and select a repository visible to their Gitea account, then scope results
to that exact repository, issues, pull requests, open work, closed work, or all accessible work;
the server applies every scope before pagination. Clearing Repository returns to organization-wide
Search. The selected scope is bounded and addressable, survives preview/back, reload, sharing,
and sign-in continuation, and a scope change cancels obsolete requests before restarting at the
first page. Repository lookup failure leaves organization-wide Search usable.
My Work also has an account-synced **Later** queue. **Later today** defers an item for four
hours, while **Tomorrow** returns it at 09:00 in the device's local timezone. **Choose date & time**
accepts a valid future local date and time and returns the item at that exact instant; the picker
+11 -3
View File
@@ -7,9 +7,12 @@ function createBatchFindWork({
onProgress = () => {},
storage = typeof localStorage === 'undefined' ? null : localStorage,
owner = () => '',
journalName = 'find-work-batch',
autoMount = true,
}) {
let request = null;
const journalKey = () => 'stackchain.find-work-batch.v1.' + encodeURIComponent(String(owner() || ''));
const journalKey = () => 'stackchain.' + journalName + '.v1.' +
encodeURIComponent(String(owner() || ''));
function readJournal() {
if (!storage || !String(owner() || '')) return null;
@@ -125,6 +128,11 @@ function createBatchFindWork({
return request;
}
function pending() {
const journal = readJournal();
return journal ? journal.items.filter(item => item.state !== 'queued').length : 0;
}
function mountRecovery(button, opener) {
const show = () => {
const journal = readJournal();
@@ -144,10 +152,10 @@ function createBatchFindWork({
opener.addEventListener('click', show);
}
if (typeof document !== 'undefined') mountRecovery(
if (autoMount && typeof document !== 'undefined') mountRecovery(
document.getElementById('resume-find-work-batch'), document.getElementById('find-work')
);
return { run, resume, mountRecovery };
return { run, resume, pending, mountRecovery };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createBatchFindWork;
+73 -17
View File
@@ -15,6 +15,15 @@
return current;
};
filterCommands.searchUrl = function searchUrl(query, page, scope, continuation) {
const streamPages = continuation ?
(continuation.issues ? '&issues_page=' + encodeURIComponent(continuation.issues) : '') +
(continuation.pulls ? '&pulls_page=' + encodeURIComponent(continuation.pulls) : '') : '';
return 'api/v1/search?q=' + encodeURIComponent(query) + '&limit=10&page=' + page +
'&kind=' + encodeURIComponent(scope.kind) + '&state=' + encodeURIComponent(scope.state) +
(scope.repository ? '&repository=' + encodeURIComponent(scope.repository) : '') + streamPages;
};
filterCommands.createGlobalSearchController = function createGlobalSearchController(options) {
const search = options.search;
const onState = options.onState;
@@ -22,6 +31,46 @@
let timer = null;
let generation = 0;
let activeController = null;
let scope = { kind:'all', state:'all' };
let state = { status: 'idle', query: '', items: [], more: false, next: 1, scope };
function publish(next) {
state = next;
onState(next);
return state;
}
async function requestPage(query, page, current, append, continuation) {
const requestController = new AbortController();
activeController = requestController;
try {
const requestScope = { ...scope };
const result = await search(
query, requestController.signal, page, requestScope, continuation
);
const partial = result.partial === true;
const incoming = result.items || result;
const combined = append ? state.items.concat(incoming) : incoming;
const items = [...new Map((combined || []).map(item =>
[`${item.kind}:${item.repository}:${item.number}`, item]
)).values()];
if (current === generation) return publish({
status: 'ready', query, items, partial,
more: !!result.has_more,
next: result.next_page,
continuation:result.continuation,
failedStreams:result.failed_streams || [],
scope:requestScope,
});
} catch (error) {
if (error && error.name === 'AbortError') return;
if (current === generation) return publish(append
? { ...state, status: 'ready' }
: { status: 'error', query, items: [], error, scope:{ ...scope } });
} finally {
if (activeController === requestController) activeController = null;
}
}
return {
setQuery(value) {
@@ -32,25 +81,32 @@
if (activeController !== null) activeController.abort();
activeController = null;
if (query.length < 2) {
onState({ status: 'idle', query, items: [] });
publish({ status: 'idle', query, items: [], more: false, next: 1, scope:{ ...scope } });
return;
}
onState({ status: 'loading', query, items: [] });
timer = setTimeout(async () => {
const requestController = new AbortController();
activeController = requestController;
try {
const result = await search(query, requestController.signal);
const items = Array.isArray(result) ? result : result.items;
const partial = !Array.isArray(result) && result.partial === true;
if (current === generation) onState({ status: 'ready', query, items, partial });
} catch (error) {
if (error && error.name === 'AbortError') return;
if (current === generation) onState({ status: 'error', query, items: [], error });
} finally {
if (activeController === requestController) activeController = null;
}
}, delay);
publish({ status: 'loading', query, items: [], more: false, next: 1, scope:{ ...scope } });
timer = setTimeout(() => requestPage(query, 1, current, false), delay);
},
setScope(value) {
const repository = typeof value?.repository === 'string' &&
/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value.repository.trim())
? value.repository.trim() : '';
const next = {
kind:['all', 'issue', 'pull'].includes(value?.kind) ? value.kind : 'all',
state:['all', 'open', 'closed'].includes(value?.state) ? value.state : 'all',
...(repository ? { repository } : {}),
};
if (next.kind === scope.kind && next.state === scope.state &&
(next.repository || '') === (scope.repository || '')) return;
const query = state.query;
scope = next;
this.setQuery(query);
},
loadMore() {
if (state.status !== 'ready' || !state.more || activeController) return Promise.resolve(state);
const current = generation;
const page = state.next;
return requestPage(state.query, page, current, true, state.continuation);
},
};
};
+55 -1
View File
@@ -114,11 +114,27 @@ textarea { resize: vertical; min-height: 120px; }
#cmd-palette { position: fixed; left: 50%; top: 10%; transform: translateX(-50%); width: min(900px, 94vw); background: rgba(11,21,38,.96); border: 1px solid #2a496e; border-radius: 12px; box-shadow: 0 20px 70px rgba(0,0,0,.55); padding: 10px; z-index: 30; display: none; backdrop-filter: blur(12px); }
#cmd-palette.open { display: block; }
.cmd-palette-header { display:none; align-items:center; justify-content:space-between; gap:10px; }
.cmd-palette-header-actions { display:flex; gap:8px; }
.cmd-search-scope { display:grid; grid-template-columns:auto minmax(120px,1fr) auto minmax(120px,1fr); gap:6px 10px; align-items:center; margin:8px 0 0; padding:8px; border:1px solid #1f3a5f; border-radius:8px; }
.cmd-search-scope legend { padding:0 4px; color:#93a4b8; font-size:12px; }
.cmd-search-scope label { font-size:12px; color:#cbd5e1; }
.cmd-search-scope select { min-width:0; padding:7px; border-radius:8px; border:1px solid #31577f; background:#0b1526; color:#e5e7eb; }
.cmd-search-scope input { min-width:0; padding:7px; border-radius:8px; border:1px solid #31577f; background:#0b1526; color:#e5e7eb; }
.cmd-repository-status { grid-column:2 / -1; min-height:16px; color:#93a4b8; font-size:12px; }
#cmd-results { margin-top:8px; max-height:min(65vh,520px); overflow-y:auto; }
.cmd-item { padding: 10px; min-height:44px; cursor: pointer; border-radius: 10px; color:#e5e7eb; display:flex; gap:10px; align-items:center; justify-content:space-between; }
.cmd-item:hover, .cmd-item.selected { background: #10233a; outline:1px solid #31577f; }
.cmd-meta { color:#93a4b8; font-size:12px; text-align:right; }
.cmd-status { padding:10px; color:#93a4b8; font-size:13px; }
.cmd-select-result { min-height:44px; width:100%; display:grid; grid-template-columns:28px 1fr auto; gap:10px; align-items:center; text-align:left; }
.cmd-select-result input { width:22px; height:22px; }
.cmd-select-result[disabled] { cursor:not-allowed; opacity:.62; }
.search-batch-actions { position:sticky; bottom:0; z-index:3; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0 calc(10px + env(safe-area-inset-bottom)); background:#0b1526; border-top:1px solid #2a496e; }
.search-batch-actions[hidden] { display:none; }
.search-batch-actions span { grid-column:1 / -1; }
.search-batch-actions button, .search-batch-recovery { min-height:44px; }
.search-batch-recovery { width:100%; font-weight:700; }
.cmd-group { padding:8px 10px 3px; color:#60a5fa; font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; }
#whiteboard-modal, #markdown-modal { position: fixed; inset: 0; background: rgba(5,12,21,.55); display: none; align-items: center; justify-content: center; z-index: 40; backdrop-filter: blur(6px); }
#whiteboard-modal.open, #markdown-modal.open { display: flex; }
@@ -385,6 +401,9 @@ textarea { resize: vertical; min-height: 120px; }
.update-sheet-header button { min-height:44px; }
.update-triage-progress { margin:10px 0; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
.update-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
.update-new-activity { scroll-margin-block:18px 96px; border-color:#60a5fa; box-shadow:0 0 0 2px rgba(96,165,250,.25); }
.update-new-activity::before { content:'New activity'; display:inline-block; margin-bottom:6px; padding:2px 7px; border-radius:999px; background:#1d4ed8; color:#fff; font-size:12px; font-weight:700; }
.update-new-activity-jump { position:sticky; bottom:92px; z-index:4; min-height:44px; width:100%; margin:10px 0; border-color:#60a5fa; background:#17345a; }
.update-reply { display:grid; gap:8px; margin-top:16px; }
.update-reply textarea { width:100%; min-height:112px; resize:vertical; }
.update-reply button { min-height:44px; width:100%; }
@@ -403,6 +422,10 @@ textarea { resize: vertical; min-height: 120px; }
.update-sheet-actions #acknowledge-update-next { min-height:44px; width:100%; }
.update-sheet-actions #mute-update-next { min-height:44px; width:100%; }
.update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.update-more-actions { margin-top:14px; }
.update-more-actions > summary { min-height:44px; display:flex; align-items:center; justify-content:center; cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.update-decision-bar { display:grid; grid-template-columns:repeat(auto-fit,minmax(76px,1fr)); gap:6px; margin-top:14px; padding:8px; background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.update-decision-bar button, .update-decision-bar summary { min-height:44px; min-width:0; padding-inline:6px; }
.update-retry { min-height:44px; width:100%; margin-top:10px; }
.issue-sheet { position:fixed; inset:0; z-index:56; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.issue-sheet.open { display:flex; }
@@ -532,8 +555,23 @@ textarea { resize: vertical; min-height: 120px; }
.search-preview-header { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; }
.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-conversation { display:grid; gap:8px; padding-top:8px; border-top:1px solid #2a496e; }
.search-preview-conversation h2 { margin:0; font-size:1rem; }
.search-preview-comment { min-width:0; padding:10px 0; border-bottom:1px solid #1b2d45; overflow-wrap:anywhere; }
.search-preview-conversation button { min-height:44px; width:100%; }
.search-preview-reply { display:grid; gap:8px; padding-top:8px; border-top:1px solid #2a496e; }
.search-preview-reply[hidden] { display:none; }
.search-preview-reply h2 { margin:0; font-size:1rem; }
.search-preview-reply textarea { box-sizing:border-box; width:100%; min-height:110px; resize:vertical; scroll-margin-bottom:calc(190px + env(safe-area-inset-bottom)); }
.search-preview-reply-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.search-preview-reply-actions button { min-height:44px; }
.search-preview-navigation { display:grid; grid-template-columns:minmax(0,1fr) auto minmax(0,1fr); align-items:center; gap:8px; }
.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-actions button, .search-preview-actions a { min-height:44px; box-sizing:border-box; display:flex; align-items:center; justify-content:center; }
.search-preview-primary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
@media (max-width:420px) {
.search-preview-primary-actions { grid-template-columns:1fr; }
}
.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; }
@@ -661,6 +699,12 @@ textarea { resize: vertical; min-height: 120px; }
.work-filter { flex:1 1 calc(50% - 8px); }
.review-sheet-panel { width:100%; border-left:0; padding:14px; }
.update-sheet-panel { width:100%; border-left:0; padding:14px; overflow-x:hidden; }
.update-sheet-panel { padding-bottom:calc(82px + env(safe-area-inset-bottom)); }
.update-gesture-status { display:block; padding:6px 10px; border-radius:999px; text-align:center; background:#102641; }
.update-sheet-panel[data-triage-gesture="keep"] { box-shadow:inset 8px 0 #60a5fa; }
.update-sheet-panel[data-triage-gesture="read"] { box-shadow:inset -8px 0 #34d399; }
.update-decision-bar { position:fixed; inset:auto 0 0; z-index:57; margin:0; padding-bottom:calc(8px + env(safe-area-inset-bottom)); }
.update-more-actions > summary { display:none; }
.issue-sheet-panel { width:100%; border-left:0; padding:14px; }
.issue-blocker-row { grid-template-columns:1fr; }
.issue-blocker-remove { width:100%; }
@@ -668,8 +712,11 @@ textarea { resize: vertical; min-height: 120px; }
#cmd-palette { left:0; top:var(--search-viewport-top,0px); transform:none; width:100%; height:var(--search-viewport-height,100dvh); border:0; border-radius:0; padding:12px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); z-index:46; }
#cmd-palette.open { display:flex; flex-direction:column; }
.cmd-palette-header { display:flex; flex:0 0 auto; min-height:44px; }
#close-command-palette { min-height:44px; }
#close-command-palette, #cmd-load-more { min-height:44px; }
#cmd-input { flex:0 0 auto; min-height:44px; }
.cmd-search-scope { grid-template-columns:auto minmax(0,1fr); }
#cmd-search-kind, #cmd-search-state { min-height:44px; width:100%; }
#cmd-search-repository { min-height:44px; width:100%; }
#cmd-results { flex:1; min-height:0; overflow-y:auto; max-height:none; overscroll-behavior:contain; padding-bottom:env(safe-area-inset-bottom); }
.create-issue-panel { width:100%; border-left:0; padding:14px; }
.pull-sheet-panel { width:100%; border-left:0; padding:14px; }
@@ -698,6 +745,9 @@ textarea { resize: vertical; min-height: 120px; }
.mobile-queue-list [data-mobile-queue-count] { min-width:28px; padding:3px 8px; border-radius:999px; text-align:center; background:#1d426d; }
.mobile-queue-list [data-mobile-queue="agenda"][data-deadlines="true"] { border-color:#f59e0b; background:#30240f; box-shadow:inset 3px 0 #f59e0b; }
.mobile-queue-list [data-recommended="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px #60a5fa; }
.mobile-update-outcome { margin-top:12px; padding:12px; border:1px solid #31577f; border-radius:12px; background:#0b1b30; }
.mobile-update-outcome p { margin:0 0 10px; }
.mobile-update-outcome button { width:100%; min-height:44px; }
.mobile-today-hud { position:fixed; left:8px; right:8px; bottom:calc(56px + env(safe-area-inset-bottom)); z-index:44; display:grid; grid-template-columns:minmax(0,1fr) minmax(112px,auto); grid-template-areas:"summary complete" "progress toggle"; gap:4px 8px; max-width:100%; padding:8px; border:1px solid #31577f; border-radius:12px 12px 0 0; background:rgba(16,38,65,.98); box-shadow:0 -8px 24px rgba(0,0,0,.28); }
.mobile-today-summary { grid-area:summary; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; font-weight:700; }
.mobile-today-hud [data-work-session-progress] { grid-area:progress; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
@@ -706,6 +756,10 @@ textarea { resize: vertical; min-height: 120px; }
.mobile-today-hud button { min-height:44px; max-width:100%; }
.mobile-today-hud [data-work-session-adjust-plan] { grid-column:1 / -1; }
}
@media (min-width:701px) { .update-gesture-status { display:none; } }
@media (prefers-reduced-motion: reduce) {
.update-sheet-panel[data-triage-gesture] { transition:none; }
}
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
+353 -67
View File
@@ -82,15 +82,28 @@
findWork: () => qs('#find-work').click(),
});
function showMobileQueueCompletion(completedName) {
function showMobileQueueCompletion(completedName, cleared = true) {
const next = mobileQueueLauncher.recommend();
qs('#mobile-queue-heading').textContent = completedName + ' cleared';
qs('#mobile-queue-heading').textContent = completedName + (cleared ? ' cleared' : '');
document.querySelectorAll('[data-mobile-queue]').forEach(row => row.removeAttribute('data-recommended'));
const row = qs('[data-mobile-queue="' + next.name + '"]');
row.setAttribute('data-recommended', 'true');
qs('#mobile-queue-sheet').showModal();
row.focus();
}
let keptUpdateIdentities = [];
function showUpdateTriageOutcome(outcome) {
const box = qs('#mobile-update-outcome');
const review = qs('#review-kept-updates');
keptUpdateIdentities = outcome.keptIdentities;
box.hidden = false;
qs('#mobile-update-outcome-summary').textContent = outcome.reviewed + ' reviewed · ' + outcome.resolved +
' marked read · ' + outcome.kept + ' kept unread';
review.hidden = !outcome.kept;
review.textContent = 'Review ' + outcome.kept + ' kept unread';
showMobileQueueCompletion(outcome.kept ? 'Updates reviewed' : 'Updates', !outcome.kept);
if (outcome.kept) review.focus();
}
const mobileTaskDock = createMobileTaskDock({
nav: qs('#mobile-task-dock'),
sessionHud: qs('[data-mobile-today-hud]'),
@@ -180,6 +193,7 @@
let draft = null;
let reviewFiles = [];
let selectedReviewHead = '';
let reviewHandoffSubmitted = false;
let activeInlineTarget = null;
let bulkConfirmationPending = false;
let bulkMarkPending = false;
@@ -761,8 +775,9 @@
}
async function fetchNotificationConversation(notificationId, page) {
const pageQuery = Number.isInteger(page) ? '&page=' + encodeURIComponent(page) : '';
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) +
'/conversation?page=' + encodeURIComponent(page) + '&limit=20', {
'/conversation?limit=20' + pageQuery, {
headers: { Accept: 'application/json' },
});
const payload = await response.json().catch(() => ({}));
@@ -910,8 +925,36 @@
refresh: refreshMyWorkView,
select: qs,
});
const updateReadPosition = createUpdateReadPosition({
panel: qs('#update-sheet .update-sheet-panel'),
jump: qs('#jump-update-new-activity'),
});
const issueDetailPosition = createWorkDetailPosition({ panel: qs('#issue-sheet .issue-sheet-panel') });
const pullDetailPosition = createWorkDetailPosition({ panel: qs('#pull-sheet .pull-sheet-panel') });
const reviewDetailPosition = createWorkDetailPosition({ panel: qs('#review-sheet .review-sheet-panel') });
const workDetailIdentity = (kind, item) => kind + ':' + item.key;
qs('#jump-update-new-activity').addEventListener('click', () => updateReadPosition.jump());
qs('#focus-update-reply').addEventListener('click', () => {
const composer = qs('#update-reply');
composer.scrollIntoView({ behavior:'smooth', block:'center' });
qs('#update-reply').focus({ preventScroll:true });
});
qs('#toggle-update-more').addEventListener('click', event => {
const more = qs('.update-more-actions');
more.open = !more.open;
event.currentTarget.setAttribute('aria-expanded', String(more.open));
if (more.open) more.scrollIntoView({ behavior:'smooth', block:'end' });
});
const updateDecision = createUpdateDecisionTransaction({
controls: [
qs('#keep-update-unread'), qs('#mark-update-read-next'),
qs('#review-update-now'), qs('#focus-update-reply'), qs('#toggle-update-more'),
],
status: qs('#update-gesture-status'),
});
const notificationReader = createNotificationReader({
load: fetchNotificationDetail,
getScope: () => confirmedOwnerLogin,
loadConversation: fetchNotificationConversation,
markRead: markNotificationRead,
acknowledge: acknowledgeNotification,
@@ -923,12 +966,16 @@
}
selectedUpdate = item;
selectedUpdateDetail = null;
updateReadPosition.open(String(item.notification_id));
updateMentions.dismiss();
qs('#update-sheet').classList.add('open');
qs('.update-more-actions').open = false;
qs('#toggle-update-more').setAttribute('aria-expanded', 'false');
qs('#update-sheet-key').textContent = item.key || '';
qs('#update-sheet-title').textContent = item.title || 'Unread update';
qs('#update-comments').textContent = '';
qs('#update-conversation-status').textContent = '';
qs('#retry-update-conversation').hidden = true;
qs('#load-older-update-comments').hidden = true;
qs('#update-subject-body').textContent = '';
qs('#update-subject-type').textContent = item.subject_type || 'Update';
@@ -943,6 +990,7 @@
qs('#update-ownership-action').hidden = true;
qs('#update-ownership-start').hidden = true;
qs('#create-update-follow-up').hidden = true;
qs('#review-update-now').hidden = true;
qs('#retry-update-load').hidden = true;
setOfflineUpdateControls(false);
qs('#keep-update-unread').focus();
@@ -957,6 +1005,7 @@
qs('#acknowledge-update-next').hidden = !detail.acknowledge_supported;
qs('#mute-update-next').hidden = !detail.mute_supported;
qs('#create-update-follow-up').hidden = !['Issue', 'Pull'].includes(detail.subject_type);
qs('#review-update-now').hidden = !updateReviewHandoff.eligible(detail, selectedUpdate);
if (offlineWorkMode) {
setOfflineUpdateControls(true);
} else {
@@ -968,6 +1017,10 @@
qs('#retry-update-load').hidden = true;
},
onConversation: renderUpdateConversation,
onConversationStatus: message => {
qs('#update-conversation-status').textContent = message;
qs('#retry-update-conversation').hidden = !message.startsWith('Conversation temporarily unavailable.');
},
onItems: items => {
const readId = selectedUpdate?.notification_id;
lastMyWork = items;
@@ -978,6 +1031,9 @@
},
onStatus: message => {
qs('#update-sheet-status').textContent = message;
if (message === 'Update ready.' && updateTriage.active() && !offlineWorkMode) {
notificationReader.prefetch(updateTriage.next());
}
qs('#retry-update-load').hidden = !message.startsWith('Could not load update.');
if (message === 'Inbox cleared.') {
qs('#my-work-action-status').textContent = message;
@@ -998,13 +1054,49 @@
onProgress: state => {
const progress = qs('#update-triage-progress');
progress.hidden = false;
progress.textContent = 'Update ' + state.index + ' of ' + state.total;
progress.textContent = 'Update ' + state.index + ' of ' + state.total +
(state.reason ? ' · ' + state.reason : '');
},
onFinish: () => {
onFinish: outcome => {
notificationReader.prefetch();
qs('#update-triage-progress').hidden = true;
showMobileQueueCompletion('Updates');
showUpdateTriageOutcome(outcome);
},
});
const updateReviewHandoff = createUpdateReviewHandoff({
openReview: item => {
qs('#update-sheet').classList.remove('open');
reviewHandoffSubmitted = false;
openReviewSheet(item, qs('#review-update-now'),
offlineWorkMode ? offlineWorkStore.loadDetail(confirmedOwnerLogin, item) : null);
},
restoreUpdate: () => {
qs('#review-sheet').classList.remove('open');
selectedReview = null;
qs('#update-sheet').classList.add('open');
qs('#review-update-now').focus();
},
admitRead: notificationId => offlineWorkMode ?
notificationReadOutbox.enqueueDurably(notificationId) : markNotificationRead(notificationId),
advance: source => {
mobileComposerViewport.close(qs('#review-sheet .review-sheet-panel'));
qs('#review-sheet').classList.remove('open');
selectedReview = null;
offlineReview = false;
progress = null;
draft = null;
reviewFiles = [];
selectedReviewHead = '';
notificationReader.acceptReadAndNext(lastMyWork, source);
if (updateTriage.active()) updateTriage.acceptCompleted();
reviewHandoffSubmitted = false;
},
});
qs('#review-update-now').addEventListener('click', () => updateReviewHandoff.begin(selectedUpdate));
qs('#review-kept-updates').addEventListener('click', () => {
qs('#mobile-queue-sheet').close();
updateTriage.reviewKept(keptUpdateIdentities);
});
const updateTriageLauncher = createUpdateTriageLauncher({
selectUpdates: () => selectMobileQueue('update'),
@@ -2139,6 +2231,7 @@
});
const laterPickerElement = qs('#later-picker');
const laterPickerInput = qs('#later-picker-time');
let searchDefer = null;
const laterPicker = createLaterPicker({
history: window.history,
eventTarget: window,
@@ -2157,6 +2250,13 @@
}
},
onConfirm: (item, until, context) => {
if (context === 'search') {
return () => searchDefer.run(item, until).then(outcome => {
if (outcome === 'deferred') return next();
}).catch(error => {
qs('#search-preview-status').textContent = error.message + ' Retry.';
});
}
if (context === 'detail') {
const inSession = workSession.active();
const deferred = detailDefer.deferUntil(item, until, {
@@ -2979,6 +3079,8 @@
qs('#load-older-update-comments').hidden = !Number.isInteger(state?.older_page);
qs('#update-conversation-status').textContent = comments.length ?
comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.';
const newest = qs('#update-comments .issue-comment:last-child');
updateReadPosition.ready(String(selectedUpdate?.notification_id || ''), newest);
}
function renderPullConversation(state) {
@@ -3076,6 +3178,7 @@
async function openIssueSheet(item, trigger, offlineDetail = null) {
if (!item) return;
issueDetailPosition.open(workDetailIdentity('issue', item));
qs('#issue-planning').inert = false;
qs('#issue-handoff').inert = false;
selectedIssue = item;
@@ -3268,6 +3371,7 @@
async function openPullSheet(item, trigger, offlineDetail = null) {
if (!item) return;
if (!await ensurePullWorkflow(trigger)) return;
pullDetailPosition.open(workDetailIdentity('pull', item));
if (!createPullSheet.sameTarget(selectedPull, item)) pullAttachmentController.clear();
qs('#pull-review').inert = false;
qs('#pull-ownership').inert = false;
@@ -3868,6 +3972,7 @@
async function openReviewSheet(item, trigger, cachedDetail = null) {
if (!item || !await ensurePullWorkflow(trigger)) return;
reviewDetailPosition.open(workDetailIdentity('review', item));
selectedReview = item;
reviewTrigger = trigger;
offlineReview = Boolean(cachedDetail);
@@ -3977,6 +4082,10 @@
}
function closeReviewSheet(navigate = true) {
if (updateReviewHandoff.active()) {
updateReviewHandoff.cancel();
return;
}
if (navigate && createWorkRoute.parse(window.location.hash)) {
workRoute.close();
return;
@@ -4135,7 +4244,6 @@
function openModal(id) { qs('#' + id).classList.add('open'); }
function closeModal(id) { qs('#' + id).classList.remove('open'); }
/* Creative ambient background */
(function bg(){
const c=qs('#bg'),ctx=c.getContext('2d');
let w,h,time=0;
@@ -4157,7 +4265,6 @@
draw();
})();
/* Whiteboard */
function initWhiteboard() {
const canvas = qs('#wb'), ctx = canvas.getContext('2d');
let drawing = false;
@@ -4172,34 +4279,29 @@
qs('#wb-save').addEventListener('click', () => { const a=document.createElement('a'); a.href=canvas.toDataURL(); a.download='whiteboard.png'; a.click(); });
}
/* Markdown */
qs('#md-input').addEventListener('input', renderMD);
function renderMD() {
const raw = qs('#md-input').value || '';
qs('#md-preview').innerHTML = '<pre>' + escapeHtml(raw) + '</pre><div style="margin-top:8px;">' + renderMarkdown(raw) + '</div>';
}
/* Commands */
const commands = [
{ name: 'Open whiteboard', run: () => { openModal('whiteboard-modal'); initWhiteboard(); } },
{ name: 'Open markdown widget', run: () => { qs('#md-input').focus(); } },
{ name: 'Refresh now', run: load },
{ name: 'Scroll issues', run: () => qs('#work').scrollIntoView({ behavior:'smooth', block:'start' }) },
{ name: 'Whiteboard', run: () => { openModal('whiteboard-modal'); initWhiteboard(); } },
{ name: 'Markdown', run: () => { qs('#md-input').focus(); } },
{ name: 'Refresh', run: load },
];
let commandSearchState = { status:'idle', query:'', items:[] };
let commandItems = [];
let commandSelection = -1;
async function searchGlobalWork(query, signal) {
const response = await fetch('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10', {
let searchBatchPlanning = null;
async function searchGlobalWork(query, signal, page = 1, scope = {kind:'all', state:'all'}, continuation) {
const response = await fetch(filterCommands.searchUrl(query, page, scope, continuation), {
headers: { Accept:'application/json' },
signal,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Search is temporarily unavailable.');
return {
items: Array.isArray(payload.items) ? payload.items : [],
partial: payload.partial === true,
};
return payload;
}
const commandSearch = filterCommands.createGlobalSearchController({
search: searchGlobalWork,
@@ -4208,24 +4310,27 @@
renderCommands(state.query);
},
});
function safeSearchUrl(value) {
try {
const url = new URL(value);
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
} catch (_) { return '';
}
function currentSearchScope() {
return { kind:qs('#cmd-search-kind').value, state:qs('#cmd-search-state').value,
repository:qs('#cmd-search-repository').value.trim() };
}
function applySearchScope(scope = {kind:'all', state:'all'}) {
qs('#cmd-search-kind').value = scope.kind;
qs('#cmd-search-state').value = scope.state;
qs('#cmd-search-repository').value = scope.repository || '';
commandSearch.setScope(scope);
}
let searchPreviewDetail = null;
function searchPreviewPath(item) {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
'/preview?kind=' + encodeURIComponent(item.kind);
}
const renderSearchConversation = conversation =>
renderSearchPreviewConversation(conversation, document, escapeHtml, fmt, renderMarkdown);
function renderSearchPreview(state) {
const sheet = qs('#search-preview');
const status = qs('#search-preview-status');
const claimButton = qs('#claim-search-result');
const deferButton = qs('#defer-search-result');
const queueButton = qs('#queue-search-result');
const startButton = qs('#start-search-result');
const shareButton = qs('#share-search-result');
qs('#close-search-preview').textContent = searchPreviewReturnKind === 'today-readiness'
? 'Back to blockers' : 'Back to search';
@@ -4236,8 +4341,13 @@
sheet.classList.add('open');
claimButton.hidden = true;
claimButton.disabled = false;
deferButton.hidden = true;
deferButton.disabled = false;
queueButton.hidden = true;
startButton.hidden = true;
startButton.disabled = false;
shareButton.disabled = true;
renderSearchPreviewReply(state, null, searchPreview, document);
if (state.status === 'loading') {
searchPreviewDetail = null;
@@ -4245,6 +4355,7 @@
qs('#search-preview-title').textContent = state.item.title || 'Work preview';
qs('#search-preview-meta').textContent = '';
qs('#search-preview-body').textContent = '';
renderSearchConversation(null);
qs('#open-search-result-gitea').href = safeSearchUrl(state.item.url) || '#';
status.textContent = 'Loading preview…';
return;
@@ -4256,6 +4367,7 @@
const detail = state.detail;
if (!detail) return;
searchPreviewDetail = detail;
shareButton.disabled = state.status === 'sharing';
qs('#search-preview-key').textContent = detail.repository + ' #' + detail.number;
qs('#search-preview-title').textContent = detail.title || 'Untitled work item';
qs('#search-preview-meta').textContent =
@@ -4264,31 +4376,72 @@
(detail.labels?.length ? ' · ' + detail.labels.join(', ') : '') +
(detail.assignees?.length ? ' · assigned to ' + detail.assignees.join(', ') : '');
qs('#search-preview-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
renderSearchConversation(state.conversation);
renderSearchPreviewReply(state, detail, searchPreview, document);
qs('#open-search-result-gitea').href = safeSearchUrl(detail.url) || '#';
claimButton.hidden = !(detail.claimable || (detail.assigned_to_me && detail.kind === 'issue'));
claimButton.hidden = !(detail.claimable || detail.assigned_to_me);
claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me';
claimButton.disabled = state.status === 'claiming';
startButton.hidden = !(detail.kind === 'issue' && (detail.reopenable ||
(detail.state === 'open' && (detail.claimable || detail.assigned_to_me))));
startButton.textContent = detail.reopenable ? 'Reopen & resume' :
deferButton.hidden = !(detail.kind === 'issue' && detail.state === 'open' &&
(detail.claimable || detail.assigned_to_me));
deferButton.textContent = detail.assigned_to_me ? 'Defer' : 'Assign & defer';
deferButton.disabled = state.status === 'claiming' || searchDefer?.pending();
queueButton.hidden = !(detail.kind === 'issue' && detail.state === 'open' &&
(detail.claimable || detail.assigned_to_me));
queueButton.textContent = detail.assigned_to_me ? 'Add to Today' : 'Assign & add to Today';
startButton.hidden = !(detail.reviewable || (detail.kind === 'issue' &&
(detail.reopenable || (detail.state === 'open' && (detail.claimable || detail.assigned_to_me)))));
startButton.textContent = detail.reviewable ? 'Review now' : detail.reopenable ? 'Reopen & resume' :
(detail.assigned_to_me ? 'Start in Today' : 'Assign & start');
startButton.disabled = state.status === 'claiming' || state.status === 'reopening';
status.textContent = state.status === 'reopening' ? 'Reopening…' :
(state.status === 'claiming' ? 'Assigning this issue to you…' :
(state.status === 'claimed' ? 'Assignment confirmed. Opening My Work…' :
(detail.claimable ? 'This issue is open and unassigned.' :
(detail.assigned_to_me ? 'This issue is already in My Work.' :
(detail.reopenable ? 'Closed—reopen to resume.' : 'Read-only preview.')))));
const shareStatus = {
sharing:'Opening share options…', shared:'Search result shared.', copied:'Search result link copied.',
'share-canceled':'Share canceled.', 'share-error':'Could not share this result. Try again.',
};
if (shareStatus[state.status]) status.textContent = shareStatus[state.status];
else if (state.status === 'reopening') status.textContent = 'Reopening…';
else if (state.status === 'claiming') status.textContent = 'Assigning this issue to you…';
else if (state.status === 'claimed') status.textContent = 'Assignment confirmed. Opening My Work…';
else if (detail.claimable) status.textContent = 'This issue is open and unassigned.';
else if (detail.assigned_to_me) status.textContent = 'This item is already in My Work.';
else if (detail.reopenable) status.textContent = 'Closed—reopen to resume.';
else status.textContent = 'Ready.';
}
const searchPreview = createSearchPreview({
fetchJson: item => fetchReviewJson(searchPreviewPath(item), { headers:{ Accept:'application/json' } }),
fetchConversation:(item,page)=>fetchReviewJson(searchPreviewConversationPath(item,page),{
headers:{Accept:'application/json'},
}),
mutate: (detail, action) => fetchReviewJson(
'api/v1/repos/' + detail.repository.split('/').map(encodeURIComponent).join('/') +
'/issues/' + encodeURIComponent(detail.number) + '/' + action,
{ method:'PATCH', headers:{ Accept:'application/json' } }
),
...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto),
share: url => createWorkRoute.share(url, navigator, navigator.clipboard),
session:[()=>commandSearchState, commandSearch, item=>taskOverlayHistory.update({preview:item})],
onState: renderSearchPreview,
});
searchBatchPlanning = mountSearchBatchPlanning(
document, createBatchFindWork, todayWork, ()=>planningOwnerLogin, fetchReviewJson,
searchPreviewPath, queueToday, acceptClaimedIssue, index=>commandItems[index]?.result,
()=>renderCommands(qs('#cmd-input').value), escapeHtml, escAttr
);
searchDefer = createSearchDefer({
claim: detail => searchPreview.claim(detail),
accept: confirmed => acceptClaimedIssue(confirmed),
defer: (item, until) => laterWork.defer(item, until),
refresh: refreshMyWorkView,
announce: message => {
qs('#search-preview-status').textContent = message;
qs('#cmd-search-action-status').textContent = message;
qs('#my-work-action-status').textContent = message;
},
formatTime: fmt,
});
const canonicalSearchPreviewUrl = () => searchPreviewUrl(
taskOverlayHistory.currentState(), currentSearchScope(), window.location
);
function createSearchStart(claim) {
return createAssignAndStart({
available: createAndStart.available,
@@ -4299,6 +4452,7 @@
refreshMyWorkView();
return createAndStart.complete(item);
},
queue: confirmed => queueToday(acceptClaimedIssue(confirmed)),
recover: confirmed => {
const item = acceptClaimedIssue(confirmed);
taskOverlayHistory.leave();
@@ -4307,6 +4461,7 @@
},
announce: message => {
qs('#search-preview-status').textContent = message;
qs('#cmd-search-action-status').textContent = message;
qs('#my-work-action-status').textContent = message;
},
});
@@ -4331,10 +4486,11 @@
renderCommands(qs('#cmd-input').value);
qs('#cmd-input').focus();
}
async function openPreviewIssueInMyWork(detail) {
const next = searchPreview.next;
async function openPreviewWorkInMyWork(detail) {
await load();
const item = lastMyWork.find(candidate =>
candidate.kind === 'issue' && candidate.key === detail.repository + '#' + detail.number
candidate.kind === detail.kind && candidate.key === detail.repository + '#' + detail.number
);
if (!item) return false;
taskOverlayHistory.leave();
@@ -4350,14 +4506,18 @@
mobileSearchViewport.rememberScroll();
searchPreviewReturnKind = 'search';
searchPreview.open(item.result).catch(() => {});
taskOverlayHistory.open('search-preview');
taskOverlayHistory.open('search-preview', {
query:qs('#cmd-input').value, scope:currentSearchScope(), preview:item.result,
});
}
qs('#cmd-palette').classList.remove('open');
qs('#cmd-input').setAttribute('aria-expanded', 'false');
}
function renderCommands(filter) {
const el = qs('#cmd-results');
const local = filterCommands(commands, filter).map(command => ({ command }));
const loadMore = qs('#cmd-load-more');
const selecting = searchBatchPlanning?.plan.snapshot().active === true;
const local = selecting ? [] : filterCommands(commands, filter).map(command => ({ command }));
const remote = commandSearchState.query === String(filter || '').trim()
? commandSearchState.items.map(result => ({ result })) : [];
commandItems = local.concat(remote);
@@ -4368,6 +4528,7 @@
html += remote.map((item, remoteIdx) => {
const idx = local.length + remoteIdx;
const result = item.result;
if (selecting) return searchBatchPlanning.resultHtml(result, idx);
return '<div class="cmd-item' + (idx === commandSelection ? ' selected' : '') + '" role="option" aria-selected="' + (idx === commandSelection) + '" data-idx="' + idx + '"><span>' + escapeHtml(result.title) + '</span><span class="cmd-meta">' + escapeHtml(result.repository) + ' #' + escapeHtml(result.number) + ' · ' + escapeHtml(result.kind === 'pull' ? 'Pull request' : 'Issue') + ' · ' + escapeHtml(result.state) + '</span></div>';
}).join('');
if (commandSearchState.status === 'loading') html += '<div class="cmd-status">Searching accessible work…</div>';
@@ -4375,13 +4536,16 @@
else if (commandSearchState.partial) html += '<div class="cmd-status">Some results are temporarily unavailable.</div>';
else if (String(filter || '').trim().length >= 2 && !remote.length) html += '<div class="cmd-status">No matching issues or pull requests.</div>';
el.innerHTML = html;
loadMore.hidden = !commandSearchState.more;
loadMore.textContent = commandSearchState.partial ? 'Retry missing results' : 'More results';
el.querySelectorAll('.cmd-item').forEach((item) => {
item.addEventListener('click', () => runCommandItem(commandItems[Number(item.dataset.idx)]));
});
}
function openCommandPalette(navigate = true) {
if (navigate) {
taskOverlayHistory.open('search');
taskOverlayHistory.open('search', { scope:currentSearchScope() });
return;
}
qs('#cmd-palette').classList.add('open');
@@ -4395,7 +4559,7 @@
const taskOverlayHistory = createTaskOverlayHistory({
history: window.history,
eventTarget: window,
onChange(kind, previous) {
onChange(kind, previous, detail) {
if (previous === 'new' && kind !== 'new') {
if (!suppressCreateDraftOnHistoryClose) saveIssueCaptureDraft();
suppressCreateDraftOnHistoryClose = false;
@@ -4430,7 +4594,20 @@
}
if (kind === 'new' && previous !== 'new') openCreateIssueSheet(false);
if (kind === 'find' && previous !== 'find') openFindWorkSheet(false);
if (kind === 'search' && previous !== 'search-preview') openCommandPalette(false);
if (kind === 'search' && previous !== 'search-preview') {
if (detail?.scope) applySearchScope(detail.scope);
if (detail?.query !== undefined) {
qs('#cmd-input').value = detail.query;
commandSearch.setQuery(detail.query);
}
openCommandPalette(false);
}
if (kind === 'search-preview' && detail?.preview && previous !== 'search') {
if (detail?.scope) applySearchScope(detail.scope);
if (detail?.query !== undefined) qs('#cmd-input').value = detail.query;
searchPreviewReturnKind = 'search';
searchPreview.open(detail.preview).catch(() => taskOverlayHistory.close());
}
if (kind === 'plan-today' && previous !== 'plan-today' && previous !== 'plan-today-preview') openPlanToday(planTodayTrigger, false);
if (kind === 'today-readiness' && previous !== 'today-readiness') renderTodayReadiness(todayReadiness.snapshot());
},
@@ -4438,8 +4615,65 @@
taskOverlayHistory.start();
qs('#open-palette').addEventListener('click', openCommandPalette);
qs('#close-command-palette').addEventListener('click', () => taskOverlayHistory.close());
qs('#cmd-load-more').addEventListener('click', () => commandSearch.loadMore());
function changeSearchScope() {
const scope = currentSearchScope();
commandSelection = -1;
taskOverlayHistory.update({ scope });
commandSearch.setScope(scope);
}
qs('#cmd-search-kind').addEventListener('change', changeSearchScope);
qs('#cmd-search-state').addEventListener('change', changeSearchScope);
let repositoryLookupController = null;
let repositoryLookupTimer = null;
async function suggestSearchRepositories() {
const input = qs('#cmd-search-repository');
const query = input.value.trim();
const status = qs('#cmd-repository-status');
if (repositoryLookupTimer !== null) clearTimeout(repositoryLookupTimer);
if (repositoryLookupController) repositoryLookupController.abort();
repositoryLookupController = null;
if (!query) {
qs('#cmd-search-repositories').innerHTML = '';
status.textContent = 'Searching all accessible repositories.';
changeSearchScope();
return;
}
if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(query)) changeSearchScope();
if (query.length < 2) {
status.textContent = 'Enter at least 2 characters to find a repository.';
return;
}
status.textContent = 'Finding accessible repositories…';
repositoryLookupTimer = setTimeout(async () => {
const controller = new AbortController();
repositoryLookupController = controller;
try {
const response = await fetch('api/v1/repositories/search?q=' + encodeURIComponent(query) + '&limit=20', {
headers:{ Accept:'application/json' }, signal:controller.signal,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Repository lookup unavailable.');
if (repositoryLookupController !== controller || input.value.trim() !== query) return;
const items = Array.isArray(payload.items) ? payload.items : [];
qs('#cmd-search-repositories').innerHTML = items.map(item =>
'<option value="' + escapeHtml(item.full_name) + '"></option>'
).join('');
status.textContent = items.length ? 'Choose an accessible repository.' : 'No accessible repositories found.';
} catch (error) {
if (error?.name !== 'AbortError') status.textContent = 'Repository lookup unavailable; global Search still works.';
} finally {
if (repositoryLookupController === controller) repositoryLookupController = null;
}
}, 200);
}
qs('#cmd-search-repository').addEventListener('input', suggestSearchRepositories);
qs('#cmd-search-repository').addEventListener('change', changeSearchScope);
qs('#cmd-search-repository').addEventListener('search', suggestSearchRepositories);
qs('#cmd-input').addEventListener('input', (e) => {
commandSelection = -1;
taskOverlayHistory.update({ query:e.target.value });
renderCommands(e.target.value);
commandSearch.setQuery(e.target.value);
});
@@ -4505,6 +4739,9 @@
}
});
qs('#close-search-preview').addEventListener('click', closeSearchPreview);
qs('#share-search-result').addEventListener('click', () => {
searchPreview.share(canonicalSearchPreviewUrl()).catch(() => {});
});
document.querySelectorAll('.share-work-route').forEach(button => {
button.addEventListener('click', async () => {
const status = qs('#work-route-share-status');
@@ -4521,17 +4758,46 @@
try {
const claimed = searchPreviewDetail;
if (claimed.claimable) await searchPreview.claim(claimed);
const opened = await openPreviewIssueInMyWork(claimed);
const opened = await openPreviewWorkInMyWork(claimed);
if (!opened) {
qs('#search-preview-status').textContent =
'Assignment confirmed. Refresh My Work to open the issue.';
qs('#search-preview-status').textContent = 'Refresh My Work to open this item.';
}
} catch (error) {
qs('#search-preview-status').textContent = error.message + ' Retry assignment.';
}
});
qs('#defer-search-result').addEventListener('click', event => {
const detail = searchPreviewDetail;
if (!detail || detail.kind !== 'issue' || detail.state !== 'open' ||
(!detail.claimable && !detail.assigned_to_me) || searchDefer.pending()) return;
laterPicker.open(detail, event.currentTarget, 'search');
});
qs('#queue-search-result').addEventListener('click', async () => {
const detail = searchPreviewDetail;
if (!detail || detail.kind !== 'issue' || detail.state !== 'open' ||
(!detail.claimable && !detail.assigned_to_me)) return;
try {
if (todayWork.contains(detail)) {
qs('#cmd-search-action-status').textContent = 'Already in Today.';
await next();
return;
}
const outcome = await searchAssignAndStart.run(detail, {
alreadyOwned: detail.assigned_to_me,
destination: 'queue',
});
if (outcome === 'queued') await next();
} catch (error) {
qs('#search-preview-status').textContent = error.message + ' Retry.';
}
});
qs('#start-search-result').addEventListener('click', async () => {
const detail = searchPreviewDetail;
if (detail?.reviewable) {
taskOverlayHistory.leave();
detail.kind = 'review';
return openRoutedWork(detail, null, { replace:true });
}
if (!detail || detail.kind !== 'issue') return;
try {
if (detail.reopenable) await searchReopenAndStart.run(detail);
@@ -5505,15 +5771,26 @@
button.focus();
}
});
qs('#keep-update-unread').addEventListener('click', () => {
if (updateTriage.active()) updateTriage.keepUnreadAndNext();
else closeUpdateSheet(true);
const updateDecisionActions = createUpdateDecisionActions({
transaction:updateDecision, triage:updateTriage, close:()=>closeUpdateSheet(true),
reader:notificationReader, items:()=>lastMyWork, undo:notificationUndo,
keepControl:qs('#keep-update-unread'), readControl:qs('#mark-update-read-next'),
});
const keepUpdateUnread = () => updateDecisionActions.keepUnread();
const markUpdateRead = () => updateDecisionActions.markRead();
qs('#keep-update-unread').addEventListener('click', keepUpdateUnread);
createUpdateTriageGesture({
surface: qs('#update-sheet .update-sheet-panel'), enabled: () => selectedUpdateDetail,
keepUnread: keepUpdateUnread, markRead: markUpdateRead,
});
qs('#update-ownership-action').addEventListener('click', () => updateOwnership.act());
qs('#update-ownership-start').addEventListener('click', () => updateOwnership.start());
qs('#retry-update-load').addEventListener('click', () => {
if (selectedUpdate) notificationReader.open(selectedUpdate, lastMyWork);
});
qs('#retry-update-conversation').addEventListener('click', () => {
notificationReader.retryConversation();
});
qs('#load-older-update-comments').addEventListener('click', async () => {
const button = qs('#load-older-update-comments');
const panel = qs('#update-sheet .update-sheet-panel');
@@ -5591,18 +5868,7 @@
updateReplyAttachmentController.setBusy(false);
}
});
qs('#mark-update-read-next').addEventListener('click', async () => {
qs('#mark-update-read-next').disabled = true;
try {
const result = await notificationReader.markReadAndNext(lastMyWork);
if (result) {
notificationUndo.offer(result.item, result.items);
if (updateTriage.active()) updateTriage.acceptCompleted();
}
} finally {
qs('#mark-update-read-next').disabled = false;
}
});
qs('#mark-update-read-next').addEventListener('click', markUpdateRead);
qs('#acknowledge-update-next').addEventListener('click', async () => {
const button = qs('#acknowledge-update-next');
button.disabled = true;
@@ -5710,6 +5976,17 @@
)) return;
const button = qs('#submit-review');
button.disabled = true;
if (updateReviewHandoff.active() && reviewHandoffSubmitted) {
try {
await updateReviewHandoff.complete();
} catch (error) {
qs('#review-submit-status').textContent = 'Review submitted, but the update is still unread. ' +
error.message + ' Retry to continue.';
button.disabled = false;
button.focus();
}
return;
}
if (offlineReview) {
const queuedTodayReview = reviewingActiveTodayItem();
qs('#review-submit-status').textContent = 'Queueing review safely…';
@@ -5727,6 +6004,11 @@
draftFingerprint: localStorage.getItem(draft.storageKey) || '',
progressFingerprint: localStorage.getItem(progress?.storageKey) || '',
});
if (updateReviewHandoff.active()) {
reviewHandoffSubmitted = true;
await updateReviewHandoff.complete();
return;
}
if (queuedTodayReview) {
const advanced = completeTodayItem(selectedReview, {
successMessage: 'Review queued. Next Today item opened.',
@@ -5755,6 +6037,11 @@
expected_head_sha: selectedReviewHead,
comments: snapshot.comments,
});
if (updateReviewHandoff.active()) {
reviewHandoffSubmitted = true;
await updateReviewHandoff.complete();
return;
}
const canContinueToMerge = createReviewController.prepareMergeContinuation({
storage: localStorage,
item,
@@ -6407,7 +6694,6 @@
if (!document.hidden) deviceSetup?.render();
});
/* Widgets */
function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); }
setInterval(widgetTick, 1000);
})();
+91 -15
View File
@@ -426,10 +426,40 @@
<div id="cmd-palette" role="dialog" aria-label="Command palette">
<div class="cmd-palette-header">
<strong>Search work</strong>
<button id="close-command-palette" type="button">Close</button>
<div class="cmd-palette-header-actions">
<button id="select-search-results" type="button">Select</button>
<button id="close-command-palette" type="button">Close</button>
</div>
</div>
<input id="cmd-input" type="text" role="combobox" aria-autocomplete="list" aria-controls="cmd-results" aria-expanded="false" placeholder="Search commands, issues, and pull requests..." />
<fieldset class="cmd-search-scope">
<legend>Filter search results</legend>
<label for="cmd-search-kind">Type</label>
<select id="cmd-search-kind">
<option value="all">All work</option>
<option value="issue">Issues</option>
<option value="pull">Pull requests</option>
</select>
<label for="cmd-search-state">Status</label>
<select id="cmd-search-state">
<option value="all">Any status</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
<label for="cmd-search-repository">Repository</label>
<input id="cmd-search-repository" type="search" list="cmd-search-repositories" autocomplete="off" placeholder="All repositories" aria-describedby="cmd-repository-status" />
<datalist id="cmd-search-repositories"></datalist>
<span id="cmd-repository-status" class="cmd-repository-status" aria-live="polite"></span>
</fieldset>
<div id="cmd-search-action-status" class="small" aria-live="polite"></div>
<button id="resume-search-batch" class="search-batch-recovery" type="button" hidden>Resume batch</button>
<div id="cmd-results" class="stack" role="listbox" aria-label="Commands and work search results"></div>
<button id="cmd-load-more" hidden>More results</button>
<div class="search-batch-actions" id="search-batch-actions" hidden>
<span id="search-selection-status" class="small" aria-live="polite">No issues selected.</span>
<button id="cancel-search-selection" type="button">Cancel</button>
<button id="queue-selected-search-results" type="button" disabled>Assign &amp; add to Today</button>
</div>
</div>
<div class="search-preview" id="search-preview" role="dialog" aria-modal="true" aria-labelledby="search-preview-title">
@@ -444,12 +474,36 @@
<div id="search-preview-status" class="small" aria-live="polite">Choose a search result.</div>
<div id="search-preview-meta" class="row"></div>
<div id="search-preview-body" class="search-preview-body markdown-content"></div>
<section id="search-preview-conversation" class="search-preview-conversation" aria-labelledby="search-preview-conversation-title">
<h2 id="search-preview-conversation-title">Current conversation</h2>
<div id="search-preview-comments"></div>
<div id="search-preview-conversation-status" class="small" aria-live="polite"></div>
<button id="retry-search-preview-conversation" type="button" hidden>Retry conversation</button>
<button id="load-older-search-preview-comments" type="button" hidden>Load older messages</button>
</section>
<section class="search-preview-reply" aria-labelledby="search-preview-reply-title" hidden>
<h2 id="search-preview-reply-title">Reply</h2>
<textarea id="search-preview-reply" maxlength="10000" placeholder="Write a reply"></textarea>
<div id="search-preview-reply-status" class="small" aria-live="polite"></div>
<div class="search-preview-reply-actions">
<button id="send-search-preview-reply" type="button">Send</button>
<button id="send-search-preview-reply-next" type="button">Send &amp; next</button>
</div>
</section>
<div class="search-preview-actions">
<nav class="search-preview-navigation" aria-label="Search result navigation" hidden>
<button id="previous-search-result" type="button">Previous</button>
<span id="search-preview-position" aria-live="polite"></span>
<button id="next-search-result" type="button">Next</button>
</nav>
<div class="search-preview-primary-actions">
<button id="claim-search-result" type="button" hidden>Assign to me</button>
<button id="defer-search-result" type="button" hidden>Assign &amp; defer</button>
<button id="queue-search-result" type="button" hidden>Assign &amp; add to Today</button>
<button id="start-search-result" type="button" hidden>Assign &amp; start</button>
</div>
<button id="share-search-result" type="button" disabled>Share result</button>
<a id="open-search-result-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
</div>
</section>
@@ -743,15 +797,15 @@
</div>
<div class="update-sheet" id="update-sheet" role="dialog" aria-modal="true" aria-labelledby="update-sheet-title">
<section class="update-sheet-panel">
<section class="update-sheet-panel" data-triage-gesture="">
<div class="update-sheet-header">
<div>
<div class="small" id="update-sheet-key"></div>
<h3 id="update-sheet-title">Unread update</h3>
</div>
<button id="keep-update-unread" type="button">Keep unread &amp; next</button>
</div>
<div id="update-triage-progress" class="update-triage-progress small" aria-live="polite" hidden></div>
<div id="update-gesture-status" class="update-gesture-status small" aria-live="polite">Swipe right to keep unread or left to mark read. Buttons remain available.</div>
<div id="update-sheet-status" class="small" aria-live="polite">Choose an update.</div>
<button class="update-retry" id="retry-update-load" type="button" hidden>Retry loading update</button>
<div class="row">
@@ -760,8 +814,10 @@
</div>
<h2>Full conversation</h2>
<div id="update-comments"></div>
<button class="update-new-activity-jump" id="jump-update-new-activity" type="button" hidden>Jump to new activity</button>
<button class="conversation-more" id="load-older-update-comments" type="button" hidden>Load older messages</button>
<div id="update-conversation-status" class="small" aria-live="assertive"></div>
<button class="update-retry" id="retry-update-conversation" type="button" hidden>Retry conversation</button>
<details>
<summary><h2>Subject context</h2></summary>
<div class="update-sheet-content muted markdown-content" id="update-subject-body"></div>
@@ -787,19 +843,21 @@
</div>
<div id="update-reply-status" class="small" aria-live="assertive"></div>
</section>
<div class="update-sheet-actions">
<div class="update-ownership-actions">
<button id="update-ownership-action" type="button" hidden aria-describedby="update-sheet-status">Take ownership</button>
<button id="update-ownership-start" type="button" hidden aria-describedby="update-sheet-status">Take ownership &amp; start</button>
<details class="update-more-actions">
<summary>More actions</summary>
<div class="update-sheet-actions">
<div class="update-ownership-actions">
<button id="update-ownership-action" type="button" hidden aria-describedby="update-sheet-status">Take ownership</button>
<button id="update-ownership-start" type="button" hidden aria-describedby="update-sheet-status">Take ownership &amp; start</button>
</div>
<button class="share-work-route" type="button">Share</button>
<button id="acknowledge-update-next" type="button" hidden aria-label="Acknowledge and open next update">👍 Acknowledge &amp; next</button>
<button id="mute-update-next" type="button" hidden>Mute future updates &amp; next</button>
<button id="create-update-follow-up" type="button" hidden>Create follow-up</button>
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date &amp; time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
</div>
<button class="share-work-route" type="button">Share</button>
<button id="acknowledge-update-next" type="button" hidden aria-label="Acknowledge and open next update">👍 Acknowledge &amp; next</button>
<button id="mute-update-next" type="button" hidden>Mute future updates &amp; next</button>
<button id="create-update-follow-up" type="button" hidden>Create follow-up</button>
<button id="mark-update-read-next" type="button">Mark read &amp; next</button>
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date &amp; time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
</div>
</details>
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
<button type="button" data-work-session-previous>Previous</button>
@@ -807,6 +865,13 @@
<button type="button" data-work-session-complete="update" hidden>Done for Today &amp; next</button>
<button type="button" data-work-session-next>Next work item</button>
</nav>
<div class="update-decision-bar" aria-label="Update decisions">
<button id="keep-update-unread" type="button" aria-label="Keep unread and open next update">Keep unread</button>
<button id="mark-update-read-next" type="button" aria-label="Mark read and open next update">Mark read</button>
<button id="review-update-now" type="button" hidden>Review now</button>
<button id="focus-update-reply" type="button">Reply</button>
<button id="toggle-update-more" type="button" aria-expanded="false">More</button>
</div>
</section>
</div>
@@ -984,6 +1049,10 @@
<dialog class="mobile-queue-sheet" id="mobile-queue-sheet" aria-labelledby="mobile-queue-heading">
<section class="mobile-queue-panel">
<header><h2 id="mobile-queue-heading">Work queues</h2><button id="close-mobile-queues" type="button">Close</button></header>
<div id="mobile-update-outcome" class="mobile-update-outcome" role="status" aria-live="polite" hidden>
<p id="mobile-update-outcome-summary"></p>
<button id="review-kept-updates" type="button" hidden>Review kept unread</button>
</div>
<p class="small muted">Choose what to work through next.</p>
<div class="mobile-queue-list">
<button data-mobile-queue="today" type="button"><span><strong>Today</strong><small>Planned work</small></span><span data-mobile-queue-count="today">0</span></button>
@@ -1014,6 +1083,7 @@
<script src="static/markdown.js"></script>
<script src="static/commands.js"></script>
<script src="static/search-preview.js"></script>
<script src="static/search-defer.js"></script>
<script src="static/widgets.js"></script>
<script src="static/drafts.js"></script>
<script src="static/unfiled-captures.js"></script>
@@ -1056,6 +1126,7 @@
<script src="static/later-picker.js"></script>
<script src="static/pick-work.js"></script>
<script src="static/batch-find-work.js"></script>
<script src="static/search-batch-plan.js"></script>
<script src="static/conversation.js"></script>
<script src="static/comment-actions.js"></script>
<script src="static/issue-attachment.js"></script>
@@ -1074,7 +1145,12 @@
<script src="static/mobile-work-entry.js"></script>
<script src="static/mobile-queue-launcher.js"></script>
<script src="static/update-triage-session.js"></script>
<script src="static/update-review-handoff.js"></script>
<script src="static/update-read-position.js"></script>
<script src="static/work-detail-position.js"></script>
<script src="static/update-triage-launcher.js"></script>
<script src="static/update-triage-gesture.js"></script>
<script src="static/update-decision-transaction.js"></script>
<script src="static/agenda-session-launcher.js"></script>
<script src="static/mobile-launch.js"></script>
<script src="static/mobile-app-shortcuts.js"></script>
+68 -10
View File
@@ -4,7 +4,8 @@
}(typeof self !== 'undefined' ? self : this, function createLoginController(options) {
function validShareContinuation(value) {
if (typeof value !== 'string' || !value.startsWith('./?') || value.includes('#')) return './';
const limits = { title: 200, text: 8000, url: 2048, launch: 8, shared: 5 };
const limits = { title: 200, text: 8000, url: 2048, launch: 8, shared: 5, search: 200,
preview: 200, search_kind: 5, search_state: 6, search_repository: 161 };
const params = new URLSearchParams(value.slice(3));
const entries = Array.from(params.entries());
if (!entries.length) return './';
@@ -15,6 +16,22 @@
));
if (invalid) return './';
if (Object.keys(limits).some(name => params.getAll(name).length > 1)) return './';
const search = params.get('search');
const preview = params.get('preview');
if (search !== null || preview !== null) {
if (entries.some(([name]) => ![
'search', 'preview', 'search_kind', 'search_state', 'search_repository'
].includes(name))) return './';
if (search === null || preview === null) return './';
if (!/^(issue|pull):[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+:[1-9]\d*$/.test(preview)) return './';
const kind = params.get('search_kind');
const state = params.get('search_state');
const repository = params.get('search_repository');
if (kind !== null && !['all', 'issue', 'pull'].includes(kind)) return './';
if (state !== null && !['all', 'open', 'closed'].includes(state)) return './';
if (repository !== null && !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) return './';
return value;
}
const launch = params.get('launch');
const shared = params.get('shared');
if (launch && !['continue', 'new', 'agenda'].includes(launch)) return './';
@@ -33,7 +50,31 @@
const continuation = validShareContinuation(options.continuation);
const setIntervalImpl = options.setIntervalImpl || setInterval;
const clearIntervalImpl = options.clearIntervalImpl || clearInterval;
const setTimeoutImpl = options.setTimeoutImpl || setTimeout;
const clearTimeoutImpl = options.clearTimeoutImpl || clearTimeout;
const requestTimeoutMs = options.requestTimeoutMs || 15000;
let timer = null;
let activeAttempt = false;
function setAttemptActive(active) {
activeAttempt = active;
button.disabled = active;
if (passkeyButton) passkeyButton.disabled = active || !credentials?.get;
}
async function requestWithDeadline(url, init) {
const controller = new AbortController();
const timeout = setTimeoutImpl(() => {
const error = new Error('Sign-in request timed out');
error.name = 'TimeoutError';
controller.abort(error);
}, requestTimeoutMs);
try {
return await fetchImpl(url, { ...init, signal: controller.signal });
} finally {
clearTimeoutImpl(timeout);
}
}
function decodeBase64Url(value) {
const padded = String(value).replaceAll('-', '+').replaceAll('_', '/')
@@ -63,7 +104,12 @@
};
}
if (continuation !== './') status.textContent = 'Sign in to continue your shared capture.';
if (continuation !== './') {
const continuationParams = new URLSearchParams(continuation.slice(3));
status.textContent = continuationParams.has('preview')
? 'Sign in to open the shared Search result.'
: 'Sign in to continue your shared capture.';
}
async function showReason(reason) {
if (reason === 'session-expired') {
@@ -105,18 +151,24 @@
}
async function submit(accessToken, deviceLabel = 'This device') {
if (activeAttempt) return false;
setAttemptActive(true);
status.textContent = 'Signing in…';
let response;
try {
response = await fetchImpl('api/v1/session', {
response = await requestWithDeadline('api/v1/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ access_token: accessToken, device_label: deviceLabel }),
});
} catch (_error) {
} catch (error) {
form.reset();
status.textContent = 'Sign-in failed. Check your connection and try again.';
return;
status.textContent = error?.name === 'TimeoutError'
? 'Sign-in timed out. Check your connection and try again.'
: 'Sign-in failed. Check your connection and try again.';
return false;
} finally {
setAttemptActive(false);
}
form.reset();
if (response.ok) {
@@ -135,9 +187,11 @@
status.textContent = 'Passkeys are not supported in this browser. Use the access token.';
return false;
}
if (activeAttempt) return false;
setAttemptActive(true);
status.textContent = 'Waiting for your passkey…';
try {
const optionsResponse = await fetchImpl('api/v1/passkeys/authentication/options', {
const optionsResponse = await requestWithDeadline('api/v1/passkeys/authentication/options', {
method: 'POST', headers: { Accept: 'application/json' },
});
if (!optionsResponse.ok) throw new Error('No enrolled passkey');
@@ -148,7 +202,7 @@
...item, id: decodeBase64Url(item.id),
}));
const credential = await credentials.get({ publicKey });
const response = await fetchImpl('api/v1/passkeys/authentication/verify', {
const response = await requestWithDeadline('api/v1/passkeys/authentication/verify', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -162,9 +216,13 @@
if (!response.ok) throw new Error('Passkey verification failed');
location.replace(continuation);
return true;
} catch (_error) {
status.textContent = 'Passkey sign-in was not completed. Try again or use the access token.';
} catch (error) {
status.textContent = error?.name === 'TimeoutError'
? 'Passkey sign-in timed out. Check your connection and try again.'
: 'Passkey sign-in was not completed. Try again or use the access token.';
return false;
} finally {
setAttemptActive(false);
}
}
+80 -8
View File
@@ -44,6 +44,19 @@ function needsAttention(item) {
return Boolean(item && (item.has_update || item.is_review || urgencyReason(item)));
}
function updateActionability(item) {
const priorityLabels = ['p0', 'priority-high', 'critical'];
if ((item?.labels || []).some(label => priorityLabels.includes(String(label).toLowerCase()))) {
return { priority: 0, reason: 'Critical' };
}
if (item?.is_review) return { priority: 1, reason: 'Review requested' };
if (item?.is_assigned && ['Overdue', 'Due today'].includes(item?.due_label)) {
return { priority: 2, reason: item.due_label };
}
if (item?.is_assigned) return { priority: 3, reason: 'Assigned to you' };
return { priority: 4, reason: '' };
}
function buildMyWork(data, now = new Date()) {
const login = data.user?.login || '';
const issues = (data.issues || []).map((item) => ({ ...item, kind: 'issue' }));
@@ -82,13 +95,15 @@ function buildMyWork(data, now = new Date()) {
const subjectKind = String(update.subject_type || '').toLowerCase().includes('pull') ? 'pull' : 'issue';
const existing = byKey.get(subjectKind + ':' + key);
if (existing) {
const actionability = updateActionability(existing);
existing.has_update = true;
existing.needs_attention = true;
existing.attention_reason = 'Unread update';
existing.notification_id = update.id;
existing.url = update.url || existing.url;
existing.updated_at = update.updated_at || existing.updated_at;
existing._priority = Math.min(existing._priority, 1);
existing.update_reason = actionability.reason;
existing._priority = actionability.priority;
return;
}
if (!update.url) return;
@@ -103,7 +118,8 @@ function buildMyWork(data, now = new Date()) {
attention_reason: 'Unread update',
notification_id: update.id,
reason: 'Unread update',
_priority: 1,
update_reason: '',
_priority: 4,
};
work.push(item);
byKey.set(subjectKind + ':' + key, item);
@@ -379,13 +395,52 @@ function createNotificationReader({
loadSaved = () => null,
loadConversation = null,
onConversation = () => {},
onConversationStatus = () => {},
createPager = typeof createConversationPager === 'function' ? createConversationPager : null,
getScope = () => '',
}) {
let selected = null;
let loadVersion = 0;
let marking = false;
let conversationPager = null;
let offlineHydrated = false;
let prefetched = null;
let prefetchKey = '';
async function hydrateConversation(item, version) {
if (!loadConversation || !createPager || !item || offlineHydrated) return false;
onConversationStatus('Loading conversation…');
try {
const initial = await loadConversation(item.notification_id, null);
if (selected !== item || version !== loadVersion) return false;
conversationPager = createPager({
loadPage: page => loadConversation(item.notification_id, page),
});
const state = conversationPager.reset(initial);
onConversation(state);
onConversationStatus(state.comments.length + ' of ' +
Math.max(state.total || 0, state.comments.length) + ' messages loaded.');
return true;
} catch (_error) {
if (selected === item && version === loadVersion) {
conversationPager = null;
onConversationStatus('Conversation temporarily unavailable. Retry.');
}
return false;
}
}
function prefetch(item) {
if (!item) { prefetched = null; prefetchKey = ''; return false; }
const notificationId = item?.notification_id;
if (!Number.isInteger(notificationId) || offlineHydrated) return false;
const key = getScope() + notificationId;
if (prefetchKey === key) return prefetched;
if (prefetched) return false;
prefetchKey = key;
prefetched = load(notificationId);
return prefetched;
}
async function advanceAfterRead(items, current, queueing = false) {
const updated = acknowledgeNotification(items, current.notification_id);
@@ -407,10 +462,19 @@ function createNotificationReader({
selected = item;
offlineHydrated = Boolean(savedDetail && !Array.isArray(savedDetail));
const version = ++loadVersion;
const preload = !offlineHydrated && prefetchKey === getScope() + item.notification_id ? prefetched : null;
prefetched = null;
prefetchKey = '';
onOpen(item);
onStatus('Loading update…');
if (!preload) onStatus('Loading update…');
try {
const detail = offlineHydrated ? savedDetail : await load(item.notification_id);
let detail;
if (offlineHydrated) detail = savedDetail;
else if (preload) detail = await preload.catch(() => {
onStatus('Loading update…');
return load(item.notification_id);
});
else detail = await load(item.notification_id);
if (selected !== item || version !== loadVersion) return false;
onDetail(detail);
if (createPager && loadConversation && detail.conversation) {
@@ -422,6 +486,9 @@ function createNotificationReader({
conversationPager = null;
}
onStatus('Update ready.');
if (!offlineHydrated && detail.conversation_available) {
void hydrateConversation(item, version);
}
return true;
} catch (_error) {
if (selected === item && version === loadVersion) {
@@ -433,6 +500,11 @@ function createNotificationReader({
return {
open,
prefetch,
retryConversation() {
if (!selected || offlineHydrated) return Promise.resolve(false);
return hydrateConversation(selected, loadVersion);
},
commentPager() {
return conversationPager;
},
@@ -516,7 +588,7 @@ function createNotificationReplier({
if ((storage.getItem(keyFor(item)) || '') !== body) storage.removeItem(operationKeyFor(item));
storage.setItem(keyFor(item), body);
}
catch (_error) { /* Keep the editable textarea as the fallback. */ }
catch (_error) {}
},
async submit(item, body, attachment = null) {
if (pending) return false;
@@ -542,15 +614,15 @@ function createNotificationReplier({
return { queued:true };
}
try { storage.removeItem(keyFor(item)); storage.removeItem(operationKeyFor(item)); }
catch (_error) { /* Confirmed delivery is authoritative. */ }
catch (_error) {}
onStatus('Reply posted. You can mark this update read when ready.');
return delivery.confirmed[0];
}
const result = await post(item.notification_id, body, operationId);
try { storage.removeItem(keyFor(item)); }
catch (_error) { /* The posted reply is still authoritative. */ }
catch (_error) {}
try { storage.removeItem(operationKeyFor(item)); }
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
catch (_error) {}
onStatus('Reply posted. You can mark this update read when ready.');
return result;
} catch (error) {
+138
View File
@@ -0,0 +1,138 @@
(function (root, factory) {
const createSearchBatchPlan = factory();
if (typeof module === 'object' && module.exports) module.exports = createSearchBatchPlan;
if (root) {
root.createSearchBatchPlan = createSearchBatchPlan;
root.mountSearchBatchPlanning = createSearchBatchPlan.mount;
}
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
function createSearchBatchPlan({
limit = 50,
resolve = item => Promise.resolve(item),
claim = item => Promise.resolve(item),
onChange = () => {},
} = {}) {
const maximum = Number.isInteger(limit) && limit > 0 ? limit : 50;
const selected = new Map();
let active = false;
const identity = item => [item?.kind || '', item?.repository || '', item?.number || ''].join(':');
const eligible = item => item?.kind === 'issue' && item?.state === 'open' &&
Boolean(item.repository) && Number.isInteger(item.number);
function snapshot() {
return { active, count:selected.size, items:Array.from(selected.values()) };
}
function changed() {
const state = snapshot();
onChange(state);
return state;
}
function start() {
active = true;
selected.clear();
return changed();
}
function cancel() {
active = false;
selected.clear();
return changed();
}
function toggle(item) {
if (!active) return 'inactive';
if (!eligible(item)) return 'ineligible';
const key = identity(item);
if (selected.has(key)) {
selected.delete(key);
changed();
return 'removed';
}
if (selected.size >= maximum) return 'limit';
selected.set(key, { ...item });
changed();
return 'selected';
}
async function prepare(item) {
const detail = await resolve(item);
if (detail?.assigned_to_me) return detail;
if (detail?.claimable) return claim(detail);
throw new Error('This issue is no longer available to assign.');
}
return { identity, eligible, start, cancel, toggle, snapshot, prepare, limit:maximum };
}
createSearchBatchPlan.mount = function mountSearchBatchPlanning(
document, batchFactory, todayWork, getOwner, fetchJson, previewPath, queueToday,
acceptIssue, lookup, render, escapeHtml, escapeAttribute
) {
const get = selector => document.querySelector(selector);
let processor;
const plan = createSearchBatchPlan({
resolve:item => fetchJson(previewPath(item), {headers:{Accept:'application/json'}}),
claim:detail => fetchJson(
'api/v1/repos/' + detail.repository.split('/').map(encodeURIComponent).join('/') +
'/issues/' + encodeURIComponent(detail.number) + '/claim',
{method:'PATCH', headers:{Accept:'application/json'}}
),
onChange:state => {
get('#search-batch-actions').hidden = !state.active;
get('#select-search-results').hidden = state.active;
get('#queue-selected-search-results').disabled = state.count === 0;
get('#search-selection-status').textContent = state.count ?
state.count + ' issue' + (state.count === 1 ? '' : 's') + ' selected.' : 'No issues selected.';
render();
},
});
processor = batchFactory({
capacity:() => Math.max(0, todayWork.limit - todayWork.read().length),
owner:getOwner,
journalName:'search-today-batch',
autoMount:false,
claim:item => plan.prepare(item),
queue:confirmed => queueToday(acceptIssue(confirmed)),
onProgress:progress => {
get('#queue-selected-search-results').disabled = progress.status === 'running';
if (progress.status === 'running') {
get('#search-selection-status').textContent = 'Planning ' + progress.processed + ' of ' + progress.selected + '…';
} else if (progress.status === 'full') {
get('#search-selection-status').textContent = 'Today has ' + progress.available + ' remaining slot' +
(progress.available === 1 ? '.' : 's.');
} else if (progress.status === 'complete') {
get('#cmd-search-action-status').textContent = progress.failed.length ?
progress.queued.length + ' queued · ' + progress.failed.length + ' need retry.' :
progress.queued.length + ' added to Today.';
get('#resume-search-batch').hidden = processor.pending() === 0;
if (!progress.failed.length) plan.cancel();
}
},
});
get('#select-search-results').addEventListener('click', () => plan.start());
get('#cancel-search-selection').addEventListener('click', () => plan.cancel());
get('#queue-selected-search-results').addEventListener('click', () => processor.run(plan.snapshot().items));
get('#resume-search-batch').addEventListener('click', () => processor.resume());
get('#open-palette').addEventListener('click', () => restore());
get('#cmd-results').addEventListener('change', event => {
const index = event.target?.dataset?.searchSelect;
if (index === undefined) return;
const result = lookup(Number(index));
if (result) plan.toggle(result);
});
function restore() {
const pending = processor.pending();
get('#resume-search-batch').hidden = pending === 0;
get('#resume-search-batch').textContent = 'Resume ' + pending + ' interrupted';
}
function resultHtml(result, index) {
const allowed = result.kind === 'issue' && result.state === 'open';
const selected = plan.snapshot().items.some(item => plan.identity(item) === plan.identity(result));
return '<label class="cmd-select-result"' + (allowed ? '' : ' aria-disabled="true"') + '>' +
'<input type="checkbox" data-search-select="' + index + '" ' + (selected ? 'checked ' : '') +
(allowed ? '' : 'disabled ') + 'aria-label="Select ' + escapeAttribute(result.title) + '" />' +
'<span>' + escapeHtml(result.title) + '</span><span class="cmd-meta">' +
escapeHtml(result.repository) + ' #' + escapeHtml(result.number) + ' · ' +
(allowed ? 'Open issue' : 'Not eligible') + '</span></label>';
}
return { plan, processor, restore, resultHtml };
};
return createSearchBatchPlan;
});
+28
View File
@@ -0,0 +1,28 @@
function createSearchDefer({ claim, accept, defer, refresh, announce, formatTime }) {
let request = null;
function run(detail, until) {
if (request) return request;
const wasClaimable = Boolean(detail?.claimable);
request = Promise.resolve(wasClaimable ? claim(detail) : detail)
.then(confirmed => {
const item = accept(confirmed);
const outcome = defer(item, until);
refresh();
if (outcome === 'deferred') {
announce((wasClaimable ? 'Assigned and deferred until ' : 'Deferred until ') + formatTime(until) + '.');
} else {
announce(wasClaimable
? 'Assignment succeeded, but Later could not be saved. Open this issue in My Work to retry.'
: 'Could not save Later on this device. Retry scheduling.');
}
return outcome;
})
.finally(() => { request = null; });
return request;
}
return { run, pending:() => Boolean(request) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createSearchDefer;
+304 -9
View File
@@ -1,40 +1,225 @@
(function (root, factory) {
const createSearchPreview = factory();
if (typeof module === 'object' && module.exports) module.exports = createSearchPreview;
if (root) root.createSearchPreview = createSearchPreview;
if (root) {
root.createSearchPreview = createSearchPreview;
root.safeSearchUrl = value => {
try {
const url = new URL(value);
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
} catch (_) { return ''; }
};
root.searchPreviewUrl = (state, defaultScope, location) => {
const { query, preview, scope = defaultScope } = state;
const params = new URLSearchParams({
search:query || '', preview:preview.kind + ':' + preview.repository + ':' + preview.number,
search_kind:scope.kind, search_state:scope.state,
});
if (scope.repository) params.set('search_repository', scope.repository);
return new URL('?' + params, location.origin + location.pathname).href;
};
root.searchPreviewPath = item => {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
'/preview?kind=' + encodeURIComponent(item.kind);
};
root.searchPreviewConversationPath = (item, page) => {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
const query = new URLSearchParams({ kind:item.kind, limit:'20' });
if (page) query.set('page', String(page));
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
'/preview/conversation?' + query.toString();
};
root.searchPreviewReplyPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
'/comments?kind=' + encodeURIComponent(item.kind);
root.searchPreviewReplyOptions = (fetchJson, storage, crypto) => ({
storage,
createOperationId:() => crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
postReply:(item,body,operationId) => fetchJson(root.searchPreviewReplyPath(item), {
method:'POST', headers:{Accept:'application/json','Content-Type':'application/json','Idempotency-Key':operationId},
body:JSON.stringify({body}),
}),
});
root.renderSearchPreviewConversation = (conversation, document, escapeHtml, formatTime, renderMarkdown) => {
const comments = document.querySelector('#search-preview-comments');
const status = document.querySelector('#search-preview-conversation-status');
const retry = document.querySelector('#retry-search-preview-conversation');
const older = document.querySelector('#load-older-search-preview-comments');
if (!conversation) {
comments.textContent = status.textContent = '';
retry.hidden = older.hidden = true;
return;
}
comments.innerHTML = (conversation.comments || []).map(comment =>
'<article class="search-preview-comment" data-comment-id="' + Number(comment.id || 0) + '">' +
'<div class="small">' + escapeHtml(comment.author || 'Unknown author') +
(comment.created_at ? ' · ' + escapeHtml(formatTime(comment.created_at)) : '') + '</div>' +
'<div class="markdown-content">' + renderMarkdown(comment.body || '') + '</div></article>'
).join('');
retry.hidden = conversation.status !== 'error';
older.hidden = conversation.status !== 'ready' || !conversation.olderPage;
older.disabled = conversation.status === 'loading';
if (conversation.status === 'loading') status.textContent = conversation.comments?.length
? 'Loading older messages…' : 'Loading current conversation…';
else if (conversation.status === 'error') status.textContent =
'Conversation unavailable. Preview and planning actions still work.';
else if (!conversation.comments?.length) status.textContent = 'No conversation yet.';
else status.textContent = conversation.comments.length +
(conversation.comments.length === 1 ? ' message.' : ' messages.');
};
root.renderSearchPreviewReply = (state, detail, preview, document) => {
const section = document.querySelector('.search-preview-reply');
const input = document.querySelector('#search-preview-reply');
const status = document.querySelector('#search-preview-reply-status');
const buttons = ['#send-search-preview-reply', '#send-search-preview-reply-next']
.map(selector => document.querySelector(selector));
section.hidden = detail?.commentable !== true;
status.textContent = '';
if (section.hidden) {
buttons.forEach(button => { button.disabled = true; });
return;
}
input.value = preview.replyDraft();
const replying = state.status === 'replying';
buttons.forEach(button => { button.disabled = replying || !input.value.trim(); });
if (replying) status.textContent = 'Sending reply…';
else if (state.status === 'replied') status.textContent = 'Reply posted.';
else if (state.status === 'reply-error') status.textContent =
state.error?.message || 'Reply failed. Your draft is safe; retry when ready.';
};
}
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
return function createSearchPreview({ fetchJson, mutate, onState }) {
return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
if (Array.isArray(session)) {
getSession = session[0];
loadMore = () => session[1].loadMore();
onNavigate = session[2];
navigationRoot = document;
}
let generation = 0;
let current = null;
let mutationRequest = null;
let shareRequest = null;
let moveRequest = null;
let replyRequest = null;
let conversation = null;
function sameItem(left, right) {
return left && right && left.kind === right.kind && left.repository === right.repository &&
Number(left.number) === Number(right.number);
}
function replyKey(item, suffix) {
return 'stackchain.search-reply.' + [item?.kind, item?.repository, item?.number]
.map(value => encodeURIComponent(String(value || ''))).join('.') + '.' + suffix;
}
function stored(key) {
try { return storage?.getItem(key) || ''; }
catch (_) { return ''; }
}
function save(key, value) {
try {
if (value) storage?.setItem(key, value);
else storage?.removeItem(key);
} catch (_) { /* Volatile drafts still remain in the textarea. */ }
}
function navigation(item) {
const session = typeof getSession === 'function' ? getSession() : null;
const items = Array.isArray(session?.items) ? session.items : [];
const index = items.findIndex(candidate => sameItem(candidate, item));
if (index < 0) return null;
return {
position:index + 1,
total:items.length,
hasPrevious:index > 0,
hasNext:index < items.length - 1 || session.more === true,
};
}
function publish(state) {
const position = navigation(state.item || current);
if (navigationRoot) {
const bar = navigationRoot.querySelector('.search-preview-navigation');
bar.hidden = !position;
if (position) {
navigationRoot.querySelector('#search-preview-position').textContent = position.position + ' of ' + position.total;
navigationRoot.querySelector('#previous-search-result').disabled = !position.hasPrevious;
navigationRoot.querySelector('#next-search-result').disabled = !position.hasNext;
}
}
onState(position ? { ...state, navigation:position } : state);
if (state.complete && navigationRoot) {
navigationRoot.querySelector('#search-preview-status').textContent = 'Search pass complete.';
}
}
function run(action, pending, success, detail) {
if (mutationRequest) return mutationRequest;
onState({ status: pending, item: current, detail });
publish({ status: pending, item: current, detail });
mutationRequest = mutate(detail, action).then(result => {
onState({ status: success, item: current, detail, result });
publish({ status: success, item: current, detail, result });
return result;
}).catch(error => {
onState({ status: 'ready', item: current, detail, error });
publish({ status: 'ready', item: current, detail, error });
throw error;
}).finally(() => { mutationRequest = null; });
return mutationRequest;
}
return {
function loadConversation(detail, requestGeneration, page) {
if (typeof fetchConversation !== 'function') return Promise.resolve(null);
const previousComments = page && Array.isArray(conversation?.comments)
? conversation.comments : [];
conversation = { status:'loading', comments:previousComments, olderPage:page ?? null };
publish({ status:'ready', item:current, detail, conversation });
return fetchConversation(detail, page).then(result => {
if (requestGeneration !== generation) return result;
const incoming = Array.isArray(result?.comments) ? result.comments : [];
const liveComments = Array.isArray(conversation?.comments) ? conversation.comments : previousComments;
const comments = [...incoming, ...liveComments].filter((comment, index, all) =>
all.findIndex(candidate => candidate?.id === comment?.id) === index);
conversation = {
status:'ready',
comments,
olderPage:result?.older_page ?? null,
};
publish({ status:'ready', item:current, detail:current, conversation });
return result;
}).catch(error => {
if (requestGeneration === generation) {
conversation = {
status:'error', comments:previousComments,
olderPage:page ?? conversation?.olderPage ?? null, error,
};
publish({ status:'ready', item:current, detail:current, conversation });
}
return null;
});
}
const api = {
open(item) {
generation += 1;
const requestGeneration = generation;
current = { ...item };
onState({ status: 'loading', item: current });
conversation = null;
publish({ status: 'loading', item: current });
return fetchJson(current).then(detail => {
if (requestGeneration === generation) {
onState({ status: 'ready', item: current, detail });
current = { ...current, ...detail };
if (typeof fetchConversation === 'function') {
loadConversation(current, requestGeneration);
} else {
publish({ status: 'ready', item: current, detail });
}
}
return detail;
}).catch(error => {
if (requestGeneration === generation) {
onState({ status: 'error', item: current, error });
publish({ status: 'error', item: current, error });
}
throw error;
});
@@ -44,6 +229,98 @@
current = null;
onState({ status: 'closed' });
},
previous() {
const session = typeof getSession === 'function' ? getSession() : null;
const items = Array.isArray(session?.items) ? session.items : [];
const index = items.findIndex(item => sameItem(item, current));
if (index <= 0) return Promise.resolve(current);
const item = items[index - 1];
if (typeof onNavigate === 'function') onNavigate(item);
return api.open(item);
},
next() {
if (moveRequest) return moveRequest;
moveRequest = (async () => {
let session = typeof getSession === 'function' ? getSession() : null;
let items = Array.isArray(session?.items) ? session.items : [];
let index = items.findIndex(item => sameItem(item, current));
if (index < 0) return current;
if (index === items.length - 1 && session?.more === true && typeof loadMore === 'function') {
await loadMore();
session = getSession();
items = Array.isArray(session?.items) ? session.items : [];
index = items.findIndex(item => sameItem(item, current));
}
if (index >= 0 && index < items.length - 1) {
const item = items[index + 1];
if (typeof onNavigate === 'function') onNavigate(item);
return api.open(item);
}
publish({ status:'ready', item:current, detail:current, complete:true });
return current;
})().finally(() => { moveRequest = null; });
return moveRequest;
},
retryConversation() {
if (!current) return Promise.resolve(null);
return loadConversation(current, generation);
},
loadOlderConversation() {
if (!current || !conversation?.olderPage) return Promise.resolve(null);
return loadConversation(current, generation, conversation.olderPage);
},
saveReplyDraft(body) {
if (!current) return '';
const key = replyKey(current, 'draft');
const next = String(body || '');
if (stored(key) !== next) save(replyKey(current, 'operation'), '');
save(key, next);
return next;
},
replyDraft() {
return current ? stored(replyKey(current, 'draft')) : '';
},
reply({ advance = false } = {}) {
if (replyRequest) return replyRequest;
if (!current || typeof postReply !== 'function') return Promise.reject(new Error('Replying is unavailable.'));
const body = api.replyDraft().trim();
if (!body) return Promise.reject(new Error('Write a reply first.'));
const item = { ...current };
const operationKey = replyKey(item, 'operation');
let operationId = stored(operationKey);
if (!operationId) {
operationId = String(createOperationId?.() || Date.now()).slice(0, 128);
save(operationKey, operationId);
}
publish({ status:'replying', item:current, detail:current, conversation });
replyRequest = postReply(item, body, operationId).then(comment => {
const comments = [...(conversation?.comments || [])];
if (!comments.some(candidate => candidate?.id === comment?.id)) comments.push(comment);
conversation = { status:'ready', comments, olderPage:conversation?.olderPage ?? null };
save(replyKey(item, 'draft'), '');
save(operationKey, '');
publish({ status:'replied', item:current, detail:current, conversation, result:comment });
return advance ? api.next().then(() => comment) : comment;
}).catch(error => {
publish({ status:'reply-error', item:current, detail:current, conversation, error });
throw error;
}).finally(() => { replyRequest = null; });
return replyRequest;
},
share(url) {
if (shareRequest) return shareRequest;
if (!current || typeof share !== 'function') return Promise.reject(new Error('Sharing is unavailable.'));
const detail = current;
publish({ status:'sharing', item:current, detail });
shareRequest = share(url).then(result => {
publish({ status:result, item:current, detail });
return result;
}).catch(error => {
publish({ status:error?.name === 'AbortError' ? 'share-canceled' : 'share-error', item:current, detail, error });
throw error;
}).finally(() => { shareRequest = null; });
return shareRequest;
},
claim(detail) {
return run('claim', 'claiming', 'claimed', detail);
},
@@ -51,5 +328,23 @@
return run('reopen', 'reopening', 'reopened', detail);
},
};
if (navigationRoot) {
navigationRoot.querySelector('#previous-search-result').addEventListener('click', () => api.previous().catch(() => {}));
navigationRoot.querySelector('#next-search-result').addEventListener('click', () => api.next().catch(() => {}));
navigationRoot.querySelector('#retry-search-preview-conversation').addEventListener('click', () => api.retryConversation());
navigationRoot.querySelector('#load-older-search-preview-comments').addEventListener('click', () => api.loadOlderConversation());
const reply = navigationRoot.querySelector('#search-preview-reply');
reply?.addEventListener('input', event => {
api.saveReplyDraft(event.target.value);
const disabled = !event.target.value.trim();
navigationRoot.querySelector('#send-search-preview-reply').disabled = disabled;
navigationRoot.querySelector('#send-search-preview-reply-next').disabled = disabled;
});
reply?.addEventListener('focus', event => event.target.scrollIntoView({ block:'center', behavior:'smooth' }));
navigationRoot.querySelector('#send-search-preview-reply')?.addEventListener('click', () => api.reply().catch(() => {}));
navigationRoot.querySelector('#send-search-preview-reply-next')?.addEventListener('click', () =>
api.reply({ advance:true }).catch(() => {}));
}
return api;
};
});
+1
View File
@@ -131,6 +131,7 @@
passkey_counter_anomaly: 'Passkey counter anomaly',
device_revoked: 'Device access revoked', all_sessions_revoked: 'All device access revoked',
issue_closed: 'Issue closed', pull_merged: 'Pull request merged',
comment_deleted: 'Comment deleted',
pull_review_approved: 'Pull request approved',
pull_review_changes_requested: 'Changes requested', gitea_time_logged: 'Gitea time logged',
};
+8 -1
View File
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v100';
const CACHE = 'stackchain-dashboard-shell-v101';
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;
@@ -18,6 +18,7 @@ const SHELL = [
BASE + 'static/markdown.js',
BASE + 'static/commands.js',
BASE + 'static/search-preview.js',
BASE + 'static/search-defer.js',
BASE + 'static/widgets.js',
BASE + 'static/drafts.js',
BASE + 'static/unfiled-captures.js',
@@ -59,6 +60,7 @@ const SHELL = [
BASE + 'static/later-picker.js',
BASE + 'static/pick-work.js',
BASE + 'static/batch-find-work.js',
BASE + 'static/search-batch-plan.js',
BASE + 'static/conversation.js',
BASE + 'static/comment-actions.js',
BASE + 'static/issue-attachment.js',
@@ -77,7 +79,12 @@ const SHELL = [
BASE + 'static/mobile-work-entry.js',
BASE + 'static/mobile-queue-launcher.js',
BASE + 'static/update-triage-session.js',
BASE + 'static/update-review-handoff.js',
BASE + 'static/update-read-position.js',
BASE + 'static/work-detail-position.js',
BASE + 'static/update-triage-launcher.js',
BASE + 'static/update-triage-gesture.js',
BASE + 'static/update-decision-transaction.js',
BASE + 'static/agenda-session-launcher.js',
BASE + 'static/mobile-launch.js',
BASE + 'static/mobile-app-shortcuts.js',
+154 -22
View File
@@ -6,21 +6,132 @@
'use strict';
const allowed = new Set(['new', 'find', 'search', 'search-preview', 'plan-today', 'plan-today-preview', 'today-readiness']);
const searchKinds = new Set(['search', 'search-preview']);
const maxQueryLength = 200;
return function createTaskOverlayHistory({ history, eventTarget, onChange }) {
let active = allowed.has(history.state?.taskOverlay) ? history.state.taskOverlay : null;
function cleanQuery(value) {
const query = typeof value === 'string' ? value.trim() : '';
return query.length <= maxQueryLength ? query : '';
}
function cleanScope(value) {
const repository = typeof value?.repository === 'string' &&
/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value.repository.trim())
? value.repository.trim() : '';
return {
kind:['all', 'issue', 'pull'].includes(value?.kind) ? value.kind : 'all',
state:['all', 'open', 'closed'].includes(value?.state) ? value.state : 'all',
repository,
};
}
function cleanPreview(value) {
if (!value || !['issue', 'pull'].includes(value.kind)) return null;
const repository = typeof value.repository === 'string' ? value.repository : '';
const parts = repository.split('/');
const validPart = part => /^[A-Za-z0-9_.-]+$/.test(part);
const number = Number(value.number);
if (parts.length !== 2 || !parts.every(validPart) || !Number.isInteger(number) || number < 1) return null;
return { kind:value.kind, repository, number };
}
function previewToken(preview) {
return preview ? `${preview.kind}:${preview.repository}:${preview.number}` : '';
}
function parsePreview(value) {
const match = /^(issue|pull):([^/:]+\/[^/:]+):(\d+)$/.exec(value || '');
return match ? cleanPreview({ kind:match[1], repository:match[2], number:Number(match[3]) }) : null;
}
return function createTaskOverlayHistory({ history, location, eventTarget, onChange }) {
let started = false;
let restoredFromUrl = false;
function stateKind(state = history.state) {
return allowed.has(state?.taskOverlay) ? state.taskOverlay : null;
function stateDetail(state = history.state) {
const kind = allowed.has(state?.taskOverlay) ? state.taskOverlay : null;
if (!searchKinds.has(kind)) return { kind };
const query = cleanQuery(state.searchQuery);
const scope = state.searchScope === undefined ? null : cleanScope(state.searchScope);
const preview = kind === 'search-preview' ? cleanPreview(state.searchPreview) : null;
if (kind === 'search-preview' && state.searchPreview !== undefined && !preview) {
return { kind:'search', ...(query ? { query } : {}) };
}
return { kind, ...(query ? { query } : {}), ...(scope ? { scope } : {}), ...(preview ? { preview } : {}) };
}
function urlDetail() {
if (!location || typeof URLSearchParams === 'undefined') return { kind:null };
const params = new URLSearchParams(location.search || '');
const rawQuery = params.get('search');
if (rawQuery === null) return { kind:null };
const query = cleanQuery(rawQuery);
if (rawQuery.trim() && !query) return { kind:null };
const preview = parsePreview(params.get('preview'));
const hasScope = params.has('search_kind') || params.has('search_state') || params.has('search_repository');
const scope = hasScope ? cleanScope({
kind:params.get('search_kind'), state:params.get('search_state'),
repository:params.get('search_repository'),
}) : null;
return {
kind:preview ? 'search-preview' : 'search',
...(query ? { query } : {}),
...(scope ? { scope } : {}),
...(preview ? { preview } : {}),
};
}
function toState(detail, base = history.state) {
const state = { ...(base || {}), taskOverlay:detail.kind };
delete state.searchQuery;
delete state.searchPreview;
delete state.searchScope;
if (searchKinds.has(detail.kind)) {
if (detail.query) state.searchQuery = detail.query;
if (detail.scope) state.searchScope = cleanScope(detail.scope);
if (detail.kind === 'search-preview' && detail.preview) state.searchPreview = detail.preview;
}
return state;
}
function urlFor(detail) {
if (!location || typeof URLSearchParams === 'undefined') return undefined;
const params = new URLSearchParams(location.search || '');
params.delete('search');
params.delete('preview');
params.delete('search_kind');
params.delete('search_state');
params.delete('search_repository');
if (searchKinds.has(detail.kind)) {
params.set('search', detail.query || '');
if (detail.scope) {
params.set('search_kind', detail.scope.kind);
params.set('search_state', detail.scope.state);
if (detail.scope.repository) params.set('search_repository', detail.scope.repository);
}
if (detail.kind === 'search-preview' && detail.preview) params.set('preview', previewToken(detail.preview));
}
const query = params.toString();
return `${location.pathname || ''}${query ? `?${query}` : ''}${location.hash || ''}`;
}
let initial = stateDetail();
if (!initial.kind) {
const fromUrl = urlDetail();
if (fromUrl.kind) {
initial = fromUrl;
restoredFromUrl = true;
history.replaceState(toState(fromUrl), '', urlFor(fromUrl));
}
}
let active = initial;
function apply(state) {
const next = stateKind(state);
if (next === active) return;
const previous = active;
const next = stateDetail(state);
if (JSON.stringify(next) === JSON.stringify(active)) return;
const previous = active.kind;
active = next;
onChange(next, previous);
onChange(next.kind, previous, next);
}
return {
@@ -28,33 +139,54 @@
if (started) return;
started = true;
eventTarget.addEventListener('popstate', event => apply(event.state));
if (restoredFromUrl) onChange(active.kind, null, active);
},
open(kind) {
open(kind, detail = {}) {
if (!allowed.has(kind)) return false;
if (active === kind) return true;
const previous = active;
const state = { ...(history.state || {}), taskOverlay: kind };
history.pushState(state, '');
active = kind;
onChange(kind, previous);
const next = stateDetail(toState({
kind, query:cleanQuery(detail.query),
scope:detail.scope === undefined ? undefined : cleanScope(detail.scope),
preview:cleanPreview(detail.preview),
}));
if (active.kind === next.kind && JSON.stringify(active) === JSON.stringify(next)) return true;
const previous = active.kind;
history.pushState(toState(next), '', urlFor(next));
active = next;
onChange(next.kind, previous, next);
return true;
},
update(detail = {}) {
if (!searchKinds.has(active.kind)) return false;
const next = stateDetail(toState({
kind:active.kind,
query:detail.query === undefined ? active.query : cleanQuery(detail.query),
scope:detail.scope === undefined ? active.scope : cleanScope(detail.scope),
preview:detail.preview === undefined ? active.preview : cleanPreview(detail.preview),
}));
history.replaceState(toState(next), '', urlFor(next));
active = next;
return true;
},
close() {
if (!active) return false;
if (!active.kind) return false;
history.back();
return true;
},
leave() {
if (!active) return false;
const previous = active;
if (!active.kind) return false;
const previous = active.kind;
const state = { ...(history.state || {}) };
delete state.taskOverlay;
history.replaceState(state, '');
active = null;
onChange(null, previous);
delete state.searchQuery;
delete state.searchPreview;
delete state.searchScope;
active = { kind:null };
history.replaceState(state, '', urlFor(active));
onChange(null, previous, active);
return true;
},
current() { return active; },
current() { return active.kind; },
currentState() { return { ...active, ...(active.preview ? { preview:{ ...active.preview } } : {}) }; },
};
};
});
+38
View File
@@ -0,0 +1,38 @@
(function(r){
function transaction({controls=[],status}) {
let busy=false, disabled=[];
async function run(message, action, {origin,failure}={}) {
if (busy) return false;
busy=true;
disabled=controls.map(control=>control.disabled);
controls.forEach(control=>{ control.disabled=true; });
status.textContent=message;
let completed=false;
try { completed=Boolean(await action()); }
catch (error) { failure=failure || error.message || 'Could not complete decision. Retry.'; }
if (!completed && failure) status.textContent=failure;
busy=false;
controls.forEach((control,index)=>{ control.disabled=disabled[index]; });
if (!completed) origin?.focus?.();
return completed;
}
return {run,busy:()=>busy};
}
function actions(o) {
return {
keepUnread:()=>o.transaction.run('Keeping unread…',()=>{
if(o.triage.active()) o.triage.keepUnreadAndNext(); else o.close();
return true;
},{origin:o.keepControl,failure:'Could not keep this update unread. Retry.'}),
markRead:()=>o.transaction.run('Marking read…',async()=>{
const result=await o.reader.markReadAndNext(o.items());
if(!result) return false;
o.undo.offer(result.item,result.items);
if(o.triage.active()) o.triage.acceptCompleted();
return true;
},{origin:o.readControl,failure:'Could not mark update read. Retry.'}),
};
}
if(typeof module==='object'&&module.exports){module.exports=transaction;module.exports.createActions=actions;}
else {r.createUpdateDecisionTransaction=transaction;r.createUpdateDecisionActions=actions;}
})(typeof self!=='undefined'?self:this);
+49
View File
@@ -0,0 +1,49 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createUpdateReadPosition = factory;
})(typeof self !== 'undefined' ? self : this, function createUpdateReadPosition(options) {
const panel = options.panel;
const jump = options.jump;
let identity = '';
let openingTop = 0;
let movedByUser = false;
let positioned = false;
let target = null;
function moveToTarget() {
if (!target) return false;
target.classList?.add('update-new-activity');
target.scrollIntoView({ block:'end' });
positioned = true;
jump.hidden = true;
return true;
}
panel.addEventListener('scroll', () => {
if (identity && !positioned && panel.scrollTop !== openingTop) movedByUser = true;
}, { passive:true });
return {
open(nextIdentity) {
identity = String(nextIdentity || '');
openingTop = panel.scrollTop;
movedByUser = false;
positioned = false;
target = null;
jump.hidden = true;
},
ready(readyIdentity, newest) {
if (!identity || String(readyIdentity || '') !== identity || positioned || !newest) return false;
target = newest;
target.classList?.add('update-new-activity');
if (movedByUser) {
jump.hidden = false;
return false;
}
return moveToTarget();
},
jump() {
return moveToTarget();
},
};
});
+62
View File
@@ -0,0 +1,62 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createUpdateReviewHandoff = factory;
})(typeof self !== 'undefined' ? self : this, function createUpdateReviewHandoff(options) {
let source = null;
let completing = null;
function eligible(detail, item = detail) {
const merged = { ...(item || {}), ...(detail || {}) };
return Boolean(
merged.is_review && merged.update_reason === 'Review requested' &&
merged.subject_type === 'Pull' && /^[^/]+\/[^/]+$/.test(String(merged.repository || '')) &&
Number.isInteger(Number(merged.number)) && Number(merged.number) > 0 &&
Number.isInteger(Number(merged.notification_id))
);
}
function reviewItem(item) {
return {
...item,
kind: 'review',
is_review: true,
key: item.key || item.repository + '#' + item.number,
};
}
function begin(item) {
if (!eligible(item) || source) return false;
source = item;
options.openReview(reviewItem(item));
return true;
}
function cancel() {
if (!source || completing) return false;
const item = source;
source = null;
options.restoreUpdate?.(item);
return true;
}
function complete() {
if (!source) return Promise.resolve(false);
if (completing) return completing;
const item = source;
completing = Promise.resolve(options.admitRead(item.notification_id)).then(() => {
options.advance(item);
source = null;
return true;
}).finally(() => { completing = null; });
return completing;
}
return {
eligible,
begin,
cancel,
complete,
active: () => Boolean(source),
source: () => source,
};
});
+92
View File
@@ -0,0 +1,92 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createUpdateTriageGesture = factory;
})(typeof self !== 'undefined' ? self : this, function createUpdateTriageGesture(options) {
const viewportWidth = options.viewportWidth || (() => root.innerWidth);
const surface = options.surface;
const threshold = options.threshold || 72;
const edgeInset = options.edgeInset || 24;
const interactive = 'a,button,input,textarea,select,label,summary,[contenteditable="true"],[role="button"]';
let active = null;
let busy = false;
function horizontalScroller(target) {
for (let node = target; node && node !== surface; node = node.parentElement) {
if (Number(node.scrollWidth) > Number(node.clientWidth) + 1) return true;
}
return false;
}
function ignored(event) {
return event.pointerType === 'mouse' || event.clientX <= edgeInset ||
event.clientX >= viewportWidth() - edgeInset ||
Boolean(event.target?.closest?.(interactive)) || horizontalScroller(event.target);
}
function reset() {
active = null;
delete surface.dataset.triageGesture;
}
function start(event) {
if (busy || !options.enabled() || viewportWidth() > 700 || ignored(event) || active) return;
active = {id:event.pointerId, x:event.clientX, y:event.clientY, cancelled:false};
surface.setPointerCapture?.(event.pointerId);
}
function move(event) {
if (!active || active.id !== event.pointerId) return;
const dx = event.clientX - active.x;
const dy = event.clientY - active.y;
if (Math.abs(dy) > Math.abs(dx) || Math.abs(dy) > 36) {
active.cancelled = true;
delete surface.dataset.triageGesture;
return;
}
if (Math.abs(dx) < 12) return;
event.preventDefault?.();
surface.dataset.triageGesture = dx > 0 ? 'keep' : 'read';
}
function decide(callback) {
busy = true;
let outcome;
try {
outcome = callback();
} catch (error) {
busy = false;
throw error;
}
if (outcome && typeof outcome.then === 'function') {
Promise.resolve(outcome).finally(() => { busy = false; });
} else {
busy = false;
}
}
function finish(event) {
if (!active || active.id !== event.pointerId) return;
const gesture = active;
const dx = event.clientX - gesture.x;
const dy = event.clientY - gesture.y;
surface.releasePointerCapture?.(event.pointerId);
reset();
if (gesture.cancelled || Math.abs(dx) < threshold || Math.abs(dx) <= Math.abs(dy)) return;
decide(dx > 0 ? options.keepUnread : options.markRead);
}
surface.addEventListener('pointerdown', start);
surface.addEventListener('pointermove', move);
surface.addEventListener('pointerup', finish);
surface.addEventListener('pointercancel', reset);
function destroy() {
surface.removeEventListener('pointerdown', start);
surface.removeEventListener('pointermove', move);
surface.removeEventListener('pointerup', finish);
surface.removeEventListener('pointercancel', reset);
reset();
}
return {destroy};
});
+44 -7
View File
@@ -7,18 +7,20 @@
const login = () => String(options.getLogin() || '').trim();
let state = null;
let running = false;
let lastOutcome = null;
function read() {
const owner = login();
if (!owner) return null;
try {
const value = JSON.parse(options.storage.getItem(key) || 'null');
if (value?.version !== 1 || value.login !== owner || !Array.isArray(value.identities) ||
if (![1, 2].includes(value?.version) || value.login !== owner || !Array.isArray(value.identities) ||
!value.identities.length || typeof value.current !== 'string' || !Array.isArray(value.completed)) return null;
const identities = value.identities.filter(value => typeof value === 'string' && value);
const completed = value.completed.filter(value => identities.includes(value));
const kept = Array.isArray(value.kept) ? value.kept.filter(value => identities.includes(value)) : [];
if (!identities.length || !identities.includes(value.current)) return null;
return { version:1, login:owner, identities, current:value.current, completed };
return { version:2, login:owner, identities, current:value.current, completed, kept };
} catch (_) {
return null;
}
@@ -35,11 +37,22 @@
.map(id => byIdentity.get(id));
}
function nextAvailable() {
if (!running || !state) return null;
const candidates = available().filter(item => identity(item) !== state.current);
return candidates.find(item => state.identities.indexOf(identity(item)) > state.identities.indexOf(state.current)) || candidates[0] || null;
}
function finish() {
const unread = new Set((options.getItems() || []).map(identity).filter(Boolean));
const keptIdentities = state ? state.kept.filter(id => unread.has(id)) : [];
const reviewed = state ? state.completed.length : 0;
const outcome = { reviewed, resolved:reviewed - keptIdentities.length, kept:keptIdentities.length, keptIdentities };
lastOutcome = outcome;
running = false;
state = null;
options.storage.removeItem(key);
options.onFinish();
options.onFinish(outcome);
return false;
}
@@ -51,7 +64,12 @@
state.current = identity(items[index]);
persist();
const originalIndex = state.identities.indexOf(state.current);
options.onProgress({ index:originalIndex + 1, total:state.identities.length });
const progress = {
index:originalIndex + 1,
total:state.identities.length,
};
if (items[index]?.update_reason) progress.reason = String(items[index].update_reason);
options.onProgress(progress);
options.onOpen(items[index]);
return true;
}
@@ -66,7 +84,12 @@
state.current = identity(next);
if (openNext) return openCurrent();
persist();
options.onProgress({ index:state.identities.indexOf(state.current) + 1, total:state.identities.length });
const progress = {
index:state.identities.indexOf(state.current) + 1,
total:state.identities.length,
};
if (next?.update_reason) progress.reason = String(next.update_reason);
options.onProgress(progress);
return true;
}
@@ -76,7 +99,7 @@
start() {
const identities = (options.getItems() || []).map(identity).filter(Boolean);
if (!identities.length) return finish();
state = { version:1, login:login(), identities, current:identities[0], completed:[] };
state = { version:2, login:login(), identities, current:identities[0], completed:[], kept:[] };
if (!state.login) return false;
running = true;
return openCurrent();
@@ -93,7 +116,21 @@
},
completeAndNext: advance,
acceptCompleted: () => advance(false),
keepUnreadAndNext: advance,
keepUnreadAndNext() {
if (!running || !state) return false;
if (!state.kept.includes(state.current)) state.kept.push(state.current);
return advance();
},
reviewKept(identities = lastOutcome?.keptIdentities || []) {
const unread = new Set((options.getItems() || []).map(identity).filter(Boolean));
const kept = (identities || []).filter(id => unread.has(id));
if (!kept.length) return false;
state = { version:2, login:login(), identities:kept, current:kept[0], completed:[], kept:[] };
if (!state.login) return false;
running = true;
return openCurrent();
},
next: nextAvailable,
items: () => state ? available().slice() : [],
end: finish,
};
+1
View File
@@ -0,0 +1 @@
(function(r,f){if(typeof module==='object'&&module.exports)module.exports=f;else r.createWorkDetailPosition=f})(typeof self!=='undefined'?self:this,function(o){let i='';return{open(n){n=String(n||'');if(n&&n===i)return false;i=n;o.panel.scrollTop=0;return true}}});
+3 -1
View File
@@ -28,10 +28,12 @@ FEATURE_SOURCES = {
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
"security-center": ("static/security-center.js",),
"today-timer": (
"static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-triage-launcher.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
"static/commands.js", "static/task-overlay-history.js", "static/search-preview.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
"static/assign-and-start.js", "static/queue-today.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
"static/search-batch-plan.js",
),
}
CACHE_DECLARATION = re.compile(
+91 -19
View File
@@ -463,13 +463,46 @@ def _normalize_global_search_item(item: Any, kind: str) -> dict | None:
}
async def global_search(query: str, limit: int = 10) -> dict:
"""Search accessible issues and pulls concurrently with a bounded result set."""
async def global_search(
query: str,
limit: int = 10,
page: int = 1,
kind: str = "all",
state: str = "all",
repository: str | None = None,
continuation: dict[str, int | None] | None = None,
) -> dict:
"""Search accessible work with lossless, independently retryable streams."""
item_types = ("issues", "pulls") if kind == "all" else (
"issues" if kind == "issue" else "pulls",
)
stream_limits = {
item_type: (
(limit + 1) // 2 if item_type == "issues" else limit // 2
) if kind == "all" else limit
for item_type in item_types
}
stream_pages = {
item_type: continuation.get(item_type) if continuation is not None else page
for item_type in item_types
}
active_types = tuple(
item_type for item_type in item_types
if stream_pages[item_type] is not None and stream_limits[item_type] > 0
)
async def load(item_type: str) -> Any:
params = {
"q": query, "type": item_type, "state": state,
"limit": stream_limits[item_type], "page": stream_pages[item_type],
}
if repository:
owner, repo = repository.split("/", 1)
params.update({"owner": owner, "repo": repo})
response = await _get_client().get(
"/api/v1/repos/issues/search",
headers=_auth(),
params={"q": query, "type": item_type, "limit": limit, "page": 1},
params=params,
)
response.raise_for_status()
payload = response.json()
@@ -478,30 +511,58 @@ async def global_search(query: str, limit: int = 10) -> dict:
return payload
outcomes = await asyncio.gather(
load("issues"), load("pulls"), return_exceptions=True
*(load(item_type) for item_type in active_types), return_exceptions=True
)
for outcome in outcomes:
if isinstance(outcome, asyncio.CancelledError):
raise outcome
if all(isinstance(outcome, BaseException) for outcome in outcomes):
if outcomes and all(isinstance(outcome, BaseException) for outcome in outcomes):
raise outcomes[0]
results = []
streams: list[list[dict]] = []
seen: set[tuple[str, str, int]] = set()
for outcome, kind in zip(outcomes, ("issue", "pull"), strict=True):
result_kinds = tuple(
"issue" if item_type == "issues" else "pull" for item_type in active_types
)
for outcome, result_kind in zip(outcomes, result_kinds, strict=True):
normalized_stream = []
if isinstance(outcome, BaseException):
streams.append(normalized_stream)
continue
for item in outcome:
normalized = _normalize_global_search_item(item, kind)
normalized = _normalize_global_search_item(item, result_kind)
if normalized is not None:
identity = (kind, normalized["repository"], normalized["number"])
identity = (result_kind, normalized["repository"], normalized["number"])
if identity in seen:
continue
seen.add(identity)
results.append(normalized)
normalized_stream.append(normalized)
streams.append(normalized_stream)
results = []
for index in range(max((len(stream) for stream in streams), default=0)):
for stream in streams:
if index < len(stream):
results.append(stream[index])
failed_streams = [
"issue" if item_type == "issues" else "pull"
for item_type, outcome in zip(active_types, outcomes, strict=True)
if isinstance(outcome, BaseException)
]
next_continuation = {item_type: None for item_type in item_types}
for item_type, outcome in zip(active_types, outcomes, strict=True):
if isinstance(outcome, BaseException):
next_continuation[item_type] = stream_pages[item_type]
elif len(outcome) >= stream_limits[item_type]:
next_continuation[item_type] = int(stream_pages[item_type]) + 1
has_more = any(value is not None for value in next_continuation.values())
return {
"items": results[:limit],
"partial": any(isinstance(outcome, BaseException) for outcome in outcomes),
"partial": bool(failed_streams),
"has_more": has_more,
"next_page": page + 1,
"continuation": next_continuation,
"failed_streams": failed_streams,
}
@@ -525,6 +586,19 @@ async def work_preview(repository: str, kind: str, number: int) -> dict:
login = user.get("login") if isinstance(user, dict) else ""
state = issue.get("state") if isinstance(issue.get("state"), str) else ""
actual_kind = "pull" if isinstance(issue.get("pull_request"), dict) else "issue"
reviewable = False
if actual_kind == "pull" and state == "open" and login:
try:
pull = await fetch(f"repos/{repository}/pulls/{number}")
except Exception:
pull = None
requested_reviewers = pull.get("requested_reviewers") if isinstance(pull, dict) else []
if not isinstance(requested_reviewers, list):
requested_reviewers = []
reviewable = any(
isinstance(reviewer, dict) and reviewer.get("login") == login
for reviewer in requested_reviewers
)
return {
"kind": actual_kind,
"repository": repository,
@@ -542,6 +616,8 @@ async def work_preview(repository: str, kind: str, number: int) -> dict:
"claimable": actual_kind == "issue" and state == "open" and not assignee_names,
"reopenable": actual_kind == "issue" and state == "closed",
"assigned_to_me": bool(login and login in assignee_names),
"reviewable": reviewable,
"commentable": bool(login),
}
@@ -948,19 +1024,15 @@ async def notification_detail(thread_id: int) -> dict:
)
if supported_conversation:
assert conversation_match is not None
subject_detail, comment, conversation, subscription = await asyncio.gather(
subject_detail, comment, subscription = await asyncio.gather(
fetch(subject_path),
fetch(comment_path) if comment_path else asyncio.sleep(0, result={}),
issue_conversation_page(
conversation_match.group(1), int(conversation_match.group(3))
),
_notification_subscription(conversation_match.group(1), conversation_match.group(3)),
)
else:
subject_detail, comment, conversation, subscription = await asyncio.gather(
subject_detail, comment, subscription = await asyncio.gather(
fetch(subject_path) if subject_path else asyncio.sleep(0, result={}),
fetch(comment_path) if comment_path else asyncio.sleep(0, result={}),
asyncio.sleep(0, result={"comments": [], "page": 1, "older_page": None, "total": 0}),
asyncio.sleep(0, result={}),
)
subject_detail = subject_detail if isinstance(subject_detail, dict) else {}
@@ -1028,12 +1100,12 @@ async def notification_detail(thread_id: int) -> dict:
and subscription.get("subscribed") is True
and subscription.get("ignored") is not True
),
"conversation": conversation,
"conversation_available": bool(supported_conversation),
}
async def notification_conversation_page(
thread_id: int, page: int, limit: int = 20
thread_id: int, page: int | None, limit: int = 20
) -> dict:
thread = await fetch(f"notifications/threads/{thread_id}")
if not isinstance(thread, dict):
+194 -7
View File
@@ -5,6 +5,7 @@ import hashlib
import hmac
import math
import os
import re
import secrets
import sqlite3
import time
@@ -412,6 +413,7 @@ StepUpAction = Literal[
"merge_pull",
"submit_pull_review",
"close_issue",
"delete_comment",
"log_recap_time",
"revoke_device",
"revoke_all_sessions",
@@ -1001,6 +1003,41 @@ app.include_router(frontend_router)
def _share_target_login_redirect(request: Request) -> str:
search_values = request.query_params.getlist("search")
preview_values = request.query_params.getlist("preview")
if search_values or preview_values:
allowed = {"search", "preview", "search_kind", "search_state", "search_repository"}
kind_values = request.query_params.getlist("search_kind")
state_values = request.query_params.getlist("search_state")
repository_values = request.query_params.getlist("search_repository")
if (
set(request.query_params.keys()) - allowed
or len(search_values) != 1
or len(preview_values) != 1
or len(kind_values) > 1
or len(state_values) > 1
or len(repository_values) > 1
or len(search_values[0]) > 200
or len(preview_values[0]) > 200
or not re.fullmatch(
r"(?:issue|pull):[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+:[1-9]\d*",
preview_values[0],
)
):
return "login"
continuation_values = [
("search", search_values[0].strip()), ("preview", preview_values[0]),
]
if kind_values and kind_values[0] in {"all", "issue", "pull"}:
continuation_values.append(("search_kind", kind_values[0]))
if state_values and state_values[0] in {"all", "open", "closed"}:
continuation_values.append(("search_state", state_values[0]))
if repository_values and re.fullmatch(
r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository_values[0]
):
continuation_values.append(("search_repository", repository_values[0]))
continuation = urlencode(continuation_values)
return f"login?{urlencode({'continue': f'./?{continuation}'})}"
limits = {"title": 200, "text": 8000, "url": 2048}
if any(
len(request.query_params.get(name, "")) > limit
@@ -2689,13 +2726,33 @@ async def background_identity() -> JSONResponse:
async def global_search(
q: str = Query(min_length=2, max_length=100),
limit: int = Query(default=10, ge=1, le=25),
page: int = Query(default=1, ge=1, le=100),
kind: Literal["all", "issue", "pull"] = Query(default="all"),
state: Literal["all", "open", "closed"] = Query(default="all"),
repository: str | None = Query(
default=None, pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", max_length=161
),
issues_page: int | None = Query(default=None, ge=1, le=100),
pulls_page: int | None = Query(default=None, ge=1, le=100),
) -> JSONResponse:
query = q.strip()
if len(query) < 2:
raise HTTPException(status_code=422, detail="Search query must contain at least 2 characters")
try:
arguments = (query, limit, page, kind, state)
continuation = None
if issues_page is not None or pulls_page is not None:
continuation = {"issues": issues_page, "pulls": pulls_page}
if continuation is not None:
operation = gitea_proxy.global_search(
*arguments, repository, continuation
)
elif repository:
operation = gitea_proxy.global_search(*arguments, repository)
else:
operation = gitea_proxy.global_search(*arguments)
result = await asyncio.wait_for(
gitea_proxy.global_search(query, limit),
operation,
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
)
except Exception:
@@ -2704,7 +2761,10 @@ async def global_search(
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse({"query": query, **result})
scope = {"kind": kind, "state": state}
if repository:
scope["repository"] = repository
return JSONResponse({"query": query, "scope": scope, **result})
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview")
@@ -2728,6 +2788,74 @@ async def global_search_preview(
return JSONResponse(preview)
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview/conversation")
async def global_search_preview_conversation(
owner: str,
repo: str,
number: int = PathParam(gt=0),
kind: Literal["issue", "pull"] = Query(),
page: int | None = Query(default=None, ge=1, le=100),
limit: int = Query(default=20, ge=1, le=50),
) -> JSONResponse:
del kind # Issue and pull-request conversations share Gitea's issue-comments API.
try:
conversation = await asyncio.wait_for(
gitea_proxy.issue_conversation_page(
f"{owner}/{repo}", number, page, limit
),
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
)
except Exception:
return JSONResponse(
{"error": "This conversation is temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(conversation)
@app.post(
"/api/v1/repos/{owner}/{repo}/issues/{number}/preview/comments",
status_code=201,
)
async def comment_on_global_search_preview(
comment: IssueComment,
owner: str,
repo: str,
number: int = PathParam(gt=0),
kind: Literal["issue", "pull"] = Query(),
idempotency_key: str | None = Header(default=None, max_length=128),
) -> JSONResponse:
repository = f"{owner}/{repo}"
async def post_comment():
preview = await gitea_proxy.work_preview(repository, kind, number)
if (
preview.get("repository") != repository
or preview.get("kind") != kind
or preview.get("number") != number
):
raise HTTPException(status_code=404, detail="Search result not found")
return await gitea_proxy.comment_on_issue(repository, number, comment.body)
try:
result = await _run_idempotent_authored_action(
post_comment(),
idempotency_key=idempotency_key,
fingerprint=("search-preview-comment", repository, kind, number, comment.body),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "The reply could not be posted. Your draft is safe; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result, status_code=201)
@app.get("/api/v1/work-route")
async def resolve_work_route(
kind: Literal["issue", "pull", "review", "update"] = Query(),
@@ -3634,7 +3762,7 @@ async def notification_thread_detail(
@app.get("/api/v1/notifications/{thread_id}/conversation")
async def notification_thread_conversation(
thread_id: int = PathParam(gt=0),
page: int = Query(ge=1),
page: int | None = Query(default=None, ge=1),
limit: int = Query(default=20, ge=1, le=50),
) -> JSONResponse:
try:
@@ -4452,25 +4580,66 @@ async def _edit_conversation_comment(
async def _delete_conversation_comment(
repository: str, number: int, comment_id: int
request: Request,
step_up_grant: str | None,
repository: str,
number: int,
comment_id: int,
) -> JSONResponse:
target = f"{repository}#{number}:{comment_id}"
await _require_step_up(
request,
step_up_grant,
action="delete_comment",
target=target,
)
try:
journal = _security_event_store()
operation_id = await asyncio.to_thread(
journal.reserve,
"comment_deleted",
target=target,
)
except SecurityEventStoreError:
return JSONResponse(
{"error": "Security activity is temporarily unavailable. The comment was not deleted."},
status_code=503,
headers={"Retry-After": "1"},
)
try:
result = await asyncio.wait_for(
gitea_proxy.delete_owned_comment(repository, number, comment_id),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.CommentMutationForbiddenError as exc:
try:
await asyncio.to_thread(journal.discard, operation_id)
except SecurityEventStoreError:
pass
raise HTTPException(
status_code=403, detail="You can only change your own comments"
) from exc
except HTTPException:
try:
await asyncio.to_thread(journal.discard, operation_id)
except SecurityEventStoreError:
pass
raise
except Exception:
try:
await asyncio.to_thread(journal.discard, operation_id)
except SecurityEventStoreError:
pass
return JSONResponse(
{"error": "The comment deletion could not be confirmed. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
try:
await asyncio.to_thread(journal.finalize, operation_id)
except SecurityEventStoreError:
# Upstream deletion is authoritative; pending evidence remains truthful.
pass
return JSONResponse(result)
@@ -4490,15 +4659,21 @@ async def edit_assigned_issue_comment(
@app.delete("/api/v1/repos/{owner}/{repo}/issues/{number}/comments/{comment_id}")
async def delete_assigned_issue_comment(
request: Request,
owner: str,
repo: str,
number: int = PathParam(gt=0),
comment_id: int = PathParam(gt=0),
step_up_grant: str | None = Header(
default=None, alias="X-Step-Up-Grant", max_length=128
),
):
repository = f"{owner}/{repo}"
if not await gitea_proxy.is_assigned_issue(repository, number):
raise HTTPException(status_code=404, detail="Assigned issue not found")
return await _delete_conversation_comment(repository, number, comment_id)
return await _delete_conversation_comment(
request, step_up_grant, repository, number, comment_id
)
@app.patch("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments/{comment_id}")
@@ -4517,15 +4692,21 @@ async def edit_assigned_pull_comment(
@app.delete("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments/{comment_id}")
async def delete_assigned_pull_comment(
request: Request,
owner: str,
repo: str,
number: int = PathParam(gt=0),
comment_id: int = PathParam(gt=0),
step_up_grant: str | None = Header(
default=None, alias="X-Step-Up-Grant", max_length=128
),
):
repository = f"{owner}/{repo}"
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
return await _delete_conversation_comment(repository, number, comment_id)
return await _delete_conversation_comment(
request, step_up_grant, repository, number, comment_id
)
@app.patch("/api/v1/notifications/{thread_id}/comments/{comment_id}")
@@ -4543,14 +4724,20 @@ async def edit_notification_comment(
@app.delete("/api/v1/notifications/{thread_id}/comments/{comment_id}")
async def delete_notification_comment(
request: Request,
thread_id: int = PathParam(gt=0),
comment_id: int = PathParam(gt=0),
step_up_grant: str | None = Header(
default=None, alias="X-Step-Up-Grant", max_length=128
),
):
try:
repository, number = await gitea_proxy.notification_conversation_target(thread_id)
except Exception as exc:
raise HTTPException(status_code=404, detail="Notification conversation not found") from exc
return await _delete_conversation_comment(repository, number, comment_id)
return await _delete_conversation_comment(
request, step_up_grant, repository, number, comment_id
)
@app.post("/api/v1/repos/{owner}/{repo}/issues/{number}/attachments", status_code=201)
+3
View File
@@ -8,6 +8,8 @@ from tests.dashboard_bundle import dashboard_bundle_text
def test_api_requests_resolve_inside_dashboard_subpath():
html = dashboard_bundle_text()
api_paths = re.findall(r"fetch\(['\"]([^'\"]*api/v1/[^'\"]*)['\"]", html)
commands = (Path(__file__).parents[1] / "frontend" / "commands.js").read_text()
api_paths += re.findall(r"return ['\"]([^'\"]*api/v1/[^'\"]*)['\"]", commands)
assert api_paths
assert {
@@ -18,6 +20,7 @@ def test_api_requests_resolve_inside_dashboard_subpath():
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications?page=",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/read",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/repositories/search?q=",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/search?q=",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/work/",
}
+35
View File
@@ -185,6 +185,41 @@ flow.resume().then(result=>process.stdout.write(JSON.stringify({{result,calls}})
}
def test_search_batch_uses_an_isolated_owner_journal_and_can_resume_without_dom_mounting():
script = f"""
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
const values={{}};
const calls=[];
const storage={{
getItem:key=>values[key] || null,
setItem:(key,value)=>{{values[key]=value;}},
removeItem:key=>{{delete values[key];}},
}};
const flow=createBatchFindWork({{
capacity:()=>1, owner:()=> 'timmy', storage,
journalName:'search-today-batch', autoMount:false,
claim:item=>Promise.resolve({{...item,assigned_to_me:true}}),
queue:()=>{{calls.push('queue');return 'sync-unavailable';}},
}});
flow.run([{{repository:'stackchain/dashboard',number:809}}]).then(result=>
process.stdout.write(JSON.stringify({{result,keys:Object.keys(values),calls,pending:flow.pending()}})));
"""
assert run_node(script) == {
"result": {
"status": "complete", "selected": 1, "available": 1, "queued": [],
"failed": [{
"key": "stackchain/dashboard#809",
"reason": "assigned but Today sync is unavailable",
"assigned": True,
}],
},
"keys": ["stackchain.search-today-batch.v1.timmy"],
"calls": ["queue"],
"pending": 1,
}
def test_time_budget_blocks_claims_until_every_estimate_fits():
script = f"""
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
+782 -5
View File
@@ -11,6 +11,7 @@ FRONTEND = Path(__file__).parents[1] / "frontend"
COMMANDS = FRONTEND / "commands.js"
SEARCH_PREVIEW = FRONTEND / "search-preview.js"
MOBILE_SEARCH_VIEWPORT = FRONTEND / "mobile-search-viewport.js"
SEARCH_DEFER = FRONTEND / "search-defer.js"
class ScriptSourceParser(HTMLParser):
@@ -52,6 +53,80 @@ def test_command_script_resolves_inside_dashboard_subpath():
) == "https://forge.alexanderwhitestone.com/dashboard/static/commands.js"
def test_mobile_search_renders_touch_sized_type_and_status_scope_controls():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
commands = COMMANDS.read_text()
assert '<fieldset class="cmd-search-scope"' in html
assert '<legend>Filter search results</legend>' in html
assert '<label for="cmd-search-kind">Type</label>' in html
assert '<select id="cmd-search-kind"' in html
assert '<option value="pull">Pull requests</option>' in html
assert '<label for="cmd-search-state">Status</label>' in html
assert '<select id="cmd-search-state"' in html
assert '.cmd-search-scope' in css
assert '#cmd-search-kind, #cmd-search-state { min-height:44px;' in css
assert "commandSearch.setScope(scope)" in html
assert "taskOverlayHistory.update({ scope })" in html
assert "'&kind=' + encodeURIComponent(scope.kind)" in commands
assert "'&state=' + encodeURIComponent(scope.state)" in commands
def test_mobile_search_exposes_accessible_repository_scope_and_forwards_it():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
commands = COMMANDS.read_text()
assert '<label for="cmd-search-repository">Repository</label>' in html
assert '<input id="cmd-search-repository"' in html
assert 'list="cmd-search-repositories"' in html
assert '<datalist id="cmd-search-repositories">' in html
assert "api/v1/repositories/search?q=" in html
assert "repository:qs('#cmd-search-repository').value" in html
assert "'&repository=' + encodeURIComponent(scope.repository)" in commands
assert '#cmd-search-repository' in css
def test_mobile_search_preview_exposes_a_keyboard_safe_reply_and_continue_composer():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
preview = SEARCH_PREVIEW.read_text()
assert '<section class="search-preview-reply"' in html
assert '<textarea id="search-preview-reply"' in html
assert '<button id="send-search-preview-reply"' in html
assert '<button id="send-search-preview-reply-next"' in html
assert "searchPreviewReplyOptions(fetchReviewJson" in html
assert "root.searchPreviewReplyPath(item)" in preview
assert "api.saveReplyDraft" in preview
assert "api.reply({ advance:true })" in preview
assert ".search-preview-reply textarea" in css
assert "scroll-margin-bottom:calc(190px + env(safe-area-inset-bottom))" in css
def test_remote_command_search_repository_change_aborts_and_restarts_scoped_search():
script = f"""
const filterCommands = require({json.dumps(str(COMMANDS))});
(async () => {{
const pending = [];
const controller = filterCommands.createGlobalSearchController({{
delay: 0,
search: (query, signal, page, scope) => new Promise(resolve => pending.push({{signal, scope, resolve}})),
onState() {{}},
}});
controller.setQuery('mobile');
await new Promise(resolve => setTimeout(resolve, 0));
controller.setScope({{kind:'all', state:'all', repository:'stackchain/api'}});
await new Promise(resolve => setTimeout(resolve, 0));
if (!pending[0].signal.aborted) throw new Error('repository change did not abort old request');
if (pending[1].scope.repository !== 'stackchain/api') throw new Error('repository was not forwarded');
pending[1].resolve({{items:[],has_more:false,next_page:2}});
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_remote_command_search_ignores_stale_responses():
script = f"""
const filterCommands = require({json.dumps(str(COMMANDS))});
@@ -85,6 +160,37 @@ if (ready[0].query !== 'release' || ready[0].items[0].title !== 'Current result'
)
def test_remote_command_search_scope_change_aborts_and_restarts_without_stale_results():
script = f"""
const filterCommands = require({json.dumps(str(COMMANDS))});
(async () => {{
const pending = [];
const states = [];
const controller = filterCommands.createGlobalSearchController({{
delay: 0,
search: (query, signal, page, scope) => new Promise(resolve => pending.push({{query, signal, page, scope, resolve}})),
onState: state => states.push(state),
}});
controller.setQuery('mobile');
await new Promise(resolve => setTimeout(resolve, 0));
controller.setScope({{kind:'pull', state:'open'}});
await new Promise(resolve => setTimeout(resolve, 0));
if (!pending[0].signal.aborted) throw new Error('scope change did not abort old request');
if (JSON.stringify(pending[1].scope) !== JSON.stringify({{kind:'pull', state:'open'}})) {{
throw new Error('new request did not receive scope');
}}
pending[1].resolve({{items:[{{kind:'pull',repository:'a/b',number:2}}],has_more:false,next_page:2}});
pending[0].resolve({{items:[{{kind:'issue',repository:'a/b',number:1}}],has_more:false,next_page:2}});
await new Promise(resolve => setTimeout(resolve, 0));
const ready = states.filter(state => state.status === 'ready').at(-1);
if (ready.items.length !== 1 || ready.items[0].kind !== 'pull') throw new Error('stale scope result entered list');
if (ready.scope.kind !== 'pull' || ready.scope.state !== 'open') throw new Error('scope missing from state');
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_remote_command_search_aborts_superseded_and_cleared_queries():
script = f"""
const filterCommands = require({json.dumps(str(COMMANDS))});
@@ -142,9 +248,83 @@ if (!ready || ready.items.length !== 1 || ready.partial !== true) {{
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_remote_command_search_retries_partial_continuation_without_losing_results():
script = f"""
const filterCommands = require({json.dumps(str(COMMANDS))});
(async () => {{
const requests = [];
const states = [];
const controller = filterCommands.createGlobalSearchController({{
delay: 0,
search: (query, signal, page, scope, continuation) => {{
requests.push({{page, continuation}});
if (requests.length === 1) return Promise.resolve({{
items:[{{kind:'pull',repository:'a/b',number:2}}], partial:true,
has_more:true, next_page:2, continuation:{{issues:1,pulls:null}}, failed_streams:['issue']
}});
return Promise.resolve({{
items:[{{kind:'issue',repository:'a/b',number:1}}], partial:false,
has_more:false, next_page:2, continuation:{{issues:null,pulls:null}}, failed_streams:[]
}});
}},
onState: state => states.push(state),
}});
controller.setQuery('mobile');
await new Promise(resolve => setTimeout(resolve, 0));
await new Promise(resolve => setTimeout(resolve, 0));
await controller.loadMore();
const ready = states.filter(state => state.status === 'ready').at(-1);
if (JSON.stringify(requests[1].continuation) !== JSON.stringify({{issues:1,pulls:null}})) {{
throw new Error('retry did not preserve failed stream page: ' + JSON.stringify(requests));
}}
if (ready.partial || ready.items.length !== 2 || ready.items[0].kind !== 'pull' || ready.items[1].kind !== 'issue') {{
throw new Error('recovered results were not appended: ' + JSON.stringify(ready));
}}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_remote_command_search_appends_deduplicated_pages_and_ignores_stale_load_more():
script = f"""
const filterCommands = require({json.dumps(str(COMMANDS))});
(async () => {{
const pending = new Map();
const states = [];
const controller = filterCommands.createGlobalSearchController({{
delay: 0,
search: (query, signal, page) => new Promise(resolve => pending.set(query + ':' + page, resolve)),
onState: state => states.push(state),
}});
controller.setQuery('mobile');
await new Promise(resolve => setTimeout(resolve, 0));
pending.get('mobile:1')({{ items:[{{kind:'issue',repository:'a/b',number:1}}], has_more:true, next_page:2 }});
await new Promise(resolve => setTimeout(resolve, 0));
controller.loadMore();
await new Promise(resolve => setTimeout(resolve, 0));
controller.setQuery('release');
await new Promise(resolve => setTimeout(resolve, 0));
pending.get('mobile:2')({{ items:[{{kind:'issue',repository:'a/b',number:1}},{{kind:'pull',repository:'a/b',number:2}}], has_more:false, next_page:3 }});
pending.get('release:1')({{ items:[{{kind:'issue',repository:'a/b',number:3}}], has_more:false, next_page:2 }});
await new Promise(resolve => setTimeout(resolve, 0));
const ready = states.filter(state => state.status === 'ready').at(-1);
if (ready.query !== 'release' || ready.items.length !== 1 || ready.items[0].number !== 3) {{
throw new Error('stale page entered current query: ' + JSON.stringify(states));
}}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_command_selection_wraps_for_arrow_keys():
script = f"""
const commands = require({json.dumps(str(COMMANDS))});
if (commands.searchUrl('mobile', 2, {{kind:'all',state:'open'}}, {{issues:1,pulls:3}}) !==
'api/v1/search?q=mobile&limit=10&page=2&kind=all&state=open&issues_page=1&pulls_page=3') {{
throw new Error('independent continuation query was not encoded');
}}
if (commands.nextSelection(-1, 'ArrowDown', 3) !== 0) throw new Error('should select first');
if (commands.nextSelection(2, 'ArrowDown', 3) !== 0) throw new Error('should wrap down');
if (commands.nextSelection(0, 'ArrowUp', 3) !== 2) throw new Error('should wrap up');
@@ -160,16 +340,287 @@ def test_palette_exposes_accessible_global_work_search_under_dashboard_subpath()
assert 'role="combobox"' in html
assert 'aria-controls="cmd-results"' in html
assert 'role="listbox"' in html
assert "fetch('api/v1/search?q='" in html
assert "async function searchGlobalWork(query, signal)" in html
assert "fetch(filterCommands.searchUrl(" in html
assert "async function searchGlobalWork(query, signal, page = 1, scope" in html
assert "signal," in html
assert "Some results are temporarily unavailable." in html
assert "Retry missing results" in html
assert 'id="cmd-load-more"' in html
assert urljoin(
"https://forge.alexanderwhitestone.com/dashboard/",
"api/v1/search?q=mobile",
) == "https://forge.alexanderwhitestone.com/dashboard/api/v1/search?q=mobile"
def test_search_preview_navigates_a_loaded_result_session_and_reports_position():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const items = [1, 2, 3].map(number => ({{repository:'stackchain/api', number, kind:'issue'}}));
const states = [];
const navigated = [];
const preview = createSearchPreview({{
fetchJson: item => Promise.resolve({{...item, title:'Issue ' + item.number}}),
mutate: () => Promise.resolve(),
getSession: () => ({{items, more:false}}),
loadMore: () => Promise.resolve(),
onNavigate: item => navigated.push(item.number),
onState: state => states.push(state),
}});
await preview.open(items[1]);
await preview.previous();
await preview.next();
const ready = states.filter(state => state.status === 'ready');
process.stdout.write(JSON.stringify({{
navigated,
positions:ready.map(state => state.navigation && [state.navigation.position, state.navigation.total,
state.navigation.hasPrevious, state.navigation.hasNext]),
current:ready.at(-1).detail.number,
}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"navigated": [1, 2],
"positions": [[2, 3, True, True], [1, 3, False, True], [2, 3, True, True]],
"current": 2,
}
def test_search_preview_reply_retry_keeps_draft_and_operation_until_server_confirmation():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const item = {{repository:'stackchain/api', number:42, kind:'pull'}};
const values = new Map();
const storage = {{
getItem:key => values.has(key) ? values.get(key) : null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
const calls = [];
const states = [];
let attempt = 0;
const preview = createSearchPreview({{
fetchJson: candidate => Promise.resolve({{...candidate, title:'Review reply'}}),
mutate: () => Promise.resolve(),
postReply: (candidate, body, operationId) => {{
calls.push([candidate.number, body, operationId]);
attempt += 1;
return attempt === 1 ? Promise.reject(new Error('offline')) : Promise.resolve({{
id:91, author:'timmy', body, created_at:'2026-08-14T12:30:00Z'
}});
}},
storage,
createOperationId: () => 'reply-operation-42',
onState: state => states.push(state),
}});
await preview.open(item);
preview.saveReplyDraft('Ready to merge.');
try {{ await preview.reply(); }} catch (_) {{}}
const draftAfterFailure = preview.replyDraft();
await preview.reply();
process.stdout.write(JSON.stringify({{
calls,
draftAfterFailure,
draftAfterSuccess:preview.replyDraft(),
comments:states.filter(state => state.conversation).at(-1).conversation.comments,
}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"calls": [
[42, "Ready to merge.", "reply-operation-42"],
[42, "Ready to merge.", "reply-operation-42"],
],
"draftAfterFailure": "Ready to merge.",
"draftAfterSuccess": "",
"comments": [{
"id": 91,
"author": "timmy",
"body": "Ready to merge.",
"created_at": "2026-08-14T12:30:00Z",
}],
}
def test_search_preview_reply_and_next_advances_only_after_server_confirmation():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const items = [1, 2].map(number => ({{repository:'stackchain/api', number, kind:'issue'}}));
const values = new Map();
let confirm;
const navigated = [];
const preview = createSearchPreview({{
fetchJson:item => Promise.resolve(item), mutate:() => Promise.resolve(),
postReply:() => new Promise(resolve => {{ confirm = resolve; }}),
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
createOperationId:() => 'reply-next-1',
getSession:() => ({{items,more:false}}), loadMore:() => Promise.resolve(),
onNavigate:item => navigated.push(item.number), onState:() => {{}},
}});
await preview.open(items[0]);
preview.saveReplyDraft('Done.');
const pending = preview.reply({{advance:true}});
await Promise.resolve();
const before = [...navigated];
confirm({{id:7,author:'timmy',body:'Done.'}});
await pending;
process.stdout.write(JSON.stringify({{before,after:navigated,draft:preview.replyDraft()}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"before": [],
"after": [2],
"draft": "",
}
def test_search_preview_keeps_confirmed_reply_when_earlier_conversation_load_finishes_late():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const values = new Map();
let finishConversation;
const states = [];
const preview = createSearchPreview({{
fetchJson:item => Promise.resolve({{...item,title:'Race'}}),
fetchConversation:() => new Promise(resolve => {{ finishConversation = resolve; }}),
mutate:() => Promise.resolve(),
postReply:(_item,body) => Promise.resolve({{id:9,author:'timmy',body}}),
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
createOperationId:() => 'reply-race-9', onState:state => states.push(state),
}});
await preview.open({{repository:'stackchain/api',number:9,kind:'issue'}});
preview.saveReplyDraft('Confirmed reply');
await preview.reply();
finishConversation({{comments:[{{id:3,author:'alex',body:'Earlier'}}],older_page:null}});
await Promise.resolve();
const comments = states.filter(state => state.conversation?.status === 'ready').at(-1).conversation.comments;
process.stdout.write(JSON.stringify(comments.map(comment => comment.id)));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == [3, 9]
def test_search_preview_next_loads_one_page_at_boundary_then_announces_completion():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
let session = {{items:[1, 2].map(number => ({{repository:'stackchain/api', number, kind:'issue'}})), more:true}};
let loads = 0;
const states = [];
const preview = createSearchPreview({{
fetchJson: item => Promise.resolve(item), mutate: () => Promise.resolve(),
getSession: () => session,
loadMore: async () => {{
loads += 1;
session = {{items:[...session.items, {{repository:'stackchain/api', number:3, kind:'issue'}}], more:false}};
}},
onNavigate() {{}}, onState: state => states.push(state),
}});
await preview.open(session.items[1]);
await preview.next();
await preview.next();
const final = states.at(-1);
process.stdout.write(JSON.stringify({{loads, current:final.detail.number, complete:final.complete,
navigation:final.navigation}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"loads": 1,
"current": 3,
"complete": True,
"navigation": {"position": 3, "total": 3, "hasPrevious": True, "hasNext": False},
}
def test_remote_search_load_more_is_awaitable_for_preview_boundary_navigation():
script = f"""
const commands = require({json.dumps(str(COMMANDS))});
(async () => {{
const states = [];
const controller = commands.createGlobalSearchController({{
delay:0,
search: async (_query, _signal, page) => page === 1
? {{items:[{{kind:'issue',repository:'a/b',number:1}}], has_more:true, next_page:2}}
: {{items:[{{kind:'issue',repository:'a/b',number:2}}], has_more:false, next_page:3}},
onState: state => states.push(state),
}});
controller.setQuery('mobile');
await new Promise(resolve => setTimeout(resolve, 5));
const loaded = await controller.loadMore();
process.stdout.write(JSON.stringify({{numbers:loaded.items.map(item => item.number), more:loaded.more}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {"numbers": [1, 2], "more": False}
def test_mobile_search_preview_progressively_renders_a_retryable_paged_conversation():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
preview_source = SEARCH_PREVIEW.read_text()
assert 'id="search-preview-conversation"' in html
assert 'id="search-preview-comments"' in html
assert 'id="search-preview-conversation-status"' in html
assert 'id="retry-search-preview-conversation"' in html
assert 'id="load-older-search-preview-comments"' in html
assert 'aria-live="polite"' in html
assert "fetchConversation:" in html
assert "api.retryConversation()" in preview_source
assert "api.loadOlderConversation()" in preview_source
assert "renderMarkdown(comment.body || '')" in preview_source
assert ".search-preview-conversation" in css
assert ".search-preview-comment" in css
assert "min-height:44px" in css
def test_mobile_search_preview_renders_session_navigation_and_advances_after_planning():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
assert 'id="previous-search-result"' in html
assert 'id="search-preview-position"' in html
assert 'id="next-search-result"' in html
assert 'aria-label="Search result navigation"' in html
assert ".search-preview-navigation" in css
assert ".search-preview-header button, .search-preview-actions button" in css
assert "session:[()=>commandSearchState, commandSearch, item=>taskOverlayHistory.update({preview:item})]" in html
preview_source = SEARCH_PREVIEW.read_text()
assert "api.previous()" in preview_source
assert "api.next()" in preview_source
defer_flow = html.split("if (context === 'search')", 1)[1].split(
"if (context === 'detail')", 1
)[0]
queue_flow = html.split(
"qs('#queue-search-result').addEventListener('click'", 1
)[1].split("qs('#start-search-result')", 1)[0]
assert "next()" in defer_flow
assert "next()" in queue_flow
assert "closeSearchPreview()" not in defer_flow
assert "if (outcome === 'queued') await next()" in queue_flow
def test_search_preview_ignores_stale_result_details():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
@@ -197,6 +648,85 @@ if (ready.length !== 1 || ready[0].detail.number !== 2) {{
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_search_preview_publishes_detail_before_progressive_conversation_and_ignores_stale_comments():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const pending = new Map();
const states = [];
const preview = createSearchPreview({{
fetchJson: item => Promise.resolve({{...item, title:'Issue ' + item.number}}),
fetchConversation: item => new Promise(resolve => pending.set(item.number, resolve)),
mutate: () => Promise.resolve(),
onState: state => states.push(state),
}});
await preview.open({{repository:'stackchain/api', number:1, kind:'issue'}});
const firstReady = states.find(state => state.status === 'ready' && state.detail?.number === 1);
if (!firstReady || firstReady.conversation?.status !== 'loading') throw new Error('detail waited for conversation');
await preview.open({{repository:'stackchain/api', number:2, kind:'issue'}});
pending.get(1)({{comments:[{{id:1,body:'stale'}}], page:1, older_page:null}});
pending.get(2)({{comments:[{{id:2,body:'current'}}], page:1, older_page:null}});
await new Promise(resolve => setTimeout(resolve, 0));
const final = states.at(-1);
process.stdout.write(JSON.stringify({{
number:final.detail.number,
status:final.conversation.status,
comments:final.conversation.comments.map(comment => comment.body),
}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"number": 2,
"status": "ready",
"comments": ["current"],
}
def test_search_preview_retries_failed_conversation_and_prepends_older_messages_without_duplicates():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
let calls = 0;
const states = [];
const preview = createSearchPreview({{
fetchJson: item => Promise.resolve({{...item, title:'Issue'}}),
fetchConversation: (_item, page) => {{
calls += 1;
if (calls === 1) return Promise.reject(new Error('offline'));
if (page === 1) return Promise.resolve({{comments:[{{id:1,body:'old'}},{{id:3,body:'duplicate'}}], older_page:null}});
return Promise.resolve({{comments:[{{id:3,body:'newer'}},{{id:4,body:'newest'}}], older_page:1}});
}},
mutate: () => Promise.resolve(),
onState: state => states.push(state),
}});
await preview.open({{repository:'stackchain/api', number:42, kind:'issue'}});
await new Promise(resolve => setTimeout(resolve, 0));
if (states.at(-1).conversation.status !== 'error') throw new Error('failure unavailable');
await preview.retryConversation();
await preview.loadOlderConversation();
const final = states.at(-1).conversation;
process.stdout.write(JSON.stringify({{
calls,
status:final.status,
ids:final.comments.map(comment => comment.id),
olderPage:final.olderPage,
}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"calls": 3,
"status": "ready",
"ids": [1, 3, 4],
"olderPage": None,
}
def test_search_preview_claim_is_single_flight_and_reports_confirmation():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
@@ -249,6 +779,71 @@ if (!states.some(state => state.status === 'reopened')) throw new Error('reopen
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_search_preview_shares_canonical_url_without_closing_the_preview():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const calls = [];
const states = [];
const preview = createSearchPreview({{
fetchJson: item => Promise.resolve(item),
mutate: () => Promise.resolve(),
share: async url => {{ calls.push(url); return 'shared'; }},
onState: state => states.push(state),
}});
const detail = {{ repository:'stackchain/api', number:42, kind:'issue' }};
await preview.open(detail);
const result = await preview.share('https://forge.example/dashboard/?search=release&preview=issue%3Astackchain%2Fapi%3A42');
process.stdout.write(JSON.stringify({{ result, calls, states }}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
assert payload["result"] == "shared"
assert payload["calls"] == [
"https://forge.example/dashboard/?search=release&preview=issue%3Astackchain%2Fapi%3A42"
]
assert payload["states"][-1]["status"] == "shared"
assert payload["states"][-1]["detail"]["number"] == 42
def test_search_preview_share_is_single_flight_and_cancellation_restores_ready_context():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
let calls = 0;
let rejectShare;
const states = [];
const preview = createSearchPreview({{
fetchJson: item => Promise.resolve(item), mutate: () => Promise.resolve(),
share: () => {{ calls += 1; return new Promise((_resolve, reject) => rejectShare = reject); }},
onState: state => states.push(state),
}});
await preview.open({{ repository:'stackchain/api', number:42, kind:'issue' }});
const first = preview.share('https://forge.example/dashboard/?search=x');
const second = preview.share('https://forge.example/dashboard/?search=x');
const canceled = new Error('canceled'); canceled.name = 'AbortError'; rejectShare(canceled);
await Promise.allSettled([first, second]);
const state = states.at(-1);
process.stdout.write(JSON.stringify({{ calls, same:first === second, status:state.status,
item:state.item, detail:state.detail, errorName:state.error?.name }}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"calls": 1,
"same": True,
"status": "share-canceled",
"item": {"repository": "stackchain/api", "number": 42, "kind": "issue"},
"detail": {"repository": "stackchain/api", "number": 42, "kind": "issue"},
"errorName": "AbortError",
}
def test_remote_search_selection_opens_native_preview_without_navigation():
html = dashboard_bundle_text()
@@ -261,12 +856,60 @@ def test_remote_search_selection_opens_native_preview_without_navigation():
assert "qs('#cmd-input').value = ''" not in remote_branch
def test_search_preview_share_action_uses_canonical_stackchain_url_and_keeps_context():
html = dashboard_bundle_text()
assert "share: url => createWorkRoute.share(url, navigator, navigator.clipboard)" in html
handler = html.split("qs('#share-search-result').addEventListener('click'", 1)[1].split(
"qs('#claim-search-result')", 1
)[0]
assert "canonicalSearchPreviewUrl()" in handler
assert "searchPreview.share" in handler
assert "closeSearchPreview" not in handler
assert "taskOverlayHistory.close" not in handler
def test_assigned_issue_preview_hands_off_to_existing_my_work_sheet():
html = dashboard_bundle_text()
assert "detail.claimable || (detail.assigned_to_me && detail.kind === 'issue')" in html
assert "claimButton.hidden = !(detail.claimable || detail.assigned_to_me)" in html
assert "claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me'" in html
assert "openPreviewIssueInMyWork" in html
assert "openPreviewWorkInMyWork" in html
def test_assigned_pull_preview_hands_off_to_existing_my_work_sheet_without_claiming():
html = dashboard_bundle_text()
assert "claimButton.hidden = !(detail.claimable || detail.assigned_to_me)" in html
assert "async function openPreviewWorkInMyWork(detail)" in html
handoff = html.split("async function openPreviewWorkInMyWork(detail)", 1)[1].split(
"function runCommandItem", 1
)[0]
assert "candidate.kind === detail.kind" in handoff
assert "candidate.key === detail.repository + '#' + detail.number" in handoff
click_handler = html.split(
"qs('#claim-search-result').addEventListener('click'", 1
)[1].split("qs('#start-search-result')", 1)[0]
assert "if (claimed.claimable) await searchPreview.claim(claimed)" in click_handler
assert "await openPreviewWorkInMyWork(claimed)" in click_handler
def test_requested_pull_preview_opens_native_review_without_assignment_mutation():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
assert "startButton.hidden = !(detail.reviewable" in html
assert "detail.reviewable ? 'Review now'" in html
handler = html.split(
"qs('#start-search-result').addEventListener('click'", 1
)[1].split("qs('#close-whiteboard')", 1)[0]
assert "detail.kind = 'review'" in handler
assert "openRoutedWork" in handler
assert "{ replace:true }" in handler
assert "searchPreview.claim" not in handler
assert "fetchReviewJson" not in handler
assert ".search-preview-actions button, .search-preview-actions a { min-height:44px;" in css
def test_search_preview_offers_assign_and_start_for_eligible_issues():
@@ -281,10 +924,144 @@ def test_search_preview_offers_assign_and_start_for_eligible_issues():
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css
def test_search_preview_queues_eligible_issue_and_continues_preserved_search():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
assert 'id="queue-search-result"' in html
assert "queueButton.hidden = !(detail.kind === 'issue'" in html
assert "detail.assigned_to_me ? 'Add to Today' : 'Assign & add to Today'" in html
orchestrator = html.split("function createSearchStart(claim)", 1)[1].split(
"const searchAssignAndStart", 1
)[0]
assert "queue: confirmed => queueToday(acceptClaimedIssue(confirmed))" in orchestrator
handler = html.split("qs('#queue-search-result').addEventListener('click'", 1)[1].split(
"qs('#start-search-result')", 1
)[0]
assert "destination: 'queue'" in handler
assert "alreadyOwned: detail.assigned_to_me" in handler
assert "next()" in handler
assert "closeSearchPreview()" not in handler
assert "mobileSearchViewport.restoreScroll()" in html
assert "@media (max-width:420px)" in css
assert ".search-preview-primary-actions { grid-template-columns:1fr; }" in css
def test_search_queue_confirmation_is_accessible_and_existing_today_item_is_not_readded():
html = dashboard_bundle_text()
assert 'id="cmd-search-action-status"' in html
assert 'aria-live="polite"' in html
orchestrator = html.split("function createSearchStart(claim)", 1)[1].split(
"const searchAssignAndStart", 1
)[0]
assert "qs('#cmd-search-action-status').textContent = message" in orchestrator
handler = html.split("qs('#queue-search-result').addEventListener('click'", 1)[1].split(
"qs('#start-search-result')", 1
)[0]
assert "todayWork.contains(detail)" in handler
assert "Already in Today." in handler
assert handler.index("todayWork.contains(detail)") < handler.index("searchAssignAndStart.run")
def test_search_defer_claims_once_then_schedules_the_canonical_issue_without_touching_today():
script = f"""
const createSearchDefer = require({json.dumps(str(SEARCH_DEFER))});
(async () => {{
const calls = [];
let releaseClaim;
const flow = createSearchDefer({{
claim: detail => {{
calls.push(['claim', detail.number]);
return new Promise(resolve => releaseClaim = resolve);
}},
accept: confirmed => {{
calls.push(['accept', confirmed.key]);
return {{kind:'issue', repository:confirmed.repository, number:confirmed.number, key:confirmed.key}};
}},
defer: (item, until) => {{ calls.push(['defer', item.key, until.toISOString()]); return 'deferred'; }},
refresh: () => calls.push(['refresh']),
announce: message => calls.push(['announce', message]),
formatTime: value => value.toISOString(),
}});
const detail = {{kind:'issue', repository:'stackchain/api', number:42, claimable:true}};
const until = new Date('2026-08-15T09:00:00.000Z');
const first = flow.run(detail, until);
const second = flow.run(detail, until);
if (first !== second) throw new Error('confirmation was not single-flight');
releaseClaim({{kind:'issue', repository:'stackchain/api', number:42, key:'stackchain/api#42'}});
const outcome = await first;
process.stdout.write(JSON.stringify({{outcome, calls}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"outcome": "deferred",
"calls": [
["claim", 42],
["accept", "stackchain/api#42"],
["defer", "stackchain/api#42", "2026-08-15T09:00:00.000Z"],
["refresh"],
["announce", "Assigned and deferred until 2026-08-15T09:00:00.000Z."],
],
}
def test_search_preview_opens_later_picker_before_claiming_an_eligible_issue():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
assert 'id="defer-search-result"' in html
assert "const deferButton = qs('#defer-search-result')" in html
assert "deferButton.hidden = !(detail.kind === 'issue'" in html
assert "detail.assigned_to_me ? 'Defer' : 'Assign & defer'" in html
handler = html.split("qs('#defer-search-result').addEventListener('click'", 1)[1].split(
"qs('#queue-search-result')", 1
)[0]
assert "laterPicker.open(detail, event.currentTarget, 'search')" in handler
assert "searchPreview.claim" not in handler
assert ".search-preview-actions button, .search-preview-actions a { min-height:44px;" in css
assert "@media (max-width:420px)" in css
assert ".search-preview-primary-actions { grid-template-columns:1fr; }" in css
def test_search_defer_reports_recoverable_partial_outcome_when_later_storage_fails_after_claim():
script = f"""
const createSearchDefer = require({json.dumps(str(SEARCH_DEFER))});
(async () => {{
const messages = [];
let refreshes = 0;
const flow = createSearchDefer({{
claim: () => Promise.resolve({{kind:'issue', repository:'stackchain/api', number:42}}),
accept: issue => issue,
defer: () => 'unavailable',
refresh: () => refreshes += 1,
announce: message => messages.push(message),
formatTime: value => value.toISOString(),
}});
const outcome = await flow.run({{kind:'issue', repository:'stackchain/api', number:42, claimable:true}},
new Date('2026-08-15T09:00:00.000Z'));
process.stdout.write(JSON.stringify({{outcome, refreshes, messages}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"outcome": "unavailable",
"refreshes": 1,
"messages": [
"Assignment succeeded, but Later could not be saved. Open this issue in My Work to retry."
],
}
def test_closed_issue_preview_reopens_then_resumes_through_capacity_guard():
html = dashboard_bundle_text()
assert "startButton.textContent = detail.reopenable ? 'Reopen & resume'" in html
assert "detail.reopenable ? 'Reopen & resume'" in html
assert "mutate: (detail, action) => fetchReviewJson(" in html
handler = html.split("qs('#start-search-result').addEventListener('click'", 1)[1].split(
"qs('#close-whiteboard')", 1
+1 -1
View File
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v100" in worker
assert "stackchain-dashboard-shell-v101" in worker
+52
View File
@@ -891,6 +891,58 @@ async def test_anonymous_app_shortcut_preserves_only_a_known_launch_action(acces
assert invalid.headers["location"] == "login"
@pytest.mark.anyio
async def test_anonymous_search_preview_preserves_only_bounded_canonical_continuation(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
valid = await client.get(
"/", params={"search": "release blocker", "preview": "issue:stackchain/api:42"}
)
malformed = await client.get(
"/", params={"search": "release blocker", "preview": "issue:stackchain/api:0"}
)
duplicate = await client.get(
"/?search=release&search=other&preview=issue%3Astackchain%2Fapi%3A42"
)
oversized = await client.get(
"/", params={"search": "release", "preview": f"issue:{'x' * 201}/api:42"}
)
assert parse_qs(urlsplit(valid.headers["location"]).query) == {
"continue": [
"./?search=release+blocker&preview=issue%3Astackchain%2Fapi%3A42"
]
}
assert malformed.headers["location"] == "login"
assert duplicate.headers["location"] == "login"
assert oversized.headers["location"] == "login"
@pytest.mark.anyio
async def test_anonymous_search_preview_preserves_only_valid_search_scope(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
valid = await client.get("/", params={
"search": "release blocker", "preview": "pull:stackchain/api:42",
"search_kind": "pull", "search_state": "open",
"search_repository": "stackchain/api",
})
invalid = await client.get("/", params={
"search": "release blocker", "preview": "pull:stackchain/api:42",
"search_kind": "script", "search_state": "secret",
})
valid_continue = parse_qs(urlsplit(valid.headers["location"]).query)["continue"][0]
invalid_continue = parse_qs(urlsplit(invalid.headers["location"]).query)["continue"][0]
assert valid_continue.endswith(
"search=release+blocker&preview=pull%3Astackchain%2Fapi%3A42&"
"search_kind=pull&search_state=open&search_repository=stackchain%2Fapi"
)
assert invalid_continue == (
"./?search=release+blocker&preview=pull%3Astackchain%2Fapi%3A42"
)
@pytest.mark.anyio
async def test_anonymous_shared_screenshot_preserves_bounded_sign_in_continuation(access_control):
transport = httpx.ASGITransport(app=main.app)
+6
View File
@@ -777,6 +777,12 @@ def test_security_activity_explains_pending_outcome_confirmation():
assert "event.status === 'pending'" in source
def test_security_activity_labels_permanent_comment_deletion():
source = SECURITY_CENTER_JS.read_text()
assert "comment_deleted: 'Comment deleted'" in source
def test_security_activity_labels_passkey_enrollment_without_html_rendering():
source = SECURITY_CENTER_JS.read_text()
+8 -2
View File
@@ -66,10 +66,16 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
security_center = first.feature_bundles["security-center"]
assert b"function attachSecurityCenter" not in first.runtime_bytes
assert b"function attachSecurityCenter" in security_center.runtime_bytes
assert b"function createGlobalSearchController" not in first.runtime_bytes
assert b"function createGlobalSearchController" in first.feature_bundles["today-timer"].runtime_bytes
assert b"function createAssignAndStart" not in first.runtime_bytes
assert b"function createAssignAndStart" in first.feature_bundles["today-timer"].runtime_bytes
assert b"function createQueueToday" not in first.runtime_bytes
assert b"function createQueueToday" in first.feature_bundles["today-timer"].runtime_bytes
assert b"gitea_time_logged" not in first.runtime_bytes
assert b"gitea_time_logged" in security_center.runtime_bytes
# The recap adds only startup wiring; its UI remains in the lazy Today bundle.
assert len(first.runtime_gzip_bytes) <= 96 * 1024
# Core mobile workflows stay below 98 KiB gzip, including transaction-safe Update decisions.
assert len(first.runtime_gzip_bytes) <= 98 * 1024
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
+1 -13
View File
@@ -284,7 +284,6 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
"http://127.0.0.1:3000/api/v1/notifications/threads/42",
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7",
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/comments/9",
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/comments?limit=20&page=1",
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/subscriptions/check",
]
assert result == {
@@ -304,18 +303,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
"issue": {"number": 7, "assignees": [], "claimable": True},
"acknowledge_supported": True,
"mute_supported": True,
"conversation": {
"comments": [{
"id": 9,
"author": "alexander",
"body": "Logs point to the worker timeout.",
"created_at": "2026-08-06T12:30:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
}],
"page": 1,
"older_page": None,
"total": 1,
},
"conversation_available": True,
}
+413 -6
View File
@@ -6,12 +6,146 @@ import pytest
from src import gitea_proxy, main
@pytest.mark.anyio
async def test_global_search_endpoint_scopes_every_page_to_an_exact_repository(monkeypatch):
requested = []
async def search(query, limit, page, kind, state, repository):
requested.append((query, limit, page, kind, state, repository))
return {"items": [], "partial": False, "has_more": False, "next_page": 4}
monkeypatch.setattr(main.gitea_proxy, "global_search", search, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/search?q=mobile&limit=7&page=3&kind=pull&state=open&repository=stackchain%2Fapi"
)
assert response.status_code == 200
assert requested == [("mobile", 7, 3, "pull", "open", "stackchain/api")]
assert response.json()["scope"] == {
"kind": "pull", "state": "open", "repository": "stackchain/api"
}
@pytest.mark.anyio
async def test_global_search_endpoint_forwards_independent_stream_continuation(monkeypatch):
requested = []
async def search(query, limit, page, kind, state, repository, continuation):
requested.append((query, limit, page, kind, state, repository, continuation))
return {
"items": [], "partial": False, "has_more": False, "next_page": 3,
"continuation": {"issues": None, "pulls": None}, "failed_streams": [],
}
monkeypatch.setattr(main.gitea_proxy, "global_search", search, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/search?q=mobile&limit=7&page=2&issues_page=1&pulls_page=3"
)
assert response.status_code == 200
assert requested == [
("mobile", 7, 2, "all", "all", None, {"issues": 1, "pulls": 3})
]
@pytest.mark.anyio
async def test_global_search_rejects_malformed_repository_scope_before_upstream(monkeypatch):
called = False
async def search(*args):
nonlocal called
called = True
monkeypatch.setattr(main.gitea_proxy, "global_search", search, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/search?q=mobile&repository=stackchain%2Fapi%2Fsecrets"
)
assert response.status_code == 422
assert called is False
@pytest.mark.anyio
async def test_global_search_repository_scope_reaches_issue_and_pull_streams():
requests = []
async def handler(request):
requests.append(dict(request.url.params))
return httpx.Response(200, json=[])
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
await gitea_proxy.global_search(
"mobile", limit=6, page=2, repository="stackchain/api"
)
finally:
await gitea_proxy.stop_client()
assert len(requests) == 2
assert all(request["owner"] == "stackchain" and request["repo"] == "api" for request in requests)
assert all(request["page"] == "2" for request in requests)
@pytest.mark.anyio
async def test_global_search_endpoint_forwards_valid_type_and_status_scope(monkeypatch):
requested = []
async def search(query, limit, page, kind, state):
requested.append((query, limit, page, kind, state))
return {"items": [], "partial": False, "has_more": False, "next_page": 2}
monkeypatch.setattr(main.gitea_proxy, "global_search", search, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/search?q=mobile&limit=7&page=1&kind=pull&state=open"
)
assert response.status_code == 200
assert requested == [("mobile", 7, 1, "pull", "open")]
assert response.json()["scope"] == {"kind": "pull", "state": "open"}
@pytest.mark.anyio
async def test_global_search_specific_type_uses_one_full_width_scoped_stream():
requests = []
async def handler(request):
requests.append(dict(request.url.params))
return httpx.Response(200, json=[{
"number": 9,
"title": "Review mobile search",
"state": "open",
"repository": {"full_name": "stackchain/web"},
"html_url": "https://forge.example/stackchain/web/pulls/9",
}])
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.global_search(
"mobile", limit=7, page=2, kind="pull", state="open"
)
finally:
await gitea_proxy.stop_client()
assert requests == [{
"q": "mobile", "type": "pulls", "state": "open", "limit": "7", "page": "2"
}]
assert [item["kind"] for item in result["items"]] == ["pull"]
@pytest.mark.anyio
async def test_global_search_endpoint_returns_bounded_normalized_results(monkeypatch):
requested = []
async def search(query, limit):
requested.append((query, limit))
async def search(query, limit, page, kind, state):
requested.append((query, limit, page, kind, state))
return {
"items": [{
"kind": "issue",
@@ -27,12 +161,12 @@ async def test_global_search_endpoint_returns_bounded_normalized_results(monkeyp
monkeypatch.setattr(main.gitea_proxy, "global_search", search, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/search?q=mobile&limit=7")
response = await client.get("/api/v1/search?q=mobile&limit=7&page=2")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert requested == [("mobile", 7)]
assert response.json() == {"query": "mobile", "items": [{
assert requested == [("mobile", 7, 2, "all", "all")]
assert response.json() == {"query": "mobile", "scope": {"kind": "all", "state": "all"}, "items": [{
"kind": "issue",
"repository": "stackchain/api",
"number": 42,
@@ -76,6 +210,121 @@ async def test_global_search_preview_returns_normalized_action_context(monkeypat
assert response.json()["claimable"] is True
@pytest.mark.anyio
@pytest.mark.parametrize("kind", ["issue", "pull"])
async def test_global_search_preview_conversation_returns_a_bounded_authorized_page(
monkeypatch, kind
):
requested = []
async def conversation(repository, number, page, limit):
requested.append((repository, number, page, limit))
return {
"comments": [{
"id": 7,
"author": "alexander",
"body": "Current **decision**",
"created_at": "2026-08-14T12:00:00Z",
"url": "https://forge.example/stackchain/api/issues/42#issuecomment-7",
}],
"page": 3,
"older_page": 2,
"total": 41,
}
monkeypatch.setattr(
main.gitea_proxy, "issue_conversation_page", conversation, raising=False
)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
f"/api/v1/repos/stackchain/api/issues/42/preview/conversation?kind={kind}&limit=20"
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()["comments"][0]["body"] == "Current **decision**"
assert requested == [("stackchain/api", 42, None, 20)]
@pytest.mark.anyio
async def test_global_search_preview_reply_is_idempotent_for_an_authorized_pull(monkeypatch):
main._idempotency_ledger.clear()
previews = []
comments = []
async def preview(repository, kind, number):
previews.append((repository, kind, number))
return {
"repository": repository,
"kind": "pull",
"number": number,
"title": "Review mobile reply",
"state": "open",
}
async def comment(repository, number, body):
comments.append((repository, number, body))
return {
"id": 91,
"author": "timmy",
"body": body,
"created_at": "2026-08-14T12:30:00Z",
"url": "https://forge.example/stackchain/api/pulls/42#issuecomment-91",
}
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "search-reply-pull-42"}
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
first = await client.post(
"/api/v1/repos/stackchain/api/issues/42/preview/comments?kind=pull",
json={"body": "Ready to merge."},
headers=headers,
)
replay = await client.post(
"/api/v1/repos/stackchain/api/issues/42/preview/comments?kind=pull",
json={"body": "Ready to merge."},
headers=headers,
)
assert first.status_code == replay.status_code == 201
assert replay.json() == first.json()
assert previews == [("stackchain/api", "pull", 42)]
assert comments == [("stackchain/api", 42, "Ready to merge.")]
@pytest.mark.anyio
async def test_global_search_preview_reply_rejects_a_mismatched_target_before_commenting(monkeypatch):
main._idempotency_ledger.clear()
comments = []
async def preview(repository, kind, number):
return {
"repository": repository,
"kind": "issue",
"number": number,
"title": "Different target",
}
async def comment(*args):
comments.append(args)
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/issues/42/preview/comments?kind=pull",
json={"body": "Do not misroute this."},
headers={"Idempotency-Key": "search-reply-mismatch-42"},
)
assert response.status_code == 404
assert comments == []
@pytest.mark.anyio
async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results():
requests = []
@@ -110,7 +359,11 @@ async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results()
await gitea_proxy.stop_client()
assert {request["type"] for request in requests} == {"issues", "pulls"}
assert all(request["q"] == "mobile queue" and request["limit"] == "7" for request in requests)
assert {request["type"]: request["limit"] for request in requests} == {
"issues": "4", "pulls": "3"
}
assert all(request["q"] == "mobile queue" and request["state"] == "all"
for request in requests)
assert results == {
"items": [{
"kind": "issue", "repository": "stackchain/api", "number": 42,
@@ -122,6 +375,10 @@ async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results()
"url": "https://forge.example/stackchain/web/pulls/9",
}],
"partial": False,
"has_more": False,
"next_page": 2,
"continuation": {"issues": None, "pulls": None},
"failed_streams": [],
}
@@ -154,9 +411,93 @@ async def test_global_search_returns_healthy_stream_when_other_stream_fails():
"url": "https://forge.example/stackchain/web/pulls/9",
}],
"partial": True,
"has_more": True,
"next_page": 2,
"continuation": {"issues": 1, "pulls": None},
"failed_streams": ["issue"],
}
@pytest.mark.anyio
async def test_global_search_retries_a_failed_stream_without_advancing_its_page():
requests = []
issue_attempts = 0
async def handler(request):
nonlocal issue_attempts
params = dict(request.url.params)
requests.append((params["type"], params["page"], params["limit"]))
if params["type"] == "issues":
issue_attempts += 1
if issue_attempts == 1:
return httpx.Response(503)
number = int(params["page"])
return httpx.Response(200, json=[{
"number": number,
"title": f'{params["type"]} {number}',
"state": "open",
"repository": {"full_name": "stackchain/web"},
"html_url": f'https://forge.example/stackchain/web/{params["type"]}/{number}',
}])
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
partial = await gitea_proxy.global_search("search", limit=4)
recovered = await gitea_proxy.global_search(
"search", limit=4, continuation=partial["continuation"]
)
finally:
await gitea_proxy.stop_client()
assert partial["partial"] is True
assert partial["has_more"] is True
assert partial["continuation"] == {"issues": 1, "pulls": None}
assert recovered["partial"] is False
assert [item["kind"] for item in recovered["items"]] == ["issue"]
assert requests == [
("issues", "1", "2"), ("pulls", "1", "2"), ("issues", "1", "2")
]
@pytest.mark.anyio
async def test_global_search_odd_limit_keeps_both_streams_contiguous_across_pages():
requests = []
async def handler(request):
params = dict(request.url.params)
stream_limit = int(params["limit"])
page = int(params["page"])
requests.append((params["type"], page, stream_limit))
start = (page - 1) * stream_limit + 1
return httpx.Response(200, json=[{
"number": number,
"title": f'{params["type"]} {number}',
"state": "open",
"repository": {"full_name": "stackchain/web"},
"html_url": f'https://forge.example/stackchain/web/{params["type"]}/{number}',
} for number in range(start, start + stream_limit)])
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
first = await gitea_proxy.global_search("search", limit=7)
second = await gitea_proxy.global_search(
"search", limit=7, continuation=first["continuation"]
)
finally:
await gitea_proxy.stop_client()
pulls = [item["number"] for result in (first, second)
for item in result["items"] if item["kind"] == "pull"]
issues = [item["number"] for result in (first, second)
for item in result["items"] if item["kind"] == "issue"]
assert pulls == [1, 2, 3, 4, 5, 6]
assert issues == [1, 2, 3, 4, 5, 6, 7, 8]
assert requests == [
("issues", 1, 4), ("pulls", 1, 3),
("issues", 2, 4), ("pulls", 2, 3),
]
@pytest.mark.anyio
async def test_global_search_caps_combined_results_to_requested_limit():
async def handler(request):
@@ -180,6 +521,38 @@ async def test_global_search_caps_combined_results_to_requested_limit():
assert result["partial"] is False
@pytest.mark.anyio
async def test_global_search_pages_each_stream_and_balances_results():
requests = []
async def handler(request):
params = dict(request.url.params)
requests.append(params)
kind = params["type"]
page = int(params["page"])
items = [{
"number": page * 10 + number,
"title": f"{kind} page {page} result {number}",
"state": "open",
"repository": {"full_name": "stackchain/web"},
"html_url": f"https://forge.example/stackchain/web/{kind}/{page * 10 + number}",
} for number in (1, 2, 3, 4)]
return httpx.Response(200, json=items)
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.global_search("search", limit=4, page=2)
finally:
await gitea_proxy.stop_client()
assert {(request["type"], request["page"]) for request in requests} == {
("issues", "2"), ("pulls", "2")
}
assert [item["kind"] for item in result["items"]] == ["issue", "pull", "issue", "pull"]
assert result["next_page"] == 3
assert result["has_more"] is True
@pytest.mark.anyio
async def test_global_search_propagates_cancellation_to_stop_obsolete_work():
async def handler(request):
@@ -231,6 +604,8 @@ async def test_work_preview_normalizes_details_and_only_allows_unassigned_open_i
"claimable": True,
"reopenable": False,
"assigned_to_me": False,
"reviewable": False,
"commentable": True,
}
@@ -258,6 +633,38 @@ async def test_work_preview_derives_pull_kind_and_never_offers_issue_claim():
assert preview["claimable"] is False
@pytest.mark.anyio
async def test_work_preview_marks_authoritative_requested_pull_review_actionable():
requested_paths = []
async def handler(request):
requested_paths.append(request.url.path)
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
if request.url.path.endswith("/pulls/9"):
return httpx.Response(200, json={
"state": "open",
"requested_reviewers": [{"login": "timmy"}, None],
})
return httpx.Response(200, json={
"number": 9,
"title": "Improve search",
"state": "open",
"html_url": "https://forge.example/stackchain/web/pulls/9",
"pull_request": {"merged": False},
"assignees": [],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
preview = await gitea_proxy.work_preview("stackchain/web", "issue", 9)
finally:
await gitea_proxy.stop_client()
assert preview["reviewable"] is True
assert "/api/v1/repos/stackchain/web/pulls/9" in requested_paths
@pytest.mark.anyio
async def test_work_preview_offers_reopen_only_for_closed_issues():
async def handler(request):
+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-v100" in source
assert "stackchain-dashboard-shell-v101" in source
assert "BASE + 'static/later-sync.js'" in source
+160 -1
View File
@@ -169,6 +169,56 @@ const controller = createLoginController({{
}
def test_stalled_token_login_aborts_once_and_restores_both_sign_in_controls():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
const state = {{ requests: 0, timeout: null, aborted: false }};
const status = {{ textContent: '' }};
const button = {{ disabled: false }};
const passkeyButton = {{ disabled: false }};
const controller = createLoginController({{
form: {{ reset: () => {{}} }}, status, button, passkeyButton,
credentials: {{ get: async () => {{ throw new Error('unused'); }} }},
fetchImpl: (_url, options) => {{
state.requests += 1;
options.signal.addEventListener('abort', () => state.aborted = true);
return new Promise((_resolve, reject) => {{
options.signal.addEventListener('abort', () => reject(options.signal.reason));
}});
}},
location: {{ replace: () => {{}} }},
requestTimeoutMs: 25,
setTimeoutImpl: callback => {{ state.timeout = callback; return 7; }},
clearTimeoutImpl: () => {{}},
}});
(async () => {{
const first = controller.submit('operator-token');
const duplicate = controller.submit('operator-token');
state.during = {{ button: button.disabled, passkey: passkeyButton.disabled }};
state.timeout();
await Promise.all([first, duplicate]);
state.after = {{
button: button.disabled, passkey: passkeyButton.disabled,
status: status.textContent,
}};
process.stdout.write(JSON.stringify(state));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
state = json.loads(result.stdout)
assert state["requests"] == 1
assert state["during"] == {"button": True, "passkey": True}
assert state["aborted"] is True
assert state["after"] == {
"button": False,
"passkey": False,
"status": "Sign-in timed out. Check your connection and try again.",
}
def test_successful_login_resumes_valid_shared_capture_continuation():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
@@ -193,6 +243,53 @@ const controller = createLoginController({{
)
def test_successful_login_resumes_only_valid_search_preview_continuation():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
async function destination(continuation) {{
let replaced = null;
const controller = createLoginController({{
form: {{ reset: () => {{}} }}, status: {{ textContent: '' }}, button: {{ disabled: false }},
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
location: {{ replace: value => replaced = value }}, continuation,
}});
await controller.submit('operator-token');
return replaced;
}}
(async () => process.stdout.write(JSON.stringify({{
valid:await destination('./?search=release+blocker&preview=pull%3Astackchain%2Fapi%3A9&search_kind=pull&search_state=open&search_repository=stackchain%2Fapi'),
malformed:await destination('./?search=release&preview=issue%3Astackchain%2Fapi%3A0'),
unknown:await destination('./?search=release&next=https%3A%2F%2Fevil.example'),
}})))().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"valid": "./?search=release+blocker&preview=pull%3Astackchain%2Fapi%3A9&search_kind=pull&search_state=open&search_repository=stackchain%2Fapi",
"malformed": "./",
"unknown": "./",
}
def test_search_preview_login_explains_the_resumable_handoff():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
const status = {{ textContent: '' }};
createLoginController({{
form: {{ reset: () => {{}} }}, status, button: {{ disabled: false }},
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
location: {{ replace: () => {{}} }},
continuation: './?search=release&preview=issue%3Astackchain%2Fapi%3A42',
}});
process.stdout.write(status.textContent);
"""
result = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert result.stdout == "Sign in to open the shared Search result."
def test_passkey_login_uses_web_authentication_without_sending_the_operator_token():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
@@ -254,6 +351,68 @@ const controller = createLoginController({{
assert "access_token" not in json.dumps(output)
def test_stalled_passkey_network_phases_time_out_without_timing_out_biometrics():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
async function scenario(stalledPhase) {{
const state = {{ requests: [], timers: [], credentialCalls: 0 }};
const status = {{textContent:''}};
const button = {{disabled:false}};
const passkeyButton = {{disabled:false}};
const credential = {{
id:'credential-id', type:'public-key', rawId:Uint8Array.from([1]).buffer,
response:{{authenticatorData:Uint8Array.from([2]).buffer,
clientDataJSON:Uint8Array.from([3]).buffer,
signature:Uint8Array.from([4]).buffer,userHandle:null}},
}};
const controller=createLoginController({{
form:{{reset:()=>{{}}}},status,button,passkeyButton,
credentials:{{get:async()=>{{state.credentialCalls+=1;return credential;}}}},
fetchImpl:(url,options)=>{{
state.requests.push(url);
const phase=url.endsWith('/options') ? 'options' : 'verify';
if (phase === stalledPhase) return new Promise((_resolve,reject)=>{{
options.signal.addEventListener('abort',()=>reject(options.signal.reason));
}});
return Promise.resolve(new Response(JSON.stringify({{
challenge:'AQID',allowCredentials:[]
}}),{{status:200,headers:{{'Content-Type':'application/json'}}}}));
}},
location:{{replace:()=>{{}}}},requestTimeoutMs:25,
setTimeoutImpl:callback=>{{state.timers.push(callback);return state.timers.length;}},
clearTimeoutImpl:()=>{{}},
}});
const pending=controller.signInWithPasskey('Phone');
await new Promise(resolve=>setImmediate(resolve));
if (stalledPhase === 'verify') await new Promise(resolve=>setImmediate(resolve));
state.timers.at(-1)();
state.result=await pending;
state.final={{status:status.textContent,button:button.disabled,passkey:passkeyButton.disabled}};
return state;
}}
(async()=>{{
process.stdout.write(JSON.stringify({{
options:await scenario('options'),verify:await scenario('verify'),
}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
state = json.loads(result.stdout)
assert state["options"]["credentialCalls"] == 0
assert state["verify"]["credentialCalls"] == 1
assert state["verify"]["timers"] == [None, None]
for phase in ("options", "verify"):
assert state[phase]["result"] is False
assert state[phase]["final"] == {
"status": "Passkey sign-in timed out. Check your connection and try again.",
"button": False,
"passkey": False,
}
def test_login_sends_a_bounded_device_label_with_the_access_token():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
@@ -343,7 +502,7 @@ const elements = {{
'submit-sign-in': {{ disabled: false }},
}};
const context = {{
URLSearchParams, Response, setInterval, clearInterval,
URLSearchParams, Response, AbortController, setInterval, clearInterval, setTimeout, clearTimeout,
FormData: function () {{ return {{ get: () => 'operator-token' }}; }},
fetch: async () => new Response('{{}}', {{ status: 200 }}),
document: {{ getElementById: id => elements[id] }},
+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-v100" in worker
assert "stackchain-dashboard-shell-v101" in worker
+1 -1
View File
@@ -45,7 +45,7 @@ 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-v100" in worker
assert "stackchain-dashboard-shell-v101" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
+1 -1
View File
@@ -214,7 +214,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert "promptStorage:localStorage" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker
assert "stackchain-dashboard-shell-v100" in worker
assert "stackchain-dashboard-shell-v101" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css
+17
View File
@@ -636,6 +636,23 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
assert "mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention)" in html
@pytest.mark.anyio
async def test_mobile_update_reader_keeps_primary_decisions_within_thumb_reach():
html = await dashboard()
assert 'class="update-decision-bar" aria-label="Update decisions"' in html
assert 'id="keep-update-unread" type="button" aria-label="Keep unread and open next update"' in html
assert 'id="mark-update-read-next" type="button" aria-label="Mark read and open next update"' in html
assert 'id="focus-update-reply" type="button">Reply</button>' in html
assert 'class="update-more-actions"' in html
assert "qs('#focus-update-reply').addEventListener('click'" in html
assert "qs('#update-reply').focus({ preventScroll:true })" in html
assert '.update-decision-bar { position:fixed;' in html
assert 'padding-bottom:calc(8px + env(safe-area-inset-bottom))' in html
assert '.update-decision-bar button, .update-decision-bar summary { min-height:44px;' in html
assert 'padding-bottom:calc(82px + env(safe-area-inset-bottom))' in html
@pytest.mark.anyio
async def test_dashboard_renders_phone_safe_today_session_hud_above_task_dock():
html = await dashboard()
+180 -6
View File
@@ -44,6 +44,56 @@ AGENDA_SESSION_LAUNCHER = Path(__file__).parents[1] / "frontend" / "agenda-sessi
UPDATE_TRIAGE_LAUNCHER = Path(__file__).parents[1] / "frontend" / "update-triage-launcher.js"
def test_updates_rank_actionable_work_before_newer_routine_activity():
payload = {
"user": {"login": "timmy"},
"issues": [
{"number": 1, "title": "Critical incident", "repository": "stackchain/api",
"labels": ["P0"], "assignees": [], "updated_at": "2026-08-01T10:00:00Z"},
{"number": 2, "title": "Overdue assignment", "repository": "stackchain/api",
"labels": [], "assignees": ["timmy"], "due_date": "2026-08-06T23:59:59Z",
"updated_at": "2026-08-05T10:00:00Z"},
{"number": 3, "title": "Assigned follow-up", "repository": "stackchain/api",
"labels": [], "assignees": ["timmy"], "updated_at": "2026-08-06T10:00:00Z"},
],
"pull_requests": [
{"number": 4, "title": "Requested review", "repository": "stackchain/web",
"labels": [], "assignees": [], "work_reasons": ["review_requested"],
"updated_at": "2026-08-02T10:00:00Z"},
],
"notifications": [
{"id": 11, "number": 1, "repository": "stackchain/api", "unread": True,
"subject_type": "Issue", "url": "https://forge.example/api/issues/1"},
{"id": 12, "number": 2, "repository": "stackchain/api", "unread": True,
"subject_type": "Issue", "url": "https://forge.example/api/issues/2"},
{"id": 13, "number": 3, "repository": "stackchain/api", "unread": True,
"subject_type": "Issue", "url": "https://forge.example/api/issues/3"},
{"id": 14, "number": 4, "repository": "stackchain/web", "unread": True,
"subject_type": "PullRequest", "url": "https://forge.example/web/pulls/4"},
{"id": 15, "number": 5, "repository": "stackchain/web", "unread": True,
"subject_type": "Issue", "title": "Newest routine update",
"updated_at": "2026-08-07T11:00:00Z", "url": "https://forge.example/web/issues/5"},
],
}
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const updates = buildMyWork({json.dumps(payload)}, new Date('2026-08-07T12:00:00Z'))
.filter(item => item.has_update)
.map(item => ({{title:item.title, update_reason:item.update_reason}}));
process.stdout.write(JSON.stringify(updates));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == [
{"title": "Critical incident", "update_reason": "Critical"},
{"title": "Requested review", "update_reason": "Review requested"},
{"title": "Overdue assignment", "update_reason": "Overdue"},
{"title": "Assigned follow-up", "update_reason": "Assigned to you"},
{"title": "Newest routine update", "update_reason": ""},
]
def test_update_triage_launch_waits_for_complete_single_flight_discovery():
script = f"""
const createLauncher = require({json.dumps(str(UPDATE_TRIAGE_LAUNCHER))});
@@ -4530,10 +4580,12 @@ process.stdout.write(JSON.stringify({{
output = json.loads(result.stdout)
assert len(output["queue"]) == 2
assert [item["key"] for item in output["updates"]] == ["stackchain/web#8", "stackchain/api#7"]
assert output["updates"][0]["kind"] == "update"
assert output["updates"][1]["kind"] == "issue"
assert output["updates"][1]["url"].endswith("#issuecomment-9")
assert [item["key"] for item in output["updates"]] == ["stackchain/api#7", "stackchain/web#8"]
assert output["updates"][0]["kind"] == "issue"
assert output["updates"][0]["update_reason"] == "Assigned to you"
assert output["updates"][0]["url"].endswith("#issuecomment-9")
assert output["updates"][1]["kind"] == "update"
assert output["updates"][1]["update_reason"] == ""
assert output["counts"] == {
"all": 2, "attention": 2, "issue": 1, "pull": 0, "review": 0, "update": 2
}
@@ -4871,6 +4923,76 @@ reader.open(original[0], original).then(() =>
assert output["result"]["next"]["notification_id"] == 43
def test_notification_reader_consumes_one_bounded_prefetch_without_duplicate_load():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const items = [1,2,3].map(notification_id => ({{kind:'update', notification_id, has_update:true}}));
const loaded = [], details = [], statuses = [];
const releases = {{}};
const reader = buildMyWork.createNotificationReader({{
load: id => {{
loaded.push(id);
if (id === 1) return Promise.resolve({{id}});
return new Promise(resolve => {{ releases[id] = resolve; }});
}},
markRead: async () => {{}},
onOpen: () => {{}}, onDetail: detail => details.push(detail.id), onItems: () => {{}},
onStatus: status => statuses.push(status), onClose: () => {{}},
}});
(async () => {{
await reader.open(items[0]);
const first = reader.prefetch(items[1]);
const duplicate = reader.prefetch(items[1]);
const rejected = reader.prefetch(items[2]);
const advancing = reader.open(items[1]);
await Promise.resolve();
releases[2]({{id:2}});
await advancing;
process.stdout.write(JSON.stringify({{
loaded, details, same:first === duplicate, rejected, statuses,
}}));
}})();
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output == {
"loaded": [1, 2],
"details": [1, 2],
"same": True,
"rejected": False,
"statuses": ["Loading update…", "Update ready.", "Update ready."],
}
def test_notification_reader_never_consumes_prefetch_from_another_account_scope():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
let scope = 'timmy';
const loaded = [];
const reader = buildMyWork.createNotificationReader({{
load: async id => {{ loaded.push([scope, id]); return {{owner:scope, id}}; }},
getScope: () => scope,
markRead: async () => {{}}, onOpen: () => {{}}, onItems: () => {{}}, onStatus: () => {{}},
onDetail: detail => {{ if (detail.owner !== scope) throw new Error('cross-account detail'); }},
onClose: () => {{}},
}});
(async () => {{
await reader.open({{notification_id:1}});
await reader.prefetch({{notification_id:2}});
scope = 'alexander';
const opened = await reader.open({{notification_id:2}});
process.stdout.write(JSON.stringify({{loaded, opened}}));
}})();
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output == {"loaded": [["timmy", 1], ["timmy", 2], ["alexander", 2]], "opened": True}
def test_notification_reader_acknowledges_once_and_opens_next_update():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
@@ -5099,6 +5221,56 @@ reader.open(firstItem).then(async () => {{
assert output["states"] == [[41], [90]]
def test_notification_reader_renders_core_before_conversation_and_retries_failure_in_place():
script = f"""
const build = require({json.dumps(str(MY_WORK))});
const createPager = require({json.dumps(str(CONVERSATION))});
const events = [];
let attempts = 0;
let release;
const reader = build.createNotificationReader({{
load: async id => ({{id, title:'Core ready', conversation_available:true}}),
createPager,
loadConversation: () => {{
attempts += 1;
if (attempts === 1) return Promise.reject(new Error('comments offline'));
return new Promise(resolve => {{ release = () => resolve({{
comments:[{{id:47, body:'Newest'}}], page:3, older_page:2, total:47,
}}); }});
}},
markRead: async () => {{}}, onOpen: () => events.push('open'),
onDetail: detail => events.push(['detail', detail.title]),
onConversation: state => events.push(['conversation', state.comments.map(item => item.id)]),
onConversationStatus: status => events.push(['conversation-status', status]),
onItems: () => {{}}, onStatus: status => events.push(['status', status]), onClose: () => {{}},
}});
(async () => {{
const opened = await reader.open({{notification_id:42}});
const retrying = reader.retryConversation();
await Promise.resolve();
const beforeRelease = events.slice();
release();
const retried = await retrying;
process.stdout.write(JSON.stringify({{opened, retried, attempts, beforeRelease, events}}));
}})();
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["opened"] is True
assert output["retried"] is True
assert output["attempts"] == 2
assert ["detail", "Core ready"] in output["beforeRelease"]
assert ["status", "Update ready."] in output["beforeRelease"]
assert ["conversation-status", "Conversation temporarily unavailable. Retry."] in output["beforeRelease"]
assert ["conversation", [47]] not in output["beforeRelease"]
assert output["events"][-2:] == [
["conversation", [47]],
["conversation-status", "1 of 47 messages loaded."],
]
def test_notification_reader_hydrates_saved_conversation_without_server_state_actions():
script = f"""
const build = require({json.dumps(str(MY_WORK))});
@@ -6219,7 +6391,7 @@ async def test_mobile_update_reader_is_in_app_safe_area_aware_and_actionable():
assert '.update-sheet-actions button, .update-sheet-actions a { min-height:44px;' in html
assert "createNotificationReader" in html
assert "api/v1/notifications/" in html
assert "notificationReader.markReadAndNext(lastMyWork)" in html
assert "reader:notificationReader, items:()=>lastMyWork" in html
def test_notification_replier_preserves_failed_draft_and_clears_only_after_success():
@@ -7056,7 +7228,9 @@ async def test_mobile_update_sheet_renders_and_pages_the_complete_conversation()
assert 'id="load-older-update-comments"' in update_sheet
assert 'id="update-conversation-status"' in update_sheet
assert '.conversation-more' in html and 'min-height:44px' in html
assert "'/conversation?page='" in html
assert "'/conversation?limit=20' + pageQuery" in html
assert 'id="retry-update-conversation"' in update_sheet
assert "notificationReader.retryConversation()" in html
assert "loadConversation: fetchNotificationConversation" in html
assert "onConversation: renderUpdateConversation" in html
assert "notificationReader.loadOlder()" in html
+22 -9
View File
@@ -7,7 +7,7 @@ from src import gitea_proxy, main
@pytest.mark.anyio
async def test_notification_detail_opens_the_newest_conversation_page_in_chronological_order(monkeypatch):
async def test_notification_detail_returns_core_without_loading_conversation(monkeypatch):
requested_pages = []
def comment(comment_id):
@@ -53,14 +53,9 @@ async def test_notification_detail_opens_the_newest_conversation_page_in_chronol
finally:
await gitea_proxy.stop_client()
assert requested_pages == [1, 3]
assert [item["id"] for item in result["conversation"]["comments"]] == list(range(41, 48))
assert result["conversation"] == {
"comments": result["conversation"]["comments"],
"page": 3,
"older_page": 2,
"total": 47,
}
assert requested_pages == []
assert result["conversation_available"] is True
assert "conversation" not in result
assert result["issue"] == {
"number": 7,
"assignees": [],
@@ -130,6 +125,24 @@ async def test_notification_conversation_api_loads_one_bounded_older_page(monkey
assert calls == [(42, 2, 20)]
@pytest.mark.anyio
async def test_notification_conversation_api_opens_newest_page_when_page_is_omitted(monkeypatch):
calls = []
async def conversation(thread_id, page, limit):
calls.append((thread_id, page, limit))
return {"comments": [{"id": 47}], "page": 3, "older_page": 2, "total": 47}
monkeypatch.setattr(main.gitea_proxy, "notification_conversation_page", conversation)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/notifications/42/conversation?limit=20")
assert response.status_code == 200
assert response.json()["page"] == 3
assert calls == [(42, None, 20)]
@pytest.mark.anyio
async def test_notification_detail_timeout_is_sanitized_and_retryable(monkeypatch):
async def detail(_thread_id):
+1 -1
View File
@@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v100" in source
assert "stackchain-dashboard-shell-v101" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-readiness.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source
+96
View File
@@ -0,0 +1,96 @@
import json
import subprocess
from pathlib import Path
SEARCH_BATCH_PLAN = Path(__file__).parents[1] / "frontend" / "search-batch-plan.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"
def run_node(script):
return json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
def test_search_batch_selection_keeps_open_issues_across_pages_and_rejects_ineligible_results():
script = f"""
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
const states=[];
const flow=createSearchBatchPlan({{onChange:state=>states.push(state)}});
const first={{kind:'issue',state:'open',repository:'stackchain/dashboard',number:809,title:'First'}};
const second={{kind:'issue',state:'open',repository:'stackchain/api',number:17,title:'Second'}};
const pull={{kind:'pull',state:'open',repository:'stackchain/dashboard',number:810}};
const closed={{kind:'issue',state:'closed',repository:'stackchain/dashboard',number:808}};
flow.start();
const outcomes=[flow.toggle(first),flow.toggle(pull),flow.toggle(closed),flow.toggle(second)];
process.stdout.write(JSON.stringify({{outcomes,snapshot:flow.snapshot(),states}}));
"""
result = run_node(script)
assert result["outcomes"] == ["selected", "ineligible", "ineligible", "selected"]
assert result["snapshot"] == {
"active": True,
"count": 2,
"items": [
{"kind": "issue", "state": "open", "repository": "stackchain/dashboard", "number": 809, "title": "First"},
{"kind": "issue", "state": "open", "repository": "stackchain/api", "number": 17, "title": "Second"},
],
}
assert result["states"][-1]["count"] == 2
def test_search_batch_prepares_owned_issues_without_reclaiming_and_claims_unassigned_issues():
script = f"""
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
const calls=[];
const details={{
809:{{kind:'issue',state:'open',repository:'stackchain/dashboard',number:809,assigned_to_me:true}},
810:{{kind:'issue',state:'open',repository:'stackchain/dashboard',number:810,claimable:true}},
811:{{kind:'issue',state:'open',repository:'stackchain/dashboard',number:811,claimable:false}},
}};
const flow=createSearchBatchPlan({{
resolve:item=>{{calls.push('resolve:'+item.number);return Promise.resolve(details[item.number]);}},
claim:detail=>{{calls.push('claim:'+detail.number);return Promise.resolve({{...detail,assigned_to_me:true}});}},
}});
Promise.all([
flow.prepare({{number:809}}),
flow.prepare({{number:810}}),
flow.prepare({{number:811}}).catch(error=>error.message),
]).then(results=>process.stdout.write(JSON.stringify({{results,calls}})));
"""
result = run_node(script)
assert result["calls"] == ["resolve:809", "resolve:810", "resolve:811", "claim:810"]
assert result["results"][0]["assigned_to_me"] is True
assert result["results"][1]["assigned_to_me"] is True
assert result["results"][2] == "This issue is no longer available to assign."
def test_mobile_search_exposes_touch_safe_durable_batch_planning_controls():
html = HTML.read_text()
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
worker = (HTML.parent / "service-worker.js").read_text()
controller = SEARCH_BATCH_PLAN.read_text()
assert 'id="select-search-results"' in html
assert 'id="search-batch-actions"' in html
assert 'id="queue-selected-search-results"' in html
assert 'id="resume-search-batch"' in html
assert 'src="static/search-batch-plan.js"' in html
assert "mountSearchBatchPlanning(" in dashboard
assert "journalName:'search-today-batch'" in controller
assert "autoMount:false" in controller
assert "plan.toggle(result)" in controller
assert "processor.run(plan.snapshot().items)" in controller
assert "processor.resume()" in controller
assert 'aria-label="Select ' in controller
assert "result.kind === 'issue' && result.state === 'open'" in controller
assert ".search-batch-actions" in css
assert "position:sticky" in css
assert "env(safe-area-inset-bottom)" in css
assert ".cmd-select-result" in css and "min-height:44px" in css
assert "BASE + 'static/search-batch-plan.js'" in worker
+211
View File
@@ -401,6 +401,217 @@ async def test_successful_protected_actions_record_only_bounded_targets(
assert b"abc123" not in persisted
@pytest.mark.anyio
@pytest.mark.parametrize(
"path",
[
"/api/v1/repos/stackchain/api/issues/7/comments/42",
"/api/v1/repos/stackchain/api/pulls/7/comments/42",
"/api/v1/notifications/91/comments/42",
],
)
async def test_comment_deletion_requires_exact_fresh_authorization_before_gitea(
security_access, monkeypatch, path
):
calls = []
async def assigned(*_args):
return True
async def notification_target(thread_id):
assert thread_id == 91
return "stackchain/api", 7
async def delete(*args):
calls.append(args)
return {"id": 42, "deleted": True}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "notification_conversation_target", notification_target)
monkeypatch.setattr(main.gitea_proxy, "delete_owned_comment", delete)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
)
headers = {
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
}
response = await client.delete(path, headers=headers)
assert response.status_code == 428
assert response.json()["detail"] == {
"detail": "Fresh authorization required",
"code": "step_up_required",
"action": "delete_comment",
"target": "stackchain/api#7:42",
}
assert calls == []
@pytest.mark.anyio
async def test_authorized_comment_deletion_records_only_privacy_safe_target(
security_access, monkeypatch
):
async def assigned(*_args):
return True
async def delete(*_args):
return {"id": 42, "deleted": True}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "delete_owned_comment", delete)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
)
headers = {
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
}
grant = await client.post(
"/api/v1/fresh-authorization",
json={
"access_token": "correct horse battery staple",
"action": "delete_comment",
"target": "stackchain/api#7:42",
},
headers=headers,
)
response = await client.delete(
"/api/v1/repos/stackchain/api/issues/7/comments/42",
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
)
events = (await client.get("/api/v1/security-events")).json()["events"]
assert response.status_code == 200
assert (events[0]["kind"], events[0]["target"], events[0]["status"]) == (
"comment_deleted", "stackchain/api#7:42", "completed"
)
persisted = (security_access / "security.sqlite3").read_bytes()
assert b"private comment body" not in persisted
assert b"correct horse battery staple" not in persisted
@pytest.mark.anyio
async def test_comment_deletion_reservation_failure_prevents_gitea_mutation(
security_access, monkeypatch
):
calls = []
async def assigned(*_args):
return True
async def delete(*args):
calls.append(args)
return {"id": 42, "deleted": True}
class UnavailableJournal:
def reserve(self, *_args, **_kwargs):
raise SecurityEventStoreError("unavailable")
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "delete_owned_comment", delete)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
)
headers = {
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
}
grant = await client.post(
"/api/v1/fresh-authorization",
json={
"access_token": "correct horse battery staple",
"action": "delete_comment",
"target": "stackchain/api#7:42",
},
headers=headers,
)
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
response = await client.delete(
"/api/v1/repos/stackchain/api/issues/7/comments/42",
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
)
assert response.status_code == 503
assert calls == []
@pytest.mark.anyio
@pytest.mark.parametrize("finalize_fails", [False, True])
async def test_comment_deletion_journal_tracks_failed_and_confirmed_outcomes(
security_access, monkeypatch, finalize_fails
):
journal_calls = []
async def assigned(*_args):
return True
async def delete(*_args):
if not finalize_fails:
raise RuntimeError("upstream rejected deletion")
return {"id": 42, "deleted": True}
class Journal:
def reserve(self, kind, *, target):
journal_calls.append(("reserve", kind, target))
return "operation-42"
def discard(self, operation_id):
journal_calls.append(("discard", operation_id))
def finalize(self, operation_id):
journal_calls.append(("finalize", operation_id))
raise SecurityEventStoreError("unavailable")
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "delete_owned_comment", delete)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
)
headers = {
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
}
grant = await client.post(
"/api/v1/fresh-authorization",
json={
"access_token": "correct horse battery staple",
"action": "delete_comment",
"target": "stackchain/api#7:42",
},
headers=headers,
)
monkeypatch.setattr(main, "_security_event_store", lambda: Journal())
response = await client.delete(
"/api/v1/repos/stackchain/api/issues/7/comments/42",
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
)
assert journal_calls[0] == (
"reserve", "comment_deleted", "stackchain/api#7:42"
)
if finalize_fails:
assert response.status_code == 200
assert response.json() == {"id": 42, "deleted": True}
assert journal_calls[1] == ("finalize", "operation-42")
else:
assert response.status_code == 503
assert journal_calls[1] == ("discard", "operation-42")
@pytest.mark.anyio
async def test_approved_pull_review_records_a_privacy_safe_completed_event(
security_access, monkeypatch
+17 -10
View File
@@ -150,7 +150,7 @@ async function dispatchPush(payload) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v100" in source
assert "stackchain-dashboard-shell-v101" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -159,14 +159,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v100" in source
assert "stackchain-dashboard-shell-v101" in source
assert "BASE + 'static/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v100" in source
assert "stackchain-dashboard-shell-v101" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -174,7 +174,7 @@ def test_offline_review_next_ships_today_completion_atomically():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v100" in source
assert "stackchain-dashboard-shell-v101" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -182,14 +182,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-v100" in source
assert "stackchain-dashboard-shell-v101" 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-v100" in source
assert "stackchain-dashboard-shell-v101" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -198,21 +198,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-v100" in source
assert "stackchain-dashboard-shell-v101" 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-v100" in source
assert "stackchain-dashboard-shell-v101" 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-v100" in source
assert "stackchain-dashboard-shell-v101" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -761,7 +761,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v100" in source
assert "stackchain-dashboard-shell-v101" in source
assert "BASE + 'static/queue-today.js'" in source
@@ -788,6 +788,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/markdown.js",
"/dashboard/static/commands.js",
"/dashboard/static/search-preview.js",
"/dashboard/static/search-defer.js",
"/dashboard/static/widgets.js",
"/dashboard/static/drafts.js",
"/dashboard/static/unfiled-captures.js",
@@ -829,6 +830,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/later-picker.js",
"/dashboard/static/pick-work.js",
"/dashboard/static/batch-find-work.js",
"/dashboard/static/search-batch-plan.js",
"/dashboard/static/conversation.js",
"/dashboard/static/comment-actions.js",
"/dashboard/static/issue-attachment.js",
@@ -847,7 +849,12 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/mobile-work-entry.js",
"/dashboard/static/mobile-queue-launcher.js",
"/dashboard/static/update-triage-session.js",
"/dashboard/static/update-review-handoff.js",
"/dashboard/static/update-read-position.js",
"/dashboard/static/work-detail-position.js",
"/dashboard/static/update-triage-launcher.js",
"/dashboard/static/update-triage-gesture.js",
"/dashboard/static/update-decision-transaction.js",
"/dashboard/static/agenda-session-launcher.js",
"/dashboard/static/mobile-launch.js",
"/dashboard/static/mobile-app-shortcuts.js",
+130 -1
View File
@@ -207,8 +207,137 @@ def test_dashboard_routes_mobile_task_overlays_through_browser_history():
assert "createTaskOverlayHistory({" in html
assert "taskOverlayHistory.open('new')" in html
assert "taskOverlayHistory.open('find')" in html
assert "taskOverlayHistory.open('search')" in html
assert "taskOverlayHistory.open('search', { scope:currentSearchScope() })" in html
assert "taskOverlayHistory.open('search-preview')" in html
assert "taskOverlayHistory.close()" in html
assert "saveIssueCaptureDraft();" in html
assert "history.replaceState(history.state || {}, '', cleanUrl)" in html
def test_search_history_serializes_bounded_query_and_stable_preview_identity():
script = f"""
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
const pushed = [];
const replaced = [];
const location = {{ pathname:'/dashboard/', search:'?keep=1', hash:'' }};
const history = {{
state: {{ page:'dashboard' }},
pushState(state, title, url) {{ this.state = state; pushed.push([state, url]); }},
replaceState(state, title, url) {{ this.state = state; replaced.push([state, url]); }},
back() {{}},
}};
const controller = createTaskOverlayHistory({{
history, location, eventTarget:{{addEventListener() {{}}}}, onChange() {{}},
}});
controller.open('search', {{ query:' release blocker ' }});
controller.update({{ query:'release candidate' }});
controller.open('search-preview', {{
query:'release candidate', preview:{{kind:'issue', repository:'stackchain/api', number:123,
title:'must not be serialized', body:'secret'}},
}});
process.stdout.write(JSON.stringify({{pushed, replaced, current:controller.currentState()}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
assert len(payload["pushed"]) == 2
assert len(payload["replaced"]) == 1
assert payload["pushed"][0][1] == "/dashboard/?keep=1&search=release+blocker"
assert payload["replaced"][0][1] == "/dashboard/?keep=1&search=release+candidate"
assert payload["pushed"][1][1] == (
"/dashboard/?keep=1&search=release+candidate&preview=issue%3Astackchain%2Fapi%3A123"
)
assert payload["current"] == {
"kind": "search-preview",
"query": "release candidate",
"preview": {"kind": "issue", "repository": "stackchain/api", "number": 123},
}
def test_search_history_restores_direct_url_and_rejects_malformed_or_oversized_state():
script = f"""
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
function restore(search) {{
const changes = [];
const location = {{ pathname:'/dashboard/', search, hash:'' }};
const history = {{ state:null, replaceState(state) {{ this.state = state; }}, back() {{}} }};
const controller = createTaskOverlayHistory({{
history, location, eventTarget:{{addEventListener() {{}}}},
onChange(kind, previous, detail) {{ changes.push([kind, previous, detail]); }},
}});
controller.start();
return {{ state:controller.currentState(), changes }};
}}
process.stdout.write(JSON.stringify({{
valid:restore('?search=release+blocker&preview=pull%3Astackchain%2Fapi%3A9'),
malformed:restore('?search=ok&preview=issue%3Ainvalid%3A0'),
oversized:restore('?search=' + 'x'.repeat(201)),
}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
expected = {
"kind": "search-preview",
"query": "release blocker",
"preview": {"kind": "pull", "repository": "stackchain/api", "number": 9},
}
assert payload["valid"]["state"] == expected
assert payload["valid"]["changes"] == [["search-preview", None, expected]]
assert payload["malformed"]["state"] == {"kind": "search", "query": "ok"}
assert payload["oversized"]["state"] == {"kind": None}
def test_search_history_round_trips_bounded_type_status_and_repository_scope():
script = f"""
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
function restore(search) {{
const location = {{pathname:'/dashboard/', search, hash:''}};
const history = {{state:null, replaceState(state, title, url) {{this.state=state; this.url=url;}}, back() {{}}}};
const controller = createTaskOverlayHistory({{
history, location, eventTarget:{{addEventListener() {{}}}}, onChange() {{}},
}});
return {{state:controller.currentState(), url:history.url}};
}}
const location = {{pathname:'/dashboard/', search:'', hash:''}};
const history = {{state:null, pushState(state,title,url) {{this.state=state; this.url=url;}}, replaceState() {{}}, back() {{}}}};
const controller = createTaskOverlayHistory({{
history, location, eventTarget:{{addEventListener() {{}}}}, onChange() {{}},
}});
controller.open('search', {{query:'mobile', scope:{{kind:'pull',state:'open',repository:'stackchain/api'}}}});
process.stdout.write(JSON.stringify({{
opened:controller.currentState(), url:history.url,
valid:restore('?search=mobile&search_kind=issue&search_state=closed&search_repository=stackchain%2Fapi'),
invalid:restore('?search=mobile&search_kind=script&search_state=secret&search_repository=stackchain%2Fapi%2Fprivate'),
}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
assert payload["opened"] == {
"kind": "search", "query": "mobile", "scope": {
"kind": "pull", "state": "open", "repository": "stackchain/api"
}
}
assert payload["url"] == (
"/dashboard/?search=mobile&search_kind=pull&search_state=open&"
"search_repository=stackchain%2Fapi"
)
assert payload["valid"]["state"]["scope"] == {
"kind": "issue", "state": "closed", "repository": "stackchain/api"
}
assert payload["invalid"]["state"]["scope"] == {
"kind": "all", "state": "all", "repository": ""
}
def test_dashboard_persists_search_query_and_restores_canonical_preview():
html = dashboard_bundle_text()
assert "taskOverlayHistory.update({ query:e.target.value })" in html
assert "scope:currentSearchScope(), preview:item.result" in html
assert "detail?.query" in html
assert "searchPreview.open(detail.preview).catch" in html
+1 -1
View File
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text()
assert "const CACHE = 'stackchain-dashboard-shell-v100';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v101';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
+1 -1
View File
@@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
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-v100" in source
assert "stackchain-dashboard-shell-v101" in source
assert "BASE + 'static/today-sync.js'" in source
+87
View File
@@ -0,0 +1,87 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
POSITIONER = Path(__file__).resolve().parents[1] / "frontend" / "update-read-position.js"
def run_positioner(script: str) -> dict:
source = f"const createPositioner = require({json.dumps(str(POSITIONER))});\n" + script
result = subprocess.run(["node", "-e", source], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_new_update_scrolls_to_newest_activity_once_without_reusing_previous_offset():
result = run_positioner("""
const listeners = {};
const panel = {scrollTop:480, addEventListener:(name, fn)=>listeners[name]=fn};
const jump = {hidden:true, focus(){}};
const first = {calls:0, scrollIntoView(){this.calls += 1}, classList:{add(){}}};
const second = {calls:0, scrollIntoView(){this.calls += 1}, classList:{add(){}}};
const positioner = createPositioner({panel, jump});
positioner.open('101');
positioner.ready('101', first);
positioner.ready('101', first);
panel.scrollTop = 920;
positioner.open('102');
positioner.ready('102', second);
process.stdout.write(JSON.stringify({first:first.calls, second:second.calls, jumpHidden:jump.hidden}));
""")
assert result == {"first": 1, "second": 1, "jumpHidden": True}
def test_user_scroll_during_loading_preserves_position_until_explicit_jump():
result = run_positioner("""
const listeners = {};
const panel = {scrollTop:120, addEventListener:(name, fn)=>listeners[name]=fn};
const jump = {hidden:true, focus(){}};
const target = {calls:0, scrollIntoView(){this.calls += 1}, classList:{add(){}}};
const positioner = createPositioner({panel, jump});
positioner.open('201');
panel.scrollTop = 260;
listeners.scroll();
positioner.ready('201', target);
const before = {calls:target.calls, hidden:jump.hidden, top:panel.scrollTop};
positioner.jump();
process.stdout.write(JSON.stringify({before, after:{calls:target.calls, hidden:jump.hidden}}));
""")
assert result == {
"before": {"calls": 0, "hidden": False, "top": 260},
"after": {"calls": 1, "hidden": True},
}
def test_stale_or_prefetched_conversation_cannot_move_the_visible_update():
result = run_positioner("""
const panel = {scrollTop:0, addEventListener(){}};
const jump = {hidden:true};
const stale = {calls:0, scrollIntoView(){this.calls += 1}, classList:{add(){}}};
const current = {calls:0, scrollIntoView(){this.calls += 1}, classList:{add(){}}};
const positioner = createPositioner({panel, jump});
positioner.open('302');
positioner.ready('301', stale);
positioner.ready('302', current);
process.stdout.write(JSON.stringify({stale:stale.calls, current:current.calls}));
""")
assert result == {"stale": 0, "current": 1}
@pytest.mark.anyio
async def test_dashboard_mounts_mobile_new_activity_positioning_flow():
html = await dashboard()
assert '<script src="static/update-read-position.js"></script>' in html
assert 'id="jump-update-new-activity"' in html
assert "updateReadPosition.open(String(item.notification_id))" in html
assert "updateReadPosition.ready(String(selectedUpdate?.notification_id || ''), newest)" in html
assert ".update-new-activity" in html
assert "BASE + 'static/update-read-position.js'" in (POSITIONER.parent / "service-worker.js").read_text()
+96
View File
@@ -0,0 +1,96 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
HANDOFF = Path(__file__).resolve().parents[1] / "frontend" / "update-review-handoff.js"
def run_handoff(script):
source = f"const createHandoff = require({json.dumps(str(HANDOFF))});\n" + script
result = subprocess.run(["node", "-e", source], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_review_handoff_only_admits_review_requested_pull_updates():
result = run_handoff("""
const handoff = createHandoff({openReview:()=>{}, admitRead:async()=>{}, advance:()=>{}});
const cases = [
{is_review:true, update_reason:'Review requested', subject_type:'Pull', repository:'acme/app', number:7, notification_id:41},
{is_review:true, update_reason:'Mentioned', subject_type:'Pull', repository:'acme/app', number:7, notification_id:42},
{is_review:true, update_reason:'Review requested', subject_type:'Issue', repository:'acme/app', number:7, notification_id:43},
{is_review:false, update_reason:'Review requested', subject_type:'Pull', repository:'acme/app', number:7, notification_id:44},
];
process.stdout.write(JSON.stringify(cases.map(item => handoff.eligible(item))));
""")
assert result == [True, False, False, False]
def test_review_handoff_preserves_update_on_cancel_and_advances_after_read_admission():
result = run_handoff("""
(async () => {
const events = [];
const update = {is_review:true, update_reason:'Review requested', subject_type:'Pull', repository:'acme/app', number:7, notification_id:41, title:'Ship it'};
const handoff = createHandoff({
openReview:item=>events.push(['open', item.repository, item.number]),
restoreUpdate:item=>events.push(['restore', item.notification_id]),
admitRead:async id=>events.push(['read', id]),
advance:()=>events.push(['advance']),
});
handoff.begin(update);
handoff.cancel();
handoff.begin(update);
const completed = await handoff.complete();
process.stdout.write(JSON.stringify({events, completed, active:handoff.active()}));
})().catch(error => { console.error(error); process.exit(1); });
""")
assert result == {
"events": [
["open", "acme/app", 7],
["restore", 41],
["open", "acme/app", 7],
["read", 41],
["advance"],
],
"completed": True,
"active": False,
}
def test_review_handoff_keeps_current_update_when_read_admission_fails():
result = run_handoff("""
(async () => {
const events = [];
const update = {is_review:true, update_reason:'Review requested', subject_type:'Pull', repository:'acme/app', number:7, notification_id:41};
const handoff = createHandoff({
openReview:()=>events.push('open'),
admitRead:async()=>{ events.push('read'); throw new Error('queue unavailable'); },
advance:()=>events.push('advance'),
});
handoff.begin(update);
let message = '';
try { await handoff.complete(); } catch (error) { message = error.message; }
process.stdout.write(JSON.stringify({events, message, active:handoff.active()}));
})().catch(error => { console.error(error); process.exit(1); });
""")
assert result == {"events": ["open", "read"], "message": "queue unavailable", "active": True}
@pytest.mark.anyio
async def test_dashboard_wires_review_requested_update_into_existing_review_workspace():
html = await dashboard()
assert '<script src="static/update-review-handoff.js"></script>' in html
assert 'id="review-update-now"' in html
assert "updateReviewHandoff.begin(selectedUpdate)" in html
assert "await updateReviewHandoff.complete()" in html
assert "updateReviewHandoff.cancel()" in html
assert "updateReviewHandoff.eligible(detail" in html
+178
View File
@@ -0,0 +1,178 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
GESTURE = Path(__file__).resolve().parents[1] / "frontend" / "update-triage-gesture.js"
TRANSACTION = Path(__file__).resolve().parents[1] / "frontend" / "update-decision-transaction.js"
def run_gesture(script):
source = f"const createGesture = require({json.dumps(str(GESTURE))});\n" + script
result = subprocess.run(["node", "-e", source], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def run_transaction(script):
source = f"const createTransaction = require({json.dumps(str(TRANSACTION))});\n" + script
result = subprocess.run(["node", "-e", source], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_update_decision_transaction_locks_every_action_until_success():
result = run_transaction(r"""
const controls = ['keep', 'read', 'reply', 'more'].map(name => ({name, disabled:false, focus(){}}));
const status = {textContent:'Ready'};
let resolveAction;
let calls = 0;
const transaction = createTransaction({controls, status});
const pending = transaction.run('Marking read…', () => new Promise(resolve => {
calls += 1;
resolveAction = resolve;
}));
const locked = controls.every(control => control.disabled) && transaction.busy();
const rejected = transaction.run('Keeping unread…', () => { calls += 1; });
resolveAction(true);
Promise.all([pending, rejected]).then(([completed, second]) => process.stdout.write(JSON.stringify({
locked, pendingStatus: 'Marking read…', calls, completed, second,
unlocked: controls.every(control => !control.disabled) && !transaction.busy(),
finalStatus: status.textContent,
})));
""")
assert result == {
"locked": True,
"pendingStatus": "Marking read…",
"calls": 1,
"completed": True,
"second": False,
"unlocked": True,
"finalStatus": "Marking read…",
}
def test_update_decision_transaction_restores_focus_and_announces_failure():
result = run_transaction(r"""
let focused = 0;
let focusedWhileEnabled = false;
const origin = {disabled:false, focus(){ focused += 1; focusedWhileEnabled = !this.disabled; }};
const other = {disabled:false, focus(){}};
const status = {textContent:''};
const transaction = createTransaction({controls:[origin, other], status});
transaction.run('Marking read…', async () => false, {
origin,
failure:'Could not mark update read. Retry.',
}).then(completed => process.stdout.write(JSON.stringify({
completed, focused, focusedWhileEnabled, status:status.textContent,
unlocked:!origin.disabled && !other.disabled && !transaction.busy(),
})));
""")
assert result == {
"completed": False,
"focused": 1,
"focusedWhileEnabled": True,
"status": "Could not mark update read. Retry.",
"unlocked": True,
}
def test_committed_horizontal_swipes_decide_once_and_lock_while_pending():
result = run_gesture(r"""
class Surface {
constructor() { this.listeners = {}; this.dataset = {}; }
addEventListener(name, callback) { this.listeners[name] = callback; }
removeEventListener(name) { delete this.listeners[name]; }
setPointerCapture() {}
releasePointerCapture() {}
}
const surface = new Surface();
const calls = [];
let enabled = true;
let resolveRead;
let lockedCalls;
const gesture = createGesture({
surface,
enabled: () => enabled,
keepUnread: () => calls.push('keep'),
markRead: () => new Promise(resolve => { calls.push('read'); resolveRead = resolve; }),
viewportWidth: () => 390,
});
const target = {closest: () => null, parentElement: null, scrollWidth: 100, clientWidth: 100};
const event = (x, y, id=1) => ({pointerId:id, pointerType:'touch', clientX:x, clientY:y, target, preventDefault(){}});
surface.listeners.pointerdown(event(180, 200));
surface.listeners.pointermove(event(250, 205));
surface.listeners.pointerup(event(270, 206));
surface.listeners.pointerdown(event(280, 200));
surface.listeners.pointermove(event(205, 204));
surface.listeners.pointerup(event(190, 205));
surface.listeners.pointerdown(event(280, 200));
surface.listeners.pointermove(event(190, 203));
surface.listeners.pointerup(event(180, 203));
Promise.resolve().then(() => {
lockedCalls = [...calls];
resolveRead();
return Promise.resolve();
}).then(() => process.stdout.write(JSON.stringify({calls, lockedCalls, direction:surface.dataset.triageGesture || null})));
""")
assert result == {
"calls": ["keep", "read"],
"lockedCalls": ["keep", "read"],
"direction": None,
}
def test_scrolls_short_drags_edges_and_interactive_targets_never_decide():
result = run_gesture(r"""
class Surface {
constructor() { this.listeners = {}; this.dataset = {}; }
addEventListener(name, callback) { this.listeners[name] = callback; }
removeEventListener(name) { delete this.listeners[name]; }
setPointerCapture() {}
releasePointerCapture() {}
}
const surface = new Surface();
const calls = [];
createGesture({surface, enabled:()=>true, keepUnread:()=>calls.push('keep'), markRead:()=>calls.push('read'), viewportWidth:()=>390});
const plain = {closest: () => null, parentElement: null, scrollWidth:100, clientWidth:100};
const interactive = {closest: selector => selector.includes('button') ? {} : null, parentElement:null, scrollWidth:100, clientWidth:100};
const scroller = {closest: () => null, parentElement:null, scrollWidth:400, clientWidth:100};
const event = (x,y,target=plain,id=1) => ({pointerId:id,pointerType:'touch',clientX:x,clientY:y,target,preventDefault(){}});
function drag(fromX, fromY, toX, toY, target=plain) {
surface.listeners.pointerdown(event(fromX,fromY,target));
surface.listeners.pointermove(event(toX,toY,target));
surface.listeners.pointerup(event(toX,toY,target));
}
drag(180,100,205,104);
drag(180,100,220,190);
drag(8,100,120,104);
drag(180,100,280,104,interactive);
drag(180,100,280,104,scroller);
surface.listeners.pointerdown(event(180,100));
surface.listeners.pointercancel(event(280,104));
process.stdout.write(JSON.stringify({calls, direction:surface.dataset.triageGesture || null}));
""")
assert result == {"calls": [], "direction": None}
@pytest.mark.anyio
async def test_dashboard_wires_accessible_phone_only_update_swipe_triage():
html = await dashboard()
assert '<script src="static/update-triage-gesture.js"></script>' in html
assert '<script src="static/update-decision-transaction.js"></script>' in html
assert 'id="update-gesture-status"' in html
assert "createUpdateTriageGesture({" in html
assert "createUpdateDecisionActions({" in html
assert "keepUnread: keepUpdateUnread" in html
assert "markRead: markUpdateRead" in html
assert "data-triage-gesture" in html
assert "prefers-reduced-motion: reduce" in html
+99 -3
View File
@@ -17,6 +17,30 @@ def run_session(script):
return json.loads(result.stdout)
def test_update_triage_progress_exposes_the_current_actionability_reason():
result = run_session("""
const values = new Map();
const items = [
{notification_id:7, update_reason:'Review requested'},
{notification_id:8, update_reason:''},
];
const progress = [];
const session = createSession({
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
getLogin:()=> 'timmy', getItems:()=>items, onOpen:()=>{},
onProgress:value=>progress.push(value), onFinish:()=>{},
});
session.start();
session.completeAndNext();
process.stdout.write(JSON.stringify(progress));
""")
assert result == [
{"index": 1, "total": 2, "reason": "Review requested"},
{"index": 2, "total": 2},
]
def test_update_triage_persists_account_bound_pass_and_resumes_by_identity():
result = run_session("""
const values = new Map();
@@ -47,11 +71,12 @@ process.stdout.write(JSON.stringify({opened, progress, saved, resumable, isolate
{"index": 2, "total": 3},
]
assert result["saved"] == {
"version": 1,
"version": 2,
"login": "timmy",
"identities": ["1", "2", "3"],
"current": "2",
"completed": ["1"],
"kept": ["1"],
}
assert result["resumable"] is True
assert result["isolated"] is False
@@ -81,15 +106,86 @@ process.stdout.write(JSON.stringify({opened, progress, stored:values.get('stackc
}
def test_update_triage_exposes_only_the_next_surviving_snapshot_item():
result = run_session("""
const values = new Map();
let items = [1,2,3].map(notification_id => ({notification_id}));
const session = createSession({
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
getLogin:()=> 'timmy', getItems:()=>items,
onOpen:()=>{}, onProgress:()=>{}, onFinish:()=>{},
});
session.start();
const first = session.next()?.notification_id;
items = [1,3,4].map(notification_id => ({notification_id}));
const afterRemoval = session.next()?.notification_id;
session.keepUnreadAndNext();
const afterAdvance = session.next();
process.stdout.write(JSON.stringify({first, afterRemoval, afterAdvance:afterAdvance || null}));
""")
assert result == {"first": 2, "afterRemoval": 3, "afterAdvance": None}
def test_update_triage_reports_truthful_outcomes_and_reopens_only_surviving_kept_items():
result = run_session("""
const values = new Map();
let items = [1,2,3].map(notification_id => ({notification_id}));
const opened = [], outcomes = [];
const session = createSession({
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
getLogin:()=> 'timmy', getItems:()=>items,
onOpen:item=>opened.push(item.notification_id), onProgress:()=>{}, onFinish:value=>outcomes.push(value),
});
session.start();
session.keepUnreadAndNext();
session.completeAndNext();
items = [1,3].map(notification_id => ({notification_id}));
session.keepUnreadAndNext();
const reviewed = session.reviewKept();
process.stdout.write(JSON.stringify({opened, outcomes, reviewed}));
""")
assert result == {
"opened": [1, 2, 3, 1],
"outcomes": [{"reviewed": 3, "resolved": 1, "kept": 2, "keptIdentities": ["1", "3"]}],
"reviewed": True,
}
def test_update_triage_reconciles_kept_outcomes_before_reporting_cleared():
result = run_session("""
const values = new Map();
let items = [1,2].map(notification_id => ({notification_id}));
const outcomes = [];
const session = createSession({
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
getLogin:()=> 'timmy', getItems:()=>items,
onOpen:()=>{}, onProgress:()=>{}, onFinish:value=>outcomes.push(value),
});
session.start();
session.keepUnreadAndNext();
items = [2].map(notification_id => ({notification_id}));
session.completeAndNext();
process.stdout.write(JSON.stringify(outcomes[0]));
""")
assert result == {"reviewed": 2, "resolved": 2, "kept": 0, "keptIdentities": []}
@pytest.mark.anyio
async def test_dashboard_wires_resumable_updates_triage_mobile_flow():
html = await dashboard()
assert '<script src="static/update-triage-session.js"></script>' in html
assert 'id="update-triage-progress"' in html
assert 'id="keep-update-unread" type="button">Keep unread &amp; next</button>' in html
assert 'id="keep-update-unread" type="button" aria-label="Keep unread and open next update">Keep unread</button>' in html
assert "openUpdates: openUpdateTriage" in html
assert "updateTriage.acceptCompleted()" in html
assert "updateTriage.keepUnreadAndNext()" in html
assert "triage:updateTriage" in html
assert "updateTriage.reconcile()" in html
assert 'id="mobile-update-outcome"' in html
assert 'id="review-kept-updates"' in html
assert "updateTriage.reviewKept(keptUpdateIdentities)" in html
assert "showUpdateTriageOutcome(outcome)" in html
assert ".update-triage-progress" in html
+2
View File
@@ -41,8 +41,10 @@ async def test_global_search_preview_is_a_phone_safe_accessible_dialog():
assert 'id="search-preview" role="dialog" aria-modal="true"' in html
assert 'aria-labelledby="search-preview-title"' in html
assert 'id="claim-search-result"' in html
assert 'id="share-search-result"' in html
assert 'id="open-search-result-gitea"' in html
assert "static/search-preview.js" in FRONTEND_BUILD.page_sources
assert ".search-preview-panel" in css
assert "height:100dvh" in css
assert "env(safe-area-inset-bottom)" in css
assert ".search-preview-actions button, .search-preview-actions a { min-height:44px;" in css
+60
View File
@@ -0,0 +1,60 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
POSITIONER = Path(__file__).resolve().parents[1] / "frontend" / "work-detail-position.js"
def run_positioner(script: str) -> dict:
source = f"const createPositioner = require({json.dumps(str(POSITIONER))});\n" + script
result = subprocess.run(["node", "-e", source], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_different_work_item_resets_to_top_while_same_item_keeps_position():
result = run_positioner("""
const panel = {scrollTop:640};
const positioner = createPositioner({panel});
const first = positioner.open('issue:stackchain/dashboard#41');
panel.scrollTop = 380;
const same = positioner.open('issue:stackchain/dashboard#41');
const changed = positioner.open('pull:stackchain/dashboard#42');
process.stdout.write(JSON.stringify({first, same, changed, top:panel.scrollTop}));
""")
assert result == {"first": True, "same": False, "changed": True, "top": 0}
def test_each_panel_tracks_its_own_visible_identity():
result = run_positioner("""
const issuePanel = {scrollTop:500};
const pullPanel = {scrollTop:700};
const issue = createPositioner({panel:issuePanel});
const pull = createPositioner({panel:pullPanel});
issue.open('issue:stackchain/dashboard#8');
pull.open('pull:stackchain/dashboard#9');
issuePanel.scrollTop = 220;
pullPanel.scrollTop = 330;
issue.open('issue:stackchain/dashboard#8');
pull.open('review:stackchain/dashboard#9');
process.stdout.write(JSON.stringify({issueTop:issuePanel.scrollTop, pullTop:pullPanel.scrollTop}));
""")
assert result == {"issueTop": 220, "pullTop": 0}
@pytest.mark.anyio
async def test_dashboard_positions_issue_pull_and_review_openers():
html = await dashboard()
assert '<script src="static/work-detail-position.js"></script>' in html
assert "issueDetailPosition.open(workDetailIdentity('issue', item))" in html
assert "pullDetailPosition.open(workDetailIdentity('pull', item))" in html
assert "reviewDetailPosition.open(workDetailIdentity('review', item))" in html
assert "BASE + 'static/work-detail-position.js'" in (POSITIONER.parent / "service-worker.js").read_text()