Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c04cba29d9 | ||
|
|
cacecac865 | ||
|
|
10f7cc832a | ||
|
|
e5f37a866c | ||
|
|
5a4fbd1564 | ||
|
|
7bcab03b8a | ||
|
|
51fe9e5af2 | ||
|
|
2406d24cb8 | ||
|
|
745d2529e5 | ||
|
|
af9699e362 | ||
|
|
71b38687c4 | ||
|
|
0a8f33dc83 | ||
|
|
0f45b7aead | ||
|
|
a44bf46bef | ||
|
|
ec86bf55a8 | ||
|
|
7636e30482 | ||
|
|
bd054998b2 | ||
|
|
3c65bc1b31 | ||
|
|
bc082f2bd9 | ||
|
|
9ae0a89970 | ||
|
|
43163ed926 | ||
|
|
66a2b78860 | ||
|
|
20d4f1ef7e | ||
|
|
70281bb56a | ||
|
|
7b736bc4c6 | ||
|
|
fa3e5e3bf0 | ||
|
|
bc0d47e8e7 | ||
|
|
741c27eacf | ||
|
|
3896f17a58 | ||
|
|
9d14fa5ffb | ||
|
|
83be8fcdbe | ||
|
|
35f3999787 | ||
|
|
378291d3c8 | ||
|
|
584752ed24 | ||
|
|
d54f58bf36 | ||
|
|
84302d6f1e | ||
|
|
2e0d64907b | ||
|
|
80042ce561 | ||
|
|
931f656f78 | ||
|
|
18f56583ba | ||
|
|
6e2f5d1120 | ||
|
|
9bd755f521 | ||
|
|
fd2f801f3e | ||
|
|
e21cc58cca | ||
|
|
d6212b2812 | ||
|
|
6528b2e73f | ||
|
|
a9d586e689 | ||
|
|
7c86f28346 | ||
|
|
707ee4c1bc | ||
|
|
82982e5741 | ||
|
|
db08c7e75f | ||
|
|
1a3df5d7a7 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 & 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 & 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 & defer</button>
|
||||
<button id="queue-search-result" type="button" hidden>Assign & add to Today</button>
|
||||
<button id="start-search-result" type="button" hidden>Assign & 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 & 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 & 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 & 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 & next</button>
|
||||
<button id="mute-update-next" type="button" hidden>Mute future updates & 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 & 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 & next</button>
|
||||
<button id="mute-update-next" type="button" hidden>Mute future updates & 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 & 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 & 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 & 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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
});
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 } } : {}) }; },
|
||||
};
|
||||
};
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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};
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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}}});
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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/",
|
||||
}
|
||||
@@ -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))});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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] }},
|
||||
|
||||
@@ -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
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 & 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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user