Compare commits
97
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51f4223f4b | ||
|
|
241f0f3a2a | ||
|
|
34d5f86d53 | ||
|
|
3fde7cf3ad | ||
|
|
6913fc8257 | ||
|
|
437da72f3d | ||
|
|
9a2b8d97bd | ||
|
|
1e4f101219 | ||
|
|
c0a02a05d8 | ||
|
|
246f995b56 | ||
|
|
db28cc5690 | ||
|
|
26aa12019e | ||
|
|
1baa9c1f91 | ||
|
|
d64c646b68 | ||
|
|
021af60cf3 | ||
|
|
09dbdb9961 | ||
|
|
484dcc6b9c | ||
|
|
7571a4aa2d | ||
|
|
dc68305dcb | ||
|
|
386acda1dc | ||
|
|
a2606f1dc2 | ||
|
|
a2c91e0296 | ||
|
|
8148545295 | ||
|
|
70c8ea6584 | ||
|
|
f30143b5ea | ||
|
|
cf76bc3636 | ||
|
|
0fc152f623 | ||
|
|
377fd889c5 | ||
|
|
0a4c9a0ae8 | ||
|
|
1a4f930b50 | ||
|
|
49bbeadc48 | ||
|
|
5470e48e26 | ||
|
|
0fcb7f5152 | ||
|
|
0f30ca94ab | ||
|
|
468676d2f4 | ||
|
|
40623f939c | ||
|
|
110fd860b3 | ||
|
|
edf5d3fda4 | ||
|
|
e395878f92 | ||
|
|
f48b9b5a6c | ||
|
|
971656b76c | ||
|
|
48ec66cc30 | ||
|
|
966028e0a6 | ||
|
|
21eb223882 | ||
|
|
e10bd2d261 | ||
|
|
0728bbdd6a | ||
|
|
53e556ddab | ||
|
|
611b10b477 | ||
|
|
85a000422d | ||
|
|
d80091d244 | ||
|
|
5217f6a86f | ||
|
|
fc33746d4c | ||
|
|
cc449b3d2f | ||
|
|
8de2318f43 | ||
|
|
24f781d398 | ||
|
|
25f752339f | ||
|
|
fe95b4e32a | ||
|
|
2b671d91fd | ||
|
|
dcc3348c12 | ||
|
|
31fb3fc8ae | ||
|
|
a0b3db6895 | ||
|
|
6c7b6ebd4d | ||
|
|
3c86e02efe | ||
|
|
c48baac1ea | ||
|
|
311396cfac | ||
|
|
762614b2d3 | ||
|
|
31f2c71575 | ||
|
|
50f1af83f6 | ||
|
|
6c200d7c97 | ||
|
|
6954ffc050 | ||
|
|
c8a237aba4 | ||
|
|
7fa43a9e50 | ||
|
|
2e6c20d68d | ||
|
|
5af45cda57 | ||
|
|
cab5802547 | ||
|
|
d3a57f222e | ||
|
|
053f32c428 | ||
|
|
d63166b581 | ||
|
|
51710972eb | ||
|
|
0e13872311 | ||
|
|
eac5342fa5 | ||
|
|
a8e931f306 | ||
|
|
00fd96f4a4 | ||
|
|
0d5fa13d65 | ||
|
|
6270253c24 | ||
|
|
5da5d7cdc4 | ||
|
|
55bd353daf | ||
|
|
c5d31c1972 | ||
|
|
843906208c | ||
|
|
2338a07bab | ||
|
|
67d8828aea | ||
|
|
caa0fbc2fd | ||
|
|
8a85b344f8 | ||
|
|
b4662a175d | ||
|
|
fb08785ad5 | ||
|
|
2bb155a9e4 | ||
|
|
c4715fc95c |
No files matched your search
@@ -15,6 +15,8 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
with: { python-version: "3.11" }
|
||||
- run: pip install -r requirements.txt
|
||||
- run: pip install -r requirements-audit.txt
|
||||
- run: python3 -m pip_audit -r requirements.txt --strict
|
||||
- run: python3 -m pytest tests/ -q
|
||||
|
||||
build-release:
|
||||
@@ -54,8 +56,8 @@ jobs:
|
||||
run: |
|
||||
pip install -r requirements-e2e.txt
|
||||
python3 -m playwright install --with-deps chromium
|
||||
- name: Exercise mobile offline filing from the release artifact
|
||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py -q
|
||||
- name: Exercise packaged mobile work journeys
|
||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q
|
||||
|
||||
release-candidate:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -19,12 +19,8 @@ threads, create and self-assign issues, discover, claim, and release issue assig
|
||||
list repository labels and open milestones, set or clear due dates on assigned issues, create issue comments, close assigned issues,
|
||||
inspect/comment on assigned pull
|
||||
requests, merge assigned pull requests, and submit pull-request reviews.
|
||||
Assigned-issue and assigned-pull-request comments can include one PNG, JPEG, or WebP screenshot; each mobile composer automatically optimizes oversized screenshots on-device to fit the 2 MB upload boundary while leaving already-valid files unchanged.
|
||||
For online delivery, the screenshot uploads before the comment is posted—to the exact assigned issue or pull request—and produces one Markdown comment; validation or upload
|
||||
failures keep both the typed comment and removable preview available for retry. Offline screenshot comments
|
||||
admit their text and image bytes to IndexedDB before confirmation, keep only bounded metadata in
|
||||
localStorage, and use checkpointed upload/comment identities so reconnect retries cannot duplicate
|
||||
either stage. The mobile **New issue** capture-first stage accepts an ordered evidence bundle of up to
|
||||
Assigned-issue, assigned-pull-request, and unread-update conversation composers accept an ordered bundle of up to five PNG, JPEG, or WebP photos. Repeated camera captures append to the bundle, the gallery picker accepts multiple images, and each composer automatically optimizes oversized screenshots on-device to fit the 2 MB upload boundary. Before sending, the selected conversation photo can use the same touch editor as New issue evidence to crop, privacy-redact, highlight, or add an arrow; Apply replaces only that flattened derivative while preserving its caption and bundle position, and Cancel leaves the original unchanged.
|
||||
For online delivery, every photo uploads before the comment is posted to the exact conversation target, producing one ordered Markdown comment or reply; validation or upload failures keep the typed text and removable preview available for retry. Offline photo conversations admit every image Blob to IndexedDB before confirmation, keep only bounded metadata in localStorage, and checkpoint each upload separately so reconnect resumes at the first unconfirmed photo without duplicating an upload, comment, reply, or reply-and-read transition. The mobile **New issue** capture-first stage accepts an ordered evidence bundle of up to
|
||||
five PNG, JPEG, or WebP screenshots before a repository is chosen, optimizing each image independently
|
||||
to the 2 MB boundary. **Save to Drafts** durably writes every optimized Blob to IndexedDB before
|
||||
confirmation, keeps only account-bound attachment metadata in localStorage, and restores the ordered
|
||||
@@ -79,6 +75,15 @@ Today or interrupting active work. Cancel and browser Back preserve the Search p
|
||||
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.
|
||||
Commentable mobile Search previews support camera capture and gallery selection for up to five ordered
|
||||
photos, including captions, crop/annotation/redaction review, and metadata-stripping re-encoding. Operators
|
||||
can queue photo-only, text-only, or mixed replies without leaving their Search pass, including while offline.
|
||||
Durable admission account-binds the exact issue/pull kind and stores image bytes in IndexedDB before the UI
|
||||
clears or advances. Reconnect revalidates the visible Search target through the narrower preview attachment
|
||||
and comment routes instead of assigned-work routes. Existing per-photo upload operation IDs and confirmed
|
||||
Markdown checkpoints survive handoff, so retry resumes from the first unfinished photo and the final comment
|
||||
posts once. **Send & next** advances only after durable admission; a storage failure keeps the current result,
|
||||
text, evidence, and Search position unchanged.
|
||||
Named mobile Search views preserve the query, type, status, and optional repository scope. They are
|
||||
bounded to 20 per confirmed account and synchronize through a revisioned SQLite collection, so another
|
||||
device can reopen the exact Search with one tap while stale writes surface a conflict instead of silently
|
||||
|
||||
+48
-16
@@ -4,7 +4,37 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
|
||||
);
|
||||
const pending = new Map();
|
||||
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker', 'issue-content']);
|
||||
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'search-reply', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker', 'issue-content']);
|
||||
|
||||
function messageAttachments(message) {
|
||||
const values = Array.isArray(message?.attachments) ? message.attachments :
|
||||
(Array.isArray(message?.attachment) ? message.attachment : (message?.attachment ? [message.attachment] : []));
|
||||
return values.filter(Boolean).slice(0, 5);
|
||||
}
|
||||
|
||||
function attachmentMetadata(value) {
|
||||
const note = String(value.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
|
||||
return {
|
||||
filename: String(value.filename || ''),
|
||||
contentType: String(value.contentType || ''),
|
||||
stored: true,
|
||||
...(note ? { note } : {}),
|
||||
...(value.operationId ? { operationId:String(value.operationId).slice(0, 128) } : {}),
|
||||
...(value.confirmed?.markdown ? { confirmed:{ markdown:String(value.confirmed.markdown) } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function durableAttachment(value) {
|
||||
const note = String(value.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
|
||||
return {
|
||||
filename: String(value.filename || ''),
|
||||
contentType: String(value.contentType || ''),
|
||||
...(note ? { note } : {}),
|
||||
...(value.operationId ? { operationId:String(value.operationId).slice(0, 128) } : {}),
|
||||
...(value.confirmed?.markdown ? { confirmed:{ markdown:String(value.confirmed.markdown) } } : {}),
|
||||
...(value.blob ? { blob:value.blob } : { data:String(value.data || '') }),
|
||||
};
|
||||
}
|
||||
|
||||
function checklistOperation(value) {
|
||||
const action = String(value?.action || '');
|
||||
@@ -55,6 +85,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
|
||||
function enqueue(message, mirror = true) {
|
||||
if (!supportedKinds.has(message?.kind)) throw new Error('This action cannot be queued.');
|
||||
if (message.kind === 'search-reply' && !['issue', 'pull'].includes(message.targetKind)) {
|
||||
throw new Error('Choose an exact Search result before queueing a reply.');
|
||||
}
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing a message.');
|
||||
const items = read();
|
||||
@@ -90,6 +123,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
}
|
||||
if (items.length >= maxItems) throw new Error('Message outbox is full. Send or discard a queued message first.');
|
||||
const id = String(requestedOperationId || makeId()).slice(0, 128);
|
||||
const attachments = messageAttachments(message);
|
||||
const item = {
|
||||
id,
|
||||
operationId: requestedOperationId || id,
|
||||
@@ -101,14 +135,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
ownerLogin,
|
||||
status: 'queued',
|
||||
queuedAt: Number(now()),
|
||||
...(message.kind === 'search-reply' ? { targetKind:String(message.targetKind) } : {}),
|
||||
...(message.kind === 'update-reply-read' ? { replyConfirmed: message.replyConfirmed === true } : {}),
|
||||
...(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read'].includes(message.kind) && message.attachment ? {
|
||||
attachment: {
|
||||
filename: String(message.attachment.filename || ''),
|
||||
contentType: String(message.attachment.contentType || ''),
|
||||
stored: true,
|
||||
},
|
||||
} : {}),
|
||||
...(['issue-comment', 'pull-comment', 'search-reply', 'update-reply', 'update-reply-read'].includes(message.kind) && attachments.length ?
|
||||
(attachments.length === 1 ? { attachment:attachmentMetadata(attachments[0]) } :
|
||||
{ attachments:attachments.map(attachmentMetadata) }) : {}),
|
||||
...(message.kind === 'pull-review' ? {
|
||||
decision: String(message.decision || 'comment'),
|
||||
expectedHeadSha: String(message.expectedHeadSha || ''),
|
||||
@@ -140,7 +171,8 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
async function enqueueDurably(message) {
|
||||
const previousItems = read();
|
||||
const item = enqueue(message, false);
|
||||
if (message.attachment && ['update-reply', 'update-reply-read'].includes(message.kind) &&
|
||||
const attachments = messageAttachments(message);
|
||||
if (attachments.length && ['search-reply', 'update-reply', 'update-reply-read'].includes(message.kind) &&
|
||||
(!backgroundSync?.reconcile || !backgroundSync?.requestSync)) {
|
||||
write(read().filter(candidate => candidate.id !== item.id), false);
|
||||
throw new Error('Screenshot delivery needs IndexedDB. Your reply and screenshot are still here; retry.');
|
||||
@@ -148,14 +180,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
return { item, background: false, durability: 'foreground-only' };
|
||||
}
|
||||
const durableItems = read().map(candidate => candidate.id === item.id && message.attachment ? {
|
||||
const durableItems = read().map(candidate => candidate.id === item.id && attachments.length ? {
|
||||
...candidate,
|
||||
attachment: {
|
||||
filename: String(message.attachment.filename || ''),
|
||||
contentType: String(message.attachment.contentType || ''),
|
||||
...(message.attachment.blob ? { blob: message.attachment.blob } :
|
||||
{ data: String(message.attachment.data || '') }),
|
||||
},
|
||||
...(attachments.length === 1 ? { attachment:durableAttachment(attachments[0]) } :
|
||||
{ attachments:attachments.map(durableAttachment) }),
|
||||
} : candidate);
|
||||
try {
|
||||
await backgroundSync.reconcile(durableItems, 'authored');
|
||||
@@ -213,6 +241,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
if (item.kind === 'pull-review') {
|
||||
return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review';
|
||||
}
|
||||
if (item.kind === 'search-reply') {
|
||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
|
||||
'/preview/comments?kind=' + encodeURIComponent(item.targetKind);
|
||||
}
|
||||
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
|
||||
return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments';
|
||||
}
|
||||
|
||||
@@ -186,10 +186,15 @@ function createIssueSyncStore({
|
||||
const preservedAttachment = (current?.attachment?.data || current?.attachment?.blob) &&
|
||||
item?.attachment?.stored && !item.attachment.data && !item.attachment.blob
|
||||
? { attachment: current.attachment } : {};
|
||||
const preservedAttachments = current?.operationId === item?.operationId &&
|
||||
current?.attachments?.every(value => value?.data || value?.blob) &&
|
||||
item?.attachments?.every(value => value?.stored && !value.data && !value.blob)
|
||||
? { attachments:current.attachments } : {};
|
||||
const next = current && current.operationId === item.operationId ? {
|
||||
...item, ...preservedAttachment,
|
||||
...item, ...preservedAttachment, ...preservedAttachments,
|
||||
...(current.deliveredIssue ? { deliveredIssue: current.deliveredIssue } : {}),
|
||||
...(current.attachmentMarkdown ? { attachmentMarkdown: current.attachmentMarkdown } : {}),
|
||||
...(current.attachmentMarkdowns ? { attachmentMarkdowns:current.attachmentMarkdowns } : {}),
|
||||
} : { ...item };
|
||||
await records.put(next);
|
||||
return next;
|
||||
@@ -459,6 +464,13 @@ function createBackgroundIssueSync({
|
||||
item,
|
||||
);
|
||||
}
|
||||
if (item.kind === 'search-reply') {
|
||||
return authoredRequest(
|
||||
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
|
||||
'/preview/comments?kind=' + encodeURIComponent(item.targetKind),
|
||||
item,
|
||||
);
|
||||
}
|
||||
return {
|
||||
url: base + 'api/v1/repos/' + repository + '/issues',
|
||||
options: {
|
||||
@@ -508,31 +520,68 @@ function createBackgroundIssueSync({
|
||||
return '**Screenshot ' + (index + 1) + ' — ' + escaped + '**\n\n' + markdown;
|
||||
}
|
||||
|
||||
function conversationAttachments(item) {
|
||||
return (Array.isArray(item?.attachments) ? item.attachments : [item?.attachment]).filter(Boolean);
|
||||
}
|
||||
|
||||
async function uploadConversationAttachments(item, url) {
|
||||
let current = item;
|
||||
const attachments = conversationAttachments(current);
|
||||
const confirmedMarkdowns = attachments.map(value => String(value?.confirmed?.markdown || ''));
|
||||
const firstMissing = confirmedMarkdowns.findIndex(value => !value);
|
||||
const leadingConfirmed = confirmedMarkdowns.slice(0, firstMissing < 0 ? confirmedMarkdowns.length : firstMissing);
|
||||
const markdowns = Array.isArray(current.attachmentMarkdowns) ?
|
||||
current.attachmentMarkdowns.slice(0, attachments.length) :
|
||||
(current.attachmentMarkdown ? [current.attachmentMarkdown] : leadingConfirmed);
|
||||
for (let index = markdowns.length; index < attachments.length; index += 1) {
|
||||
const uploaded = await requestStage(current, url, {
|
||||
method:'POST',
|
||||
headers:{
|
||||
Accept:'application/json',
|
||||
'Idempotency-Key':String(attachments[index]?.operationId || stageOperationId(
|
||||
current.operationId, attachments.length === 1 ? 'attachment' : 'attachment-' + index,
|
||||
)).slice(0, 128),
|
||||
},
|
||||
body:attachmentMultipart(attachments[index]),
|
||||
});
|
||||
const markdown = String(uploaded?.markdown || '');
|
||||
if (!markdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
markdowns.push(markdown);
|
||||
await checkpointClaim(current, stored => ({
|
||||
...stored, attachmentMarkdowns:markdowns.slice(),
|
||||
...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}),
|
||||
}));
|
||||
current = {
|
||||
...current, attachmentMarkdowns:markdowns.slice(),
|
||||
...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
current,
|
||||
markdown:markdowns.map((value, index) =>
|
||||
evidenceMarkdown(attachments[index], value, index)).join('\n\n'),
|
||||
};
|
||||
}
|
||||
|
||||
async function deliverReplyRead(item) {
|
||||
let current = item;
|
||||
if (!current.replyConfirmed) {
|
||||
let attachmentMarkdown = current.attachmentMarkdown;
|
||||
if (current.attachment && !attachmentMarkdown) {
|
||||
const uploaded = await requestStage(current,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments', {
|
||||
method:'POST',
|
||||
headers:{ Accept:'application/json', 'Idempotency-Key':stageOperationId(current.operationId, 'attachment') },
|
||||
body:attachmentMultipart(current.attachment),
|
||||
});
|
||||
attachmentMarkdown = String(uploaded?.markdown || '');
|
||||
if (!attachmentMarkdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await checkpointClaim(current, stored => ({ ...stored, attachmentMarkdown }));
|
||||
current = { ...current, attachmentMarkdown };
|
||||
let attachmentMarkdown = '';
|
||||
if (conversationAttachments(current).length) {
|
||||
const uploaded = await uploadConversationAttachments(current,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments');
|
||||
current = uploaded.current;
|
||||
attachmentMarkdown = uploaded.markdown;
|
||||
}
|
||||
const text = String(current.body || '').trim();
|
||||
const replyBody = attachmentMarkdown ?
|
||||
(text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown) : text;
|
||||
const options = authoredRequest('', { ...current, body:replyBody }).options;
|
||||
if (current.attachment) options.headers['Idempotency-Key'] = stageOperationId(current.operationId, 'reply');
|
||||
if (conversationAttachments(current).length) options.headers['Idempotency-Key'] = stageOperationId(current.operationId, 'reply');
|
||||
await requestStage(
|
||||
current,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply',
|
||||
@@ -638,28 +687,9 @@ function createBackgroundIssueSync({
|
||||
async function deliverScreenshotComment(item) {
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
|
||||
let attachmentMarkdown = item.attachmentMarkdown;
|
||||
if (!attachmentMarkdown) {
|
||||
const uploaded = await requestStage(
|
||||
item,
|
||||
base + 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/attachments',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
|
||||
},
|
||||
body: attachmentMultipart(item.attachment),
|
||||
},
|
||||
);
|
||||
attachmentMarkdown = String(uploaded?.markdown || '');
|
||||
if (!attachmentMarkdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await checkpointClaim(item, current => ({ ...current, attachmentMarkdown }));
|
||||
}
|
||||
const uploaded = await uploadConversationAttachments(item,
|
||||
base + 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/attachments');
|
||||
const attachmentMarkdown = uploaded.markdown;
|
||||
const text = String(item.body || '').trim();
|
||||
return requestStage(
|
||||
item,
|
||||
@@ -675,26 +705,35 @@ function createBackgroundIssueSync({
|
||||
);
|
||||
}
|
||||
|
||||
async function deliverUpdateScreenshotReply(item) {
|
||||
async function deliverSearchReply(item) {
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
let current = item;
|
||||
let attachmentMarkdown = current.attachmentMarkdown;
|
||||
if (!attachmentMarkdown) {
|
||||
const uploaded = await requestStage(current,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments', {
|
||||
method:'POST',
|
||||
headers:{ Accept:'application/json', 'Idempotency-Key':stageOperationId(current.operationId, 'attachment') },
|
||||
body:attachmentMultipart(current.attachment),
|
||||
});
|
||||
attachmentMarkdown = String(uploaded?.markdown || '');
|
||||
if (!attachmentMarkdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await checkpointClaim(current, stored => ({ ...stored, attachmentMarkdown }));
|
||||
current = { ...current, attachmentMarkdown };
|
||||
let attachmentMarkdown = '';
|
||||
if (conversationAttachments(current).length) {
|
||||
const uploaded = await uploadConversationAttachments(current,
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(current.number) +
|
||||
'/preview/attachments?kind=' + encodeURIComponent(current.targetKind));
|
||||
current = uploaded.current;
|
||||
attachmentMarkdown = uploaded.markdown;
|
||||
}
|
||||
const text = String(current.body || '').trim();
|
||||
return requestStage(current,
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(current.number) +
|
||||
'/preview/comments?kind=' + encodeURIComponent(current.targetKind), {
|
||||
method:'POST',
|
||||
headers:{Accept:'application/json','Content-Type':'application/json',
|
||||
'Idempotency-Key':stageOperationId(current.operationId, 'comment')},
|
||||
body:JSON.stringify({body:attachmentMarkdown ?
|
||||
(text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown) : text}),
|
||||
});
|
||||
}
|
||||
|
||||
async function deliverUpdateScreenshotReply(item) {
|
||||
const uploaded = await uploadConversationAttachments(item,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/attachments');
|
||||
const current = uploaded.current;
|
||||
const attachmentMarkdown = uploaded.markdown;
|
||||
const text = String(current.body || '').trim();
|
||||
return requestStage(current,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply', {
|
||||
method:'POST',
|
||||
@@ -708,8 +747,9 @@ function createBackgroundIssueSync({
|
||||
const request = deliveryRequest(item);
|
||||
try {
|
||||
const delivered = item.kind === 'update-reply-read' ? await deliverReplyRead(item) :
|
||||
item.kind === 'update-reply' && item.attachment ? await deliverUpdateScreenshotReply(item) :
|
||||
item.attachment && ['issue-comment', 'pull-comment'].includes(item.kind) ?
|
||||
item.kind === 'search-reply' ? await deliverSearchReply(item) :
|
||||
item.kind === 'update-reply' && conversationAttachments(item).length ? await deliverUpdateScreenshotReply(item) :
|
||||
conversationAttachments(item).length && ['issue-comment', 'pull-comment'].includes(item.kind) ?
|
||||
await deliverScreenshotComment(item) : item.attachment && !item.kind ?
|
||||
await deliverIssueCapture(item) : item.attachments?.length && !item.kind ?
|
||||
await deliverIssueCapture(item) : item.blockers?.length && !item.kind ?
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
(function (root, factory) {
|
||||
const createConversationPhotoDrafts = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = createConversationPhotoDrafts;
|
||||
else root.createConversationPhotoDrafts = createConversationPhotoDrafts;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
return function createConversationPhotoDrafts({ store, lanes }) {
|
||||
const states = {};
|
||||
Object.entries(lanes || {}).forEach(([kind, lane]) => {
|
||||
states[kind] = { ...lane, target:null, generation:0, restoring:false, pending:Promise.resolve() };
|
||||
});
|
||||
|
||||
function state(kind) {
|
||||
const value = states[kind];
|
||||
if (!value) throw new Error('Unknown conversation photo draft lane.');
|
||||
return value;
|
||||
}
|
||||
|
||||
async function checkpoint(kind) {
|
||||
const current = state(kind);
|
||||
if (!current.target || current.restoring) return null;
|
||||
const target = { ...current.target };
|
||||
const attachments = await current.controller.serialize();
|
||||
const save = current.pending.then(() => store.save(target, attachments));
|
||||
current.pending = save.catch(() => null);
|
||||
try { return await save; }
|
||||
catch (error) { current.onError?.(error); throw error; }
|
||||
}
|
||||
|
||||
async function open(kind, target) {
|
||||
const current = state(kind);
|
||||
const generation = ++current.generation;
|
||||
current.target = { ...target };
|
||||
await current.pending;
|
||||
const attachments = await store.load(target);
|
||||
if (generation !== current.generation) return false;
|
||||
current.controller.clear();
|
||||
if (attachments?.length) {
|
||||
current.restoring = true;
|
||||
try { current.controller.restore(attachments); }
|
||||
finally { current.restoring = false; }
|
||||
}
|
||||
return Boolean(attachments?.length);
|
||||
}
|
||||
|
||||
async function leave(kind) {
|
||||
const current = state(kind);
|
||||
const generation = current.generation;
|
||||
if (!current.target) return true;
|
||||
try { await checkpoint(kind); }
|
||||
catch (_error) { return false; }
|
||||
if (generation === current.generation) {
|
||||
current.controller.clear();
|
||||
current.target = null;
|
||||
current.generation += 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function complete(kind) {
|
||||
const current = state(kind);
|
||||
if (!current.target) return false;
|
||||
const target = { ...current.target };
|
||||
await current.pending;
|
||||
await store.remove(target);
|
||||
current.controller.clear();
|
||||
current.target = null;
|
||||
current.generation += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function switchTo(kind, target, isCurrent = () => true) {
|
||||
if (!await leave(kind) || !isCurrent()) return false;
|
||||
return open(kind, target);
|
||||
}
|
||||
|
||||
function hasTarget(kind) { return Boolean(state(kind).target); }
|
||||
return { checkpoint, open, switchTo, leave, complete, hasTarget };
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
(function (root, factory) {
|
||||
const createConversationReplyDraftStore = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = createConversationReplyDraftStore;
|
||||
else root.createConversationReplyDraftStore = createConversationReplyDraftStore;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
const dbName = 'stackchain-conversation-reply-drafts-v1';
|
||||
const storeName = 'drafts';
|
||||
|
||||
function requestResult(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result ?? null);
|
||||
request.onerror = () => reject(request.error || new Error('Conversation photo draft storage failed.'));
|
||||
});
|
||||
}
|
||||
|
||||
function createTransaction(indexedDB) {
|
||||
if (!indexedDB) return null;
|
||||
let databasePromise;
|
||||
function database() {
|
||||
if (!databasePromise) databasePromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(dbName, 1);
|
||||
request.onupgradeneeded = () => {
|
||||
if (!request.result.objectStoreNames.contains(storeName)) {
|
||||
request.result.createObjectStore(storeName, { keyPath:'id' });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error('Conversation photo draft storage is unavailable.'));
|
||||
});
|
||||
return databasePromise;
|
||||
}
|
||||
return async (operation, key, value) => {
|
||||
const db = await database();
|
||||
const transaction = db.transaction(storeName, operation === 'get' ? 'readonly' : 'readwrite');
|
||||
const records = transaction.objectStore(storeName);
|
||||
if (operation === 'put') return requestResult(records.put(value));
|
||||
if (operation === 'delete') return requestResult(records.delete(key));
|
||||
return requestResult(records.get(key));
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedTarget(target) {
|
||||
const kind = String(target?.kind || '');
|
||||
if (kind === 'update') {
|
||||
const notificationId = Number(target?.notificationId || target?.notification_id || 0);
|
||||
if (!Number.isInteger(notificationId) || notificationId < 1) {
|
||||
throw new Error('Open an unread update before saving photo evidence.');
|
||||
}
|
||||
return { kind, notificationId };
|
||||
}
|
||||
const repository = String(target?.repository || '');
|
||||
const number = Number(target?.number || 0);
|
||||
if (!['issue', 'pull'].includes(kind) || !repository || !Number.isInteger(number) || number < 1) {
|
||||
throw new Error('Open a conversation before saving photo evidence.');
|
||||
}
|
||||
return { kind, repository, number };
|
||||
}
|
||||
|
||||
function attachment(value) {
|
||||
const blob = value?.blob;
|
||||
const data = String(value?.data || '');
|
||||
if (!blob && !data) throw new Error('A saved conversation photo is unavailable.');
|
||||
const note = String(value?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
|
||||
const operationId = String(value?.operationId || '').slice(0, 128);
|
||||
const markdown = String(value?.confirmed?.markdown || '');
|
||||
return {
|
||||
filename:String(value?.filename || ''), contentType:String(value?.contentType || ''),
|
||||
...(blob ? { blob } : { data }), ...(note ? { note } : {}),
|
||||
...(operationId ? { operationId } : {}), ...(markdown ? { confirmed:{ markdown } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return function createConversationReplyDraftStore({
|
||||
indexedDB = globalThis.indexedDB, transaction, getOwnerLogin = () => '',
|
||||
} = {}) {
|
||||
const transact = transaction || createTransaction(indexedDB);
|
||||
|
||||
function identity(target) {
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Confirm your Gitea account before saving conversation photos.');
|
||||
const normalized = normalizedTarget(target);
|
||||
const targetKey = normalized.kind === 'update' ? normalized.notificationId :
|
||||
normalized.repository + ':' + normalized.number;
|
||||
return {
|
||||
ownerLogin, target:normalized,
|
||||
id:[ownerLogin, normalized.kind, targetKey].map(value => encodeURIComponent(String(value))).join(':'),
|
||||
};
|
||||
}
|
||||
|
||||
async function save(target, values) {
|
||||
if (!transact) throw new Error('Photo draft storage needs IndexedDB. Your current photos are still here.');
|
||||
const { id, ownerLogin, target:normalized } = identity(target);
|
||||
const list = (Array.isArray(values) ? values : [values]).filter(Boolean).slice(0, 5).map(attachment);
|
||||
if (!list.length) { await transact('delete', id); return null; }
|
||||
await transact('put', id, { id, version:1, ownerLogin, ...normalized, attachments:list });
|
||||
return list;
|
||||
}
|
||||
|
||||
async function load(target) {
|
||||
if (!transact) return null;
|
||||
const { id, ownerLogin, target:normalized } = identity(target);
|
||||
const record = await transact('get', id);
|
||||
const same = record?.version === 1 && record.ownerLogin === ownerLogin &&
|
||||
record.kind === normalized.kind && (normalized.kind === 'update' ?
|
||||
Number(record.notificationId) === normalized.notificationId :
|
||||
record.repository === normalized.repository && Number(record.number) === normalized.number);
|
||||
if (!same || !Array.isArray(record.attachments) || !record.attachments.length) return null;
|
||||
return record.attachments.slice(0, 5).map(attachment);
|
||||
}
|
||||
|
||||
async function remove(target) {
|
||||
if (!transact) return false;
|
||||
const { id } = identity(target);
|
||||
await transact('delete', id);
|
||||
return true;
|
||||
}
|
||||
|
||||
return { save, load, remove };
|
||||
};
|
||||
});
|
||||
+209
-2
@@ -26,6 +26,7 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
|
||||
.app-menu { margin-left:auto; }
|
||||
.app-menu > summary { display:none; }
|
||||
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
#open-insights { display:none; }
|
||||
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
|
||||
.notification-undo { position:fixed; z-index:110; left:50%; bottom:calc(88px + env(safe-area-inset-bottom)); transform:translateX(-50%); box-sizing:border-box; width:min(520px,calc(100vw - 24px)); display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 12px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; box-shadow:0 12px 36px rgba(0,0,0,.5); overflow-wrap:anywhere; }
|
||||
.notification-undo[hidden] { display:none; }
|
||||
@@ -45,6 +46,11 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
|
||||
.active-devices-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.active-devices-header h2, .active-devices-header p { margin-top:0; }
|
||||
.active-devices-header button, .active-device button, .enrolled-passkey button { min-height:44px; }
|
||||
.security-section-nav { position:sticky; top:-18px; z-index:2; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin:0 -2px; padding:10px 2px; background:#0b1526; }
|
||||
.security-section-nav button { min-height:44px; min-width:0; }
|
||||
.security-section-nav button[aria-current="page"] { border-color:#55d6be; color:#55d6be; }
|
||||
.security-activity, .security-devices, .enrolled-passkeys { scroll-margin-top:64px; }
|
||||
.security-devices { margin-top:24px; padding-top:18px; border-top:1px solid #2a496e; }
|
||||
.active-devices-list { display:grid; gap:10px; margin-top:16px; }
|
||||
.device-setup-sheet { position:fixed; inset:0; z-index:95; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.device-setup-sheet[hidden] { display:none; }
|
||||
@@ -59,6 +65,10 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
|
||||
.device-setup-deadline-controls label { font-size:12px; color:#bfdbfe; }
|
||||
.device-setup-deadline-controls select, .device-setup-deadline-controls button, #push-deadline-hour, #push-deadline-days { min-height:44px; }
|
||||
.device-setup-ready { margin:0; padding:12px; border-radius:10px; background:#0f2237; color:#bfdbfe; font-weight:700; }
|
||||
.device-storage { display:grid; gap:12px; margin:14px 0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#0d1d31; }
|
||||
.device-storage h3, .device-storage p { margin:0 0 5px; }
|
||||
.device-storage-actions { display:flex; gap:8px; flex-wrap:wrap; }
|
||||
.device-storage-actions button { min-height:44px; flex:1 1 180px; }
|
||||
.device-readiness-card { display:none; min-width:0; margin:10px 0; padding:12px; border:1px solid #3b82b8; border-radius:12px; background:#102b46; overflow-x:hidden; }
|
||||
.device-readiness-card p { margin:5px 0 0; overflow-wrap:anywhere; }
|
||||
.device-readiness-summary { color:#bfdbfe; font-weight:700; }
|
||||
@@ -76,6 +86,7 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
|
||||
.security-activity-header h3, .security-activity-header p { margin:0 0 6px; }
|
||||
.security-activity-list { display:grid; gap:8px; margin:12px 0; }
|
||||
.security-event { padding:12px; border:1px solid #243d5d; border-radius:12px; background:#0d1c30; }
|
||||
.security-event button { min-height:44px; margin-top:10px; }
|
||||
.authentication-alert { border-color:#d69e2e; background:#241b09; }
|
||||
.authentication-alert strong { color:#f6c453; }
|
||||
.security-event strong, .security-event span { display:block; overflow-wrap:anywhere; }
|
||||
@@ -91,6 +102,8 @@ button:hover { filter: brightness(1.15); }
|
||||
.panel[open] > summary { margin-bottom: 8px; }
|
||||
aside, section { position: relative; z-index: 1; }
|
||||
main { position: relative; z-index: 1; display: grid; grid-template-columns: 300px 1fr 340px; gap: 14px; padding: 14px; }
|
||||
.insights-shell { display:contents; }
|
||||
.insights-header { display:none; }
|
||||
@media (max-width: 1100px) { main { grid-template-columns: 1fr; } }
|
||||
.sidebar { display:flex; flex-direction:column; gap: 12px; }
|
||||
.stack { display:flex; flex-direction:column; gap:10px; }
|
||||
@@ -201,6 +214,11 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.plan-today-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.plan-today-header h2, .plan-today-header p { margin-top:0; }
|
||||
.plan-today-header button { min-width:44px; min-height:44px; }
|
||||
.mobile-plan-today-nav { position:sticky; top:0; z-index:5; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:4px; margin:0 -18px 10px; padding:4px 18px; background:rgba(11,21,38,.98); border-block:1px solid #2a496e; }
|
||||
.mobile-plan-today-nav button { min-width:0; min-height:44px; padding:4px; border-color:transparent; font-size:12px; }
|
||||
.mobile-plan-today-nav button[aria-current="location"] { color:#bfdbfe; background:#17365a; border-color:#31577f; }
|
||||
#plan-today-fit, #plan-today-selected, #plan-today-available-work { scroll-margin-top:54px; }
|
||||
@media (min-width:601px) { .mobile-plan-today-nav { display:none; } }
|
||||
.plan-today-list button, .plan-today-candidates button { min-height:44px; }
|
||||
.plan-today-capacity { position:sticky; top:0; z-index:2; margin:8px 0; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#10233a; font-weight:700; }
|
||||
.plan-today-available { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:10px 0; font-weight:700; }
|
||||
@@ -253,12 +271,39 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.today-recap-totals { margin:12px 0; padding:12px; border-radius:10px; background:#10233d; font-weight:700; }
|
||||
.today-recap-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.today-recap-actions button { min-height:44px; width:100%; }
|
||||
.today-wrap-up-sheet { position:fixed; inset:0; z-index:89; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-wrap-up-sheet[hidden] { display:none; }
|
||||
.today-wrap-up-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.today-wrap-up-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.today-wrap-up-header h2 { margin:.2rem 0; }
|
||||
.today-wrap-up-header button { min-height:44px; min-width:44px; }
|
||||
.today-wrap-up-items { display:grid; gap:8px; margin:14px 0; }
|
||||
.today-wrap-up-item { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:12px; min-width:0; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#10233d; overflow-wrap:anywhere; }
|
||||
.today-wrap-up-item span { display:block; }
|
||||
.today-wrap-up-item label { display:flex; align-items:center; min-height:44px; gap:8px; font-weight:700; }
|
||||
.today-wrap-up-item input { width:24px; min-height:24px; }
|
||||
.today-wrap-up-actions { position:sticky; bottom:0; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.today-wrap-up-actions button { min-height:44px; width:100%; }
|
||||
.today-handoff-dialog { box-sizing:border-box; width:min(620px,100%); max-width:none; max-height:100dvh; margin:auto auto 0; padding:0; color:#e8f1ff; border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.today-handoff-dialog::backdrop { background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-handoff-panel { max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); }
|
||||
.today-handoff-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.today-handoff-panel h2 { margin:.2rem 0; }
|
||||
.today-handoff-panel header button { min-height:44px; min-width:44px; }
|
||||
.today-handoff-items { display:grid; gap:8px; margin:14px 0; }
|
||||
.today-handoff-item { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:12px; padding:12px; border:1px solid #29486c; border-radius:12px; background:#101f35; }
|
||||
.today-handoff-item span { display:block; }
|
||||
.today-handoff-item label { display:flex; align-items:center; min-height:44px; gap:8px; font-weight:700; }
|
||||
.today-handoff-item input { width:24px; min-height:24px; }
|
||||
.today-handoff-actions { position:sticky; bottom:0; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.today-handoff-actions button { min-height:44px; width:100%; }
|
||||
@media (max-width:420px) {
|
||||
.today-interruption-actions { grid-template-columns:1fr; }
|
||||
.today-recap-actions { grid-template-columns:1fr; }
|
||||
.today-recap-row { grid-template-columns:1fr; }
|
||||
.today-recap-row input { width:min(100%,8rem); }
|
||||
.today-recap-variance { grid-column:1; }
|
||||
.today-wrap-up-item { grid-template-columns:1fr; }
|
||||
}
|
||||
.today-readiness-sheet { position:fixed; inset:0; z-index:90; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-readiness-sheet[hidden] { display:none; }
|
||||
@@ -469,6 +514,30 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.issue-sheet-header button { min-height:44px; }
|
||||
.mobile-issue-detail-nav, .mobile-pull-detail-nav, .mobile-update-detail-nav, .mobile-review-detail-nav { display:none; }
|
||||
@media (max-width:600px) {
|
||||
.issue-sheet-panel, .pull-sheet-panel, .update-sheet-panel, .review-sheet-panel { padding-top:max(12px,env(safe-area-inset-top)); }
|
||||
.mobile-issue-detail-nav, .mobile-pull-detail-nav, .mobile-update-detail-nav, .mobile-review-detail-nav {
|
||||
position:sticky; top:env(safe-area-inset-top); z-index:6;
|
||||
display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:4px;
|
||||
margin:8px -6px 12px; padding:6px;
|
||||
background:rgba(11,21,38,.98); border-block:1px solid #2a496e;
|
||||
}
|
||||
.mobile-issue-detail-nav button, .mobile-pull-detail-nav button, .mobile-update-detail-nav button, .mobile-review-detail-nav button {
|
||||
min-width:0; min-height:44px; padding:4px; overflow-wrap:anywhere;
|
||||
border-color:transparent; font-size:12px;
|
||||
}
|
||||
.mobile-issue-detail-nav button[aria-current="location"], .mobile-pull-detail-nav button[aria-current="location"], .mobile-update-detail-nav button[aria-current="location"], .mobile-review-detail-nav button[aria-current="location"] {
|
||||
border-color:#60a5fa; background:#17365a; color:#fff;
|
||||
}
|
||||
#issue-overview, #issue-conversation, #issue-comment, #issue-planning { scroll-margin-top:72px; }
|
||||
#pull-overview, #pull-conversation, #pull-comment, #pull-review { scroll-margin-top:72px; }
|
||||
#update-conversation, #update-subject-context, #update-reply-workspace { scroll-margin-top:72px; }
|
||||
#review-overview, #review-files-workspace, #review-feedback, #review-history-workspace { scroll-margin-top:72px; }
|
||||
}
|
||||
@media (min-width:601px) { .mobile-issue-detail-nav, .mobile-pull-detail-nav { display:none; } }
|
||||
@media (min-width:601px) { .mobile-update-detail-nav { display:none; } }
|
||||
@media (min-width:601px) { .mobile-review-detail-nav { display:none; } }
|
||||
.completed-filed-actions { position:fixed; right:0; bottom:0; z-index:57; box-sizing:border-box; width:min(560px,100%); display:grid; grid-template-columns:minmax(0,1fr); align-items:center; gap:8px; margin:0; padding:10px 12px calc(10px + env(safe-area-inset-bottom)); border:1px solid #4ade80; border-radius:12px 0 0; background:rgba(11,21,38,.98); overflow-wrap:anywhere; }
|
||||
.completed-filed-actions[hidden] { display:none; }
|
||||
.completed-filed-actions button { min-height:44px; min-width:0; }
|
||||
@@ -528,6 +597,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
|
||||
.issue-attachment-controls { display:flex; max-width:100%; }
|
||||
.issue-attachment-trigger { min-height:44px; display:inline-flex; align-items:center; justify-content:center; padding:8px 12px; border:1px solid #60a5fa; border-radius:10px; color:#dbeafe; font-weight:700; cursor:pointer; }
|
||||
.conversation-photo-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; min-width:0; width:100%; }
|
||||
.conversation-photo-actions .issue-attachment-trigger { box-sizing:border-box; width:100%; min-width:0; text-align:center; }
|
||||
.issue-attachment-preview { display:grid; grid-template-columns:64px minmax(0,1fr); gap:8px 12px; align-items:center; max-width:100%; overflow:hidden; padding:10px; border:1px solid #315781; border-radius:10px; background:#101f34; }
|
||||
.issue-attachment-preview[hidden] { display:none; }
|
||||
.issue-attachment-preview img { grid-row:span 2; width:64px; height:64px; object-fit:cover; border-radius:8px; }
|
||||
@@ -586,9 +657,21 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.work-session-nav button { min-height:44px; width:100%; }
|
||||
.find-work-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.find-work-sheet.open { display:flex; }
|
||||
.find-work-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.find-work-panel { width:min(560px,100%); height:100dvh; overflow:auto; overflow-x:hidden; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.find-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.find-work-header-actions { display:flex; gap:8px; }
|
||||
.mobile-find-work-nav { position:sticky; top:0; z-index:4; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:6px; padding:6px 0; background:#0b1526; }
|
||||
.mobile-find-work-nav button { min-height:44px; min-width:0; padding:6px; }
|
||||
.mobile-find-work-nav button[aria-current="step"] { border-color:#60a5fa; background:#17355b; color:#fff; }
|
||||
.mobile-find-work-nav button:disabled { opacity:.5; }
|
||||
.find-work-stage { display:grid; gap:12px; min-width:0; outline:none; }
|
||||
.find-work-stage[hidden] { display:none; }
|
||||
.find-work-review { padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; }
|
||||
.find-work-review > div:first-child, .find-work-review-list { display:grid; gap:8px; }
|
||||
.find-work-review-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:10px; align-items:center; padding:10px; border:1px solid #2a496e; border-radius:10px; overflow-wrap:anywhere; }
|
||||
.find-work-review-item > div { display:grid; gap:4px; min-width:0; }
|
||||
.find-work-review-item button, .find-work-stage-actions button { min-height:44px; }
|
||||
.find-work-stage-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0 env(safe-area-inset-bottom); background:#101f36; }
|
||||
.find-work-header button, .find-work-card button, .find-work-card a, .find-work-more { min-height:44px; }
|
||||
.find-work-search { position:sticky; top:0; z-index:2; display:grid; gap:6px; padding:8px 0; background:#0b1526; }
|
||||
.find-work-search > div { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }
|
||||
@@ -628,8 +711,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.find-work-detail a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.search-preview { position:fixed; inset:0; z-index:60; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.search-preview.open { display:flex; }
|
||||
.search-preview-panel { box-sizing:border-box; width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; overflow-wrap:anywhere; }
|
||||
.search-preview-panel { box-sizing:border-box; width:min(560px,100%); height:100dvh; overflow:auto; overflow-x:hidden; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; overflow-wrap:anywhere; }
|
||||
.search-preview-header { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; }
|
||||
.mobile-search-preview-nav { display:none; }
|
||||
.search-preview-overview { display:grid; gap:12px; min-width:0; }
|
||||
.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; }
|
||||
@@ -642,6 +727,9 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.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-reply .voice-conversation-controls button,
|
||||
.search-preview-reply .voice-conversation-review-actions button { min-height:44px; }
|
||||
.search-preview-reply .conversation-photo-actions { max-width:100%; }
|
||||
.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; }
|
||||
@@ -649,6 +737,33 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
@media (max-width:420px) {
|
||||
.search-preview-primary-actions { grid-template-columns:1fr; }
|
||||
}
|
||||
@media (max-width:600px) {
|
||||
.search-preview-panel { width:100%; border-left:0; padding:14px; padding-top:max(12px,env(safe-area-inset-top)); }
|
||||
.mobile-search-preview-nav {
|
||||
position:sticky; top:env(safe-area-inset-top); z-index:6;
|
||||
display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:4px;
|
||||
margin-inline:-14px; padding:4px 14px;
|
||||
background:rgba(11,21,38,.98); border-block:1px solid #2a496e;
|
||||
}
|
||||
.mobile-search-preview-nav button {
|
||||
min-width:0; min-height:44px; padding:4px; overflow-wrap:anywhere;
|
||||
border-color:transparent; font-size:12px;
|
||||
}
|
||||
.mobile-search-preview-nav button[aria-current="location"] {
|
||||
border-color:#60a5fa; background:#17365a; color:#fff;
|
||||
}
|
||||
.mobile-search-preview-nav button[aria-disabled="true"] { opacity:.55; }
|
||||
.search-preview-navigation {
|
||||
position:sticky; bottom:0; z-index:5;
|
||||
margin-inline:-6px; padding:6px;
|
||||
padding-bottom:calc(6px + env(safe-area-inset-bottom));
|
||||
background:rgba(11,21,38,.98); border-block:1px solid #2a496e;
|
||||
}
|
||||
#search-preview-overview, #search-preview-conversation,
|
||||
#search-preview-reply-workspace, #search-preview-actions { scroll-margin-top:72px; }
|
||||
#search-preview-actions { position:static; }
|
||||
}
|
||||
@media (min-width:601px) { .mobile-search-preview-nav { display:none; } }
|
||||
.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; }
|
||||
@@ -672,18 +787,28 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; overflow-x:hidden; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.create-issue-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.create-issue-header button, .create-issue-actions button, .create-issue-capture-actions button { min-height:44px; }
|
||||
.mobile-create-issue-nav { display:none; }
|
||||
.create-issue-form { display:grid; gap:12px; }
|
||||
.create-issue-describe { display:grid; gap:12px; min-width:0; }
|
||||
.voice-issue-capture { display:grid; gap:8px; min-width:0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#101d31; }
|
||||
.voice-issue-capture[hidden], .voice-issue-review[hidden], .voice-issue-controls button[hidden] { display:none; }
|
||||
.voice-issue-heading, .voice-issue-review { display:grid; gap:6px; }
|
||||
.voice-issue-controls, .voice-issue-review-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.voice-issue-controls button, .voice-issue-review-actions button { min-width:0; min-height:44px; }
|
||||
#voice-issue-transcript { box-sizing:border-box; width:100%; min-height:96px; resize:vertical; }
|
||||
.voice-conversation { display:grid; gap:8px; min-width:0; padding:10px; border:1px solid #31577f; border-radius:12px; background:#101d31; }
|
||||
.voice-conversation[hidden], .voice-conversation-review[hidden], .voice-conversation-controls button[hidden] { display:none; }
|
||||
.voice-conversation-controls, .voice-conversation-review-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||
.voice-conversation-controls button, .voice-conversation-review-actions button { min-height:44px; min-width:0; }
|
||||
.voice-conversation-review { display:grid; gap:6px; }
|
||||
.voice-conversation-review textarea { box-sizing:border-box; width:100%; min-height:96px; resize:vertical; }
|
||||
.create-issue-capture-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.create-issue-capture-actions[hidden] { display:none; }
|
||||
.create-issue-filing { display:grid; gap:12px; min-width:0; }
|
||||
.create-issue-filing[hidden] { display:none; }
|
||||
.create-issue-attachment { display:grid; gap:8px; min-width:0; }
|
||||
.photo-evidence-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; min-width:0; }
|
||||
.photo-evidence-actions .issue-attachment-trigger { box-sizing:border-box; width:100%; min-width:0; text-align:center; }
|
||||
#create-issue-attachment-preview { grid-template-columns:1fr; }
|
||||
#create-issue-attachment-image { grid-row:auto; width:100%; height:auto; max-height:320px; }
|
||||
.issue-evidence-tray { display:flex; gap:8px; max-width:100%; padding:2px 0 6px; overflow-x:auto; overscroll-behavior-inline:contain; scrollbar-width:thin; }
|
||||
@@ -697,6 +822,14 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.issue-evidence-note textarea:focus-visible { outline:2px solid #60a5fa; outline-offset:2px; }
|
||||
.issue-evidence-review-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.issue-evidence-review-actions button { min-width:44px; min-height:44px; }
|
||||
.conversation-evidence-review { grid-template-columns:1fr; width:100%; min-width:0; box-sizing:border-box; overflow-x:hidden; }
|
||||
.conversation-evidence-review > img { grid-row:auto; width:100%; height:auto; max-height:280px; }
|
||||
.conversation-evidence-review .issue-evidence-tray { overflow-x:auto; }
|
||||
.conversation-evidence-review .issue-evidence-review-actions { grid-template-columns:repeat(4,minmax(0,1fr)); }
|
||||
.conversation-evidence-review .issue-evidence-review-actions button { min-width:44px; min-height:44px; width:100%; }
|
||||
@media (max-width:390px) {
|
||||
.conversation-evidence-review .issue-evidence-review-actions { grid-template-columns:1fr; }
|
||||
}
|
||||
.issue-evidence-editor { position:fixed; inset:0; z-index:70; display:grid; place-items:end center; overflow-x:hidden; background:rgba(2,6,15,.92); }
|
||||
.issue-evidence-editor[hidden] { display:none; }
|
||||
.issue-evidence-editor-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; display:grid; gap:12px; padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); background:#0b1526; border:1px solid #2a496e; border-radius:14px 14px 0 0; }
|
||||
@@ -812,6 +945,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.app-menu:not([open]) > .app-menu-panel { display:none; }
|
||||
.app-menu-panel { position:absolute; top:calc(100% + 6px); right:0; width:min(280px,calc(100vw - 20px)); display:grid; gap:8px; padding:10px; border:1px solid #2a496e; border-radius:12px; background:#0b1526; box-shadow:0 18px 45px rgba(0,0,0,.5); }
|
||||
.app-menu-panel button { min-height:44px; width:100%; }
|
||||
#open-insights { display:block; }
|
||||
.insights-shell { display:none; }
|
||||
.insights-shell[data-mobile-open="true"] { position:fixed; inset:0; z-index:90; display:grid; align-content:start; gap:12px; overflow:auto; overflow-x:hidden; padding:max(12px,env(safe-area-inset-top)) max(12px,env(safe-area-inset-right)) max(12px,env(safe-area-inset-bottom)) max(12px,env(safe-area-inset-left)); background:#050c15; }
|
||||
.insights-header { position:sticky; top:0; z-index:2; display:flex; align-items:flex-start; justify-content:space-between; gap:12px; padding:10px; border:1px solid #2a496e; border-radius:12px; background:#0b1526; }
|
||||
.insights-header h2, .insights-header p { margin:0 0 4px; }
|
||||
.insights-header button { min-height:44px; flex:none; }
|
||||
.device-setup-panel { width:100%; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); }
|
||||
.device-setup-step { grid-template-columns:1fr; }
|
||||
.device-setup-action { width:100%; }
|
||||
@@ -832,6 +971,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.card-planning:not([open]) > .card-planning-actions { display:none; }
|
||||
.card-planning[open] > .card-planning-actions { display:grid; margin-top:8px; }
|
||||
.plan-today-panel { width:100%; border-left:0; padding:14px; }
|
||||
.mobile-plan-today-nav { margin-inline:-14px; padding-inline:14px; }
|
||||
.plan-today-capacity { position:static; }
|
||||
.plan-today-item { grid-template-columns:1fr; }
|
||||
.plan-today-item-actions { display:grid; grid-template-columns:repeat(3,1fr); width:100%; }
|
||||
.plan-today-candidate-actions { grid-template-columns:repeat(2,1fr); width:100%; }
|
||||
@@ -863,6 +1004,20 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.saved-search-row { align-items:stretch; }
|
||||
#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; }
|
||||
.mobile-create-issue-nav {
|
||||
position:sticky; top:env(safe-area-inset-top); z-index:6;
|
||||
display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:4px;
|
||||
margin-inline:-14px; padding:4px 14px;
|
||||
background:rgba(11,21,38,.98); border-block:1px solid #2a496e;
|
||||
}
|
||||
.mobile-create-issue-nav button {
|
||||
min-width:0; min-height:44px; padding:4px; border-color:transparent; font-size:12px;
|
||||
}
|
||||
.mobile-create-issue-nav button[aria-current="location"] {
|
||||
border-color:#60a5fa; background:#17365a; color:#fff;
|
||||
}
|
||||
.mobile-create-issue-nav button[aria-disabled="true"] { opacity:.55; }
|
||||
#create-issue-describe, #create-issue-evidence, #create-issue-filing { scroll-margin-top:72px; }
|
||||
.pull-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.create-issue-panel.composer-keyboard-active,
|
||||
.issue-sheet-panel.composer-keyboard-active,
|
||||
@@ -882,6 +1037,14 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.mobile-queue-panel { padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
|
||||
.mobile-queue-panel header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.mobile-queue-panel h2 { margin:0; }
|
||||
.mobile-delivery-recovery { box-sizing:border-box; width:100%; max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
||||
.mobile-delivery-recovery::backdrop { background:rgba(3,9,18,.78); }
|
||||
.mobile-delivery-recovery-panel { box-sizing:border-box; display:grid; gap:12px; width:100%; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); overflow-wrap:anywhere; }
|
||||
.mobile-delivery-recovery-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.mobile-delivery-recovery-panel h2, .mobile-delivery-recovery-panel p { margin:0; }
|
||||
.mobile-delivery-recovery-panel button { min-height:48px; }
|
||||
.mobile-delivery-recovery-panel > #mobile-delivery-recovery-action { width:100%; font-weight:800; }
|
||||
.delivery-recovery-reason { padding:12px; border:1px solid #31577f; border-radius:12px; background:#0b1c30; }
|
||||
.mobile-start-day { display:grid; gap:12px; max-width:100%; overflow-wrap:anywhere; margin-top:12px; padding:14px; border:1px solid #31577f; border-radius:14px; background:linear-gradient(135deg,#173b64,#102641); }
|
||||
.mobile-start-day h3, .mobile-start-day p { margin:0; }
|
||||
.mobile-start-day p + p { margin-top:4px; }
|
||||
@@ -912,3 +1075,47 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.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); } }
|
||||
/* One-handed live-data recovery on the mobile Home surface. */
|
||||
.mobile-pull-refresh {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.mobile-pull-refresh {
|
||||
position: sticky;
|
||||
top: calc(3.25rem + env(safe-area-inset-top));
|
||||
z-index: 8;
|
||||
display: block;
|
||||
width: max-content;
|
||||
max-width: calc(100vw - 2rem);
|
||||
margin: 0 auto -2rem;
|
||||
padding: .45rem .8rem;
|
||||
border: 1px solid rgba(125, 211, 252, .45);
|
||||
border-radius: 999px;
|
||||
background: rgba(11, 18, 32, .96);
|
||||
color: #dbeafe;
|
||||
font-size: .78rem;
|
||||
box-shadow: 0 .35rem 1rem rgba(0, 0, 0, .28);
|
||||
transform: translateY(.5rem);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mobile-pull-refresh[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-pull-refresh[data-state="ready"],
|
||||
.mobile-pull-refresh[data-state="success"] {
|
||||
border-color: rgba(52, 211, 153, .7);
|
||||
}
|
||||
|
||||
.mobile-pull-refresh[data-state="error"] {
|
||||
border-color: rgba(251, 113, 133, .75);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) and (prefers-reduced-motion: no-preference) {
|
||||
.mobile-pull-refresh {
|
||||
transition: transform 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
}
|
||||
+477
-89
@@ -1,4 +1,5 @@
|
||||
(async function(){
|
||||
const workspaceLifecycle = await loadWorkspace({ document, window });
|
||||
const qs = (s, el=document) => el.querySelector(s);
|
||||
const fmt = (d) => new Date(d).toLocaleString();
|
||||
const cardPlanning = createCardPlanning(document);
|
||||
@@ -15,6 +16,36 @@
|
||||
],
|
||||
});
|
||||
mobileComposerViewport.start();
|
||||
const issueDetailPanel = qs('#issue-sheet .issue-sheet-panel');
|
||||
const mobileIssueDetailNavigation = createMobileIssueDetailNavigation({
|
||||
root:issueDetailPanel,
|
||||
buttons:Object.fromEntries(Array.from(document.querySelectorAll('[data-issue-section]')).map(button => [button.dataset.issueSection, button])),
|
||||
targets:{
|
||||
overview:qs('#issue-overview'),
|
||||
conversation:qs('#issue-conversation'),
|
||||
reply:qs('#issue-comment'),
|
||||
actions:qs('#issue-planning'),
|
||||
},
|
||||
planning:qs('#issue-planning'),
|
||||
onSectionChange:(section, options) => workRoute.section(section, options),
|
||||
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
});
|
||||
mobileIssueDetailNavigation.start();
|
||||
const pullDetailPanel = qs('#pull-sheet .pull-sheet-panel');
|
||||
const mobilePullDetailNavigation = createMobileIssueDetailNavigation({
|
||||
root:pullDetailPanel,
|
||||
buttons:Object.fromEntries(Array.from(document.querySelectorAll('[data-pull-section]')).map(button => [button.dataset.pullSection, button])),
|
||||
targets:{
|
||||
overview:qs('#pull-overview'),
|
||||
conversation:qs('#pull-conversation'),
|
||||
reply:qs('#pull-comment'),
|
||||
review:qs('#pull-review'),
|
||||
},
|
||||
beforeNavigate:{review(target) { target.open = true; }},
|
||||
onSectionChange:(section, options) => workRoute.section(section, options),
|
||||
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
});
|
||||
mobilePullDetailNavigation.start();
|
||||
[
|
||||
[qs('.app-menu'), qs('#app-menu-toggle')],
|
||||
[qs('.work-settings'), qs('#work-settings-toggle')],
|
||||
@@ -59,7 +90,23 @@
|
||||
qs('#my-work').scrollIntoView({block:'start'});
|
||||
qs('#my-work').focus();
|
||||
}
|
||||
async function recoverMobileDelivery(item) {
|
||||
if (!item?.outbox_id || !activeFlushLogin) throw new Error('Sign in again to retry.');
|
||||
const authored = item.kind === 'authored-outbox';
|
||||
const result = await (authored ? authoredOutbox : issueOutbox).retry(item.outbox_id, activeFlushLogin);
|
||||
(authored ? applyAuthoredOutboxResult : applyOutboxResult)(result);
|
||||
}
|
||||
let mobileQueueCounts = {};
|
||||
const mobileDeliveryRecovery = createMobileDeliveryRecovery({
|
||||
getItems: () => draftInbox.partition(lastDrafts).deliveries,
|
||||
getIndex: item => lastDrafts.indexOf(item),
|
||||
activate: recoverMobileDelivery,
|
||||
beforeOpen: () => selectMobileQueue('draft'),
|
||||
onComplete: () => {
|
||||
if (!mobileStartDay.completePhase('delivery')) showMobileQueueCompletion('Delivery', true);
|
||||
},
|
||||
});
|
||||
mobileDeliveryRecovery.start();
|
||||
function openFiledFollowUp() {
|
||||
selectMobileQueue('filed');
|
||||
const target = filedFollowUpTarget(completedFiledReview.visible(lastMyWork));
|
||||
@@ -73,6 +120,7 @@
|
||||
return target.kind === 'update' ? 'opened-update' : 'opened-issue';
|
||||
}
|
||||
const mobileQueueLauncher = createMobileQueueLauncher({
|
||||
openDelivery: () => mobileDeliveryRecovery.open(),
|
||||
openToday: () => mobileWorkEntry.open(),
|
||||
openAgenda: openAgendaSession,
|
||||
openUpdates: openUpdateTriage,
|
||||
@@ -176,6 +224,20 @@
|
||||
},
|
||||
});
|
||||
mobileTaskDock.start();
|
||||
const mobileInsights = createMobileInsights({
|
||||
root: qs('#insights-sheet'),
|
||||
launcher: qs('#open-insights'),
|
||||
closeButton: qs('#close-insights'),
|
||||
dock: qs('#mobile-task-dock'),
|
||||
hud: qs('[data-mobile-today-hud]'),
|
||||
backgrounds: [qs('header'), qs('#my-work')],
|
||||
menu: qs('.app-menu'),
|
||||
history: window.history,
|
||||
location: window.location,
|
||||
eventTarget: window,
|
||||
mediaQuery: window.matchMedia('(max-width: 600px)'),
|
||||
});
|
||||
mobileInsights.start();
|
||||
qs('#empty-work-find').addEventListener('click', () => qs('#find-work').click());
|
||||
qs('#empty-work-create').addEventListener('click', () => qs('#new-issue').click());
|
||||
let liveMode = true;
|
||||
@@ -295,7 +357,9 @@
|
||||
const marker = `stackchain.today-rollover-reviewed.v1.${encodeURIComponent(planningOwnerLogin)}.${todayRollover.localDate()}`;
|
||||
if (!localStorage.getItem(marker)) {
|
||||
localStorage.setItem(marker, '1');
|
||||
setTimeout(() => openPlanToday(qs('#plan-today')), 0);
|
||||
setTimeout(() => {
|
||||
if (!workSession.checkpointed()) openPlanToday(qs('#plan-today'));
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
onStatus: (state, detail = {}) => {
|
||||
@@ -309,19 +373,36 @@
|
||||
},
|
||||
});
|
||||
todaySync.startLifecycle({ window, document });
|
||||
const todayHandoff = createTodayHandoff({
|
||||
storage:localStorage,
|
||||
getLogin:() => planningOwnerLogin,
|
||||
identity:item => todayWork.identity(item),
|
||||
items:() => [...todayMyWork, ...activeMyWork],
|
||||
todayWork,
|
||||
todaySync,
|
||||
});
|
||||
const todayHandoffView = todayHandoff.mount({ qs, escapeHtml,
|
||||
onComplete:() => refreshMyWorkView(),
|
||||
});
|
||||
const promptTodayHandoff = () => todayHandoffView.open();
|
||||
const laterWork = createLaterWork({
|
||||
storage: localStorage,
|
||||
getLogin: () => planningOwnerLogin,
|
||||
onChange: (action, itemId, wakeAt) => {
|
||||
if (laterSync.enqueue(action, itemId, wakeAt)) laterSync.flush();
|
||||
onChange: (action, itemId, wakeAt, handoff) => {
|
||||
if (laterSync.enqueue(action, itemId, wakeAt, handoff)) laterSync.flush();
|
||||
},
|
||||
onExpire: ids => {
|
||||
onExpire: (ids, handoffs) => {
|
||||
if (handoffs.length) {
|
||||
todayHandoff.capture(handoffs);
|
||||
todayHandoffView.reset();
|
||||
}
|
||||
const queued = ids.map(id => laterSync.enqueue('restore', id)).every(Boolean);
|
||||
if (queued) laterSync.flush();
|
||||
},
|
||||
onWake: () => {
|
||||
qs('#my-work-action-status').textContent = 'Deferred work is ready again.';
|
||||
refreshMyWorkView();
|
||||
setTimeout(promptTodayHandoff, 0);
|
||||
},
|
||||
});
|
||||
const laterSync = createLaterSync({
|
||||
@@ -494,13 +575,133 @@
|
||||
window.location.hash = '#/my-work/agenda';
|
||||
qs('#my-work-action-status').textContent = 'Overdue sweep cancelled. No remaining deadline was changed.';
|
||||
});
|
||||
let searchReplyAttachmentTarget = null;
|
||||
let searchReplyRestoreGeneration = 0;
|
||||
let restoringSearchReplyPhotos = false;
|
||||
const searchReplyDraftStore = 'indexedDB' in window ? createSearchReplyDraftStore({
|
||||
indexedDB:window.indexedDB,
|
||||
getOwnerLogin:() => confirmedOwnerLogin,
|
||||
}) : null;
|
||||
const sameSearchReplyTarget = (left, right) => left && right && left.kind === right.kind &&
|
||||
left.repository === right.repository && Number(left.number) === Number(right.number);
|
||||
async function persistSearchReplyPhotos() {
|
||||
const target = searchReplyAttachmentTarget ? { ...searchReplyAttachmentTarget } : null;
|
||||
if (!target || !searchReplyDraftStore || !searchReplyAttachmentController || restoringSearchReplyPhotos) return;
|
||||
try {
|
||||
const attachments = await searchReplyAttachmentController.serialize();
|
||||
await searchReplyDraftStore.save(target, attachments);
|
||||
} catch (error) {
|
||||
qs('#search-preview-reply-status').textContent = error?.message ||
|
||||
'Photos could not be saved. They remain in this preview; retry before leaving.';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async function restoreSearchReplyPhotos(target) {
|
||||
if (!searchReplyDraftStore || !target) return;
|
||||
const generation = ++searchReplyRestoreGeneration;
|
||||
try {
|
||||
const attachments = await searchReplyDraftStore.load(target);
|
||||
if (generation !== searchReplyRestoreGeneration || !sameSearchReplyTarget(target, searchReplyAttachmentTarget)) return;
|
||||
if (attachments?.length) {
|
||||
restoringSearchReplyPhotos = true;
|
||||
try { searchReplyAttachmentController.restore(attachments); }
|
||||
finally { restoringSearchReplyPhotos = false; }
|
||||
updateSearchReplyButtons(searchReplyAttachmentController.state());
|
||||
qs('#search-preview-reply-status').textContent = 'Saved photo evidence restored.';
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation === searchReplyRestoreGeneration) {
|
||||
qs('#search-preview-reply-status').textContent = error?.message || 'Saved photo evidence could not be restored.';
|
||||
}
|
||||
}
|
||||
}
|
||||
const updateSearchReplyButtons = state => {
|
||||
const enabled = Boolean(state) || Boolean(qs('#search-preview-reply').value.trim());
|
||||
qs('#send-search-preview-reply').disabled = !enabled;
|
||||
qs('#send-search-preview-reply-next').disabled = !enabled;
|
||||
};
|
||||
const searchReplyAttachmentController = issueAttachment.mount({
|
||||
maxFiles: 5,
|
||||
input: qs('#search-reply-attachment'),
|
||||
inputs: [qs('#take-search-reply-photo'), qs('#search-reply-attachment')],
|
||||
preview: qs('#search-reply-attachment-preview'),
|
||||
image: qs('#search-reply-attachment-image'),
|
||||
meta: qs('#search-reply-attachment-meta'),
|
||||
remove: qs('#remove-search-reply-attachment'),
|
||||
tray: qs('#search-reply-attachment-tray'),
|
||||
earlier: qs('#move-search-reply-attachment-earlier'),
|
||||
later: qs('#move-search-reply-attachment-later'),
|
||||
note: qs('#search-reply-attachment-note'),
|
||||
noteLabel: qs('#search-reply-attachment-note-label'),
|
||||
status: qs('#search-preview-reply-status'),
|
||||
readyMessage: 'Photo ready to send with this Search reply.',
|
||||
removedMessage: 'Photo removed. Your Search reply is unchanged.',
|
||||
onChange: state => {
|
||||
updateSearchReplyButtons(state);
|
||||
persistSearchReplyPhotos().catch(() => {});
|
||||
},
|
||||
onCheckpoint:() => persistSearchReplyPhotos(),
|
||||
editor: {
|
||||
document,
|
||||
edit: qs('#edit-search-reply-attachment'),
|
||||
dialog: qs('#issue-evidence-editor'),
|
||||
canvas: qs('#issue-evidence-editor-canvas'),
|
||||
exportCanvas: qs('#issue-evidence-editor-export'),
|
||||
crop: qs('#crop-issue-evidence'), redact: qs('#redact-issue-evidence'),
|
||||
highlight: qs('#highlight-issue-evidence'), arrow: qs('#arrow-issue-evidence'),
|
||||
undo: qs('#undo-issue-evidence-edit'), reset: qs('#reset-issue-evidence-edit'),
|
||||
cancel: qs('#cancel-issue-evidence-edit'), apply: qs('#apply-issue-evidence-edit'),
|
||||
status: qs('#issue-evidence-editor-status'),
|
||||
appliedMessage: 'Edited photo flattened and ready to send.',
|
||||
},
|
||||
createObjectURL: file => URL.createObjectURL(file),
|
||||
revokeObjectURL: url => URL.revokeObjectURL(url),
|
||||
upload: payload => {
|
||||
const target = searchReplyAttachmentTarget;
|
||||
if (!target || target.repository !== payload.repository || Number(target.number) !== Number(payload.number)) {
|
||||
return Promise.reject(new Error('Search result changed. Reopen it before sending this photo.'));
|
||||
}
|
||||
const repository = payload.repository.split('/').map(encodeURIComponent).join('/');
|
||||
return fetchReviewJson(
|
||||
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(payload.number) +
|
||||
'/preview/attachments?kind=' + encodeURIComponent(target.kind),
|
||||
{
|
||||
method:'POST',
|
||||
headers:{Accept:'application/json','Idempotency-Key':payload.operation_id},
|
||||
body:issueAttachment.multipart(payload),
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
const issueAttachmentController = issueAttachment.mount({
|
||||
maxFiles: 5,
|
||||
input: qs('#issue-attachment'),
|
||||
inputs: [qs('#take-issue-comment-photo'), qs('#issue-attachment')],
|
||||
preview: qs('#issue-attachment-preview'),
|
||||
image: qs('#issue-attachment-image'),
|
||||
meta: qs('#issue-attachment-meta'),
|
||||
remove: qs('#remove-issue-attachment'),
|
||||
tray: qs('#issue-attachment-tray'),
|
||||
earlier: qs('#move-issue-attachment-earlier'),
|
||||
later: qs('#move-issue-attachment-later'),
|
||||
note: qs('#issue-attachment-note'),
|
||||
noteLabel: qs('#issue-attachment-note-label'),
|
||||
status: qs('#issue-comment-status'),
|
||||
onChange:() => conversationPhotoDrafts.checkpoint('issue').catch(() => {}),
|
||||
onCheckpoint:() => conversationPhotoDrafts.checkpoint('issue'),
|
||||
editor: {
|
||||
document,
|
||||
edit: qs('#edit-issue-attachment'),
|
||||
dialog: qs('#issue-evidence-editor'),
|
||||
canvas: qs('#issue-evidence-editor-canvas'),
|
||||
exportCanvas: qs('#issue-evidence-editor-export'),
|
||||
crop: qs('#crop-issue-evidence'), redact: qs('#redact-issue-evidence'),
|
||||
highlight: qs('#highlight-issue-evidence'), arrow: qs('#arrow-issue-evidence'),
|
||||
undo: qs('#undo-issue-evidence-edit'), reset: qs('#reset-issue-evidence-edit'),
|
||||
cancel: qs('#cancel-issue-evidence-edit'), apply: qs('#apply-issue-evidence-edit'),
|
||||
status: qs('#issue-evidence-editor-status'),
|
||||
appliedMessage: 'Edited photo flattened and ready to send.',
|
||||
},
|
||||
createObjectURL: file => URL.createObjectURL(file),
|
||||
revokeObjectURL: url => URL.revokeObjectURL(url),
|
||||
readDataUrl: file => new Promise((resolve, reject) => {
|
||||
@@ -525,12 +726,34 @@
|
||||
},
|
||||
});
|
||||
const pullAttachmentController = issueAttachment.mount({
|
||||
maxFiles: 5,
|
||||
input: qs('#pull-attachment'),
|
||||
inputs: [qs('#take-pull-comment-photo'), qs('#pull-attachment')],
|
||||
preview: qs('#pull-attachment-preview'),
|
||||
image: qs('#pull-attachment-image'),
|
||||
meta: qs('#pull-attachment-meta'),
|
||||
remove: qs('#remove-pull-attachment'),
|
||||
tray: qs('#pull-attachment-tray'),
|
||||
earlier: qs('#move-pull-attachment-earlier'),
|
||||
later: qs('#move-pull-attachment-later'),
|
||||
note: qs('#pull-attachment-note'),
|
||||
noteLabel: qs('#pull-attachment-note-label'),
|
||||
status: qs('#pull-comment-status'),
|
||||
onChange:() => conversationPhotoDrafts.checkpoint('pull').catch(() => {}),
|
||||
onCheckpoint:() => conversationPhotoDrafts.checkpoint('pull'),
|
||||
editor: {
|
||||
document,
|
||||
edit: qs('#edit-pull-attachment'),
|
||||
dialog: qs('#issue-evidence-editor'),
|
||||
canvas: qs('#issue-evidence-editor-canvas'),
|
||||
exportCanvas: qs('#issue-evidence-editor-export'),
|
||||
crop: qs('#crop-issue-evidence'), redact: qs('#redact-issue-evidence'),
|
||||
highlight: qs('#highlight-issue-evidence'), arrow: qs('#arrow-issue-evidence'),
|
||||
undo: qs('#undo-issue-evidence-edit'), reset: qs('#reset-issue-evidence-edit'),
|
||||
cancel: qs('#cancel-issue-evidence-edit'), apply: qs('#apply-issue-evidence-edit'),
|
||||
status: qs('#issue-evidence-editor-status'),
|
||||
appliedMessage: 'Edited photo flattened and ready to send.',
|
||||
},
|
||||
createObjectURL: file => URL.createObjectURL(file),
|
||||
revokeObjectURL: url => URL.revokeObjectURL(url),
|
||||
upload: payload => {
|
||||
@@ -546,14 +769,36 @@
|
||||
},
|
||||
});
|
||||
const updateReplyAttachmentController = issueAttachment.mount({
|
||||
maxFiles: 5,
|
||||
input: qs('#update-reply-attachment'),
|
||||
inputs: [qs('#take-update-reply-photo'), qs('#update-reply-attachment')],
|
||||
preview: qs('#update-reply-attachment-preview'),
|
||||
image: qs('#update-reply-attachment-image'),
|
||||
meta: qs('#update-reply-attachment-meta'),
|
||||
remove: qs('#remove-update-reply-attachment'),
|
||||
tray: qs('#update-reply-attachment-tray'),
|
||||
earlier: qs('#move-update-reply-attachment-earlier'),
|
||||
later: qs('#move-update-reply-attachment-later'),
|
||||
note: qs('#update-reply-attachment-note'),
|
||||
noteLabel: qs('#update-reply-attachment-note-label'),
|
||||
status: qs('#update-reply-status'),
|
||||
onChange:() => conversationPhotoDrafts.checkpoint('update').catch(() => {}),
|
||||
onCheckpoint:() => conversationPhotoDrafts.checkpoint('update'),
|
||||
readyMessage: 'Screenshot ready to send with this reply.',
|
||||
removedMessage: 'Screenshot removed. Your reply is unchanged.',
|
||||
editor: {
|
||||
document,
|
||||
edit: qs('#edit-update-reply-attachment'),
|
||||
dialog: qs('#issue-evidence-editor'),
|
||||
canvas: qs('#issue-evidence-editor-canvas'),
|
||||
exportCanvas: qs('#issue-evidence-editor-export'),
|
||||
crop: qs('#crop-issue-evidence'), redact: qs('#redact-issue-evidence'),
|
||||
highlight: qs('#highlight-issue-evidence'), arrow: qs('#arrow-issue-evidence'),
|
||||
undo: qs('#undo-issue-evidence-edit'), reset: qs('#reset-issue-evidence-edit'),
|
||||
cancel: qs('#cancel-issue-evidence-edit'), apply: qs('#apply-issue-evidence-edit'),
|
||||
status: qs('#issue-evidence-editor-status'),
|
||||
appliedMessage: 'Edited photo flattened and ready to send.',
|
||||
},
|
||||
createObjectURL: file => URL.createObjectURL(file),
|
||||
revokeObjectURL: url => URL.revokeObjectURL(url),
|
||||
upload: payload => fetchReviewJson(
|
||||
@@ -562,8 +807,48 @@
|
||||
body:issueAttachment.multipart(payload) },
|
||||
),
|
||||
});
|
||||
const conversationPhotoDraftStore = createConversationReplyDraftStore({
|
||||
indexedDB:window.indexedDB, getOwnerLogin:() => confirmedOwnerLogin,
|
||||
});
|
||||
const photoDraftLane = (controller, status) => ({ controller, onError:error => {
|
||||
qs(status).textContent = error.message + ' Your photos remain here; retry before leaving.';
|
||||
} });
|
||||
const conversationPhotoDrafts = createConversationPhotoDrafts({
|
||||
store:conversationPhotoDraftStore,
|
||||
lanes:{
|
||||
issue:photoDraftLane(issueAttachmentController, '#issue-comment-status'),
|
||||
pull:photoDraftLane(pullAttachmentController, '#pull-comment-status'),
|
||||
update:photoDraftLane(updateReplyAttachmentController, '#update-reply-status'),
|
||||
},
|
||||
});
|
||||
const voiceTranscriptStore = createVoiceTranscriptStore();
|
||||
function mountConversationVoice(kind, draftSelector) {
|
||||
return createVoiceConversationCapture({
|
||||
Recognition: window.SpeechRecognition || window.webkitSpeechRecognition,
|
||||
transcriptStore: voiceTranscriptStore,
|
||||
getLogin: () => confirmedOwnerLogin,
|
||||
elements: {
|
||||
root:qs('#voice-' + kind), start:qs('#start-voice-' + kind),
|
||||
stop:qs('#stop-voice-' + kind), review:qs('#voice-' + kind + '-review'),
|
||||
transcript:qs('#voice-' + kind + '-transcript'), append:qs('#append-voice-' + kind),
|
||||
replace:qs('#replace-with-voice-' + kind), discard:qs('#discard-voice-' + kind),
|
||||
status:qs('#voice-' + kind + '-status'), draft:qs(draftSelector),
|
||||
},
|
||||
});
|
||||
}
|
||||
const issueVoiceReply = mountConversationVoice('issue-comment', '#issue-comment');
|
||||
const pullVoiceReply = mountConversationVoice('pull-comment', '#pull-comment');
|
||||
const updateVoiceReply = mountConversationVoice('update-reply', '#update-reply');
|
||||
const searchVoiceReply = mountConversationVoice('search-reply', '#search-preview-reply');
|
||||
function conversationVoiceTarget(kind, item) {
|
||||
return kind + ':' + String(item?.repository || '').trim().toLowerCase() + '#' + Number(item?.number);
|
||||
}
|
||||
function searchConversationVoiceTarget(item) {
|
||||
return 'search:' + conversationVoiceTarget(item?.kind, item);
|
||||
}
|
||||
const createIssueAttachmentController = issueAttachment.mount({
|
||||
input: qs('#create-issue-attachment'),
|
||||
inputs: [qs('#take-create-issue-photo'), qs('#create-issue-attachment')],
|
||||
preview: qs('#create-issue-attachment-preview'),
|
||||
image: qs('#create-issue-attachment-image'),
|
||||
meta: qs('#create-issue-attachment-meta'),
|
||||
@@ -863,6 +1148,7 @@
|
||||
}
|
||||
return offlineToday.warm(confirmedOwnerLogin, todayMyWork);
|
||||
}
|
||||
let findWorkNavigation = null;
|
||||
const findWorkController = createFindWork({
|
||||
fetchJson: fetchReviewJson,
|
||||
onItems: renderAvailableIssues,
|
||||
@@ -877,9 +1163,20 @@
|
||||
qs('#find-work-selection-status').textContent = state.count ?
|
||||
state.count + ' issue' + (state.count === 1 ? '' : 's') + ' selected.' : 'No work selected.';
|
||||
qs('#claim-selected-work').disabled = state.count === 0;
|
||||
findWorkNavigation?.sync({ selectedCount:state.count });
|
||||
renderAvailableIssues(findWorkController.items());
|
||||
},
|
||||
});
|
||||
|
||||
findWorkNavigation = createMobileFindWorkNavigation({
|
||||
document,
|
||||
history:window.history,
|
||||
eventTarget:window,
|
||||
controller:findWorkController,
|
||||
todayWork,
|
||||
openFit:openFindWorkEstimateReview,
|
||||
});
|
||||
findWorkNavigation.start();
|
||||
let findWorkSearchTimer = null;
|
||||
|
||||
function updateFindWorkMatchStatus() {
|
||||
@@ -1121,6 +1418,34 @@
|
||||
panel: qs('#update-sheet .update-sheet-panel'),
|
||||
jump: qs('#jump-update-new-activity'),
|
||||
});
|
||||
const mobileUpdateDetailNavigation = createMobileUpdateDetailNavigation({
|
||||
root:qs('#update-sheet .update-sheet-panel'),
|
||||
buttons:Object.fromEntries(Array.from(document.querySelectorAll('[data-update-section]')).map(button => [button.dataset.updateSection, button])),
|
||||
targets:{
|
||||
conversation:qs('#update-conversation'),
|
||||
context:qs('#update-subject-context'),
|
||||
reply:qs('#update-reply-workspace'),
|
||||
},
|
||||
replyComposer:qs('#update-reply'),
|
||||
jumpToNewActivity:() => updateReadPosition.jump(),
|
||||
onSectionChange:(section, options) => workRoute.section(section, options),
|
||||
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
});
|
||||
mobileUpdateDetailNavigation.start();
|
||||
const mobileReviewDetailNavigation = createMobileReviewDetailNavigation({
|
||||
root:qs('#review-sheet .review-sheet-panel'),
|
||||
buttons:Object.fromEntries(Array.from(document.querySelectorAll('[data-review-section]')).map(button => [button.dataset.reviewSection, button])),
|
||||
targets:{
|
||||
overview:qs('#review-overview'),
|
||||
files:qs('#review-files-workspace'),
|
||||
feedback:qs('#review-feedback'),
|
||||
history:qs('#review-history-workspace'),
|
||||
},
|
||||
summaryComposer:qs('#review-summary'),
|
||||
onSectionChange:(section, options) => workRoute.section(section, options),
|
||||
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
});
|
||||
mobileReviewDetailNavigation.start();
|
||||
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') });
|
||||
@@ -1153,12 +1478,13 @@
|
||||
queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId),
|
||||
loadSaved: item => offlineWorkStore.loadDetail(confirmedOwnerLogin, item),
|
||||
onOpen: item => {
|
||||
if (selectedUpdate && selectedUpdate.notification_id !== item.notification_id) {
|
||||
updateReplyAttachmentController.clear();
|
||||
}
|
||||
selectedUpdate = item;
|
||||
void conversationPhotoDrafts.switchTo('update',
|
||||
{ kind:'update', notificationId:item.notification_id }, () => selectedUpdate === item);
|
||||
void updateVoiceReply.open('update:' + item.notification_id);
|
||||
selectedUpdateDetail = null;
|
||||
updateReadPosition.open(String(item.notification_id));
|
||||
mobileUpdateDetailNavigation.reset();
|
||||
updateMentions.dismiss();
|
||||
qs('#update-sheet').classList.add('open');
|
||||
qs('.update-more-actions').open = false;
|
||||
@@ -1360,6 +1686,25 @@
|
||||
workRoute.open(routedWorkItem(item), options);
|
||||
}
|
||||
|
||||
function navigateWorkSection(kind, section) {
|
||||
if (['issue', 'filed'].includes(kind)) {
|
||||
mobileIssueDetailNavigation.navigate(section, { focus:false });
|
||||
} else if (kind === 'pull') {
|
||||
mobilePullDetailNavigation.navigate(section, { focus:false });
|
||||
} else if (kind === 'update') {
|
||||
mobileUpdateDetailNavigation.navigate(section, { focus:false });
|
||||
} else if (kind === 'review') {
|
||||
mobileReviewDetailNavigation.navigate(section, { focus:false });
|
||||
}
|
||||
}
|
||||
|
||||
async function openRoutedWorkSection(item) {
|
||||
if (item.kind === 'update') await notificationReader.open(item, lastMyWork);
|
||||
else if (item.kind === 'review') await openReviewSheet(item, reviewTrigger);
|
||||
else if (item.kind === 'issue' || item.kind === 'filed') await openIssueSheet(item, issueTrigger);
|
||||
else if (item.kind === 'pull') await openPullSheet(item, pullTrigger);
|
||||
}
|
||||
|
||||
const workRoute = createWorkRoute.createController({
|
||||
location: window.location,
|
||||
history: window.history,
|
||||
@@ -1377,14 +1722,17 @@
|
||||
qs('#retry-work-route').hidden = true;
|
||||
qs('#my-work-action-status').textContent = 'Loading shared work item…';
|
||||
},
|
||||
onOpen: item => {
|
||||
onOpen: async item => {
|
||||
qs('#retry-work-route').hidden = true;
|
||||
qs('#my-work-action-status').textContent = '';
|
||||
closeOpenWorkSheets();
|
||||
if (item.kind === 'update') notificationReader.open(item, lastMyWork);
|
||||
else if (item.kind === 'review') openReviewSheet(item, reviewTrigger);
|
||||
else if (item.kind === 'issue' || item.kind === 'filed') openIssueSheet(item, issueTrigger);
|
||||
else if (item.kind === 'pull') openPullSheet(item, pullTrigger);
|
||||
await openRoutedWorkSection(item);
|
||||
const route = createWorkRoute.parse(window.location.hash);
|
||||
if (route?.section === item.section) navigateWorkSection(item.kind, item.section);
|
||||
},
|
||||
onSection: (section, options) => {
|
||||
const route = createWorkRoute.parse(window.location.hash);
|
||||
if (options.restore) navigateWorkSection(route?.kind, section);
|
||||
},
|
||||
onQueue: openWorkQueueRoute,
|
||||
onClose: () => {
|
||||
@@ -1409,6 +1757,9 @@
|
||||
qs('#retry-work-route').addEventListener('click', () => workRoute.sync());
|
||||
|
||||
function closeOpenWorkSheets() {
|
||||
issueVoiceReply.cancel();
|
||||
pullVoiceReply.cancel();
|
||||
updateVoiceReply.cancel();
|
||||
['#issue-sheet .issue-sheet-panel', '#pull-sheet .pull-sheet-panel', '#review-sheet .review-sheet-panel', '#update-sheet .update-sheet-panel']
|
||||
.forEach(selector => mobileComposerViewport.close(qs(selector)));
|
||||
['#issue-sheet', '#pull-sheet', '#review-sheet', '#update-sheet'].forEach(selector =>
|
||||
@@ -1491,11 +1842,18 @@
|
||||
interruptionPrompt.resolve(button.dataset.todayInterruption)
|
||||
);
|
||||
});
|
||||
const todayWrapUpView = setupTodayWrapUp({ todayWork, laterWork, todaySync, qs, escapeHtml,
|
||||
onComplete:() => {
|
||||
todayRecapView['completeReplan']();
|
||||
refreshMyWorkView();
|
||||
warmTodayOffline();
|
||||
},
|
||||
});
|
||||
const todayRecapView = setupTodayRecap(
|
||||
timer, timerView, todayWork, api, qs, escapeHtml, closeOpenWorkSheets, updateWorkSessionActions,
|
||||
() => planningOwnerLogin,
|
||||
identity => [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity) || null,
|
||||
actualMinutes => openPlanToday(qs('#plan-today'), true, actualMinutes)
|
||||
actualMinutes => todayWrapUpView.open(todayMyWork, actualMinutes)
|
||||
);
|
||||
function updateDetailDeferLabels(active) {
|
||||
document.querySelectorAll('[data-detail-defer-preset=today]').forEach(button => {
|
||||
@@ -1930,26 +2288,22 @@
|
||||
));
|
||||
}
|
||||
|
||||
function closeFindWorkEstimateReview() {
|
||||
qs('#find-work-estimate-review').hidden = true;
|
||||
qs('#batch-find-work-actions').hidden = !findWorkController.selection().active;
|
||||
}
|
||||
|
||||
function openFindWorkEstimateReview(items) {
|
||||
const plan = todayWork.planning();
|
||||
const planned = Object.values(plan.estimates).reduce((sum, minutes) => sum + minutes, 0);
|
||||
qs('#find-work-estimate-summary').textContent = formatPlanMinutes(Math.max(0, plan.capacity_minutes - planned)) +
|
||||
' remaining. Estimate selected work before assigning it.';
|
||||
const estimatesRequired = Number.isInteger(plan.capacity_minutes) && plan.capacity_minutes > 0;
|
||||
qs('#find-work-estimate-summary').textContent = estimatesRequired ?
|
||||
formatPlanMinutes(Math.max(0, plan.capacity_minutes - planned)) +
|
||||
' remaining. Estimate selected work before assigning it.' :
|
||||
items.length + ' selected. Confirm to assign and queue this batch in Today.';
|
||||
qs('#find-work-estimate-list').innerHTML = items.map(item => {
|
||||
const id = String(item.repository || '') + '#' + String(item.number || '');
|
||||
return '<div class="find-work-estimate-row"><div><span class="small">' + escapeHtml(id) +
|
||||
'</span><strong>' + escapeHtml(item.title || 'Untitled work') + '</strong></div><label class="small">' +
|
||||
'<input type="number" inputmode="numeric" min="5" max="1440" step="5" data-find-work-estimate="' +
|
||||
escAttr(id) + '" aria-label="Estimate for ' + escAttr(item.title || id) + ' in minutes" /> min</label></div>';
|
||||
'</span><strong>' + escapeHtml(item.title || 'Untitled work') + '</strong></div>' +
|
||||
(estimatesRequired ? '<label class="small"><input type="number" inputmode="numeric" min="5" max="1440" step="5" data-find-work-estimate="' +
|
||||
escAttr(id) + '" aria-label="Estimate for ' + escAttr(item.title || id) + ' in minutes" /> min</label>' : '') + '</div>';
|
||||
}).join('');
|
||||
qs('#batch-find-work-actions').hidden = true;
|
||||
qs('#find-work-estimate-review').hidden = false;
|
||||
qs('[data-find-work-estimate]')?.focus();
|
||||
if (estimatesRequired) qs('[data-find-work-estimate]')?.focus();
|
||||
}
|
||||
|
||||
function formatCalendarDueDate(value) {
|
||||
@@ -2207,7 +2561,7 @@
|
||||
|
||||
function renderPlanIssueDependencies(detail, loading = false) {
|
||||
const preview = planTodayPreview.snapshot();
|
||||
const assignedIssue = Boolean(selectedIssue && selectedIssueDetail === detail);
|
||||
const assignedIssue = Boolean(!preview.open && selectedIssue && selectedIssueDetail === detail);
|
||||
if (!assignedIssue && (!preview.open || preview.item?.kind !== 'issue')) return;
|
||||
const panel = qs('#issue-blockers');
|
||||
const list = qs('#issue-blocker-list');
|
||||
@@ -2650,7 +3004,8 @@
|
||||
function listDrafts() {
|
||||
const unfiled = unfiledCaptures.list().map(item => ({
|
||||
id:'unfiled:' + item.id, capture_id:item.id, kind:'unfiled-issue', ...unfiledDraftSummary(item),
|
||||
title:item.title, preview:item.body + (item.hasAttachment ? ' · Screenshot attached' : ''),
|
||||
title:unfiledDraftDisplayTitle(item),
|
||||
preview:[item.body, item.hasAttachment ? 'Photo evidence attached' : ''].filter(Boolean).join(' · '),
|
||||
hasAttachment:item.hasAttachment, copy_text:[item.title, item.body].filter(Boolean).join('\n\n'),
|
||||
updated_at:item.savedAt, quarantined:item.quarantined,
|
||||
ownership:item.quarantined ? 'Saved by ' + item.ownerLogin +
|
||||
@@ -2686,6 +3041,7 @@
|
||||
counts.today = todayMyWork.length;
|
||||
counts.later = laterMyWork.length;
|
||||
counts.draft = lastDrafts.length;
|
||||
counts.delivery = draftInbox.partition(lastDrafts).actionable;
|
||||
if (!launchFilterResolved) {
|
||||
selectedWorkFilter = mobileLaunch.chooseFilter({
|
||||
saved: savedWorkFilter, today: counts.today, attention: counts.attention,
|
||||
@@ -2701,7 +3057,10 @@
|
||||
if (element) element.textContent = count;
|
||||
});
|
||||
mobileQueueCounts = counts;
|
||||
mobileStartDay.reconcile({authoritative:authoritativeMyWorkRefresh});
|
||||
mobileStartDay.reconcile({
|
||||
authoritative:authoritativeMyWorkRefresh,
|
||||
authoritativePhases:['delivery'],
|
||||
});
|
||||
mobileStartDay.render();
|
||||
mobileTaskDock.updateQueues(counts);
|
||||
mobileTaskDock.updateWork(mobileWorkEntry.mode());
|
||||
@@ -2734,6 +3093,7 @@
|
||||
}
|
||||
if (reconcileSession && workSession.active()) workSession.reconcile();
|
||||
updateWorkSessionActions();
|
||||
if (todayHandoff.pending().length) setTimeout(promptTodayHandoff, 0);
|
||||
}
|
||||
|
||||
function activeWorkStreams() {
|
||||
@@ -2826,7 +3186,7 @@
|
||||
'<div class="draft-actions">' + outboxActions + '</div></article>';
|
||||
};
|
||||
const deliverySummary = '<section class="delivery-center" aria-labelledby="delivery-center-title">' +
|
||||
'<div><h3 id="delivery-center-title">Delivery center</h3>' +
|
||||
'<div><h3 id="delivery-center-title" tabindex="-1">Delivery center</h3>' +
|
||||
'<p class="small">Waiting <strong data-delivery-count="waiting">' + deliveryCenter.counts.waiting + '</strong> · ' +
|
||||
'Sending <strong data-delivery-count="sending">' + deliveryCenter.counts.sending + '</strong> · ' +
|
||||
'Needs attention <strong data-delivery-count="attention">' + deliveryCenter.counts.attention + '</strong> · ' +
|
||||
@@ -2845,14 +3205,14 @@
|
||||
const button = event.currentTarget;
|
||||
if (!activeFlushLogin || !deliveryCenter.retryable.length) return;
|
||||
button.disabled = true;
|
||||
qs('#my-work-action-status').textContent = 'Retrying safe waiting deliveries…';
|
||||
qs('#my-work-action-status').textContent = 'Retrying waiting deliveries…';
|
||||
const [issueResult, authoredResult] = await Promise.all([issueOutbox.flush(activeFlushLogin), authoredOutbox.flush(activeFlushLogin)]);
|
||||
applyOutboxResult(issueResult);
|
||||
applyAuthoredOutboxResult(authoredResult);
|
||||
qs('#my-work-action-status').textContent = 'Waiting deliveries retried. Items needing attention were skipped.';
|
||||
qs('#my-work-action-status').textContent = 'Waiting deliveries retried; attention stayed queued.';
|
||||
});
|
||||
list.querySelectorAll('.draft-resume').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
button.onclick=async()=>{
|
||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||
if (!item) return;
|
||||
if (item.kind === 'unfiled-issue') {
|
||||
@@ -2861,7 +3221,7 @@
|
||||
} catch (error) { qs('#my-work-action-status').textContent = error.message; }
|
||||
} else if (item.kind === 'new-issue') openCreateIssueSheet();
|
||||
else if (item.route) workRoute.open(item.route);
|
||||
});
|
||||
};
|
||||
});
|
||||
list.querySelectorAll('.draft-continue').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
@@ -2908,7 +3268,7 @@
|
||||
});
|
||||
});
|
||||
list.querySelectorAll('.draft-review-checklist').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
button.onclick=async()=>{
|
||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||
const queued = authoredOutbox.list().find(candidate => candidate.id === item?.outbox_id);
|
||||
if (!item?.checklist_conflict || !queued || !activeFlushLogin) return;
|
||||
@@ -2941,7 +3301,7 @@
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
});
|
||||
list.querySelectorAll('.draft-authorize').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
@@ -2962,12 +3322,12 @@
|
||||
});
|
||||
});
|
||||
list.querySelectorAll('.draft-copy').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
button.onclick=async()=>{
|
||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||
if (!item?.copy_text) return;
|
||||
await navigator.clipboard.writeText(item.copy_text);
|
||||
qs('#my-work-action-status').textContent = 'Queued content copied without sending it.';
|
||||
});
|
||||
};
|
||||
});
|
||||
list.querySelectorAll('.draft-discard').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
@@ -2986,6 +3346,7 @@
|
||||
qs('#my-work-action-status').textContent = 'Draft discarded.';
|
||||
});
|
||||
});
|
||||
mobileDeliveryRecovery.render();
|
||||
}
|
||||
|
||||
function updateWorkPaginationControls() {
|
||||
@@ -3512,6 +3873,9 @@
|
||||
qs('#issue-planning').inert = false;
|
||||
qs('#issue-handoff').inert = false;
|
||||
selectedIssue = item;
|
||||
void conversationPhotoDrafts.switchTo('issue',
|
||||
{ kind:'issue', repository:item.repository, number:item.number }, () => selectedIssue === item);
|
||||
void issueVoiceReply.open(conversationVoiceTarget('issue', item));
|
||||
dismissedChecklistBody = null;
|
||||
issueMentions.dismiss();
|
||||
selectedIssueOffline = Boolean(offlineDetail);
|
||||
@@ -3677,7 +4041,8 @@
|
||||
return;
|
||||
}
|
||||
mobileComposerViewport.close(qs('#issue-sheet .issue-sheet-panel'));
|
||||
issueAttachmentController.clear();
|
||||
issueVoiceReply.cancel();
|
||||
void conversationPhotoDrafts.leave('issue');
|
||||
qs('#issue-sheet').classList.remove('open');
|
||||
selectedIssue = null;
|
||||
selectedIssueOffline = false;
|
||||
@@ -3829,10 +4194,12 @@
|
||||
if (!item) return;
|
||||
if (!await ensurePullWorkflow(trigger)) return;
|
||||
pullDetailPosition.open(workDetailIdentity('pull', item));
|
||||
if (!createPullSheet.sameTarget(selectedPull, item)) pullAttachmentController.clear();
|
||||
qs('#pull-review').inert = false;
|
||||
qs('#pull-ownership').inert = false;
|
||||
selectedPull = item;
|
||||
void conversationPhotoDrafts.switchTo('pull',
|
||||
{ kind:'pull', repository:item.repository, number:item.number }, () => selectedPull === item);
|
||||
void pullVoiceReply.open(conversationVoiceTarget('pull', item));
|
||||
pullMentions.dismiss();
|
||||
pullTrigger = trigger;
|
||||
selectedPullDetail = null;
|
||||
@@ -3897,7 +4264,8 @@
|
||||
return;
|
||||
}
|
||||
mobileComposerViewport.close(qs('#pull-sheet .pull-sheet-panel'));
|
||||
pullAttachmentController.clear();
|
||||
pullVoiceReply.cancel();
|
||||
void conversationPhotoDrafts.leave('pull');
|
||||
qs('#pull-sheet').classList.remove('open');
|
||||
selectedPull = null;
|
||||
selectedPullDetail = null;
|
||||
@@ -4020,6 +4388,7 @@
|
||||
}
|
||||
findingWork = true;
|
||||
qs('#find-work-sheet').classList.add('open');
|
||||
findWorkNavigation.open(batchFindWork.pending() > 0);
|
||||
const retainedItems = findWorkController.items();
|
||||
if (retainedItems.length) renderAvailableIssues(retainedItems);
|
||||
else qs('#find-work-list').textContent = '';
|
||||
@@ -4314,7 +4683,11 @@
|
||||
}
|
||||
clearSharedLaunchUrl();
|
||||
}
|
||||
qs('#create-issue-title').focus();
|
||||
const mobileCapture = window.matchMedia?.('(max-width: 600px)').matches === true;
|
||||
const continuingCapture = Boolean(rUC || captureDraft.title || captureDraft.body || captureDraft.repository);
|
||||
if (unfiledShouldFocusTitle({mobile:mobileCapture, continuing:continuingCapture})) {
|
||||
qs('#create-issue-title').focus();
|
||||
}
|
||||
}
|
||||
|
||||
function setIssueFilingMode(enabled) {
|
||||
@@ -4487,6 +4860,7 @@
|
||||
async function openReviewSheet(item, trigger, cachedDetail = null) {
|
||||
if (!item || !await ensurePullWorkflow(trigger)) return;
|
||||
reviewDetailPosition.open(workDetailIdentity('review', item));
|
||||
mobileReviewDetailNavigation.reset();
|
||||
selectedReview = item;
|
||||
reviewTrigger = trigger;
|
||||
offlineReview = Boolean(cachedDetail);
|
||||
@@ -4622,7 +4996,8 @@
|
||||
return;
|
||||
}
|
||||
mobileComposerViewport.close(qs('#update-sheet .update-sheet-panel'));
|
||||
updateReplyAttachmentController.clear();
|
||||
updateVoiceReply.cancel();
|
||||
void conversationPhotoDrafts.leave('update');
|
||||
qs('#update-sheet').classList.remove('open');
|
||||
selectedUpdate = null;
|
||||
selectedUpdateDetail = null;
|
||||
@@ -4849,6 +5224,7 @@
|
||||
qs('#close-search-preview').textContent = searchPreviewReturnKind === 'today-readiness'
|
||||
? 'Back to blockers' : 'Back to search';
|
||||
if (state.status === 'closed') {
|
||||
searchVoiceReply.cancel();
|
||||
sheet.classList.remove('open');
|
||||
return;
|
||||
}
|
||||
@@ -4864,6 +5240,9 @@
|
||||
renderSearchPreviewReply(state, null, searchPreview, document);
|
||||
|
||||
if (state.status === 'loading') {
|
||||
searchReplyAttachmentTarget = { ...state.item };
|
||||
restoreSearchReplyPhotos(searchReplyAttachmentTarget);
|
||||
searchVoiceReply.open(searchConversationVoiceTarget(state.item));
|
||||
searchPreviewDetail = null;
|
||||
qs('#search-preview-key').textContent = state.item.repository + ' #' + state.item.number;
|
||||
qs('#search-preview-title').textContent = state.item.title || 'Work preview';
|
||||
@@ -4932,6 +5311,30 @@
|
||||
{ method:'PATCH', headers:{ Accept:'application/json' } }
|
||||
),
|
||||
...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto),
|
||||
queueReply:async (item,body,operationId) => {
|
||||
searchReplyAttachmentTarget = item;
|
||||
const serialized = await searchReplyAttachmentController.serialize();
|
||||
const attachments = (Array.isArray(serialized) ? serialized : [serialized]).filter(Boolean);
|
||||
const admitted = await authoredOutbox.enqueueDurably({
|
||||
kind:'search-reply',targetKind:item.kind,repository:item.repository,number:item.number,
|
||||
body,operationId,...(attachments.length ? {attachments} : {}),
|
||||
});
|
||||
renderOutbox();
|
||||
return { id:admitted.item.id, queued:true };
|
||||
},
|
||||
prepareReply:(item,body) => {
|
||||
searchReplyAttachmentTarget = item;
|
||||
return searchReplyAttachmentController.prepareComment(item, body);
|
||||
},
|
||||
afterReply:item => {
|
||||
if (!searchReplyDraftStore) return null;
|
||||
return searchReplyDraftStore.remove(item);
|
||||
},
|
||||
hasAttachments:() => Boolean(searchReplyAttachmentController.state()),
|
||||
clearAttachments:() => {
|
||||
searchReplyAttachmentTarget = null;
|
||||
searchReplyAttachmentController.clear();
|
||||
},
|
||||
share: url => createWorkRoute.share(url, navigator, navigator.clipboard),
|
||||
session:[()=>commandSearchState, commandSearch, item=>taskOverlayHistory.update({preview:item})],
|
||||
onState: renderSearchPreview,
|
||||
@@ -5331,15 +5734,13 @@
|
||||
});
|
||||
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
|
||||
qs('#find-work').addEventListener('click', openFindWorkSheet);
|
||||
qs('#close-find-work').addEventListener('click', closeFindWorkSheet);
|
||||
qs('#select-find-work').addEventListener('click', () => findWorkController.startSelection());
|
||||
|
||||
qs('#fill-find-work-today').addEventListener('click', () => {
|
||||
const remainingSlots = todayWork.limit - todayWork.read().length;
|
||||
const outcome = findWorkController.fillSelection(remainingSlots);
|
||||
qs('#find-work-status').textContent = remainingSlots < 1 ? 'Today is full.' :
|
||||
outcome.selected ? outcome.selected + ' ranked work selected.' : 'No matching work.';
|
||||
if (outcome.selected && todayWork.planning().capacity_minutes !== null)
|
||||
openFindWorkEstimateReview(findWorkController.selectedItems());
|
||||
if (outcome.selected) findWorkNavigation.go('review');
|
||||
});
|
||||
qs('#find-work-search-form').addEventListener('submit', event => event.preventDefault());
|
||||
qs('#find-work-search').addEventListener('input', event => {
|
||||
@@ -5365,47 +5766,30 @@
|
||||
input.dispatchEvent(new Event('input', { bubbles:true }));
|
||||
input.focus();
|
||||
});
|
||||
qs('#cancel-find-work-selection').addEventListener('click', () => findWorkController.cancelSelection());
|
||||
|
||||
qs('#claim-selected-work').addEventListener('click', async event => {
|
||||
if (todayWork.planning().capacity_minutes !== null) {
|
||||
openFindWorkEstimateReview(findWorkController.selectedItems());
|
||||
return;
|
||||
}
|
||||
event.currentTarget.disabled = true;
|
||||
const outcome = await batchFindWork.run(findWorkController.selectedItems());
|
||||
if (outcome.status === 'complete') {
|
||||
const assignedOnly = outcome.failed.filter(item => item.assigned).length;
|
||||
const unavailable = outcome.failed.length - assignedOnly;
|
||||
qs('#find-work-status').textContent = outcome.queued.length + ' queued' +
|
||||
(unavailable ? ' · ' + unavailable + ' unavailable' : '') +
|
||||
(assignedOnly ? ' · ' + assignedOnly + ' assigned but not queued' : '') + '.';
|
||||
if (outcome.failed.length) {
|
||||
renderAvailableIssues(findWorkController.items());
|
||||
event.currentTarget.disabled = false;
|
||||
} else {
|
||||
findWorkController.cancelSelection();
|
||||
}
|
||||
refreshMyWorkView();
|
||||
} else {
|
||||
event.currentTarget.disabled = false;
|
||||
}
|
||||
});
|
||||
qs('#cancel-find-work-estimates').addEventListener('click', closeFindWorkEstimateReview);
|
||||
qs('#confirm-find-work-estimates').addEventListener('click', async event => {
|
||||
event.currentTarget.disabled = true;
|
||||
const button = event.currentTarget;
|
||||
button.disabled = true;
|
||||
const outcome = await batchFindWork.run(findWorkController.selectedItems(), findWorkEstimateValues());
|
||||
event.currentTarget.disabled = false;
|
||||
button.disabled = false;
|
||||
if (outcome.status === 'estimates-required') {
|
||||
const first = outcome.invalid[0];
|
||||
document.querySelector('[data-find-work-estimate="' + CSS.escape(first) + '"]')?.focus();
|
||||
return;
|
||||
}
|
||||
if (outcome.status !== 'complete') return;
|
||||
const assignedOnly = outcome.failed.filter(item => item.assigned).length;
|
||||
const unavailable = outcome.failed.length - assignedOnly;
|
||||
qs('#find-work-status').textContent = outcome.queued.length + ' queued' +
|
||||
(outcome.failed.length ? ' · ' + outcome.failed.length + ' unavailable.' : '.');
|
||||
closeFindWorkEstimateReview();
|
||||
if (!outcome.failed.length) findWorkController.cancelSelection();
|
||||
(unavailable ? ' · ' + unavailable + ' unavailable' : '') +
|
||||
(assignedOnly ? ' · ' + assignedOnly + ' assigned but not queued' : '') + '.';
|
||||
if (!outcome.failed.length) {
|
||||
findWorkNavigation.complete();
|
||||
findWorkController.cancelSelection();
|
||||
} else {
|
||||
renderAvailableIssues(findWorkController.items());
|
||||
findWorkNavigation.sync({ selectedCount:findWorkController.selection().count, recovery:true });
|
||||
}
|
||||
refreshMyWorkView();
|
||||
});
|
||||
qs('#load-more-available').addEventListener('click', async event => {
|
||||
@@ -6083,7 +6467,7 @@
|
||||
}
|
||||
const stillOpen = kind === 'issue' ? selectedIssue === item : selectedPull === item;
|
||||
if (!stillOpen) return;
|
||||
attachmentController.clear();
|
||||
await conversationPhotoDrafts.complete(kind);
|
||||
if (!result.completed) status.textContent = 'Comment saved, but Today still needs completion.';
|
||||
else if (result.delivery === 'posted') status.textContent = 'Comment posted.';
|
||||
else if (result.background) status.textContent = 'Queued for sync when the connection returns.';
|
||||
@@ -6117,7 +6501,7 @@
|
||||
const operationId = localStorage.getItem('stackchain.issue-comment.v1:' + selectedIssue.repository + '#' + selectedIssue.number + ':operation');
|
||||
const admission = await queueIssueScreenshotComment(selectedIssue, body, operationId);
|
||||
refreshMyWorkView();
|
||||
issueAttachmentController.clear();
|
||||
await conversationPhotoDrafts.complete('issue');
|
||||
qs('#issue-comment-status').textContent = admission.background ?
|
||||
'Queued with screenshot for sync when the connection returns.' :
|
||||
'Saved with screenshot for next launch; background delivery unavailable.';
|
||||
@@ -6131,7 +6515,7 @@
|
||||
const operationId = localStorage.getItem('stackchain.issue-comment.v1:' + selectedIssue.repository + '#' + selectedIssue.number + ':operation');
|
||||
const admission = await queueIssueScreenshotComment(selectedIssue, body, operationId);
|
||||
refreshMyWorkView();
|
||||
issueAttachmentController.clear();
|
||||
await conversationPhotoDrafts.complete('issue');
|
||||
qs('#issue-comment-status').textContent = admission.background ?
|
||||
'Queued with screenshot for sync when the connection returns.' :
|
||||
'Saved with screenshot for next launch; background delivery unavailable.';
|
||||
@@ -6154,7 +6538,7 @@
|
||||
else renderIssueConversation(conversation);
|
||||
}
|
||||
qs('#issue-comment').value = '';
|
||||
issueAttachmentController.clear();
|
||||
await conversationPhotoDrafts.complete('issue');
|
||||
qs('#issue-comment-status').textContent = 'Comment posted.';
|
||||
} catch (error) {
|
||||
if (canQueueMessage(error)) {
|
||||
@@ -6163,9 +6547,9 @@
|
||||
const admission = await authoredOutbox.enqueueDurably({ kind:'issue-comment', repository:selectedIssue.repository,
|
||||
number:selectedIssue.number, body:preparedBody, operationId });
|
||||
refreshMyWorkView();
|
||||
await conversationPhotoDrafts.complete('issue');
|
||||
if (admission.background) {
|
||||
qs('#issue-comment').value = '';
|
||||
issueAttachmentController.clear();
|
||||
qs('#issue-comment-status').textContent = 'Queued for sync when the connection returns.';
|
||||
} else {
|
||||
qs('#issue-comment-status').textContent = 'Saved for next launch; background delivery unavailable.';
|
||||
@@ -6424,7 +6808,7 @@
|
||||
renderPullConversation(pullConversation.append(admission.delivered));
|
||||
}
|
||||
qs('#pull-comment').value = '';
|
||||
pullAttachmentController.clear();
|
||||
await conversationPhotoDrafts.complete('pull');
|
||||
qs('#pull-comment-status').textContent = admission.delivered ? 'Comment posted.' :
|
||||
(admission.background ? 'Queued with screenshot for sync when the connection returns.' :
|
||||
'Saved with screenshot for next launch; background delivery unavailable.');
|
||||
@@ -6436,7 +6820,7 @@
|
||||
if (selectedPull === item && pullConversation) renderPullConversation(pullConversation.append(comment));
|
||||
pullController.saveDraft(item, '');
|
||||
if (selectedPull === item) qs('#pull-comment').value = '';
|
||||
pullAttachmentController.clear();
|
||||
await conversationPhotoDrafts.complete('pull');
|
||||
qs('#pull-comment-status').textContent = 'Comment posted.';
|
||||
} catch (error) {
|
||||
if (canQueueMessage(error) && !pullAttachmentController.state()) {
|
||||
@@ -6541,13 +6925,13 @@
|
||||
updateReplyAttachmentController.setBusy(false);
|
||||
if (result?.queued) {
|
||||
qs('#update-reply').value = '';
|
||||
updateReplyAttachmentController.clear();
|
||||
await conversationPhotoDrafts.complete('update');
|
||||
refreshMyWorkView();
|
||||
qs('#my-work-action-status').textContent = 'Reply queued for sync.';
|
||||
} else if (result) {
|
||||
notificationReader.appendReply(result);
|
||||
qs('#update-reply').value = '';
|
||||
updateReplyAttachmentController.clear();
|
||||
await conversationPhotoDrafts.complete('update');
|
||||
qs('#mark-update-read-next').focus();
|
||||
} else {
|
||||
qs('#update-reply').focus();
|
||||
@@ -6572,7 +6956,7 @@
|
||||
const operationId = globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random();
|
||||
try {
|
||||
const result = await updateReplyReadNext.submit(item, body, operationId, attachment);
|
||||
if (result?.accepted) updateReplyAttachmentController.clear();
|
||||
if (result?.accepted) await conversationPhotoDrafts.complete('update');
|
||||
if (result?.accepted) qs('#my-work-action-status').textContent =
|
||||
result.delivery === 'posted' ? 'Reply posted and update marked read.' :
|
||||
'Reply and read acknowledgement queued for sync.';
|
||||
@@ -6847,6 +7231,7 @@
|
||||
intervalMs: 8000,
|
||||
});
|
||||
function load() { return contextPoller.refresh({ force: true }); }
|
||||
createMobilePullRefresh({refresh:load}).start();
|
||||
const liveDataStatusSheet = qs('#live-data-status-sheet');
|
||||
const liveDataStatusTrigger = qs('#open-live-data-status');
|
||||
let latestLiveFreshness = {};
|
||||
@@ -6995,6 +7380,9 @@
|
||||
flushAuthored: flushAuthoredOutbox,
|
||||
flushNotificationReads: flushNotificationReadOutbox,
|
||||
});
|
||||
const reconnectAfterOnline = () => setTimeout(reconnectOutboxes, 500);
|
||||
window.addEventListener('online', reconnectAfterOnline);
|
||||
workspaceLifecycle.replayOnline(reconnectAfterOnline);
|
||||
async function setOfflineWorkEnabled(enabled) {
|
||||
keepWorkOffline.checked = enabled;
|
||||
offlineWorkStore.setEnabled(enabled);
|
||||
@@ -7052,7 +7440,6 @@
|
||||
updateDeliveryReceiptControls();
|
||||
if (!navigator.onLine) await showOfflineStatus();
|
||||
window.addEventListener('offline', showOfflineStatus);
|
||||
window.addEventListener('online', reconnectOutboxes);
|
||||
|
||||
qs('#refresh').addEventListener('click', load);
|
||||
qs('#plan-today').addEventListener('click', event => openPlanToday(event.currentTarget));
|
||||
@@ -7406,6 +7793,7 @@
|
||||
await controller.enableDeadline();
|
||||
},
|
||||
});
|
||||
await createDeviceStorage.mount(document).start();
|
||||
await deviceSetup.start();
|
||||
return deviceSetup;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else {
|
||||
root.createDeviceStorage = factory;
|
||||
root.createDeviceStorage.mount = document => factory({
|
||||
summary:document.querySelector('#device-storage-summary'),
|
||||
detail:document.querySelector('#device-storage-detail'),
|
||||
clearCachesButton:document.querySelector('#clear-device-caches'),
|
||||
clearAllButton:document.querySelector('#clear-private-device-data'),
|
||||
localStorage:root.localStorage,
|
||||
sessionStorage:root.sessionStorage,
|
||||
caches:root.caches,
|
||||
storageManager:root.navigator?.storage,
|
||||
privateDatabases:root.stackchainPrivateDatabases,
|
||||
inspectPrivateDatabases:root.inspectStackchainPrivateDatabases,
|
||||
clearPrivateDeviceData:root.stackchainPrivateDeviceData,
|
||||
});
|
||||
}
|
||||
})(typeof self !== 'undefined' ? self : this, function createDeviceStorage(options) {
|
||||
let privateItemCount = 0;
|
||||
let privateRecordCount = 0;
|
||||
let inventoryUnavailable = false;
|
||||
let fullClearArmed = false;
|
||||
const ownedCaches = async () => (await options.caches?.keys?.() || [])
|
||||
.filter(name => name.startsWith('stackchain-dashboard-'));
|
||||
|
||||
function ownedStorageCount(storage) {
|
||||
if (!storage) return 0;
|
||||
let count = 0;
|
||||
for (let index = 0; index < storage.length; index += 1) {
|
||||
if (storage.key(index)?.startsWith('stackchain.')) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function megabytes(value) {
|
||||
return Math.round((Number(value) || 0) / (1024 * 1024));
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
let estimate = null;
|
||||
try { estimate = await options.storageManager?.estimate?.(); }
|
||||
catch (_error) { /* Storage estimates are optional. */ }
|
||||
options.summary.textContent = estimate?.quota
|
||||
? `${megabytes(estimate.usage)} MB of ${megabytes(estimate.quota)} MB browser storage used.`
|
||||
: 'Browser storage usage is unavailable.';
|
||||
const itemCount = ownedStorageCount(options.localStorage)
|
||||
+ (options.sessionStorage === options.localStorage ? 0 : ownedStorageCount(options.sessionStorage));
|
||||
privateItemCount = itemCount;
|
||||
const inventory = await options.inspectPrivateDatabases?.(options.privateDatabases)
|
||||
|| { recordCount: 0, unavailable: true };
|
||||
privateRecordCount = inventory.recordCount;
|
||||
inventoryUnavailable = inventory.unavailable;
|
||||
const cacheCount = (await ownedCaches()).length;
|
||||
options.detail.textContent = `${itemCount} private browser item${itemCount === 1 ? '' : 's'} · `
|
||||
+ `${inventoryUnavailable ? 'private work status unknown' : `${privateRecordCount} private work record${privateRecordCount === 1 ? '' : 's'}`} · `
|
||||
+ `${cacheCount} cached app cop${cacheCount === 1 ? 'y' : 'ies'}`;
|
||||
return { itemCount, privateRecordCount, inventoryUnavailable, cacheCount };
|
||||
}
|
||||
|
||||
async function clearCaches() {
|
||||
options.clearCachesButton.disabled = true;
|
||||
try {
|
||||
await Promise.all((await ownedCaches()).map(name => options.caches.delete(name)));
|
||||
await refresh();
|
||||
options.summary.textContent = 'Cached app copies cleared. Private work was kept.';
|
||||
} catch (error) {
|
||||
options.summary.textContent = `Cached copies could not be cleared: ${error.message}`;
|
||||
} finally {
|
||||
options.clearCachesButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function clearAll() {
|
||||
if ((privateItemCount > 0 || privateRecordCount > 0 || inventoryUnavailable) && !fullClearArmed) {
|
||||
fullClearArmed = true;
|
||||
options.clearAllButton.textContent = 'Confirm: clear private work';
|
||||
options.summary.textContent = 'Private drafts or queued work may not be synced. Press confirm to clear them from this device.';
|
||||
return;
|
||||
}
|
||||
options.clearAllButton.disabled = true;
|
||||
try {
|
||||
await options.clearPrivateDeviceData();
|
||||
privateItemCount = 0;
|
||||
fullClearArmed = false;
|
||||
options.clearAllButton.textContent = 'Clear all private data';
|
||||
options.detail.textContent = '0 private browser items · 0 private work records · 0 cached app copies';
|
||||
options.summary.textContent = 'All Stackchain private data was cleared from this device.';
|
||||
} catch (error) {
|
||||
options.summary.textContent = `Private data was not fully cleared: ${error.message}`;
|
||||
} finally {
|
||||
options.clearAllButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function start() {
|
||||
options.clearCachesButton.addEventListener('click', clearCaches);
|
||||
options.clearAllButton.addEventListener('click', clearAll);
|
||||
return refresh();
|
||||
}
|
||||
|
||||
return { refresh, start };
|
||||
});
|
||||
+9
-2
@@ -240,7 +240,14 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
}
|
||||
|
||||
function partition(items = list()) {
|
||||
const deliveries = items.filter(item => item.kind === 'issue-outbox' || item.kind === 'authored-outbox');
|
||||
const isDelivery = item => item.kind === 'issue-outbox' || item.kind === 'authored-outbox';
|
||||
const needsAction = item => isDelivery(item) && (
|
||||
item.status === 'attention' || item.status === 'authorization' || item.status === 'completion' ||
|
||||
item.delivery_state === 'uncertain' || item.quarantined
|
||||
);
|
||||
const deliveries = items.filter(isDelivery).sort((left, right) =>
|
||||
Number(needsAction(right)) - Number(needsAction(left))
|
||||
);
|
||||
const drafts = items.filter(item => item.kind !== 'issue-outbox' && item.kind !== 'authored-outbox');
|
||||
const counts = deliveries.reduce((summary, item) => {
|
||||
const state = item.status === 'sending' ? 'sending' : (item.status === 'attention' ? 'attention' :
|
||||
@@ -251,7 +258,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
const retryable = deliveries.filter(item =>
|
||||
item.status === 'queued' && !item.authorization_required && !item.quarantined && item.delivery_state !== 'uncertain'
|
||||
);
|
||||
return { drafts, deliveries, counts, retryable };
|
||||
return { drafts, deliveries, counts, retryable, actionable:deliveries.filter(needsAction).length };
|
||||
}
|
||||
|
||||
function deliveryLabel(item) {
|
||||
|
||||
+387
-97
@@ -16,6 +16,7 @@
|
||||
<details class="app-menu">
|
||||
<summary id="app-menu-toggle" aria-label="Open dashboard menu">Menu</summary>
|
||||
<div class="app-menu-panel">
|
||||
<button id="open-insights" type="button" aria-controls="insights-sheet">Insights</button>
|
||||
<button id="refresh">Refresh</button>
|
||||
<button id="open-palette">Command</button>
|
||||
<button id="open-device-setup" type="button">Device setup</button>
|
||||
@@ -47,22 +48,15 @@
|
||||
<div id="active-devices-sheet" class="active-devices-sheet" hidden>
|
||||
<section class="active-devices-panel" role="dialog" aria-modal="true" aria-label="Active devices and passkeys">
|
||||
<div class="active-devices-header">
|
||||
<div><h2>Active devices</h2><p class="small muted">Review signed-in devices and remove access you no longer trust.</p></div>
|
||||
<div><h2>Security Center</h2><p class="small muted">Investigate activity, remove untrusted access, and verify the result.</p></div>
|
||||
<button id="close-active-devices" type="button" aria-label="Close active devices">Close</button>
|
||||
</div>
|
||||
<div id="active-devices-status" class="small" role="status" aria-live="polite"></div>
|
||||
<button id="enroll-passkey" type="button">Add a passkey for this device</button>
|
||||
<div id="active-devices-list" class="active-devices-list"></div>
|
||||
<section class="enrolled-passkeys" aria-labelledby="enrolled-passkeys-title">
|
||||
<div class="enrolled-passkeys-header">
|
||||
<h3 id="enrolled-passkeys-title">Enrolled passkeys</h3>
|
||||
<p class="small muted">Passkeys can remain enrolled after a device signs out. Remove credentials you no longer trust.</p>
|
||||
<p class="small muted">A recovery token will be required after sign-out if you remove this device’s passkey.</p>
|
||||
</div>
|
||||
<div id="enrolled-passkeys-status" class="small" role="status" aria-live="polite"></div>
|
||||
<div id="enrolled-passkeys-list" class="enrolled-passkeys-list"></div>
|
||||
</section>
|
||||
<section class="security-activity" aria-labelledby="security-activity-title">
|
||||
<nav class="security-section-nav" aria-label="Security Center sections">
|
||||
<button id="security-section-activity" type="button">Activity</button>
|
||||
<button id="security-section-devices" type="button">Devices</button>
|
||||
<button id="security-section-passkeys" type="button">Passkeys</button>
|
||||
</nav>
|
||||
<section id="security-activity-section" class="security-activity" aria-labelledby="security-activity-title">
|
||||
<div class="security-activity-header">
|
||||
<h3 id="security-activity-title">Security activity</h3>
|
||||
<p class="small muted">Recent sign-ins, device changes, and protected actions.</p>
|
||||
@@ -71,6 +65,24 @@
|
||||
<div id="security-activity-list" class="security-activity-list"></div>
|
||||
<button id="load-more-security-activity" type="button" hidden>Load older activity</button>
|
||||
</section>
|
||||
<section id="security-devices-section" class="security-devices" aria-labelledby="security-devices-title">
|
||||
<div class="security-activity-header">
|
||||
<h3 id="security-devices-title">Active devices</h3>
|
||||
<p class="small muted">Review signed-in devices and remove access you no longer trust.</p>
|
||||
</div>
|
||||
<div id="active-devices-status" class="small" role="status" aria-live="polite"></div>
|
||||
<button id="enroll-passkey" type="button">Add a passkey for this device</button>
|
||||
<div id="active-devices-list" class="active-devices-list"></div>
|
||||
</section>
|
||||
<section id="security-passkeys-section" class="enrolled-passkeys" aria-labelledby="enrolled-passkeys-title">
|
||||
<div class="enrolled-passkeys-header">
|
||||
<h3 id="enrolled-passkeys-title">Enrolled passkeys</h3>
|
||||
<p class="small muted">Passkeys can remain enrolled after a device signs out. Remove credentials you no longer trust.</p>
|
||||
<p class="small muted">A recovery token will be required after sign-out if you remove this device’s passkey.</p>
|
||||
</div>
|
||||
<div id="enrolled-passkeys-status" class="small" role="status" aria-live="polite"></div>
|
||||
<div id="enrolled-passkeys-list" class="enrolled-passkeys-list"></div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -107,11 +119,23 @@
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<section class="device-storage" aria-labelledby="device-storage-heading">
|
||||
<div>
|
||||
<h3 id="device-storage-heading">Private device storage</h3>
|
||||
<p class="small" id="device-storage-summary" role="status" aria-live="polite">Checking private storage…</p>
|
||||
<p class="small muted" id="device-storage-detail"></p>
|
||||
</div>
|
||||
<div class="device-storage-actions">
|
||||
<button id="clear-device-caches" type="button">Clear cached copies</button>
|
||||
<button id="clear-private-device-data" type="button">Clear all private data</button>
|
||||
</div>
|
||||
</section>
|
||||
<p id="device-setup-ready-status" class="device-setup-ready" role="status" aria-live="polite"></p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<div id="mobile-pull-refresh" class="mobile-pull-refresh" role="status" aria-live="polite" hidden></div>
|
||||
<section class="panel my-work" id="my-work" tabindex="-1">
|
||||
<div class="my-work-header">
|
||||
<div>
|
||||
@@ -273,6 +297,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="insights-shell" id="insights-sheet" aria-labelledby="insights-heading">
|
||||
<div class="insights-header">
|
||||
<div><h2 id="insights-heading">Insights</h2><p class="small muted">Context, activity, live signals, and workspace tools.</p></div>
|
||||
<button id="close-insights" type="button">Close</button>
|
||||
</div>
|
||||
<aside class="sidebar">
|
||||
<details class="panel stack" data-panel-key="context" open>
|
||||
<summary><h2>Context & view</h2></summary>
|
||||
@@ -334,6 +363,7 @@
|
||||
<div id="md-preview" style="margin-top:8px;"></div>
|
||||
</details>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="plan-today-sheet" id="plan-today-sheet" role="dialog" aria-modal="true" aria-labelledby="plan-today-title" hidden>
|
||||
@@ -342,6 +372,12 @@
|
||||
<div><h2 id="plan-today-title">Plan Today</h2><p class="small muted">Choose and order the work you want to finish next.</p></div>
|
||||
<button id="cancel-plan-today" type="button">Cancel</button>
|
||||
</div>
|
||||
<nav class="mobile-plan-today-nav" aria-label="Plan Today sections">
|
||||
<button type="button" data-plan-today-section="fit" aria-current="location">Fit</button>
|
||||
<button type="button" data-plan-today-section="today">Today</button>
|
||||
<button type="button" data-plan-today-section="available">Available</button>
|
||||
</nav>
|
||||
<section id="plan-today-fit" aria-label="Fit today's work">
|
||||
<div class="plan-today-capacity" id="plan-today-capacity" role="status" aria-live="polite">0 of 5 selected</div>
|
||||
<label class="plan-today-available" for="plan-today-available">Available today
|
||||
<span><input id="plan-today-available" type="number" inputmode="numeric" min="15" max="1440" step="15" placeholder="Minutes" /> min</span>
|
||||
@@ -356,11 +392,12 @@
|
||||
<div class="plan-today-skipped" id="plan-today-skipped"></div>
|
||||
<div class="small plan-today-error" id="plan-today-error" role="alert"></div>
|
||||
<button class="discard-recap-replan" id="discard-recap-replan" type="button" hidden>Discard recap feedback</button>
|
||||
<section aria-labelledby="today-plan-heading">
|
||||
</section>
|
||||
<section id="plan-today-selected" aria-labelledby="today-plan-heading">
|
||||
<h3 id="today-plan-heading">Today, in order</h3>
|
||||
<div class="plan-today-list" id="plan-today-list"></div>
|
||||
</section>
|
||||
<section aria-labelledby="today-candidates-heading">
|
||||
<section id="plan-today-available-work" aria-labelledby="today-candidates-heading">
|
||||
<h3 id="today-candidates-heading">Available My Work</h3>
|
||||
<div class="plan-today-candidates" id="plan-today-candidates"></div>
|
||||
</section>
|
||||
@@ -399,13 +436,38 @@
|
||||
<div id="today-recap-history" class="today-recap-history"></div>
|
||||
</section>
|
||||
<div class="today-recap-actions">
|
||||
<button id="save-today-recap" type="button">Save recap & adjust plan</button>
|
||||
<button id="save-today-recap" type="button">Save recap & wrap up</button>
|
||||
<button id="save-log-today-recap" type="button">Log selected time to Gitea</button>
|
||||
<button id="discard-today-recap" type="button">Keep timer & close</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<dialog class="today-handoff-dialog" id="today-handoff-dialog" aria-labelledby="today-handoff-title">
|
||||
<section class="today-handoff-panel">
|
||||
<header><div><div class="small">Yesterday’s commitments</div><h2 id="today-handoff-title">Review tomorrow’s plan</h2></div><button id="close-today-handoff" type="button">Not now</button></header>
|
||||
<p class="small muted">Choose the work you still intend to do Today. Unchecked work stays safely in My Work.</p>
|
||||
<div id="today-handoff-status" class="small" role="status" aria-live="polite"></div>
|
||||
<div id="today-handoff-items" class="today-handoff-items"></div>
|
||||
<div class="today-handoff-actions"><button id="confirm-today-handoff" type="button">Add selected to Today</button></div>
|
||||
</section>
|
||||
</dialog>
|
||||
|
||||
<div class="today-wrap-up-sheet" id="today-wrap-up-sheet" role="dialog" aria-modal="true" aria-labelledby="today-wrap-up-title" hidden>
|
||||
<section class="today-wrap-up-panel">
|
||||
<div class="today-wrap-up-header">
|
||||
<div><div class="small">End your day intentionally</div><h2 id="today-wrap-up-title">Wrap up Today</h2></div>
|
||||
<button id="close-today-wrap-up" type="button">Not now</button>
|
||||
</div>
|
||||
<p class="small muted">Choose unfinished work to schedule for tomorrow at 09:00. Unchecked work stays in Today.</p>
|
||||
<div id="today-wrap-up-status" class="small" role="status" aria-live="polite"></div>
|
||||
<div id="today-wrap-up-items" class="today-wrap-up-items"></div>
|
||||
<div class="today-wrap-up-actions">
|
||||
<button id="finish-today-wrap-up" type="button">Finish wrap-up</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="today-readiness-sheet" id="today-readiness-sheet" role="dialog" aria-modal="true" aria-labelledby="today-readiness-title" hidden>
|
||||
<section class="today-readiness-panel">
|
||||
<div class="today-readiness-header">
|
||||
@@ -533,9 +595,17 @@
|
||||
</div>
|
||||
<button id="close-search-preview" type="button">Back to search</button>
|
||||
</div>
|
||||
<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>
|
||||
<nav class="mobile-search-preview-nav" aria-label="Search preview sections">
|
||||
<button type="button" data-search-preview-section="overview">Overview</button>
|
||||
<button type="button" data-search-preview-section="conversation">Conversation</button>
|
||||
<button type="button" data-search-preview-section="reply">Reply</button>
|
||||
<button type="button" data-search-preview-section="actions">Act</button>
|
||||
</nav>
|
||||
<section id="search-preview-overview" class="search-preview-overview">
|
||||
<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>
|
||||
<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>
|
||||
@@ -543,16 +613,53 @@
|
||||
<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>
|
||||
<section class="search-preview-reply" id="search-preview-reply-workspace" 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>
|
||||
<section class="voice-conversation" id="voice-search-reply" aria-label="Dictate search result reply" hidden>
|
||||
<div class="voice-conversation-controls">
|
||||
<button id="start-voice-search-reply" type="button">Dictate reply</button>
|
||||
<button id="stop-voice-search-reply" type="button" hidden>Stop listening</button>
|
||||
</div>
|
||||
<div id="voice-search-reply-status" class="small" aria-live="polite"></div>
|
||||
<div class="voice-conversation-review" id="voice-search-reply-review" hidden>
|
||||
<label for="voice-search-reply-transcript">Review transcript</label>
|
||||
<textarea id="voice-search-reply-transcript" maxlength="10000"></textarea>
|
||||
<div class="voice-conversation-review-actions">
|
||||
<button id="append-voice-search-reply" type="button">Use transcript</button>
|
||||
<button id="replace-with-voice-search-reply" type="button" hidden>Replace draft</button>
|
||||
<button id="discard-voice-search-reply" type="button">Discard</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="issue-attachment-controls conversation-photo-actions">
|
||||
<label class="issue-attachment-trigger" for="take-search-reply-photo">Take photo</label>
|
||||
<input class="visually-hidden" id="take-search-reply-photo" type="file" accept="image/*" capture="environment" />
|
||||
<label class="issue-attachment-trigger" for="search-reply-attachment">Choose existing</label>
|
||||
<input class="visually-hidden" id="search-reply-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple />
|
||||
</div>
|
||||
<div class="issue-attachment-preview conversation-evidence-review" id="search-reply-attachment-preview" hidden>
|
||||
<img id="search-reply-attachment-image" alt="Selected reply photo preview" />
|
||||
<span class="small" id="search-reply-attachment-meta"></span>
|
||||
<div class="issue-evidence-tray" id="search-reply-attachment-tray" role="toolbar" aria-label="Search reply photos" hidden></div>
|
||||
<label class="issue-evidence-note" for="search-reply-attachment-note">
|
||||
<span id="search-reply-attachment-note-label">Photo caption (optional)</span>
|
||||
<textarea id="search-reply-attachment-note" maxlength="240" rows="2" disabled placeholder="What does this photo show?"></textarea>
|
||||
</label>
|
||||
<div class="issue-evidence-review-actions" aria-label="Reorder selected photo">
|
||||
<button id="move-search-reply-attachment-earlier" type="button">Move earlier</button>
|
||||
<button id="edit-search-reply-attachment" type="button">Edit photo</button>
|
||||
<button id="remove-search-reply-attachment" type="button">Remove selected</button>
|
||||
<button id="move-search-reply-attachment-later" type="button">Move later</button>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<div class="search-preview-actions" id="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>
|
||||
@@ -597,6 +704,12 @@
|
||||
</div>
|
||||
<button id="close-issue-sheet" type="button">Close sheet</button>
|
||||
</div>
|
||||
<nav class="mobile-issue-detail-nav" aria-label="Issue sections">
|
||||
<button type="button" data-issue-section="overview">Overview</button>
|
||||
<button type="button" data-issue-section="conversation">Conversation</button>
|
||||
<button type="button" data-issue-section="reply">Reply</button>
|
||||
<button type="button" data-issue-section="actions">Plan & actions</button>
|
||||
</nav>
|
||||
<div id="issue-sheet-status" class="small" aria-live="polite">Choose an issue.</div>
|
||||
<section class="completed-filed-actions" id="completed-filed-actions" aria-label="Completed Filed review" hidden>
|
||||
<span id="completed-filed-progress" role="status" aria-live="polite">Completed Filed issue ready for review.</span>
|
||||
@@ -607,7 +720,7 @@
|
||||
</section>
|
||||
<button class="issue-retry" id="retry-issue-load" type="button" hidden>Reload latest issue</button>
|
||||
<div class="row"><span id="issue-labels"></span><span class="small" id="issue-assignees"></span></div>
|
||||
<div class="issue-sheet-content markdown-content" id="issue-sheet-body"></div>
|
||||
<div id="issue-overview"><div class="issue-sheet-content markdown-content" id="issue-sheet-body"></div></div>
|
||||
<form class="checklist-step-editor" id="checklist-step-editor" aria-label="Manage checklist step" hidden>
|
||||
<label for="checklist-step-label">Checklist step</label>
|
||||
<input id="checklist-step-label" type="text" maxlength="240" autocomplete="off" />
|
||||
@@ -651,6 +764,7 @@
|
||||
<button id="cancel-issue-blocker" type="button">Cancel</button>
|
||||
</div>
|
||||
</section>
|
||||
<div id="issue-conversation" tabindex="-1"></div>
|
||||
<h2>Full conversation</h2>
|
||||
<div id="issue-comments"></div>
|
||||
<button class="conversation-more" id="load-older-issue-comments" type="button" hidden>Load older messages</button>
|
||||
@@ -659,16 +773,44 @@
|
||||
<section class="issue-comment-composer" aria-labelledby="issue-comment-title">
|
||||
<h2 id="issue-comment-title">Add comment</h2>
|
||||
<textarea id="issue-comment" maxlength="10000" placeholder="Write a comment"></textarea>
|
||||
<section class="voice-conversation" id="voice-issue-comment" aria-label="Dictate issue comment" hidden>
|
||||
<div class="voice-conversation-controls">
|
||||
<button id="start-voice-issue-comment" type="button">Dictate reply</button>
|
||||
<button id="stop-voice-issue-comment" type="button" hidden>Stop listening</button>
|
||||
</div>
|
||||
<div id="voice-issue-comment-status" class="small" aria-live="polite"></div>
|
||||
<div class="voice-conversation-review" id="voice-issue-comment-review" hidden>
|
||||
<label for="voice-issue-comment-transcript">Review transcript</label>
|
||||
<textarea id="voice-issue-comment-transcript" maxlength="10000"></textarea>
|
||||
<div class="voice-conversation-review-actions">
|
||||
<button id="append-voice-issue-comment" type="button">Use transcript</button>
|
||||
<button id="replace-with-voice-issue-comment" type="button" hidden>Replace draft</button>
|
||||
<button id="discard-voice-issue-comment" type="button">Discard</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="mention-options" id="issue-comment-mentions" role="listbox" aria-label="Teammates" hidden></div>
|
||||
<div class="mention-status small" id="issue-comment-mention-status" aria-live="polite"></div>
|
||||
<div class="issue-attachment-controls">
|
||||
<input class="visually-hidden" id="issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
|
||||
<label class="issue-attachment-trigger" for="issue-attachment">Attach screenshot</label>
|
||||
<div class="issue-attachment-controls conversation-photo-actions">
|
||||
<label class="issue-attachment-trigger" for="take-issue-comment-photo">Take photo</label>
|
||||
<input class="visually-hidden" id="take-issue-comment-photo" type="file" accept="image/*" capture="environment" />
|
||||
<label class="issue-attachment-trigger" for="issue-attachment">Choose existing</label>
|
||||
<input class="visually-hidden" id="issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple />
|
||||
</div>
|
||||
<div class="issue-attachment-preview" id="issue-attachment-preview" hidden>
|
||||
<div class="issue-attachment-preview conversation-evidence-review" id="issue-attachment-preview" hidden>
|
||||
<img id="issue-attachment-image" alt="Selected screenshot preview" />
|
||||
<span class="small" id="issue-attachment-meta"></span>
|
||||
<button id="remove-issue-attachment" type="button">Remove screenshot</button>
|
||||
<div class="issue-evidence-tray" id="issue-attachment-tray" role="toolbar" aria-label="Comment photos" hidden></div>
|
||||
<label class="issue-evidence-note" for="issue-attachment-note">
|
||||
<span id="issue-attachment-note-label">Photo caption (optional)</span>
|
||||
<textarea id="issue-attachment-note" maxlength="240" rows="2" disabled placeholder="What does this photo show?"></textarea>
|
||||
</label>
|
||||
<div class="issue-evidence-review-actions" aria-label="Reorder selected photo">
|
||||
<button id="move-issue-attachment-earlier" type="button">Move earlier</button>
|
||||
<button id="edit-issue-attachment" type="button">Edit photo</button>
|
||||
<button id="remove-issue-attachment" type="button">Remove selected</button>
|
||||
<button id="move-issue-attachment-later" type="button">Move later</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comment-actions">
|
||||
<button id="send-issue-comment" type="button">Post comment</button>
|
||||
@@ -756,42 +898,60 @@
|
||||
<div><div class="small">Open · unassigned</div><h3 id="find-work-heading">Find work</h3></div>
|
||||
<div class="find-work-header-actions"><button id="select-find-work" type="button">Select work</button><button id="close-find-work" type="button">Close</button></div>
|
||||
</div>
|
||||
<p class="small">Claim an available issue and continue it in My Work.</p>
|
||||
<form class="find-work-search" id="find-work-search-form" role="search">
|
||||
<label for="find-work-search">Search repository, #number, or title</label>
|
||||
<div><input id="find-work-search" type="search" maxlength="100" autocomplete="off" />
|
||||
<button id="clear-find-work-search" type="button" hidden>Clear</button></div>
|
||||
<span id="find-work-match-status" class="small" aria-live="polite"></span>
|
||||
</form>
|
||||
<details class="find-work-filters" id="find-work-filters">
|
||||
<summary>Filter work <span id="find-work-filter-count" class="small"></span></summary>
|
||||
<div class="find-work-filter-panel" aria-label="Filter available work">
|
||||
<fieldset><legend>Repositories</legend><div id="find-work-repository-filters" class="find-work-filter-options"></div></fieldset>
|
||||
<fieldset><legend>Labels</legend><div id="find-work-label-filters" class="find-work-filter-options"></div></fieldset>
|
||||
<button id="clear-find-work-filters" type="button">Clear filters</button>
|
||||
</div>
|
||||
</details>
|
||||
<button class="fill-find-work-today" id="fill-find-work-today" type="button"
|
||||
aria-describedby="find-work-selection-status">Fill remaining Today slots</button>
|
||||
<div id="find-work-status" class="small" aria-live="assertive">Open Find Work to load available issues.</div>
|
||||
<button class="find-work-batch-recovery" id="resume-find-work-batch" type="button"
|
||||
aria-describedby="find-work-status" hidden>Resume assigning & queueing</button>
|
||||
<div class="find-work-list" id="find-work-list"></div>
|
||||
<button class="find-work-more" id="load-more-available" type="button" hidden>Load more available issues</button>
|
||||
<section class="find-work-estimate-review" id="find-work-estimate-review" aria-labelledby="find-work-estimate-heading" hidden>
|
||||
<div><strong id="find-work-estimate-heading">Fit this work into Today</strong>
|
||||
<span class="small" id="find-work-estimate-summary" aria-live="polite"></span></div>
|
||||
<div id="find-work-estimate-list"></div>
|
||||
<div class="find-work-estimate-actions">
|
||||
<button id="cancel-find-work-estimates" type="button">Back</button>
|
||||
<button id="confirm-find-work-estimates" type="button">Confirm & assign</button>
|
||||
<p class="small">Choose available issues, review the batch, then fit it into Today.</p>
|
||||
<nav class="mobile-find-work-nav" aria-label="Find Work steps">
|
||||
<button type="button" data-find-work-stage="discover">Discover</button>
|
||||
<button type="button" data-find-work-stage="review" disabled>Review</button>
|
||||
<button type="button" data-find-work-stage="fit" disabled>Fit & assign</button>
|
||||
</nav>
|
||||
<section class="find-work-stage" id="find-work-discover-stage" tabindex="-1" aria-labelledby="find-work-heading">
|
||||
<form class="find-work-search" id="find-work-search-form" role="search">
|
||||
<label for="find-work-search">Search repository, #number, or title</label>
|
||||
<div><input id="find-work-search" type="search" maxlength="100" autocomplete="off" />
|
||||
<button id="clear-find-work-search" type="button" hidden>Clear</button></div>
|
||||
<span id="find-work-match-status" class="small" aria-live="polite"></span>
|
||||
</form>
|
||||
<details class="find-work-filters" id="find-work-filters">
|
||||
<summary>Filter work <span id="find-work-filter-count" class="small"></span></summary>
|
||||
<div class="find-work-filter-panel" aria-label="Filter available work">
|
||||
<fieldset><legend>Repositories</legend><div id="find-work-repository-filters" class="find-work-filter-options"></div></fieldset>
|
||||
<fieldset><legend>Labels</legend><div id="find-work-label-filters" class="find-work-filter-options"></div></fieldset>
|
||||
<button id="clear-find-work-filters" type="button">Clear filters</button>
|
||||
</div>
|
||||
</details>
|
||||
<button class="fill-find-work-today" id="fill-find-work-today" type="button"
|
||||
aria-describedby="find-work-selection-status">Fill remaining Today slots</button>
|
||||
<div id="find-work-status" class="small" aria-live="assertive">Open Find Work to load available issues.</div>
|
||||
<button class="find-work-batch-recovery" id="resume-find-work-batch" type="button"
|
||||
aria-describedby="find-work-status" hidden>Resume assigning & queueing</button>
|
||||
<div class="find-work-list" id="find-work-list"></div>
|
||||
<button class="find-work-more" id="load-more-available" type="button" hidden>Load more available issues</button>
|
||||
<div class="find-work-batch-actions" id="batch-find-work-actions" hidden>
|
||||
<span id="find-work-selection-status" class="small" aria-live="polite">No work selected.</span>
|
||||
<button id="cancel-find-work-selection" type="button">Cancel</button>
|
||||
<button id="claim-selected-work" type="button" disabled>Review selected</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="find-work-stage find-work-review" id="find-work-review-stage" tabindex="-1" aria-labelledby="find-work-review-heading" hidden>
|
||||
<div><strong id="find-work-review-heading">Review selected work</strong>
|
||||
<span class="small" id="find-work-review-summary" aria-live="polite"></span></div>
|
||||
<div class="find-work-review-list" id="find-work-review-list"></div>
|
||||
<div class="find-work-stage-actions">
|
||||
<button id="back-find-work-discover" type="button">Back to discover</button>
|
||||
<button id="continue-find-work-fit" type="button">Continue</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="find-work-stage" id="find-work-fit-stage" tabindex="-1" aria-labelledby="find-work-estimate-heading" hidden>
|
||||
<div class="find-work-estimate-review" id="find-work-estimate-review">
|
||||
<div><strong id="find-work-estimate-heading">Fit this work into Today</strong>
|
||||
<span class="small" id="find-work-estimate-summary" aria-live="polite"></span></div>
|
||||
<div id="find-work-estimate-list"></div>
|
||||
<div class="find-work-estimate-actions">
|
||||
<button id="cancel-find-work-estimates" type="button">Back to review</button>
|
||||
<button id="confirm-find-work-estimates" type="button">Confirm & assign</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="find-work-batch-actions" id="batch-find-work-actions" hidden>
|
||||
<span id="find-work-selection-status" class="small" aria-live="polite">No work selected.</span>
|
||||
<button id="cancel-find-work-selection" type="button">Cancel</button>
|
||||
<button id="claim-selected-work" type="button" disabled>Assign & queue Today</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -801,6 +961,11 @@
|
||||
<h3 id="create-issue-heading">Capture work</h3>
|
||||
<button id="cancel-new-issue" type="button">Cancel</button>
|
||||
</div>
|
||||
<nav class="mobile-create-issue-nav" aria-label="New issue sections">
|
||||
<button type="button" data-create-issue-section="describe">Describe</button>
|
||||
<button type="button" data-create-issue-section="evidence">Evidence</button>
|
||||
<button type="button" data-create-issue-section="file">File</button>
|
||||
</nav>
|
||||
<aside class="shared-content-conflict" id="shared-content-conflict" aria-live="assertive" hidden>
|
||||
<strong>You already have an unfinished issue draft.</strong>
|
||||
<span class="small">Resume it, or replace its title and description with the content shared to Stackchain.</span>
|
||||
@@ -810,6 +975,7 @@
|
||||
</div>
|
||||
</aside>
|
||||
<form class="create-issue-form" id="create-issue-form">
|
||||
<section class="create-issue-describe" id="create-issue-describe">
|
||||
<label for="create-issue-title">Title
|
||||
<input id="create-issue-title" type="text" maxlength="255" required autocomplete="off" />
|
||||
</label>
|
||||
@@ -841,16 +1007,19 @@
|
||||
<button id="save-unfiled-issue" type="button">Save to Drafts</button>
|
||||
<button id="file-new-issue" type="button">File now</button>
|
||||
</div>
|
||||
<section class="create-issue-attachment" aria-labelledby="create-issue-attachment-label">
|
||||
<strong id="create-issue-attachment-label">Evidence screenshots <span class="small">Optional · Up to 5 · PNG, JPEG, or WebP · 2 MB each</span></strong>
|
||||
<div class="issue-attachment-controls">
|
||||
<label class="issue-attachment-trigger" for="create-issue-attachment">Add screenshots</label>
|
||||
</section>
|
||||
<section class="create-issue-attachment" id="create-issue-evidence" aria-labelledby="create-issue-attachment-label">
|
||||
<strong id="create-issue-attachment-label">Photo evidence <span class="small">Optional · Up to 5 · PNG, JPEG, or WebP · 2 MB each</span></strong>
|
||||
<div class="issue-attachment-controls photo-evidence-actions">
|
||||
<label class="issue-attachment-trigger" for="take-create-issue-photo">Take photo</label>
|
||||
<input class="visually-hidden" id="take-create-issue-photo" type="file" accept="image/*" capture="environment" />
|
||||
<label class="issue-attachment-trigger" for="create-issue-attachment">Choose existing</label>
|
||||
<input class="visually-hidden" id="create-issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple />
|
||||
</div>
|
||||
<div class="issue-attachment-preview" id="create-issue-attachment-preview" hidden>
|
||||
<img id="create-issue-attachment-image" alt="Selected screenshot preview" />
|
||||
<span class="small" id="create-issue-attachment-meta"></span>
|
||||
<div class="issue-evidence-tray" id="create-issue-evidence-tray" role="toolbar" aria-label="Evidence screenshots" hidden></div>
|
||||
<div class="issue-evidence-tray" id="create-issue-evidence-tray" role="toolbar" aria-label="Photo evidence" hidden></div>
|
||||
<label class="issue-evidence-note" for="create-issue-evidence-note">
|
||||
<span id="create-issue-evidence-note-label">Evidence note (optional)</span>
|
||||
<textarea id="create-issue-evidence-note" maxlength="240" rows="2" disabled placeholder="What does this screenshot show?"></textarea>
|
||||
@@ -941,10 +1110,11 @@
|
||||
<div id="create-issue-capture-status" class="small" aria-live="assertive"></div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="issue-evidence-editor" id="issue-evidence-editor" role="dialog" aria-modal="true" aria-labelledby="issue-evidence-editor-heading" hidden>
|
||||
</div>
|
||||
<section class="issue-evidence-editor" id="issue-evidence-editor" role="dialog" aria-modal="true" aria-labelledby="issue-evidence-editor-heading" hidden>
|
||||
<div class="issue-evidence-editor-panel">
|
||||
<header>
|
||||
<div><h3 id="issue-evidence-editor-heading">Edit screenshot evidence</h3><span class="small">Edits stay on this device until you file.</span></div>
|
||||
<div><h3 id="issue-evidence-editor-heading">Edit selected photo</h3><span class="small">Edits stay on this device until you send or file.</span></div>
|
||||
<button id="cancel-issue-evidence-edit" type="button">Cancel</button>
|
||||
</header>
|
||||
<canvas id="issue-evidence-editor-canvas" role="img" aria-label="Screenshot editing canvas. Drag to crop, redact, highlight, or point with an arrow."></canvas>
|
||||
@@ -961,7 +1131,6 @@
|
||||
<button class="issue-evidence-editor-apply" id="apply-issue-evidence-edit" type="button">Apply flattened edit</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="issue-filing-review" id="issue-filing-review" role="dialog" aria-modal="true" aria-labelledby="issue-filing-review-heading" hidden>
|
||||
<div class="issue-filing-review-panel">
|
||||
@@ -1024,6 +1193,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="update-triage-progress" class="update-triage-progress small" aria-live="polite" hidden></div>
|
||||
<nav class="mobile-update-detail-nav" aria-label="Update sections">
|
||||
<button type="button" data-update-section="activity">New activity</button>
|
||||
<button type="button" data-update-section="conversation">Conversation</button>
|
||||
<button type="button" data-update-section="context">Subject context</button>
|
||||
<button type="button" data-update-section="reply">Reply</button>
|
||||
</nav>
|
||||
<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>
|
||||
@@ -1031,31 +1206,61 @@
|
||||
<span class="pill" id="update-subject-type">Update</span>
|
||||
<span class="pill" id="update-subject-state"></span>
|
||||
</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>
|
||||
<button class="conversation-actions-retry" id="retry-update-comment-actions" type="button" hidden>Retry comment tools</button>
|
||||
<details>
|
||||
<section id="update-conversation" tabindex="-1">
|
||||
<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>
|
||||
<button class="conversation-actions-retry" id="retry-update-comment-actions" type="button" hidden>Retry comment tools</button>
|
||||
</section>
|
||||
<details id="update-subject-context">
|
||||
<summary><h2>Subject context</h2></summary>
|
||||
<div class="update-sheet-content muted markdown-content" id="update-subject-body"></div>
|
||||
</details>
|
||||
<section class="update-reply" aria-labelledby="update-reply-title">
|
||||
<section class="update-reply" id="update-reply-workspace" aria-labelledby="update-reply-title">
|
||||
<h2 id="update-reply-title">Reply</h2>
|
||||
<label for="update-reply" class="small">Continue this issue or pull-request conversation</label>
|
||||
<textarea id="update-reply" maxlength="10000" placeholder="Write a reply"></textarea>
|
||||
<section class="voice-conversation" id="voice-update-reply" aria-label="Dictate update reply" hidden>
|
||||
<div class="voice-conversation-controls">
|
||||
<button id="start-voice-update-reply" type="button">Dictate reply</button>
|
||||
<button id="stop-voice-update-reply" type="button" hidden>Stop listening</button>
|
||||
</div>
|
||||
<div id="voice-update-reply-status" class="small" aria-live="polite"></div>
|
||||
<div class="voice-conversation-review" id="voice-update-reply-review" hidden>
|
||||
<label for="voice-update-reply-transcript">Review transcript</label>
|
||||
<textarea id="voice-update-reply-transcript" maxlength="10000"></textarea>
|
||||
<div class="voice-conversation-review-actions">
|
||||
<button id="append-voice-update-reply" type="button">Use transcript</button>
|
||||
<button id="replace-with-voice-update-reply" type="button" hidden>Replace draft</button>
|
||||
<button id="discard-voice-update-reply" type="button">Discard</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="mention-options" id="update-reply-mentions" role="listbox" aria-label="Teammates" hidden></div>
|
||||
<div class="mention-status small" id="update-reply-mention-status" aria-live="polite"></div>
|
||||
<div class="issue-attachment-controls">
|
||||
<input class="visually-hidden" id="update-reply-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
|
||||
<label class="issue-attachment-trigger" for="update-reply-attachment">Attach screenshot</label>
|
||||
<div class="issue-attachment-controls conversation-photo-actions">
|
||||
<label class="issue-attachment-trigger" for="take-update-reply-photo">Take photo</label>
|
||||
<input class="visually-hidden" id="take-update-reply-photo" type="file" accept="image/*" capture="environment" />
|
||||
<label class="issue-attachment-trigger" for="update-reply-attachment">Choose existing</label>
|
||||
<input class="visually-hidden" id="update-reply-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple />
|
||||
</div>
|
||||
<div class="issue-attachment-preview" id="update-reply-attachment-preview" hidden>
|
||||
<div class="issue-attachment-preview conversation-evidence-review" id="update-reply-attachment-preview" hidden>
|
||||
<img id="update-reply-attachment-image" alt="Selected screenshot preview" />
|
||||
<span class="small" id="update-reply-attachment-meta"></span>
|
||||
<button id="remove-update-reply-attachment" type="button">Remove screenshot</button>
|
||||
<div class="issue-evidence-tray" id="update-reply-attachment-tray" role="toolbar" aria-label="Reply photos" hidden></div>
|
||||
<label class="issue-evidence-note" for="update-reply-attachment-note">
|
||||
<span id="update-reply-attachment-note-label">Photo caption (optional)</span>
|
||||
<textarea id="update-reply-attachment-note" maxlength="240" rows="2" disabled placeholder="What does this photo show?"></textarea>
|
||||
</label>
|
||||
<div class="issue-evidence-review-actions" aria-label="Reorder selected photo">
|
||||
<button id="move-update-reply-attachment-earlier" type="button">Move earlier</button>
|
||||
<button id="edit-update-reply-attachment" type="button">Edit photo</button>
|
||||
<button id="remove-update-reply-attachment" type="button">Remove selected</button>
|
||||
<button id="move-update-reply-attachment-later" type="button">Move later</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="update-reply-actions">
|
||||
<button id="send-update-reply" type="button">Send reply</button>
|
||||
@@ -1106,26 +1311,64 @@
|
||||
<div><div class="small" id="pull-sheet-key"></div><h3 id="pull-sheet-title">Assigned pull request</h3></div>
|
||||
<button id="close-pull-sheet" type="button">Close</button>
|
||||
</div>
|
||||
<div id="pull-sheet-status" class="small" aria-live="polite">Choose a pull request.</div>
|
||||
<button class="pull-retry" id="retry-pull-load" type="button" hidden>Retry loading pull request</button>
|
||||
<div class="pull-sheet-content markdown-content" id="pull-sheet-body"></div>
|
||||
<h2>Full conversation</h2><div id="pull-comments"></div>
|
||||
<button class="conversation-more" id="load-older-pull-comments" type="button" hidden>Load older messages</button>
|
||||
<div id="pull-conversation-status" class="small" aria-live="assertive"></div>
|
||||
<button class="conversation-actions-retry" id="retry-pull-comment-actions" type="button" hidden>Retry comment tools</button>
|
||||
<nav class="mobile-detail-nav mobile-pull-detail-nav" aria-label="Pull request sections">
|
||||
<button type="button" data-pull-section="overview">Overview</button>
|
||||
<button type="button" data-pull-section="conversation">Conversation</button>
|
||||
<button type="button" data-pull-section="reply">Reply</button>
|
||||
<button type="button" data-pull-section="review">Review & merge</button>
|
||||
</nav>
|
||||
<div id="pull-overview">
|
||||
<div id="pull-sheet-status" class="small" aria-live="polite">Choose a pull request.</div>
|
||||
<button class="pull-retry" id="retry-pull-load" type="button" hidden>Retry loading pull request</button>
|
||||
<div class="pull-sheet-content markdown-content" id="pull-sheet-body"></div>
|
||||
</div>
|
||||
<section id="pull-conversation" tabindex="-1">
|
||||
<h2>Full conversation</h2><div id="pull-comments"></div>
|
||||
<button class="conversation-more" id="load-older-pull-comments" type="button" hidden>Load older messages</button>
|
||||
<div id="pull-conversation-status" class="small" aria-live="assertive"></div>
|
||||
<button class="conversation-actions-retry" id="retry-pull-comment-actions" type="button" hidden>Retry comment tools</button>
|
||||
</section>
|
||||
<section class="pull-comment-composer" aria-labelledby="pull-comment-title">
|
||||
<h2 id="pull-comment-title">Add comment</h2>
|
||||
<textarea id="pull-comment" maxlength="10000" placeholder="Write a comment"></textarea>
|
||||
<section class="voice-conversation" id="voice-pull-comment" aria-label="Dictate pull-request comment" hidden>
|
||||
<div class="voice-conversation-controls">
|
||||
<button id="start-voice-pull-comment" type="button">Dictate reply</button>
|
||||
<button id="stop-voice-pull-comment" type="button" hidden>Stop listening</button>
|
||||
</div>
|
||||
<div id="voice-pull-comment-status" class="small" aria-live="polite"></div>
|
||||
<div class="voice-conversation-review" id="voice-pull-comment-review" hidden>
|
||||
<label for="voice-pull-comment-transcript">Review transcript</label>
|
||||
<textarea id="voice-pull-comment-transcript" maxlength="10000"></textarea>
|
||||
<div class="voice-conversation-review-actions">
|
||||
<button id="append-voice-pull-comment" type="button">Use transcript</button>
|
||||
<button id="replace-with-voice-pull-comment" type="button" hidden>Replace draft</button>
|
||||
<button id="discard-voice-pull-comment" type="button">Discard</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="mention-options" id="pull-comment-mentions" role="listbox" aria-label="Teammates" hidden></div>
|
||||
<div class="mention-status small" id="pull-comment-mention-status" aria-live="polite"></div>
|
||||
<div class="issue-attachment-controls">
|
||||
<input class="visually-hidden" id="pull-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
|
||||
<label class="issue-attachment-trigger" for="pull-attachment">Attach screenshot</label>
|
||||
<div class="issue-attachment-controls conversation-photo-actions">
|
||||
<label class="issue-attachment-trigger" for="take-pull-comment-photo">Take photo</label>
|
||||
<input class="visually-hidden" id="take-pull-comment-photo" type="file" accept="image/*" capture="environment" />
|
||||
<label class="issue-attachment-trigger" for="pull-attachment">Choose existing</label>
|
||||
<input class="visually-hidden" id="pull-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple />
|
||||
</div>
|
||||
<div class="issue-attachment-preview" id="pull-attachment-preview" hidden>
|
||||
<div class="issue-attachment-preview conversation-evidence-review" id="pull-attachment-preview" hidden>
|
||||
<img id="pull-attachment-image" alt="Selected screenshot preview" />
|
||||
<span class="small" id="pull-attachment-meta"></span>
|
||||
<button id="remove-pull-attachment" type="button">Remove screenshot</button>
|
||||
<div class="issue-evidence-tray" id="pull-attachment-tray" role="toolbar" aria-label="Pull-request comment photos" hidden></div>
|
||||
<label class="issue-evidence-note" for="pull-attachment-note">
|
||||
<span id="pull-attachment-note-label">Photo caption (optional)</span>
|
||||
<textarea id="pull-attachment-note" maxlength="240" rows="2" disabled placeholder="What does this photo show?"></textarea>
|
||||
</label>
|
||||
<div class="issue-evidence-review-actions" aria-label="Reorder selected photo">
|
||||
<button id="move-pull-attachment-earlier" type="button">Move earlier</button>
|
||||
<button id="edit-pull-attachment" type="button">Edit photo</button>
|
||||
<button id="remove-pull-attachment" type="button">Remove selected</button>
|
||||
<button id="move-pull-attachment-later" type="button">Move later</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comment-actions">
|
||||
<button id="send-pull-comment" type="button">Post comment</button>
|
||||
@@ -1182,6 +1425,13 @@
|
||||
</div>
|
||||
<button class="review-action" id="close-review-sheet">Close</button>
|
||||
</div>
|
||||
<nav class="mobile-review-detail-nav" aria-label="Review sections">
|
||||
<button type="button" data-review-section="overview">Overview</button>
|
||||
<button type="button" data-review-section="files">Files</button>
|
||||
<button type="button" data-review-section="feedback">Feedback</button>
|
||||
<button type="button" data-review-section="history">History</button>
|
||||
</nav>
|
||||
<section id="review-overview">
|
||||
<div id="review-sheet-status" class="small" aria-live="polite">Choose a review request.</div>
|
||||
<button class="review-retry" id="retry-review-load" hidden>Retry loading review</button>
|
||||
<div class="review-sheet-body markdown-content" id="review-sheet-body"></div>
|
||||
@@ -1191,6 +1441,8 @@
|
||||
<button class="ci-check-refresh" id="refresh-review-checks" type="button">Refresh checks</button>
|
||||
<div class="ci-check-list" id="review-check-list"></div>
|
||||
</details>
|
||||
</section>
|
||||
<section id="review-files-workspace">
|
||||
<h2>Changed files</h2>
|
||||
<div class="review-display-tools">
|
||||
<button id="review-wrap-lines" type="button" aria-pressed="false" aria-controls="review-files">Wrap lines</button>
|
||||
@@ -1211,7 +1463,8 @@
|
||||
<span class="small" id="review-progress" aria-live="polite">0 of 0 files reviewed</span>
|
||||
<button id="next-unreviewed-review" disabled>Next unreviewed</button>
|
||||
</div>
|
||||
<section class="review-feedback" aria-labelledby="review-feedback-title">
|
||||
</section>
|
||||
<section class="review-feedback" id="review-feedback" aria-labelledby="review-feedback-title">
|
||||
<h2 id="review-feedback-title">Draft feedback</h2>
|
||||
<label>Intended decision
|
||||
<select id="review-decision">
|
||||
@@ -1235,8 +1488,10 @@
|
||||
<button id="continue-review-to-merge" type="button" hidden>Continue to merge</button>
|
||||
</div>
|
||||
</section>
|
||||
<section id="review-history-workspace">
|
||||
<h2>Review history</h2>
|
||||
<div id="review-history" class="muted"></div>
|
||||
</section>
|
||||
<div class="review-sheet-actions"><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>
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
@@ -1287,6 +1542,7 @@
|
||||
<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>
|
||||
<button data-mobile-queue="agenda" type="button"><span><strong>Agenda</strong><small>Upcoming deadlines</small></span><span data-mobile-queue-count="agenda">0</span></button>
|
||||
<button data-mobile-queue="delivery" type="button"><span><strong>Delivery</strong><small>Needs recovery</small></span><span data-mobile-queue-count="delivery">0</span></button>
|
||||
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
|
||||
<button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button>
|
||||
<button data-mobile-queue="filed" type="button"><span><strong>Filed</strong><small>Issues you delegated</small></span><span data-mobile-queue-count="filed">0</span></button>
|
||||
@@ -1298,6 +1554,20 @@
|
||||
</section>
|
||||
</dialog>
|
||||
|
||||
<dialog id="mobile-delivery-recovery" class="mobile-delivery-recovery" aria-labelledby="mobile-delivery-recovery-title">
|
||||
<section class="mobile-delivery-recovery-panel">
|
||||
<header>
|
||||
<div><p class="small muted" id="mobile-delivery-recovery-progress"></p><h2 id="mobile-delivery-recovery-title">Recover delivery</h2></div>
|
||||
<button id="close-mobile-delivery-recovery" type="button">Close</button>
|
||||
</header>
|
||||
<p id="mobile-delivery-recovery-destination" class="small"></p>
|
||||
<p id="mobile-delivery-recovery-reason" class="delivery-recovery-reason"></p>
|
||||
<p id="mobile-delivery-recovery-status" class="small" role="status" aria-live="polite"></p>
|
||||
<button id="mobile-delivery-recovery-action" type="button">Recover delivery</button>
|
||||
<details><summary>Other recovery options</summary><p class="small muted">Close this guide to edit, copy, or discard the saved delivery from Drafts.</p></details>
|
||||
</section>
|
||||
</dialog>
|
||||
|
||||
<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">
|
||||
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page"><span id="mobile-work-label">Work</span></button>
|
||||
<button class="mobile-task-action" data-mobile-task="find" type="button">Find</button>
|
||||
@@ -1317,6 +1587,9 @@
|
||||
<script src="static/commands.js"></script>
|
||||
<script src="static/saved-searches.js"></script>
|
||||
<script src="static/search-preview.js"></script>
|
||||
<script src="static/search-reply-draft-store.js"></script>
|
||||
<script src="static/conversation-reply-draft-store.js"></script>
|
||||
<script src="static/conversation-photo-drafts.js"></script>
|
||||
<script src="static/search-defer.js"></script>
|
||||
<script src="static/widgets.js"></script>
|
||||
<script src="static/drafts.js"></script>
|
||||
@@ -1344,6 +1617,8 @@
|
||||
<script src="static/today-work.js"></script>
|
||||
<script src="static/today-timer.js"></script>
|
||||
<script src="static/today-recap.js"></script>
|
||||
<script src="static/today-wrap-up.js"></script>
|
||||
<script src="static/today-handoff.js"></script>
|
||||
<script src="static/today-completion.js"></script>
|
||||
<script src="static/today-readiness.js"></script>
|
||||
<script src="static/comment-next.js"></script>
|
||||
@@ -1372,7 +1647,9 @@
|
||||
<script src="static/issue-sheet.js"></script>
|
||||
<script src="static/voice-transcript-store.js"></script>
|
||||
<script src="static/voice-issue-capture.js"></script>
|
||||
<script src="static/voice-conversation-capture.js"></script>
|
||||
<script src="static/create-issue-sheet.js"></script>
|
||||
<script src="static/mobile-create-issue-nav.js"></script>
|
||||
<script src="static/create-and-start.js"></script>
|
||||
<script src="static/assign-and-start.js"></script>
|
||||
<script src="static/filed-claim.js"></script>
|
||||
@@ -1384,8 +1661,10 @@
|
||||
<script src="static/context-poller.js"></script>
|
||||
<script src="static/live-data-status.js"></script>
|
||||
<script src="static/mobile-task-dock.js"></script>
|
||||
<script src="static/mobile-pull-refresh.js"></script>
|
||||
<script src="static/mobile-work-entry.js"></script>
|
||||
<script src="static/mobile-queue-launcher.js"></script>
|
||||
<script src="static/mobile-delivery-recovery.js"></script>
|
||||
<script src="static/mobile-start-day.js"></script>
|
||||
<script src="static/update-triage-session.js"></script>
|
||||
<script src="static/update-review-handoff.js"></script>
|
||||
@@ -1396,14 +1675,25 @@
|
||||
<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-insights.js"></script>
|
||||
<script src="static/mobile-app-shortcuts.js"></script>
|
||||
<script src="static/install-app.js"></script>
|
||||
<script src="static/private-data-inventory.js"></script>
|
||||
<script src="static/private-device-data.js"></script>
|
||||
<script src="static/device-storage.js"></script>
|
||||
<script src="static/mobile-device-setup.js"></script>
|
||||
<script src="static/mobile-search-viewport.js"></script>
|
||||
<script src="static/mobile-composer-viewport.js"></script>
|
||||
<script src="static/mention-composer.js"></script>
|
||||
<script src="static/push-notifications.js"></script>
|
||||
<script src="static/issue-filing-receipt.js"></script>
|
||||
<script src="static/mobile-issue-detail-nav.js"></script>
|
||||
<script src="static/mobile-update-detail-nav.js"></script>
|
||||
<script src="static/mobile-review-detail-nav.js"></script>
|
||||
<script src="static/mobile-search-preview-nav.js"></script>
|
||||
<script src="static/mobile-plan-today-nav.js"></script>
|
||||
<script src="static/mobile-find-work-nav.js"></script>
|
||||
<script src="static/workspace-bootstrap.js"></script>
|
||||
<script src="static/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -41,6 +41,7 @@
|
||||
const maxFiles = options.maxFiles === MAX_FILES ? MAX_FILES : 1;
|
||||
const inspectPixels = options.inspectPixels === true;
|
||||
const optimizeSelectedImage = options.optimizeImage || optimizeImage;
|
||||
const onCheckpoint = options.onCheckpoint;
|
||||
const createOperationId = options.createOperationId || (() => {
|
||||
if (typeof globalThis !== 'undefined' && globalThis.crypto?.randomUUID) {
|
||||
return globalThis.crypto.randomUUID();
|
||||
@@ -49,6 +50,13 @@
|
||||
});
|
||||
let selected = [];
|
||||
let selectionGeneration = 0;
|
||||
let restoring = false;
|
||||
|
||||
function changed() {
|
||||
if (!restoring && typeof onCheckpoint === 'function') {
|
||||
Promise.resolve().then(onCheckpoint).catch(() => { /* Caller surfaces durable-storage failures. */ });
|
||||
}
|
||||
}
|
||||
|
||||
function commitSelection(file) {
|
||||
if (!file || !IMAGE_TYPES.has(file.type) || !Number.isFinite(file.size) ||
|
||||
@@ -60,6 +68,7 @@
|
||||
else throw new Error(MAX_FILES_MESSAGE);
|
||||
}
|
||||
selected.push({file, note:'', confirmed:null, serialized:null, operationId:createOperationId()});
|
||||
changed();
|
||||
return state();
|
||||
}
|
||||
|
||||
@@ -93,6 +102,7 @@
|
||||
if (!Number.isInteger(position) || position < 0 || position >= selected.length) return state();
|
||||
selectionGeneration += 1;
|
||||
selected.splice(position, 1);
|
||||
changed();
|
||||
return state();
|
||||
}
|
||||
|
||||
@@ -103,6 +113,7 @@
|
||||
from >= selected.length || to < 0 || to >= selected.length || from === to) return state();
|
||||
const [item] = selected.splice(from, 1);
|
||||
selected.splice(to, 0, item);
|
||||
changed();
|
||||
return state();
|
||||
}
|
||||
|
||||
@@ -117,6 +128,7 @@
|
||||
selected[position] = {
|
||||
...selected[position], file, confirmed:null, serialized:null, operationId:createOperationId(),
|
||||
};
|
||||
changed();
|
||||
return state();
|
||||
}
|
||||
|
||||
@@ -125,6 +137,7 @@
|
||||
if (!Number.isInteger(position) || position < 0 || position >= selected.length) return state();
|
||||
selected[position].note = normalizeNote(value);
|
||||
selected[position].serialized = null;
|
||||
changed();
|
||||
return state();
|
||||
}
|
||||
|
||||
@@ -134,15 +147,18 @@
|
||||
}
|
||||
|
||||
function restore(value) {
|
||||
if (Array.isArray(value)) {
|
||||
restoring = true;
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
clear();
|
||||
if (value.length > maxFiles) throw new Error(MAX_FILES_MESSAGE);
|
||||
value.forEach(attachment => restoreOne(attachment));
|
||||
return state();
|
||||
}
|
||||
clear();
|
||||
if (value.length > maxFiles) throw new Error(MAX_FILES_MESSAGE);
|
||||
value.forEach(attachment => restoreOne(attachment));
|
||||
restoreOne(value);
|
||||
return state();
|
||||
}
|
||||
clear();
|
||||
restoreOne(value);
|
||||
return state();
|
||||
} finally { restoring = false; }
|
||||
}
|
||||
|
||||
function restoreOne(value) {
|
||||
@@ -162,6 +178,10 @@
|
||||
...(blob ? { filename, contentType, blob } : { filename, contentType, data }),
|
||||
...(item.note ? {note:item.note} : {}),
|
||||
};
|
||||
const operationId = String(value?.operationId || '').slice(0, 128);
|
||||
if (operationId) item.operationId = operationId;
|
||||
const markdown = String(value?.confirmed?.markdown || '');
|
||||
if (markdown) item.confirmed = { markdown };
|
||||
}
|
||||
|
||||
function state() {
|
||||
@@ -178,7 +198,11 @@
|
||||
filename:item.file.name, contentType:item.file.type, blob:item.file.blob || item.file,
|
||||
...(item.note ? {note:item.note} : {}),
|
||||
};
|
||||
return { ...item.serialized };
|
||||
return {
|
||||
...item.serialized,
|
||||
operationId:item.operationId,
|
||||
...(item.confirmed?.markdown ? { confirmed:{ markdown:item.confirmed.markdown } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function serialize() {
|
||||
@@ -206,6 +230,7 @@
|
||||
evidence.confirmed = null;
|
||||
throw new Error('The server did not confirm the screenshot upload.');
|
||||
}
|
||||
await onCheckpoint?.();
|
||||
}
|
||||
if (evidence.note) {
|
||||
markdown.push('**Screenshot ' + (markdown.length + 1) + ' — ' + escapeMarkdown(evidence.note) + '**\n\n' +
|
||||
@@ -220,6 +245,8 @@
|
||||
}
|
||||
|
||||
function mount(options) {
|
||||
const inputs = Array.from(new Set((options.inputs || [options.input]).filter(Boolean)));
|
||||
if (!inputs.includes(options.input)) inputs.push(options.input);
|
||||
const controller = create({
|
||||
...options, inspectPixels:true,
|
||||
maxFiles:options.maxFiles || (options.input?.multiple ? MAX_FILES : 1),
|
||||
@@ -234,7 +261,7 @@
|
||||
}) : null;
|
||||
|
||||
function setBusy(value) {
|
||||
options.input.disabled = Boolean(value);
|
||||
inputs.forEach(input => { input.disabled = Boolean(value); });
|
||||
options.remove.disabled = Boolean(value);
|
||||
if (options.editor?.edit) options.editor.edit.disabled = Boolean(value) || !controller.state();
|
||||
review?.setBusy(value);
|
||||
@@ -248,9 +275,12 @@
|
||||
if (options.editor?.edit) options.editor.edit.disabled = true;
|
||||
options.image.src = '';
|
||||
options.preview.hidden = true;
|
||||
options.input.value = '';
|
||||
options.input.disabled = false;
|
||||
inputs.forEach(input => {
|
||||
input.value = '';
|
||||
input.disabled = false;
|
||||
});
|
||||
clearSelection();
|
||||
options.onChange?.(controller.state());
|
||||
}
|
||||
|
||||
function showPreview(file, optimized) {
|
||||
@@ -264,15 +294,16 @@
|
||||
(options.readyMessage || 'Screenshot ready to upload with this comment.');
|
||||
}
|
||||
|
||||
options.input.addEventListener('change', event => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
function selectFromInput(event) {
|
||||
const sourceInput = event.target;
|
||||
const files = Array.from(sourceInput.files || []);
|
||||
const sequence = ++selectionSequence;
|
||||
if (!files.length) return;
|
||||
let first;
|
||||
try { first = controller.select(files[0]); }
|
||||
catch (error) {
|
||||
options.status.textContent = error.message;
|
||||
options.input.value = '';
|
||||
sourceInput.value = '';
|
||||
return;
|
||||
}
|
||||
if (!reviewEnabled && files.length === 1 && (!first || typeof first.then !== 'function')) {
|
||||
@@ -282,14 +313,14 @@
|
||||
let optimized = Boolean(first && typeof first.then === 'function');
|
||||
if (optimized) {
|
||||
options.status.textContent = 'Optimizing screenshot…';
|
||||
options.input.disabled = true;
|
||||
inputs.forEach(input => { input.disabled = true; });
|
||||
}
|
||||
const selectAll = files.slice(1).reduce((pending, file) => pending.then(async () => {
|
||||
const result = controller.select(file);
|
||||
if (result && typeof result.then === 'function') {
|
||||
optimized = true;
|
||||
options.status.textContent = 'Optimizing screenshot…';
|
||||
options.input.disabled = true;
|
||||
inputs.forEach(input => { input.disabled = true; });
|
||||
await result;
|
||||
}
|
||||
}), Promise.resolve(first));
|
||||
@@ -306,15 +337,17 @@
|
||||
options.status.textContent = values.length + ' screenshots ready to file in this order.';
|
||||
}
|
||||
}
|
||||
options.onChange?.(controller.state());
|
||||
}).catch(error => {
|
||||
if (sequence === selectionSequence) {
|
||||
options.status.textContent = error.message;
|
||||
options.input.value = '';
|
||||
sourceInput.value = '';
|
||||
}
|
||||
}).finally(() => {
|
||||
if (sequence === selectionSequence) options.input.disabled = false;
|
||||
if (sequence === selectionSequence) inputs.forEach(input => { input.disabled = false; });
|
||||
});
|
||||
});
|
||||
}
|
||||
inputs.forEach(input => input.addEventListener('change', selectFromInput));
|
||||
options.remove.addEventListener('click', () => {
|
||||
const current = controller.state();
|
||||
const count = Array.isArray(current) ? current.length : (current ? 1 : 0);
|
||||
@@ -337,6 +370,7 @@
|
||||
options.meta.textContent = values.length + ' screenshots ready · latest: ' + latest.filename;
|
||||
}
|
||||
options.status.textContent = options.removedMessage || 'Latest screenshot removed. Your text is unchanged.';
|
||||
options.onChange?.(controller.state());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -371,7 +405,8 @@
|
||||
onApplied: async index => {
|
||||
const value = await controller.serialize();
|
||||
review.render(value, index);
|
||||
options.status.textContent = 'Edited screenshot flattened and ready to file.';
|
||||
options.status.textContent = options.editor.appliedMessage ||
|
||||
'Edited screenshot flattened and ready to file.';
|
||||
},
|
||||
});
|
||||
options.editor.edit.disabled = !controller.state();
|
||||
|
||||
@@ -200,6 +200,7 @@
|
||||
if (source && model) paint({source, model, canvas:options.canvas, maxDimension:1600});
|
||||
}
|
||||
function close() {
|
||||
if (!source && !model) return;
|
||||
options.dialog.hidden = true;
|
||||
if (source && typeof source.close === 'function') source.close();
|
||||
source = null;
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
const dimensionScale = Math.min(1, MAX_DIMENSION / Math.max(width, height));
|
||||
const byteScale = file.size > MAX_BYTES ? Math.sqrt(MAX_BYTES / file.size) * 0.92 : 1;
|
||||
let scale = Math.min(pixelScale, dimensionScale, byteScale);
|
||||
if (scale === 1 && file.size <= MAX_BYTES) return file;
|
||||
const canvas = makeCanvas();
|
||||
const context = canvas && canvas.getContext && canvas.getContext('2d');
|
||||
if (!context) throw new Error('decode');
|
||||
|
||||
@@ -160,15 +160,17 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
|
||||
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
function enqueue(action, itemId, wakeAt = null) {
|
||||
function enqueue(action, itemId, wakeAt = null, handoff = null) {
|
||||
if (!['defer', 'restore'].includes(action) || !itemId ||
|
||||
(action === 'defer' && typeof wakeAt !== 'string')) return false;
|
||||
(action === 'defer' && typeof wakeAt !== 'string') ||
|
||||
![null, 'today'].includes(handoff)) return false;
|
||||
pending().filter(operation => operation.item_id === itemId)
|
||||
.forEach(operation => removeOperation(operation.operation_id));
|
||||
const operation = {
|
||||
operation_id: operationId(), action, item_id: itemId, wake_at: wakeAt,
|
||||
base_revision: Math.max(0, savedRevision()),
|
||||
};
|
||||
if (handoff) operation.handoff = handoff;
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
|
||||
@@ -189,9 +191,11 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
|
||||
const marker = migrationPrefix + storageKey.slice(prefix.length);
|
||||
try {
|
||||
if (storage.getItem(marker)) return false;
|
||||
Object.entries(records || {}).forEach(([itemId, wakeAt]) =>
|
||||
enqueue('defer', itemId, wakeAt)
|
||||
);
|
||||
Object.entries(records || {}).forEach(([itemId, record]) => {
|
||||
const wakeAt = record && typeof record === 'object' ? record.wake_at : record;
|
||||
const handoff = record?.handoff === 'today' ? 'today' : null;
|
||||
enqueue('defer', itemId, wakeAt, handoff);
|
||||
});
|
||||
storage.setItem(marker, '1');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
|
||||
+22
-10
@@ -38,7 +38,7 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||
}
|
||||
}
|
||||
|
||||
function defer(item, until) {
|
||||
function defer(item, until, options = {}) {
|
||||
const key = storageKey();
|
||||
const id = identity(item);
|
||||
const wake = new Date(until);
|
||||
@@ -46,9 +46,10 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||
if (!id || Number.isNaN(wake.getTime()) || wake <= now()) return 'invalid';
|
||||
const records = read();
|
||||
const wakeAt = wake.toISOString();
|
||||
records[id] = wakeAt;
|
||||
const handoff = options.handoff === 'today' ? 'today' : null;
|
||||
records[id] = handoff ? { wake_at:wakeAt, handoff } : wakeAt;
|
||||
if (!write(records)) return 'unavailable';
|
||||
onChange('defer', id, wakeAt);
|
||||
onChange('defer', id, wakeAt, handoff);
|
||||
return 'deferred';
|
||||
}
|
||||
|
||||
@@ -93,9 +94,13 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||
function adopt(records) {
|
||||
if (!records || typeof records !== 'object' || Array.isArray(records)) return false;
|
||||
const normalized = {};
|
||||
Object.entries(records).forEach(([id, wake]) => {
|
||||
Object.entries(records).forEach(([id, record]) => {
|
||||
const wake = record && typeof record === 'object' ? record.wake_at : record;
|
||||
const wakeTime = new Date(wake).getTime();
|
||||
if (id && Number.isFinite(wakeTime)) normalized[id] = new Date(wakeTime).toISOString();
|
||||
if (id && Number.isFinite(wakeTime)) {
|
||||
const wakeAt = new Date(wakeTime).toISOString();
|
||||
normalized[id] = record?.handoff === 'today' ? { wake_at:wakeAt, handoff:'today' } : wakeAt;
|
||||
}
|
||||
});
|
||||
return write(normalized);
|
||||
}
|
||||
@@ -119,25 +124,32 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||
const wakeTimes = [];
|
||||
const expired = [];
|
||||
|
||||
Object.entries(records).forEach(([id, wake]) => {
|
||||
const handoffs = [];
|
||||
Object.entries(records).forEach(([id, record]) => {
|
||||
const wake = record && typeof record === 'object' ? record.wake_at : record;
|
||||
const wakeTime = new Date(wake).getTime();
|
||||
if (!Number.isFinite(wakeTime) || wakeTime <= current) {
|
||||
if (Number.isFinite(wakeTime)) expired.push(id);
|
||||
if (Number.isFinite(wakeTime)) {
|
||||
expired.push(id);
|
||||
if (record?.handoff === 'today') handoffs.push(id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pruneMissing && !available.has(id)) return;
|
||||
retained[id] = new Date(wakeTime).toISOString();
|
||||
const wakeAt = new Date(wakeTime).toISOString();
|
||||
retained[id] = record?.handoff === 'today' ? { wake_at:wakeAt, handoff:'today' } : wakeAt;
|
||||
wakeTimes.push(wakeTime);
|
||||
});
|
||||
|
||||
if (JSON.stringify(retained) !== JSON.stringify(records) && write(retained) && expired.length) {
|
||||
onExpire(expired);
|
||||
onExpire(expired, handoffs);
|
||||
}
|
||||
schedule(wakeTimes, current);
|
||||
const deferredIds = new Set(Object.keys(retained));
|
||||
const active = (items || []).filter(item => !deferredIds.has(identity(item)));
|
||||
const later = (items || []).flatMap(item => {
|
||||
const wake = retained[identity(item)];
|
||||
const record = retained[identity(item)];
|
||||
const wake = record && typeof record === 'object' ? record.wake_at : record;
|
||||
return wake ? [{ ...item, deferred_until: wake }] : [];
|
||||
});
|
||||
return { active, later };
|
||||
|
||||
+4
-2
@@ -4,7 +4,7 @@
|
||||
}(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, search: 200,
|
||||
const limits = { title: 200, text: 8000, url: 2048, launch: 8, shared: 6, 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());
|
||||
@@ -35,7 +35,9 @@
|
||||
const launch = params.get('launch');
|
||||
const shared = params.get('shared');
|
||||
if (launch && !['continue', 'new', 'agenda'].includes(launch)) return './';
|
||||
if (shared !== null && (shared !== 'image' || launch !== 'new')) return './';
|
||||
if (shared === 'bundle') {
|
||||
if (launch !== 'new' || entries.some(([name]) => !['launch', 'shared'].includes(name))) return './';
|
||||
} else if (shared !== null && (shared !== 'image' || launch !== 'new')) return './';
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
function createMobileCreateIssueNavigation(options) {
|
||||
const buttons = options.buttons || {};
|
||||
const targets = options.targets || {};
|
||||
const listeners = new Map();
|
||||
const prefersReducedMotion = options.prefersReducedMotion || (() => false);
|
||||
const targetNames = new Map(Object.entries(targets).map(([name, target]) => [target, name]));
|
||||
let filingAvailable = !options.filing?.hidden;
|
||||
let observer = null;
|
||||
let navigationLockUntil = 0;
|
||||
|
||||
function select(name) {
|
||||
Object.entries(buttons).forEach(([key, button]) => {
|
||||
if (!button) return;
|
||||
if (key === name) button.setAttribute('aria-current', 'location');
|
||||
else button.removeAttribute('aria-current');
|
||||
});
|
||||
}
|
||||
|
||||
function setFilingAvailable(available) {
|
||||
filingAvailable = Boolean(available);
|
||||
const button = buttons.file;
|
||||
if (!button) return;
|
||||
if (filingAvailable) button.removeAttribute('aria-disabled');
|
||||
else button.setAttribute('aria-disabled', 'true');
|
||||
}
|
||||
|
||||
function navigate(name) {
|
||||
if (name === 'file') setFilingAvailable(!options.filing?.hidden);
|
||||
if (name === 'file' && !filingAvailable) return false;
|
||||
const target = targets[name];
|
||||
if (!target) return false;
|
||||
target.scrollIntoView({
|
||||
block: 'start',
|
||||
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
|
||||
});
|
||||
navigationLockUntil = Date.now() + 500;
|
||||
select(name);
|
||||
return true;
|
||||
}
|
||||
|
||||
function reset(available = !options.filing?.hidden) {
|
||||
setFilingAvailable(available);
|
||||
select('describe');
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
Object.entries(buttons).forEach(([name, button]) => {
|
||||
if (!button || listeners.has(button)) return;
|
||||
const listener = event => {
|
||||
event.preventDefault();
|
||||
navigate(name);
|
||||
};
|
||||
listeners.set(button, listener);
|
||||
button.addEventListener('click', listener);
|
||||
});
|
||||
reset();
|
||||
const observe = options.observe || ((handler, observedTargets, root) => {
|
||||
if (typeof IntersectionObserver === 'undefined') return null;
|
||||
const instance = new IntersectionObserver(handler, {
|
||||
root,
|
||||
rootMargin: '-20% 0px -60% 0px',
|
||||
threshold: [0, 0.25, 0.5, 0.75, 1],
|
||||
});
|
||||
observedTargets.forEach(target => instance.observe(target));
|
||||
return instance;
|
||||
});
|
||||
observer = observe(entries => {
|
||||
if (Date.now() < navigationLockUntil) return;
|
||||
const visible = entries
|
||||
.filter(entry => entry.isIntersecting && targetNames.has(entry.target))
|
||||
.filter(entry => targetNames.get(entry.target) !== 'file' || filingAvailable)
|
||||
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
|
||||
if (visible) select(targetNames.get(visible.target));
|
||||
}, Array.from(targetNames.keys()).filter(Boolean), options.root || null);
|
||||
},
|
||||
stop() {
|
||||
listeners.forEach((listener, button) => button.removeEventListener('click', listener));
|
||||
listeners.clear();
|
||||
if (observer) observer.disconnect();
|
||||
observer = null;
|
||||
},
|
||||
navigate,
|
||||
reset,
|
||||
select,
|
||||
setFilingAvailable,
|
||||
};
|
||||
}
|
||||
|
||||
function attachMobileCreateIssueNavigation({ document, window }) {
|
||||
const bySection = name => document.querySelector('[data-create-issue-section="' + name + '"]');
|
||||
const filing = document.getElementById('create-issue-filing');
|
||||
const sheet = document.getElementById('create-issue-sheet');
|
||||
const navigation = createMobileCreateIssueNavigation({
|
||||
root:document.querySelector('.create-issue-panel'),
|
||||
buttons:{describe:bySection('describe'), evidence:bySection('evidence'), file:bySection('file')},
|
||||
targets:{
|
||||
describe:document.getElementById('create-issue-describe'),
|
||||
evidence:document.getElementById('create-issue-evidence'),
|
||||
file:filing,
|
||||
},
|
||||
filing,
|
||||
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
});
|
||||
navigation.start();
|
||||
document.getElementById('file-new-issue').addEventListener('click', () => {
|
||||
setTimeout(() => navigation.navigate('file'), 0);
|
||||
});
|
||||
new MutationObserver(() => {
|
||||
navigation.setFilingAvailable(!filing.hidden);
|
||||
if (!filing.hidden && sheet.classList.contains('open')) navigation.navigate('file');
|
||||
}).observe(filing, {attributes:true, attributeFilter:['hidden']});
|
||||
new MutationObserver(() => {
|
||||
if (sheet.classList.contains('open')) navigation.reset(!filing.hidden);
|
||||
}).observe(sheet, {
|
||||
attributes:true, attributeFilter:['class'],
|
||||
});
|
||||
return navigation;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined') module.exports = createMobileCreateIssueNavigation;
|
||||
else attachMobileCreateIssueNavigation({document, window});
|
||||
@@ -0,0 +1,156 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileDeliveryRecovery = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileDeliveryRecovery(options) {
|
||||
const byId = id => typeof document === 'undefined' ? null : document.getElementById(id);
|
||||
const elements = options.elements || (typeof document === 'undefined' ? null : {
|
||||
dialog: byId('mobile-delivery-recovery'), close: byId('close-mobile-delivery-recovery'),
|
||||
title: byId('mobile-delivery-recovery-title'), destination: byId('mobile-delivery-recovery-destination'),
|
||||
reason: byId('mobile-delivery-recovery-reason'), progress: byId('mobile-delivery-recovery-progress'),
|
||||
action: byId('mobile-delivery-recovery-action'), status: byId('mobile-delivery-recovery-status'),
|
||||
});
|
||||
const priority = {authorization: 0, attention: 1, uncertain: 2, waiting: 3};
|
||||
|
||||
function identity(item) {
|
||||
return String(item?.outbox_id || item?.id || '');
|
||||
}
|
||||
|
||||
function state(item) {
|
||||
if (item?.status === 'authorization') return 'authorization';
|
||||
if (item?.status === 'attention' || item?.checklist_conflict || item?.quarantined) return 'attention';
|
||||
if (item?.delivery_state === 'uncertain') return 'uncertain';
|
||||
return 'waiting';
|
||||
}
|
||||
|
||||
function primary(item) {
|
||||
const kind = state(item);
|
||||
if (kind === 'authorization') {
|
||||
return item?.outbox_kind === 'issue-close' ? 'Authorize & close' : 'Authorize & send';
|
||||
}
|
||||
if (item?.checklist_conflict) return 'Review changes';
|
||||
if (kind === 'attention') return item?.quarantined ? 'Copy content' : 'Open current item';
|
||||
if (kind === 'uncertain') return 'Verified not posted — retry';
|
||||
return 'Retry now';
|
||||
}
|
||||
|
||||
function ordered() {
|
||||
return [...(options.getItems ? options.getItems() : [])].sort((left, right) => {
|
||||
const byState = priority[state(left)] - priority[state(right)];
|
||||
if (byState) return byState;
|
||||
return identity(left).localeCompare(identity(right));
|
||||
});
|
||||
}
|
||||
|
||||
const stateLabels = {
|
||||
authorization: 'Authorization required',
|
||||
attention: 'Needs attention',
|
||||
uncertain: 'Delivery uncertain',
|
||||
waiting: 'Ready to retry',
|
||||
};
|
||||
let currentId = '';
|
||||
let active = false;
|
||||
let inFlight = null;
|
||||
|
||||
function currentItem(items) {
|
||||
return items.find(item => identity(item) === currentId) || items[0];
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
const items = ordered();
|
||||
const item = currentItem(items);
|
||||
if (item) currentId = identity(item);
|
||||
else currentId = '';
|
||||
const index = item ? items.indexOf(item) : -1;
|
||||
return {
|
||||
count: items.length,
|
||||
current: item ? {
|
||||
id: identity(item),
|
||||
state: state(item),
|
||||
primary: primary(item),
|
||||
position: index + 1,
|
||||
total: items.length,
|
||||
} : null,
|
||||
};
|
||||
}
|
||||
|
||||
function render() {
|
||||
const result = snapshot();
|
||||
if (!elements) return result;
|
||||
const items = ordered();
|
||||
const item = currentItem(items);
|
||||
if (!item) {
|
||||
if (active) {
|
||||
active = false;
|
||||
if (elements.dialog?.open) elements.dialog.close();
|
||||
if (options.onComplete) options.onComplete();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
elements.title.textContent = item.title || 'Queued delivery';
|
||||
elements.destination.textContent = [item.repository, item.details].filter(Boolean).join(' · ') || 'Saved delivery';
|
||||
elements.reason.textContent = item.last_attempt_error || item.ownership || stateLabels[state(item)];
|
||||
elements.progress.textContent = 'Delivery ' + result.current.position + ' of ' + result.current.total + ' · ' + stateLabels[state(item)];
|
||||
elements.action.textContent = primary(item);
|
||||
if (elements.status) elements.status.textContent = '';
|
||||
return result;
|
||||
}
|
||||
|
||||
function open() {
|
||||
const queue = byId('mobile-queue-sheet');
|
||||
if (queue?.open) queue.close();
|
||||
if (options.beforeOpen) options.beforeOpen();
|
||||
currentId = '';
|
||||
const result = snapshot();
|
||||
if (!result.current) return 'empty';
|
||||
active = true;
|
||||
render();
|
||||
if (elements?.dialog && !elements.dialog.open) elements.dialog.showModal();
|
||||
return 'opened';
|
||||
}
|
||||
|
||||
async function attend(item) {
|
||||
const index = options.getIndex ? options.getIndex(item) : -1;
|
||||
const selector = item.quarantined ? '.draft-copy' :
|
||||
item.checklist_conflict ? '.draft-review-checklist' : '.draft-resume';
|
||||
const action = document.querySelector('#my-work-list ' + selector + '[data-draft-index="' + index + '"]');
|
||||
if (!action) throw new Error('Delivery changed.');
|
||||
elements.dialog.close();
|
||||
action.scrollIntoView();
|
||||
action.focus();
|
||||
return await action.onclick();
|
||||
}
|
||||
|
||||
function activate() {
|
||||
if (inFlight) return inFlight;
|
||||
const items = ordered();
|
||||
const item = currentItem(items);
|
||||
const perform = state(item) === 'attention' ? attend : options.activate;
|
||||
if (!item) return Promise.resolve(render());
|
||||
if (elements?.action) elements.action.disabled = true;
|
||||
if (elements?.status) elements.status.textContent = 'Working…';
|
||||
inFlight = (async () => {
|
||||
let failure = null;
|
||||
try {
|
||||
await perform(item, state(item));
|
||||
} catch (error) {
|
||||
failure = String(error?.message || 'Delivery could not be completed.').trim().slice(0, 160);
|
||||
} finally {
|
||||
if (elements?.action) elements.action.disabled = false;
|
||||
}
|
||||
const result = render();
|
||||
if (failure && elements?.status) {
|
||||
if (active && !elements.dialog.open) elements.dialog.showModal();
|
||||
elements.status.textContent = failure + ' Try again.';
|
||||
}
|
||||
return result;
|
||||
})().finally(() => { inFlight = null; });
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (elements?.action?.addEventListener) elements.action.addEventListener('click', activate);
|
||||
if (elements?.close?.addEventListener) elements.close.addEventListener('click', () => elements.dialog.close());
|
||||
}
|
||||
|
||||
return {activate, open, render, snapshot, start};
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.createMobileFindWorkNavigation = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
const stages = ['discover', 'review', 'fit'];
|
||||
|
||||
return function createMobileFindWorkNavigation(options) {
|
||||
const document = options.document;
|
||||
const buttons = options.buttons || Object.fromEntries(stages.map(name =>
|
||||
[name, document?.querySelector('[data-find-work-stage="' + name + '"]')]
|
||||
));
|
||||
const sections = options.sections || Object.fromEntries(stages.map(name =>
|
||||
[name, document?.getElementById('find-work-' + name + '-stage')]
|
||||
));
|
||||
const history = options.history;
|
||||
const eventTarget = options.eventTarget;
|
||||
const onStageChange = options.onStageChange || (() => {});
|
||||
const listeners = new Map();
|
||||
let current = 'discover';
|
||||
let selectedCount = 0;
|
||||
let recovery = false;
|
||||
let started = false;
|
||||
|
||||
function escape(value) {
|
||||
return String(value || '').replace(/[&<>"']/g, character =>
|
||||
({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' })[character]
|
||||
);
|
||||
}
|
||||
|
||||
function renderReview() {
|
||||
const reviewList = options.reviewList || document?.getElementById('find-work-review-list');
|
||||
const reviewSummary = options.reviewSummary || document?.getElementById('find-work-review-summary');
|
||||
if (!reviewList) return;
|
||||
const items = options.controller?.selectedItems?.() || options.getSelectedItems?.() || [];
|
||||
const planning = options.todayWork?.planning?.();
|
||||
const today = options.todayWork ? {
|
||||
limit:options.todayWork.limit, count:options.todayWork.read().length,
|
||||
capacityMinutes:planning.capacity_minutes, estimates:planning.estimates,
|
||||
} : options.getTodayState?.() || {};
|
||||
const remainingSlots = Math.max(0, (Number(today.limit) || 0) - (Number(today.count) || 0));
|
||||
const planned = Object.values(today.estimates || {}).reduce((sum, minutes) => sum + minutes, 0);
|
||||
const remainingMinutes = today.capacityMinutes === null || today.capacityMinutes === undefined ? null :
|
||||
Math.max(0, today.capacityMinutes - planned);
|
||||
const formatMinutes = minutes => [Math.floor(minutes / 60) ? Math.floor(minutes / 60) + 'h' : '',
|
||||
minutes % 60 ? minutes % 60 + 'm' : ''].filter(Boolean).join(' ') || '0m';
|
||||
if (reviewSummary) reviewSummary.textContent = items.length + ' selected · ' + remainingSlots +
|
||||
' Today slot' + (remainingSlots === 1 ? '' : 's') + ' open' +
|
||||
(remainingMinutes === null ? '' : ' · ' + formatMinutes(remainingMinutes) + ' available') + '.';
|
||||
reviewList.innerHTML = items.map(item => {
|
||||
const id = String(item.repository || '') + '#' + String(item.number || '');
|
||||
return '<article class="find-work-review-item"><div><span class="small">' + escape(id) +
|
||||
'</span><strong>' + escape(item.title || 'Untitled work') + '</strong></div>' +
|
||||
'<button type="button" data-find-work-review-remove="' + escape(id) + '">Remove</button></article>';
|
||||
}).join('');
|
||||
reviewList.querySelectorAll('[data-find-work-review-remove]').forEach(button =>
|
||||
button.addEventListener('click', () => {
|
||||
const item = items.find(candidate => String(candidate.repository || '') + '#' +
|
||||
String(candidate.number || '') === button.dataset.findWorkReviewRemove);
|
||||
if (item) (options.controller?.toggleSelection || options.toggleSelection)?.(item);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function allowed(name) {
|
||||
return stages.includes(name) && (name === 'discover' || selectedCount > 0 || recovery);
|
||||
}
|
||||
|
||||
function paint(name, focus = false, notify = true) {
|
||||
if (!allowed(name)) name = 'discover';
|
||||
current = name;
|
||||
stages.forEach(stage => {
|
||||
const button = buttons[stage];
|
||||
const section = sections[stage];
|
||||
if (button) {
|
||||
button.disabled = stage !== 'discover' && selectedCount === 0 && !recovery;
|
||||
if (stage === name) button.setAttribute('aria-current', 'step');
|
||||
else button.removeAttribute('aria-current');
|
||||
}
|
||||
if (section) section.hidden = stage !== name;
|
||||
});
|
||||
if (name === 'review') renderReview();
|
||||
if (name === 'fit') options.openFit?.(options.controller?.selectedItems?.() || []);
|
||||
if (notify) onStageChange(name);
|
||||
if (focus) sections[name]?.focus?.();
|
||||
return name;
|
||||
}
|
||||
|
||||
function stateFor(name) {
|
||||
const state = { ...(history?.state || {}) };
|
||||
if (name === 'discover') delete state.findWorkStage;
|
||||
else state.findWorkStage = name;
|
||||
return state;
|
||||
}
|
||||
|
||||
function go(name) {
|
||||
if (!allowed(name) || name === current) return false;
|
||||
const currentIndex = stages.indexOf(current);
|
||||
const nextIndex = stages.indexOf(name);
|
||||
if (nextIndex < currentIndex && history?.go) {
|
||||
history.go(nextIndex - currentIndex);
|
||||
return true;
|
||||
}
|
||||
if (nextIndex === currentIndex - 1 && history?.back) {
|
||||
history.back();
|
||||
return true;
|
||||
}
|
||||
history?.pushState?.(stateFor(name), '');
|
||||
paint(name, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
function onPopState(event) {
|
||||
if (event.state?.taskOverlay !== 'find') return;
|
||||
paint(event.state?.findWorkStage || 'discover', true);
|
||||
}
|
||||
|
||||
function sync(state = {}) {
|
||||
selectedCount = Math.max(0, Number(state.selectedCount) || 0);
|
||||
recovery = state.recovery === true;
|
||||
if (!allowed(current)) paint('discover');
|
||||
else paint(current, false, false);
|
||||
}
|
||||
|
||||
function open(hasRecovery) {
|
||||
if (options.controller) {
|
||||
selectedCount = options.controller.selection().count;
|
||||
recovery = hasRecovery === true;
|
||||
}
|
||||
const restored = history?.state?.taskOverlay === 'find' ? history.state.findWorkStage : null;
|
||||
return paint(allowed(restored) ? restored : 'discover');
|
||||
}
|
||||
|
||||
function complete() {
|
||||
selectedCount = 0;
|
||||
recovery = false;
|
||||
history?.replaceState?.(stateFor('discover'), '');
|
||||
return paint('discover');
|
||||
}
|
||||
|
||||
function back() {
|
||||
if (current === 'discover') return false;
|
||||
history?.back?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
stages.forEach(name => {
|
||||
const button = buttons[name];
|
||||
if (!button) return;
|
||||
const listener = event => { event.preventDefault(); go(name); };
|
||||
listeners.set(button, listener);
|
||||
button.addEventListener('click', listener);
|
||||
});
|
||||
if (document) {
|
||||
const bind = (id, action) => {
|
||||
const button = document.getElementById(id);
|
||||
if (!button) return;
|
||||
const listener = event => { event.preventDefault(); action(); };
|
||||
listeners.set(button, listener);
|
||||
button.addEventListener('click', listener);
|
||||
};
|
||||
bind('select-find-work', () => { complete(); options.controller?.startSelection?.(); });
|
||||
bind('cancel-find-work-selection', () => { complete(); options.controller?.cancelSelection?.(); });
|
||||
bind('claim-selected-work', () => go('review'));
|
||||
bind('continue-find-work-fit', () => go('fit'));
|
||||
bind('back-find-work-discover', back);
|
||||
bind('cancel-find-work-estimates', back);
|
||||
bind('close-find-work', () => history?.go?.(-(stages.indexOf(current) + 1)));
|
||||
}
|
||||
eventTarget?.addEventListener?.('popstate', onPopState);
|
||||
paint('discover');
|
||||
},
|
||||
stop() {
|
||||
listeners.forEach((listener, button) => button.removeEventListener('click', listener));
|
||||
listeners.clear();
|
||||
eventTarget?.removeEventListener?.('popstate', onPopState);
|
||||
started = false;
|
||||
},
|
||||
sync,
|
||||
open,
|
||||
go,
|
||||
back,
|
||||
complete,
|
||||
renderReview,
|
||||
stage: () => current,
|
||||
};
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileInsights = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function (o) {
|
||||
const route = '#/insights';
|
||||
let active = false;
|
||||
let previous = '#/my-work';
|
||||
|
||||
function attr(element, name, value) {
|
||||
if (value === null) element.removeAttribute(name);
|
||||
else element.setAttribute(name, value);
|
||||
}
|
||||
|
||||
function render(open, focus = false) {
|
||||
active = Boolean(open && o.mediaQuery.matches);
|
||||
attr(o.root, 'data-mobile-open', active ? 'true' : null);
|
||||
attr(o.root, 'role', active ? 'dialog' : null);
|
||||
attr(o.root, 'aria-modal', active ? 'true' : null);
|
||||
attr(o.root, 'aria-hidden', active || !o.mediaQuery.matches ? null : 'true');
|
||||
o.root.inert = !active && o.mediaQuery.matches;
|
||||
o.backgrounds.forEach(element => { element.inert = active; });
|
||||
o.dock.hidden = active;
|
||||
attr(o.hud, 'data-overlay-hidden', active ? 'true' : null);
|
||||
if (active) o.closeButton.focus();
|
||||
else if (focus) o.launcher.focus();
|
||||
}
|
||||
|
||||
function url(hash) {
|
||||
return (o.location.pathname || '') + (o.location.search || '') + hash;
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (!o.mediaQuery.matches || active) return false;
|
||||
previous = o.location.hash && o.location.hash !== route ? o.location.hash : '#/my-work';
|
||||
if (o.menu) o.menu.open = false;
|
||||
o.history.pushState({ ...(o.history.state || {}), mobileInsights:true, previousHash:previous }, '', url(route));
|
||||
render(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
function sync() {
|
||||
render(o.location.hash === route, active && o.location.hash !== route);
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!active) return false;
|
||||
if (o.history.state?.mobileInsights) o.history.back();
|
||||
else {
|
||||
const state = { ...(o.history.state || {}) };
|
||||
delete state.mobileInsights;
|
||||
delete state.previousHash;
|
||||
o.history.replaceState(state, '', url(previous));
|
||||
render(false, true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function start() {
|
||||
o.launcher.addEventListener('click', open);
|
||||
o.closeButton.addEventListener('click', close);
|
||||
o.eventTarget.addEventListener('popstate', sync);
|
||||
o.eventTarget.addEventListener('hashchange', sync);
|
||||
o.eventTarget.addEventListener('keydown', event => {
|
||||
if (active && event.key === 'Escape') {
|
||||
event.preventDefault?.();
|
||||
close();
|
||||
}
|
||||
});
|
||||
o.mediaQuery.addEventListener?.('change', sync);
|
||||
sync();
|
||||
}
|
||||
|
||||
return { start, open, close, current:() => active };
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
function createMobileIssueDetailNavigation(options) {
|
||||
const buttons = options.buttons || {};
|
||||
const targets = options.targets || {};
|
||||
const listeners = new Map();
|
||||
const prefersReducedMotion = options.prefersReducedMotion || (() => false);
|
||||
const targetNames = new Map(Object.entries(targets).map(([name, target]) => [target, name]));
|
||||
let observer = null;
|
||||
|
||||
function select(name) {
|
||||
Object.entries(buttons).forEach(([key, button]) => {
|
||||
if (!button) return;
|
||||
if (key === name) button.setAttribute('aria-current', 'location');
|
||||
else button.removeAttribute('aria-current');
|
||||
});
|
||||
}
|
||||
|
||||
function navigate(name, navigationOptions = {}) {
|
||||
const target = targets[name];
|
||||
if (!target) return false;
|
||||
const prepare = options.beforeNavigate && options.beforeNavigate[name];
|
||||
if (prepare) prepare(target);
|
||||
if (name === 'actions' && options.planning) options.planning.open = true;
|
||||
target.scrollIntoView({
|
||||
block: 'start',
|
||||
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
|
||||
});
|
||||
if (name === 'reply' && navigationOptions.focus !== false) target.focus();
|
||||
select(name);
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
Object.entries(buttons).forEach(([name, button]) => {
|
||||
if (!button || listeners.has(button)) return;
|
||||
const listener = event => {
|
||||
event.preventDefault();
|
||||
navigate(name);
|
||||
if (options.onSectionChange) options.onSectionChange(name, { replace:false });
|
||||
};
|
||||
listeners.set(button, listener);
|
||||
button.addEventListener('click', listener);
|
||||
});
|
||||
select('overview');
|
||||
const observe = options.observe || ((handler, observedTargets) => {
|
||||
if (typeof IntersectionObserver === 'undefined') return null;
|
||||
const instance = new IntersectionObserver(handler, {
|
||||
root: options.root || null,
|
||||
rootMargin: '-20% 0px -60% 0px',
|
||||
threshold: [0, 0.25, 0.5, 0.75, 1],
|
||||
});
|
||||
observedTargets.forEach(target => instance.observe(target));
|
||||
return instance;
|
||||
});
|
||||
observer = observe(entries => {
|
||||
const visible = entries
|
||||
.filter(entry => entry.isIntersecting && targetNames.has(entry.target))
|
||||
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
|
||||
if (visible) {
|
||||
const name = targetNames.get(visible.target);
|
||||
select(name);
|
||||
if (options.onSectionChange) options.onSectionChange(name, { replace:true });
|
||||
}
|
||||
}, Array.from(targetNames.keys()).filter(Boolean));
|
||||
},
|
||||
stop() {
|
||||
listeners.forEach((listener, button) => button.removeEventListener('click', listener));
|
||||
listeners.clear();
|
||||
if (observer) observer.disconnect();
|
||||
observer = null;
|
||||
},
|
||||
navigate,
|
||||
select,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined') module.exports = createMobileIssueDetailNavigation;
|
||||
@@ -0,0 +1,104 @@
|
||||
function createMobilePlanTodayNavigation(options) {
|
||||
const buttons = options.buttons || {};
|
||||
const targets = options.targets || {};
|
||||
const root = options.root;
|
||||
const listeners = new Map();
|
||||
const prefersReducedMotion = options.prefersReducedMotion || (() => false);
|
||||
const now = options.now || (() => Date.now());
|
||||
const targetNames = new Map(Object.entries(targets).map(([name, target]) => [target, name]));
|
||||
let observer = null;
|
||||
let navigationLockUntil = 0;
|
||||
|
||||
function select(name) {
|
||||
Object.entries(buttons).forEach(([key, button]) => {
|
||||
if (!button) return;
|
||||
if (key === name) button.setAttribute('aria-current', 'location');
|
||||
else button.removeAttribute('aria-current');
|
||||
});
|
||||
}
|
||||
|
||||
function navigate(name) {
|
||||
const target = targets[name];
|
||||
if (!root || !target) return false;
|
||||
root.scrollTo({
|
||||
top: Math.max(0, target.offsetTop - root.offsetTop),
|
||||
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
|
||||
});
|
||||
navigationLockUntil = now() + 500;
|
||||
select(name);
|
||||
return true;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
select('fit');
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
Object.entries(buttons).forEach(([name, button]) => {
|
||||
if (!button || listeners.has(button)) return;
|
||||
const listener = event => {
|
||||
event.preventDefault();
|
||||
navigate(name);
|
||||
};
|
||||
listeners.set(button, listener);
|
||||
button.addEventListener('click', listener);
|
||||
});
|
||||
reset();
|
||||
const observe = options.observe || ((handler, observedTargets, observedRoot) => {
|
||||
if (typeof IntersectionObserver === 'undefined') return null;
|
||||
const instance = new IntersectionObserver(handler, {
|
||||
root: observedRoot,
|
||||
rootMargin: '-20% 0px -60% 0px',
|
||||
threshold: [0, 0.25, 0.5, 0.75, 1],
|
||||
});
|
||||
observedTargets.forEach(target => instance.observe(target));
|
||||
return instance;
|
||||
});
|
||||
observer = observe(entries => {
|
||||
if (now() < navigationLockUntil) return;
|
||||
const visible = entries
|
||||
.filter(entry => entry.isIntersecting && targetNames.has(entry.target))
|
||||
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
|
||||
if (visible) select(targetNames.get(visible.target));
|
||||
}, Array.from(targetNames.keys()).filter(Boolean), root);
|
||||
},
|
||||
stop() {
|
||||
listeners.forEach((listener, button) => button.removeEventListener('click', listener));
|
||||
listeners.clear();
|
||||
if (observer) observer.disconnect();
|
||||
observer = null;
|
||||
},
|
||||
navigate,
|
||||
reset,
|
||||
select,
|
||||
};
|
||||
}
|
||||
|
||||
function attachMobilePlanTodayNavigation({document, window}) {
|
||||
const bySection = name => document.querySelector('[data-plan-today-section="' + name + '"]');
|
||||
const sheet = document.getElementById('plan-today-sheet');
|
||||
const panel = document.querySelector('.plan-today-panel');
|
||||
const navigation = createMobilePlanTodayNavigation({
|
||||
root: panel,
|
||||
buttons: {
|
||||
fit: bySection('fit'),
|
||||
today: bySection('today'),
|
||||
available: bySection('available'),
|
||||
},
|
||||
targets: {
|
||||
fit: document.getElementById('plan-today-fit'),
|
||||
today: document.getElementById('plan-today-selected'),
|
||||
available: document.getElementById('plan-today-available-work'),
|
||||
},
|
||||
prefersReducedMotion: () => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
});
|
||||
navigation.start();
|
||||
new MutationObserver(() => {
|
||||
if (!sheet.hidden) navigation.reset();
|
||||
}).observe(sheet, {attributes: true, attributeFilter: ['hidden']});
|
||||
return navigation;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined') module.exports = createMobilePlanTodayNavigation;
|
||||
else attachMobilePlanTodayNavigation({document, window});
|
||||
@@ -0,0 +1,88 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||||
else root.createMobilePullRefresh = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobilePullRefresh(options) {
|
||||
const surface = options.surface || document.querySelector('#my-work');
|
||||
const indicator = options.indicator || document.querySelector('#mobile-pull-refresh');
|
||||
const threshold = options.threshold || 64;
|
||||
const isMobile = options.isMobile || (() => window.matchMedia('(max-width: 600px)').matches);
|
||||
const getScrollY = options.getScrollY || (() => window.scrollY);
|
||||
const isBlocked = options.isBlocked || (() => Array.from(options.overlays || document.querySelectorAll('[role="dialog"], #whiteboard-modal, #markdown-modal')).some(overlay =>
|
||||
(overlay.checkVisibility ? overlay.checkVisibility() : !overlay.closest('[hidden]')) &&
|
||||
(overlay.classList.contains('open') || overlay.getAttribute('aria-modal') === 'true')));
|
||||
let gesture = null;
|
||||
let inFlight = null;
|
||||
|
||||
function show(state, text) {
|
||||
indicator.hidden = false;
|
||||
indicator.dataset.state = state;
|
||||
indicator.textContent = text;
|
||||
}
|
||||
|
||||
function resetGesture() {
|
||||
gesture = null;
|
||||
}
|
||||
|
||||
function startsOnExcludedTarget(target) {
|
||||
if (!target) return false;
|
||||
if (target.closest?.('button, a, input, textarea, select, summary, [contenteditable="true"], [role="button"]')) return true;
|
||||
for (let node = target; node && node !== surface; node = node.parentElement) {
|
||||
if ((node.scrollWidth || 0) > (node.clientWidth || 0)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function onPointerDown(event) {
|
||||
if (inFlight || event.pointerType !== 'touch' || !isMobile() || getScrollY() > 0 || isBlocked() || startsOnExcludedTarget(event.target)) return;
|
||||
gesture = {id:event.pointerId, x:event.clientX, y:event.clientY, ready:false};
|
||||
surface.setPointerCapture?.(event.pointerId);
|
||||
}
|
||||
|
||||
function onPointerMove(event) {
|
||||
if (!gesture || gesture.id !== event.pointerId) return;
|
||||
const dx = event.clientX - gesture.x;
|
||||
const dy = event.clientY - gesture.y;
|
||||
if (dy <= 0 || Math.abs(dx) > dy) return resetGesture();
|
||||
event.preventDefault();
|
||||
gesture.ready = dy >= threshold;
|
||||
show(gesture.ready ? 'ready' : 'pulling', gesture.ready ? 'Release to refresh' : 'Pull to refresh');
|
||||
}
|
||||
|
||||
async function onPointerUp(event) {
|
||||
if (!gesture || gesture.id !== event.pointerId) return;
|
||||
const shouldRefresh = gesture.ready;
|
||||
resetGesture();
|
||||
surface.releasePointerCapture?.(event.pointerId);
|
||||
if (!shouldRefresh || inFlight) return;
|
||||
show('refreshing', 'Refreshing My Work…');
|
||||
inFlight = Promise.resolve().then(options.refresh);
|
||||
try {
|
||||
await inFlight;
|
||||
show('success', 'My Work is up to date');
|
||||
} catch (_) {
|
||||
show('error', 'Could not refresh · pull to retry');
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerCancel() {
|
||||
resetGesture();
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
surface.addEventListener('pointerdown', onPointerDown);
|
||||
surface.addEventListener('pointermove', onPointerMove);
|
||||
surface.addEventListener('pointerup', onPointerUp);
|
||||
surface.addEventListener('pointercancel', onPointerCancel);
|
||||
},
|
||||
stop() {
|
||||
surface.removeEventListener('pointerdown', onPointerDown);
|
||||
surface.removeEventListener('pointermove', onPointerMove);
|
||||
surface.removeEventListener('pointerup', onPointerUp);
|
||||
surface.removeEventListener('pointercancel', onPointerCancel);
|
||||
resetGesture();
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -9,10 +9,12 @@
|
||||
draft: 'No drafts are ready to open.',
|
||||
};
|
||||
const continuation = [
|
||||
['delivery', 'Recover Delivery'],
|
||||
['attention', 'Start Attention'],
|
||||
['today', 'Continue Today'],
|
||||
['update', 'Resume Updates'],
|
||||
['agenda', 'Open Agenda'],
|
||||
['filed', 'Review Filed'],
|
||||
['later', 'Start Later'],
|
||||
['draft', 'Open Drafts'],
|
||||
];
|
||||
@@ -27,6 +29,7 @@
|
||||
}
|
||||
|
||||
function open(name) {
|
||||
if (name === 'delivery' && options.openDelivery) return options.openDelivery();
|
||||
if (name === 'today') return options.openToday();
|
||||
if (name === 'agenda') return options.openAgenda();
|
||||
if (name === 'update' && options.openUpdates) return options.openUpdates();
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
function createMobileReviewDetailNavigation(options) {
|
||||
const buttons = options.buttons || {};
|
||||
const targets = options.targets || {};
|
||||
const listeners = new Map();
|
||||
const prefersReducedMotion = options.prefersReducedMotion || (() => false);
|
||||
const targetNames = new Map(Object.entries(targets).map(([name, target]) => [target, name]));
|
||||
let observer = null;
|
||||
|
||||
function select(name) {
|
||||
Object.entries(buttons).forEach(([key, button]) => {
|
||||
if (!button) return;
|
||||
if (key === name) button.setAttribute('aria-current', 'location');
|
||||
else button.removeAttribute('aria-current');
|
||||
});
|
||||
}
|
||||
|
||||
function navigate(name, navigationOptions = {}) {
|
||||
const target = targets[name];
|
||||
if (!target) return false;
|
||||
target.scrollIntoView({
|
||||
block:'start',
|
||||
behavior:prefersReducedMotion() ? 'auto' : 'smooth',
|
||||
});
|
||||
if (name === 'feedback' && options.summaryComposer && navigationOptions.focus !== false) {
|
||||
options.summaryComposer.focus({ preventScroll:true });
|
||||
}
|
||||
select(name);
|
||||
return true;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
select('overview');
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
Object.entries(buttons).forEach(([name, button]) => {
|
||||
if (!button || listeners.has(button)) return;
|
||||
const listener = event => {
|
||||
event.preventDefault();
|
||||
navigate(name);
|
||||
if (options.onSectionChange) options.onSectionChange(name, { replace:false });
|
||||
};
|
||||
listeners.set(button, listener);
|
||||
button.addEventListener('click', listener);
|
||||
});
|
||||
reset();
|
||||
const observe = options.observe || ((handler, observedTargets, root) => {
|
||||
if (typeof IntersectionObserver === 'undefined') return null;
|
||||
const instance = new IntersectionObserver(handler, {
|
||||
root:root || null,
|
||||
rootMargin:'-20% 0px -60% 0px',
|
||||
threshold:[0, 0.25, 0.5, 0.75, 1],
|
||||
});
|
||||
observedTargets.forEach(target => instance.observe(target));
|
||||
return instance;
|
||||
});
|
||||
observer = observe(entries => {
|
||||
const visible = entries
|
||||
.filter(entry => entry.isIntersecting && targetNames.has(entry.target))
|
||||
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
|
||||
if (visible) {
|
||||
const name = targetNames.get(visible.target);
|
||||
select(name);
|
||||
if (options.onSectionChange) options.onSectionChange(name, { replace:true });
|
||||
}
|
||||
}, Array.from(targetNames.keys()).filter(Boolean), options.root || null);
|
||||
},
|
||||
stop() {
|
||||
listeners.forEach((listener, button) => button.removeEventListener('click', listener));
|
||||
listeners.clear();
|
||||
if (observer) observer.disconnect();
|
||||
observer = null;
|
||||
},
|
||||
navigate,
|
||||
reset,
|
||||
select,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined') module.exports = createMobileReviewDetailNavigation;
|
||||
@@ -0,0 +1,130 @@
|
||||
function createMobileSearchPreviewNavigation(options) {
|
||||
const buttons = options.buttons || {};
|
||||
const targets = options.targets || {};
|
||||
const focusTargets = options.focusTargets || {};
|
||||
const listeners = new Map();
|
||||
const prefersReducedMotion = options.prefersReducedMotion || (() => false);
|
||||
const targetNames = new Map(Object.entries(targets).map(([name, target]) => [target, name]));
|
||||
let replyAvailable = options.replyAvailable !== false;
|
||||
let observer = null;
|
||||
let navigationLockUntil = 0;
|
||||
|
||||
function select(name) {
|
||||
Object.entries(buttons).forEach(([key, button]) => {
|
||||
if (!button) return;
|
||||
if (key === name) button.setAttribute('aria-current', 'location');
|
||||
else button.removeAttribute('aria-current');
|
||||
});
|
||||
}
|
||||
|
||||
function setReplyAvailable(available) {
|
||||
replyAvailable = Boolean(available);
|
||||
const button = buttons.reply;
|
||||
if (!button) return;
|
||||
if (replyAvailable) button.removeAttribute('aria-disabled');
|
||||
else button.setAttribute('aria-disabled', 'true');
|
||||
}
|
||||
|
||||
function navigate(name) {
|
||||
if (name === 'reply' && !replyAvailable) return false;
|
||||
const target = targets[name];
|
||||
if (!target) return false;
|
||||
target.scrollIntoView({
|
||||
block: 'start',
|
||||
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
|
||||
});
|
||||
navigationLockUntil = Date.now() + 500;
|
||||
select(name);
|
||||
const focusTarget = focusTargets[name] || (name === 'reply' ? target : null);
|
||||
if (focusTarget) focusTarget.focus({preventScroll:true});
|
||||
return true;
|
||||
}
|
||||
|
||||
function reset(available = replyAvailable) {
|
||||
setReplyAvailable(available);
|
||||
select('overview');
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
Object.entries(buttons).forEach(([name, button]) => {
|
||||
if (!button || listeners.has(button)) return;
|
||||
const listener = event => {
|
||||
event.preventDefault();
|
||||
navigate(name);
|
||||
};
|
||||
listeners.set(button, listener);
|
||||
button.addEventListener('click', listener);
|
||||
});
|
||||
reset(replyAvailable);
|
||||
const observe = options.observe || ((handler, observedTargets, root) => {
|
||||
if (typeof IntersectionObserver === 'undefined') return null;
|
||||
const instance = new IntersectionObserver(handler, {
|
||||
root,
|
||||
rootMargin: '-20% 0px -60% 0px',
|
||||
threshold: [0, 0.25, 0.5, 0.75, 1],
|
||||
});
|
||||
observedTargets.forEach(target => instance.observe(target));
|
||||
return instance;
|
||||
});
|
||||
observer = observe(entries => {
|
||||
if (Date.now() < navigationLockUntil) return;
|
||||
const visible = entries
|
||||
.filter(entry => entry.isIntersecting && targetNames.has(entry.target))
|
||||
.filter(entry => targetNames.get(entry.target) !== 'reply' || replyAvailable)
|
||||
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
|
||||
if (visible) select(targetNames.get(visible.target));
|
||||
}, Array.from(targetNames.keys()).filter(Boolean), options.root || null);
|
||||
},
|
||||
stop() {
|
||||
listeners.forEach((listener, button) => button.removeEventListener('click', listener));
|
||||
listeners.clear();
|
||||
if (observer) observer.disconnect();
|
||||
observer = null;
|
||||
},
|
||||
navigate,
|
||||
reset,
|
||||
select,
|
||||
setReplyAvailable,
|
||||
};
|
||||
}
|
||||
|
||||
function attachMobileSearchPreviewNavigation({document, window}) {
|
||||
const bySection = name => document.querySelector('[data-search-preview-section="' + name + '"]');
|
||||
const preview = document.getElementById('search-preview');
|
||||
const reply = document.getElementById('search-preview-reply-workspace');
|
||||
const navigation = createMobileSearchPreviewNavigation({
|
||||
root:document.querySelector('.search-preview-panel'),
|
||||
buttons:{
|
||||
overview:bySection('overview'),
|
||||
conversation:bySection('conversation'),
|
||||
reply:bySection('reply'),
|
||||
actions:bySection('actions'),
|
||||
},
|
||||
targets:{
|
||||
overview:document.getElementById('search-preview-overview'),
|
||||
conversation:document.getElementById('search-preview-conversation'),
|
||||
reply,
|
||||
actions:document.getElementById('search-preview-actions'),
|
||||
},
|
||||
focusTargets:{reply:document.getElementById('search-preview-reply')},
|
||||
replyAvailable:!reply.hidden,
|
||||
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
});
|
||||
navigation.start();
|
||||
new MutationObserver(() => navigation.setReplyAvailable(!reply.hidden)).observe(reply, {
|
||||
attributes:true, attributeFilter:['hidden'],
|
||||
});
|
||||
new MutationObserver(() => {
|
||||
if (preview.classList.contains('open')) navigation.reset(!reply.hidden);
|
||||
}).observe(preview, {attributes:true, attributeFilter:['class']});
|
||||
['previous-search-result', 'next-search-result'].forEach(id => {
|
||||
document.getElementById(id).addEventListener('click', () => {
|
||||
setTimeout(() => navigation.reset(!reply.hidden), 0);
|
||||
});
|
||||
});
|
||||
return navigation;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined') module.exports = createMobileSearchPreviewNavigation;
|
||||
else attachMobileSearchPreviewNavigation({document, window});
|
||||
@@ -5,6 +5,7 @@
|
||||
const checkpointKey = 'stackchain.mobile-start-day.v1';
|
||||
const storage = options.storage || (typeof localStorage !== 'undefined' ? localStorage : null);
|
||||
const reviewOrder = [
|
||||
['delivery', 'Delivery recovery'],
|
||||
['agenda', 'Agenda'],
|
||||
['attention', 'Attention'],
|
||||
['update', 'Updates'],
|
||||
@@ -73,13 +74,19 @@
|
||||
.filter(phase => phase.count > 0);
|
||||
const total = phases.reduce((sum, phase) => sum + phase.count, 0);
|
||||
const today = count(counts.today);
|
||||
const delivery = count(counts.delivery);
|
||||
const other = total - delivery;
|
||||
const next = phases.length ? phases[0].name : (today ? 'today' : 'find');
|
||||
const nextLabel = phases.length ? 'Review ' + phases[0].label : (today ? 'Continue Today' : 'Find Work');
|
||||
const nextLabel = next === 'delivery' ? 'Review Delivery' :
|
||||
(phases.length ? 'Review ' + phases[0].label : (today ? 'Continue Today' : 'Find Work'));
|
||||
return {
|
||||
total,
|
||||
next,
|
||||
label: nextLabel,
|
||||
summary: total ? total + ' items before Today · ' + today + ' planned' :
|
||||
summary: delivery ? delivery + (delivery === 1 ? ' delivery needs' : ' deliveries need') +
|
||||
' action before Today' + (other ? ' · ' + other + ' other ' + (other === 1 ? 'item' : 'items') : '') +
|
||||
' · ' + today + ' planned' :
|
||||
total ? total + ' items before Today · ' + today + ' planned' :
|
||||
(today ? 'Review clear · ' + today + ' planned' : 'Review clear · Today is empty'),
|
||||
phases,
|
||||
};
|
||||
@@ -107,9 +114,9 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
function reconcile({authoritative = false} = {}) {
|
||||
function reconcile({authoritative = false, authoritativePhases = []} = {}) {
|
||||
const saved = checkpoint();
|
||||
if (!authoritative || !saved?.phase) return false;
|
||||
if (!saved?.phase || (!authoritative && !authoritativePhases.includes(saved.phase))) return false;
|
||||
const counts = options.getCounts ? options.getCounts() : {};
|
||||
if (count(counts[saved.phase]) > 0) return false;
|
||||
return completePhase(saved.phase);
|
||||
|
||||
@@ -66,9 +66,9 @@
|
||||
function updateWork(mode) {
|
||||
const text = {
|
||||
continue:'Continue', resume:'Resume', start:'Start', plan:'Plan', find:'Find',
|
||||
attention:'Attention', update:'Updates', agenda:'Agenda', later:'Later', draft:'Drafts',
|
||||
delivery:'Delivery', attention:'Attention', update:'Updates', agenda:'Agenda', filed:'Filed', later:'Later', draft:'Drafts',
|
||||
}[mode] || 'Work';
|
||||
const queue = ['Attention', 'Updates', 'Agenda', 'Later', 'Drafts'].includes(text);
|
||||
const queue = ['Delivery', 'Attention', 'Updates', 'Agenda', 'Filed', 'Later', 'Drafts'].includes(text);
|
||||
if (options.workLabel) options.workLabel.textContent = text;
|
||||
if (buttons.work) buttons.work.setAttribute('aria-label', queue
|
||||
? (mode === 'update' ? 'Resume ' : 'Open ') + text
|
||||
@@ -91,9 +91,9 @@
|
||||
}
|
||||
|
||||
function updateQueues(counts) {
|
||||
const names = 'today agenda attention update filed later draft'.split(' ');
|
||||
const names = 'today agenda delivery attention update filed later draft'.split(' ');
|
||||
const normalized = Object.fromEntries(names.map(name => [name, Math.max(0, Number(counts?.[name]) || 0)]));
|
||||
const actionableNames = ['today', 'attention', 'update', 'filed', 'later', 'draft'];
|
||||
const actionableNames = ['today', 'delivery', 'attention', 'update', 'filed', 'later', 'draft'];
|
||||
const active = actionableNames.reduce((total, name) => total + (normalized[name] > 0 ? 1 : 0), 0);
|
||||
Object.entries(options.queueCounts || {}).forEach(([name, element]) => {
|
||||
element.textContent = String(normalized[name] || 0);
|
||||
@@ -117,6 +117,7 @@
|
||||
? 'Queues: no active queues; no upcoming deadlines'
|
||||
: 'Queues: Today ' + normalized.today
|
||||
+ ', Agenda ' + normalized.agenda + ' due'
|
||||
+ ', Delivery ' + normalized.delivery
|
||||
+ ', Attention ' + normalized.attention
|
||||
+ ', Updates ' + normalized.update
|
||||
+ ', Filed ' + normalized.filed
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
function createMobileUpdateDetailNavigation(options) {
|
||||
const buttons = options.buttons || {};
|
||||
const targets = options.targets || {};
|
||||
const listeners = new Map();
|
||||
const prefersReducedMotion = options.prefersReducedMotion || (() => false);
|
||||
const targetNames = new Map(Object.entries(targets).map(([name, target]) => [target, name]));
|
||||
let observer = null;
|
||||
|
||||
function select(name) {
|
||||
Object.entries(buttons).forEach(([key, button]) => {
|
||||
if (!button) return;
|
||||
if (key === name) button.setAttribute('aria-current', 'location');
|
||||
else button.removeAttribute('aria-current');
|
||||
});
|
||||
}
|
||||
|
||||
function navigate(name, navigationOptions = {}) {
|
||||
if (name === 'activity') {
|
||||
if (options.jumpToNewActivity) options.jumpToNewActivity();
|
||||
select(name);
|
||||
return true;
|
||||
}
|
||||
const target = targets[name];
|
||||
if (!target) return false;
|
||||
if (name === 'context') target.open = true;
|
||||
target.scrollIntoView({
|
||||
block:'start',
|
||||
behavior:prefersReducedMotion() ? 'auto' : 'smooth',
|
||||
});
|
||||
if (name === 'reply' && options.replyComposer && navigationOptions.focus !== false) {
|
||||
options.replyComposer.focus({ preventScroll:true });
|
||||
}
|
||||
select(name);
|
||||
return true;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (targets.context) targets.context.open = false;
|
||||
select('activity');
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
Object.entries(buttons).forEach(([name, button]) => {
|
||||
if (!button || listeners.has(button)) return;
|
||||
const listener = event => {
|
||||
event.preventDefault();
|
||||
navigate(name);
|
||||
if (options.onSectionChange) options.onSectionChange(name, { replace:false });
|
||||
};
|
||||
listeners.set(button, listener);
|
||||
button.addEventListener('click', listener);
|
||||
});
|
||||
reset();
|
||||
const observe = options.observe || ((handler, observedTargets) => {
|
||||
if (typeof IntersectionObserver === 'undefined') return null;
|
||||
const instance = new IntersectionObserver(handler, {
|
||||
root:options.root || null,
|
||||
rootMargin:'-20% 0px -60% 0px',
|
||||
threshold:[0, 0.25, 0.5, 0.75, 1],
|
||||
});
|
||||
observedTargets.forEach(target => instance.observe(target));
|
||||
return instance;
|
||||
});
|
||||
observer = observe(entries => {
|
||||
const visible = entries
|
||||
.filter(entry => entry.isIntersecting && targetNames.has(entry.target))
|
||||
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
|
||||
if (visible) {
|
||||
const name = targetNames.get(visible.target);
|
||||
select(name);
|
||||
if (options.onSectionChange) options.onSectionChange(name, { replace:true });
|
||||
}
|
||||
}, Array.from(targetNames.keys()).filter(Boolean));
|
||||
},
|
||||
stop() {
|
||||
listeners.forEach((listener, button) => button.removeEventListener('click', listener));
|
||||
listeners.clear();
|
||||
if (observer) observer.disconnect();
|
||||
observer = null;
|
||||
},
|
||||
navigate,
|
||||
reset,
|
||||
select,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined') module.exports = createMobileUpdateDetailNavigation;
|
||||
@@ -5,7 +5,7 @@
|
||||
function mode() {
|
||||
if (options.isTodayActive()) return 'continue';
|
||||
const recommended = options.queueLauncher && options.queueLauncher.recommend();
|
||||
if (recommended && recommended.name !== 'today') return recommended.name;
|
||||
if (recommended && !['today', 'find'].includes(recommended.name)) return recommended.name;
|
||||
if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume';
|
||||
if (options.getTodayCount() > 0) return 'start';
|
||||
if (options.getEligibleCount() > 0) return 'plan';
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.inspectStackchainPrivateDatabases = factory(root.indexedDB);
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function createPrivateDataInspector(indexedDB) {
|
||||
function requestResult(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error('Private storage inventory failed.'));
|
||||
request.onblocked = () => reject(new Error('Private storage inventory was blocked.'));
|
||||
});
|
||||
}
|
||||
|
||||
async function countDatabase(name) {
|
||||
const database = await requestResult(indexedDB.open(name));
|
||||
try {
|
||||
const storeNames = Array.from(database.objectStoreNames);
|
||||
if (!storeNames.length) return 0;
|
||||
const transaction = database.transaction(storeNames, 'readonly');
|
||||
const counts = await Promise.all(storeNames.map(storeName =>
|
||||
requestResult(transaction.objectStore(storeName).count())
|
||||
));
|
||||
return counts.reduce((total, count) => total + Number(count || 0), 0);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
return async function inspectPrivateDatabases(registeredNames) {
|
||||
if (!indexedDB?.databases) return { recordCount: 0, unavailable: true };
|
||||
try {
|
||||
const existing = new Set((await indexedDB.databases()).map(database => database.name));
|
||||
const counts = await Promise.all(
|
||||
registeredNames.filter(name => existing.has(name)).map(countDatabase)
|
||||
);
|
||||
return {
|
||||
recordCount: counts.reduce((total, count) => total + count, 0),
|
||||
unavailable: false,
|
||||
};
|
||||
} catch (_error) {
|
||||
return { recordCount: 0, unavailable: true };
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -4,6 +4,8 @@
|
||||
'stackchain-offline-work-v2',
|
||||
'stackchain-unfiled-captures-v1',
|
||||
'stackchain-voice-transcripts-v1',
|
||||
'stackchain-search-reply-drafts-v1',
|
||||
'stackchain-conversation-reply-drafts-v1',
|
||||
]);
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = databases;
|
||||
else root.stackchainPrivateDatabases = databases;
|
||||
|
||||
@@ -55,9 +55,9 @@
|
||||
}
|
||||
|
||||
return async function clearPrivateDeviceData() {
|
||||
await stopWorkerOutbox();
|
||||
removeOwnedStorage(localStorage);
|
||||
if (sessionStorage !== localStorage) removeOwnedStorage(sessionStorage);
|
||||
await stopWorkerOutbox();
|
||||
for (const name of privateDatabases) await deletePrivateDatabase(name);
|
||||
const keys = await caches?.keys?.() || [];
|
||||
await Promise.all(
|
||||
|
||||
@@ -5,7 +5,10 @@ function createReconnectOutboxes({
|
||||
flushAuthored,
|
||||
flushNotificationReads,
|
||||
}) {
|
||||
return async function reconnectOutboxes() {
|
||||
let activeReconnect = null;
|
||||
let followUpRequested = false;
|
||||
|
||||
async function reconnectOnce() {
|
||||
const snapshot = await refresh();
|
||||
const freshness = snapshot?.freshness?.sections?.context;
|
||||
const identityFresh = snapshot?.context && !snapshot.context.error &&
|
||||
@@ -19,6 +22,22 @@ function createReconnectOutboxes({
|
||||
flushNotificationReads(),
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
return function reconnectOutboxes() {
|
||||
if (activeReconnect) {
|
||||
followUpRequested = true;
|
||||
return activeReconnect;
|
||||
}
|
||||
activeReconnect = (async () => {
|
||||
let result = false;
|
||||
do {
|
||||
followUpRequested = false;
|
||||
result = await reconnectOnce();
|
||||
} while (followUpRequested);
|
||||
return result;
|
||||
})().finally(() => { activeReconnect = null; });
|
||||
return activeReconnect;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+31
-10
@@ -81,15 +81,18 @@
|
||||
}
|
||||
input.value = preview.replyDraft();
|
||||
const replying = state.status === 'replying';
|
||||
buttons.forEach(button => { button.disabled = replying || !input.value.trim(); });
|
||||
if (replying) status.textContent = 'Sending reply…';
|
||||
buttons.forEach(button => {
|
||||
button.disabled = replying || (!input.value.trim() && !preview.hasReplyAttachments?.());
|
||||
});
|
||||
if (replying) status.textContent = 'Saving reply for delivery…';
|
||||
else if (state.status === 'queued') status.textContent = 'Reply queued. It will send when connected.';
|
||||
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, fetchConversation, mutate, share, postReply, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
|
||||
return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, queueReply, prepareReply, afterReply, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
|
||||
if (Array.isArray(session)) {
|
||||
getSession = session[0];
|
||||
loadMore = () => session[1].loadMore();
|
||||
@@ -201,7 +204,11 @@
|
||||
}
|
||||
|
||||
const api = {
|
||||
hasReplyAttachments() {
|
||||
return Boolean(hasAttachments?.());
|
||||
},
|
||||
open(item) {
|
||||
if (current && !sameItem(current, item) && hasAttachments?.()) clearAttachments?.();
|
||||
generation += 1;
|
||||
const requestGeneration = generation;
|
||||
current = { ...item };
|
||||
@@ -225,6 +232,7 @@
|
||||
});
|
||||
},
|
||||
close() {
|
||||
clearAttachments?.();
|
||||
generation += 1;
|
||||
current = null;
|
||||
onState({ status: 'closed' });
|
||||
@@ -282,9 +290,11 @@
|
||||
},
|
||||
reply({ advance = false } = {}) {
|
||||
if (replyRequest) return replyRequest;
|
||||
if (!current || typeof postReply !== 'function') return Promise.reject(new Error('Replying is unavailable.'));
|
||||
if (!current || (typeof postReply !== 'function' && typeof queueReply !== '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.'));
|
||||
if (!body && !hasAttachments?.()) return Promise.reject(new Error('Write a reply or add a photo first.'));
|
||||
const item = { ...current };
|
||||
const operationKey = replyKey(item, 'operation');
|
||||
let operationId = stored(operationKey);
|
||||
@@ -293,13 +303,24 @@
|
||||
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 };
|
||||
replyRequest = (typeof queueReply === 'function' ?
|
||||
Promise.resolve(queueReply(item, body, operationId)) :
|
||||
Promise.resolve(typeof prepareReply === 'function' ? prepareReply(item, body) : body)
|
||||
.then(preparedBody => {
|
||||
if (!String(preparedBody || '').trim()) throw new Error('Write a reply or add a photo first.');
|
||||
return postReply(item, preparedBody, operationId);
|
||||
})
|
||||
).then(async comment => {
|
||||
await afterReply?.(item);
|
||||
if (!comment?.queued) {
|
||||
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 });
|
||||
clearAttachments?.();
|
||||
publish({ status:comment?.queued ? 'queued' : '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 });
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
(function (root, factory) {
|
||||
const createSearchReplyDraftStore = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = createSearchReplyDraftStore;
|
||||
else root.createSearchReplyDraftStore = createSearchReplyDraftStore;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
const dbName = 'stackchain-search-reply-drafts-v1';
|
||||
const storeName = 'drafts';
|
||||
|
||||
function requestResult(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result ?? null);
|
||||
request.onerror = () => reject(request.error || new Error('Search reply draft storage failed.'));
|
||||
});
|
||||
}
|
||||
|
||||
function createTransaction(indexedDB) {
|
||||
if (!indexedDB) return null;
|
||||
let databasePromise;
|
||||
function database() {
|
||||
if (!databasePromise) databasePromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(dbName, 1);
|
||||
request.onupgradeneeded = () => {
|
||||
if (!request.result.objectStoreNames.contains(storeName)) {
|
||||
request.result.createObjectStore(storeName, { keyPath:'id' });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error('Search reply draft storage is unavailable.'));
|
||||
});
|
||||
return databasePromise;
|
||||
}
|
||||
return async (operation, key, value) => {
|
||||
const db = await database();
|
||||
const transaction = db.transaction(storeName, operation === 'get' ? 'readonly' : 'readwrite');
|
||||
const records = transaction.objectStore(storeName);
|
||||
if (operation === 'put') return requestResult(records.put(value));
|
||||
if (operation === 'delete') return requestResult(records.delete(key));
|
||||
return requestResult(records.get(key));
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedTarget(target) {
|
||||
const kind = String(target?.kind || '');
|
||||
const repository = String(target?.repository || '');
|
||||
const number = Number(target?.number || 0);
|
||||
if (!['issue', 'pull'].includes(kind) || !repository || !Number.isInteger(number) || number < 1) {
|
||||
throw new Error('Choose a Search result before saving photo evidence.');
|
||||
}
|
||||
return { kind, repository, number };
|
||||
}
|
||||
|
||||
function attachment(value) {
|
||||
const blob = value?.blob;
|
||||
const data = String(value?.data || '');
|
||||
if (!blob && !data) throw new Error('A saved Search photo is unavailable.');
|
||||
const note = String(value?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
|
||||
const operationId = String(value?.operationId || '').slice(0, 128);
|
||||
const markdown = String(value?.confirmed?.markdown || '');
|
||||
return {
|
||||
filename:String(value?.filename || ''),
|
||||
contentType:String(value?.contentType || ''),
|
||||
...(blob ? { blob } : { data }),
|
||||
...(note ? { note } : {}),
|
||||
...(operationId ? { operationId } : {}),
|
||||
...(markdown ? { confirmed:{ markdown } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return function createSearchReplyDraftStore({
|
||||
indexedDB = globalThis.indexedDB,
|
||||
transaction,
|
||||
getOwnerLogin = () => '',
|
||||
} = {}) {
|
||||
const transact = transaction || createTransaction(indexedDB);
|
||||
|
||||
function identity(target) {
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Confirm your Gitea account before saving Search photos.');
|
||||
const normalized = normalizedTarget(target);
|
||||
return {
|
||||
ownerLogin,
|
||||
target:normalized,
|
||||
id:[ownerLogin, normalized.kind, normalized.repository, normalized.number]
|
||||
.map(value => encodeURIComponent(String(value))).join(':'),
|
||||
};
|
||||
}
|
||||
|
||||
async function save(target, values) {
|
||||
if (!transact) throw new Error('Photo draft storage needs IndexedDB. Your current photos are still here.');
|
||||
const { id, ownerLogin, target:normalized } = identity(target);
|
||||
const list = (Array.isArray(values) ? values : [values]).filter(Boolean).slice(0, 5).map(attachment);
|
||||
if (!list.length) {
|
||||
await transact('delete', id);
|
||||
return null;
|
||||
}
|
||||
await transact('put', id, { id, version:1, ownerLogin, ...normalized, attachments:list });
|
||||
return list;
|
||||
}
|
||||
|
||||
async function load(target) {
|
||||
if (!transact) return null;
|
||||
const { id, ownerLogin, target:normalized } = identity(target);
|
||||
const record = await transact('get', id);
|
||||
if (record?.version !== 1 || record.ownerLogin !== ownerLogin || record.kind !== normalized.kind ||
|
||||
record.repository !== normalized.repository || Number(record.number) !== normalized.number ||
|
||||
!Array.isArray(record.attachments) || !record.attachments.length) return null;
|
||||
return record.attachments.slice(0, 5).map(attachment);
|
||||
}
|
||||
|
||||
async function remove(target) {
|
||||
if (!transact) return false;
|
||||
const { id } = identity(target);
|
||||
await transact('delete', id);
|
||||
return true;
|
||||
}
|
||||
|
||||
return { save, load, remove };
|
||||
};
|
||||
});
|
||||
@@ -12,8 +12,49 @@
|
||||
const activityList = root.document.getElementById('security-activity-list');
|
||||
const activityStatus = root.document.getElementById('security-activity-status');
|
||||
const loadMoreActivity = root.document.getElementById('load-more-security-activity');
|
||||
const sectionButtons = Object.fromEntries(['activity', 'devices', 'passkeys'].map(section => [
|
||||
section, root.document.getElementById(`security-section-${section}`),
|
||||
]));
|
||||
const sections = Object.fromEntries(['activity', 'devices', 'passkeys'].map(section => [
|
||||
section, root.document.getElementById(`security-${section}-section`),
|
||||
]));
|
||||
let activityCursor = null;
|
||||
|
||||
const navigate = (section, { history = true } = {}) => {
|
||||
if (!sections[section]) section = 'activity';
|
||||
Object.entries(sectionButtons).forEach(([name, button]) => {
|
||||
if (!button) return;
|
||||
if (name === section) button.setAttribute('aria-current', 'page');
|
||||
else button.removeAttribute('aria-current');
|
||||
});
|
||||
sections[section]?.scrollIntoView?.({ block: 'start' });
|
||||
if (history && root.history?.pushState) {
|
||||
root.history.pushState(
|
||||
{ ...(root.history.state || {}), stackchainSecuritySection: section },
|
||||
'',
|
||||
`${root.location.pathname}${root.location.search || ''}#security/${section}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Object.entries(sectionButtons).forEach(([section, button]) => {
|
||||
button?.addEventListener('click', () => navigate(section));
|
||||
});
|
||||
root.document.getElementById('close-active-devices')?.addEventListener('click', () => {
|
||||
if (root.history?.state?.stackchainSecuritySection) root.history.back();
|
||||
});
|
||||
root.addEventListener?.('popstate', event => {
|
||||
const section = event.state?.stackchainSecuritySection;
|
||||
if (section && !devicesSheet.hidden) {
|
||||
navigate(section, { history: false });
|
||||
return;
|
||||
}
|
||||
if (!devicesSheet.hidden) {
|
||||
devicesSheet.hidden = true;
|
||||
devicesButton?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
const renderDevices = async () => {
|
||||
devicesStatus.textContent = 'Loading active devices…';
|
||||
devicesList.replaceChildren();
|
||||
@@ -40,7 +81,9 @@
|
||||
revoke.textContent = device.current ? 'Sign out' : 'Revoke';
|
||||
revoke.addEventListener('click', async () => {
|
||||
if (device.current) await boundary.signOut();
|
||||
else if (await boundary.revokeActiveDevice(device)) await renderDevices();
|
||||
else if (await boundary.revokeActiveDevice(device)) {
|
||||
await Promise.all([renderDevices(), renderSecurityActivity()]);
|
||||
}
|
||||
});
|
||||
row.append(details, revoke);
|
||||
devicesList.append(row);
|
||||
@@ -82,6 +125,7 @@
|
||||
: 'Passkey removed.';
|
||||
await renderPasskeys();
|
||||
if (outcome.session_revoked) await renderDevices();
|
||||
await renderSecurityActivity();
|
||||
} else {
|
||||
remove.disabled = false;
|
||||
passkeysStatus.textContent = `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`;
|
||||
@@ -122,7 +166,11 @@
|
||||
const details = root.document.createElement('span');
|
||||
details.className = 'small muted';
|
||||
details.textContent = formatted.detail;
|
||||
row.append(title, details);
|
||||
const action = root.document.createElement('button');
|
||||
action.type = 'button';
|
||||
action.textContent = 'Review devices';
|
||||
action.addEventListener('click', () => navigate('devices'));
|
||||
row.append(title, details, action);
|
||||
activityList.append(row);
|
||||
});
|
||||
}
|
||||
@@ -150,6 +198,13 @@
|
||||
.filter(value => typeof value === 'string' && value).join(' · ');
|
||||
details.textContent = `${new Date(event.created_at * 1000).toLocaleString()}${context ? ' · ' + context : ''}`;
|
||||
row.append(title, details);
|
||||
if (event.kind === 'passkey_counter_anomaly') {
|
||||
const action = root.document.createElement('button');
|
||||
action.type = 'button';
|
||||
action.textContent = 'Review passkeys';
|
||||
action.addEventListener('click', () => navigate('passkeys'));
|
||||
row.append(action);
|
||||
}
|
||||
activityList.append(row);
|
||||
});
|
||||
activityCursor = page.next_cursor;
|
||||
@@ -169,6 +224,7 @@
|
||||
if (!devicesSheet) return;
|
||||
devicesSheet.hidden = false;
|
||||
root.document.getElementById('close-active-devices')?.focus();
|
||||
navigate('activity', { history: root.history?.state?.stackchainSecuritySection !== 'activity' });
|
||||
return Promise.all([renderDevices(), renderPasskeys(), renderSecurityActivity()]);
|
||||
};
|
||||
devicesButton?.addEventListener('click', open);
|
||||
@@ -186,5 +242,5 @@
|
||||
enrollPasskey.disabled = false;
|
||||
}
|
||||
});
|
||||
return { open, renderDevices, renderPasskeys, renderSecurityActivity };
|
||||
return { open, navigate, renderDevices, renderPasskeys, renderSecurityActivity };
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/private-data-registry.js');
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v108';
|
||||
const CACHE = 'stackchain-dashboard-shell-v117';
|
||||
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;
|
||||
@@ -16,12 +16,16 @@ const SHELL = [
|
||||
BASE + 'static/icons/stackchain-512.png',
|
||||
BASE + 'static/session.js',
|
||||
BASE + 'static/feature-loader.js',
|
||||
BASE + 'static/workspace-bootstrap.js',
|
||||
BASE + 'static/conversation-action-hydrator.js',
|
||||
BASE + 'static/security-center.js',
|
||||
BASE + 'static/markdown.js',
|
||||
BASE + 'static/commands.js',
|
||||
BASE + 'static/saved-searches.js',
|
||||
BASE + 'static/search-preview.js',
|
||||
BASE + 'static/search-reply-draft-store.js',
|
||||
BASE + 'static/conversation-reply-draft-store.js',
|
||||
BASE + 'static/conversation-photo-drafts.js',
|
||||
BASE + 'static/search-defer.js',
|
||||
BASE + 'static/widgets.js',
|
||||
BASE + 'static/drafts.js',
|
||||
@@ -48,6 +52,8 @@ const SHELL = [
|
||||
BASE + 'static/today-work.js',
|
||||
BASE + 'static/today-timer.js',
|
||||
BASE + 'static/today-recap.js',
|
||||
BASE + 'static/today-wrap-up.js',
|
||||
BASE + 'static/today-handoff.js',
|
||||
BASE + 'static/today-completion.js',
|
||||
BASE + 'static/today-readiness.js',
|
||||
BASE + 'static/comment-next.js',
|
||||
@@ -74,10 +80,19 @@ const SHELL = [
|
||||
BASE + 'static/issue-attachment.js',
|
||||
BASE + 'static/issue-filing-review.js',
|
||||
BASE + 'static/issue-sheet.js',
|
||||
BASE + 'static/mobile-issue-detail-nav.js',
|
||||
BASE + 'static/mobile-update-detail-nav.js',
|
||||
BASE + 'static/mobile-review-detail-nav.js',
|
||||
BASE + 'static/mobile-search-preview-nav.js',
|
||||
BASE + 'static/mobile-plan-today-nav.js',
|
||||
BASE + 'static/mobile-find-work-nav.js',
|
||||
BASE + 'static/mobile-pull-refresh.js',
|
||||
BASE + 'static/checklist-conflict.js',
|
||||
BASE + 'static/voice-transcript-store.js',
|
||||
BASE + 'static/voice-issue-capture.js',
|
||||
BASE + 'static/voice-conversation-capture.js',
|
||||
BASE + 'static/create-issue-sheet.js',
|
||||
BASE + 'static/mobile-create-issue-nav.js',
|
||||
BASE + 'static/create-and-start.js',
|
||||
BASE + 'static/assign-and-start.js',
|
||||
BASE + 'static/filed-claim.js',
|
||||
@@ -91,6 +106,7 @@ const SHELL = [
|
||||
BASE + 'static/mobile-task-dock.js',
|
||||
BASE + 'static/mobile-work-entry.js',
|
||||
BASE + 'static/mobile-queue-launcher.js',
|
||||
BASE + 'static/mobile-delivery-recovery.js',
|
||||
BASE + 'static/mobile-start-day.js',
|
||||
BASE + 'static/update-triage-session.js',
|
||||
BASE + 'static/update-review-handoff.js',
|
||||
@@ -101,8 +117,12 @@ const SHELL = [
|
||||
BASE + 'static/update-decision-transaction.js',
|
||||
BASE + 'static/agenda-session-launcher.js',
|
||||
BASE + 'static/mobile-launch.js',
|
||||
BASE + 'static/mobile-insights.js',
|
||||
BASE + 'static/mobile-app-shortcuts.js',
|
||||
BASE + 'static/install-app.js',
|
||||
BASE + 'static/private-data-inventory.js',
|
||||
BASE + 'static/private-device-data.js',
|
||||
BASE + 'static/device-storage.js',
|
||||
BASE + 'static/mobile-device-setup.js',
|
||||
BASE + 'static/mobile-search-viewport.js',
|
||||
BASE + 'static/mobile-composer-viewport.js',
|
||||
@@ -356,15 +376,40 @@ self.addEventListener('install', event => {
|
||||
event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL)).then(() => self.skipWaiting()));
|
||||
});
|
||||
|
||||
async function warmOptionalFeature(cache, staleCacheNames, asset) {
|
||||
for (const cacheName of staleCacheNames) {
|
||||
const staleCache = await caches.open(cacheName);
|
||||
const cached = await staleCache.match(asset);
|
||||
if (cached) {
|
||||
await cache.put(asset, cached);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await cache.add(asset);
|
||||
}
|
||||
|
||||
async function cachedOptionalFeature(request) {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(CACHE);
|
||||
await cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil((async () => {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE)
|
||||
.map(key => caches.delete(key))
|
||||
const staleCacheNames = keys.filter(
|
||||
key => key.startsWith('stackchain-dashboard-') && key !== CACHE
|
||||
);
|
||||
const cache = await caches.open(CACHE);
|
||||
await Promise.allSettled(OPTIONAL_FEATURES.map(asset => cache.add(asset)));
|
||||
await Promise.allSettled(
|
||||
OPTIONAL_FEATURES.map(asset => warmOptionalFeature(cache, staleCacheNames, asset))
|
||||
);
|
||||
await Promise.all(staleCacheNames.map(key => caches.delete(key)));
|
||||
await self.clients.claim();
|
||||
})());
|
||||
});
|
||||
@@ -606,6 +651,6 @@ self.addEventListener('fetch', event => {
|
||||
return;
|
||||
}
|
||||
if (url.origin === self.location.origin && OPTIONAL_FEATURES.includes(url.pathname)) {
|
||||
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
|
||||
event.respondWith(cachedOptionalFeature(request));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createTodayHandoff = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createTodayHandoff(options) {
|
||||
const prefix = 'stackchain.today-handoff.v1.';
|
||||
const storage = options.storage || null;
|
||||
const selected = new Set();
|
||||
const excluded = new Set();
|
||||
|
||||
function key() {
|
||||
const login = String(options.getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? prefix + encodeURIComponent(login) : '';
|
||||
}
|
||||
|
||||
function pending() {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return [];
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(storageKey) || '[]');
|
||||
return Array.isArray(value) ? value.filter(id => typeof id === 'string' && id) : [];
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function write(ids) {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const unique = [...new Set(ids.filter(id => typeof id === 'string' && id))];
|
||||
try {
|
||||
if (unique.length) storage.setItem(storageKey, JSON.stringify(unique));
|
||||
else storage.removeItem(storageKey);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function capture(ids) {
|
||||
const merged = [...pending()];
|
||||
(ids || []).forEach(id => {
|
||||
if (typeof id === 'string' && id && !merged.includes(id)) merged.push(id);
|
||||
if (typeof id === 'string' && id) {
|
||||
selected.add(id);
|
||||
excluded.delete(id);
|
||||
}
|
||||
});
|
||||
return write(merged);
|
||||
}
|
||||
|
||||
function review(items) {
|
||||
const byIdentity = new Map((items || options.items?.() || []).map(item => [options.identity(item), item]));
|
||||
return pending().flatMap(identity => {
|
||||
const item = byIdentity.get(identity);
|
||||
if (!excluded.has(identity)) selected.add(identity);
|
||||
return item ? [{ identity, item, selected:selected.has(identity) }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function choose(identity, include) {
|
||||
if (!pending().includes(identity)) return false;
|
||||
if (include) {
|
||||
selected.add(identity);
|
||||
excluded.delete(identity);
|
||||
} else {
|
||||
selected.delete(identity);
|
||||
excluded.add(identity);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function render(items, list, escapeHtml) {
|
||||
const rows = review(items);
|
||||
list.innerHTML = rows.map(row => {
|
||||
const title = String(row.item.title || row.identity).slice(0, 180);
|
||||
const context = String(row.item.key || row.item.repository || 'Work item').slice(0, 180);
|
||||
return '<div class="today-handoff-item"><span><strong>' + escapeHtml(title) + '</strong>' +
|
||||
'<span class="small muted">' + escapeHtml(context) + '</span></span><label><input type="checkbox" ' +
|
||||
'data-today-handoff="' + escapeHtml(row.identity) + '"' + (row.selected ? ' checked' : '') +
|
||||
'> Today</label></div>';
|
||||
}).join('');
|
||||
list.querySelectorAll('[data-today-handoff]').forEach(input => input.addEventListener('change', () =>
|
||||
choose(input.dataset.todayHandoff, input.checked)));
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function commit(items) {
|
||||
const rows = review(items);
|
||||
const remaining = pending();
|
||||
let scheduled = 0;
|
||||
let blocked = 0;
|
||||
for (const row of rows) {
|
||||
if (!selected.has(row.identity)) continue;
|
||||
if (options.todayWork.contains(row.item)) {
|
||||
const index = remaining.indexOf(row.identity);
|
||||
if (index >= 0) remaining.splice(index, 1);
|
||||
selected.delete(row.identity);
|
||||
continue;
|
||||
}
|
||||
const added = options.todayWork.add(row.item);
|
||||
if (added !== 'added') {
|
||||
blocked += 1;
|
||||
continue;
|
||||
}
|
||||
if (!options.todaySync.enqueue('add', row.identity)) {
|
||||
options.todayWork.remove?.(row.item);
|
||||
blocked += 1;
|
||||
continue;
|
||||
}
|
||||
const index = remaining.indexOf(row.identity);
|
||||
if (index >= 0) remaining.splice(index, 1);
|
||||
selected.delete(row.identity);
|
||||
scheduled += 1;
|
||||
}
|
||||
write(remaining);
|
||||
if (scheduled) await options.todaySync.flush();
|
||||
return { scheduled, blocked, remaining:remaining.length };
|
||||
}
|
||||
|
||||
function mount({ qs, escapeHtml, onComplete = () => {} }) {
|
||||
const list = qs('#today-handoff-items');
|
||||
let prompted = false;
|
||||
function open() {
|
||||
if (prompted) return false;
|
||||
const rows = render(null, list, escapeHtml);
|
||||
if (!rows.length) return false;
|
||||
qs('#today-handoff-status').textContent = rows.length +
|
||||
(rows.length === 1 ? ' commitment is ready to review.' : ' commitments are ready to review.');
|
||||
const dialog = qs('#today-handoff-dialog');
|
||||
if (!dialog.open) dialog.showModal();
|
||||
prompted = true;
|
||||
requestAnimationFrame(() => (list.querySelector('input') || qs('#confirm-today-handoff')).focus());
|
||||
return true;
|
||||
}
|
||||
qs('#close-today-handoff').addEventListener('click', () => qs('#today-handoff-dialog').close());
|
||||
qs('#confirm-today-handoff').addEventListener('click', async event => {
|
||||
const button = event.currentTarget;
|
||||
button.disabled = true;
|
||||
qs('#today-handoff-status').textContent = 'Saving Today’s plan…';
|
||||
try {
|
||||
const result = await commit();
|
||||
if (result.blocked) qs('#today-handoff-status').textContent =
|
||||
'Today is full. ' + result.remaining + ' commitment remains safe in My Work.';
|
||||
else qs('#today-handoff-dialog').close();
|
||||
onComplete(result);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
return { open, reset:() => { prompted = false; } };
|
||||
}
|
||||
|
||||
return { capture, choose, commit, mount, pending, render, review };
|
||||
});
|
||||
@@ -217,7 +217,7 @@ async function saveTodayRecapTime(payload) {
|
||||
}
|
||||
|
||||
function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, escapeHtml,
|
||||
describeWork = () => null, adjustPlan = () => {} }) {
|
||||
describeWork = () => null, openWrapUp = () => {} }) {
|
||||
const minutes = value => String(Math.max(0, Number(value) || 0)) + 'm';
|
||||
const render = () => {
|
||||
const draft = recap.snapshot();
|
||||
@@ -290,7 +290,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
|
||||
qs('#today-recap-status').textContent = 'Recap saved to your account.';
|
||||
await loadHistory(); render(); button.hidden = true;
|
||||
close();
|
||||
adjustPlan(handoff.actual_minutes);
|
||||
openWrapUp(handoff.actual_minutes);
|
||||
} catch (error) {
|
||||
qs('#today-recap-status').textContent = error.message || 'Recap could not be saved. Your timer is unchanged.';
|
||||
} finally { button.disabled = false; }
|
||||
@@ -310,7 +310,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
|
||||
qs('#today-recap-status').textContent = 'Selected time logged to Gitea.';
|
||||
await loadHistory(); render(); button.hidden = true;
|
||||
close();
|
||||
adjustPlan(actualMinutes);
|
||||
openWrapUp(actualMinutes);
|
||||
} catch (error) {
|
||||
qs('#today-recap-status').textContent = error.message || 'Time could not be logged. Your recap is ready to retry.';
|
||||
render();
|
||||
@@ -345,8 +345,8 @@ function openTodayRecapAfterSession(view, timer, timerView, workFilter, qs) {
|
||||
}
|
||||
|
||||
function setupTodayRecap(timer, timerView, todayWork, api, qs, escapeHtml, closeSheets, updateActions, getLogin,
|
||||
describeWork, adjustPlan) {
|
||||
const options = { timer, timerView, todayWork, api, qs, escapeHtml, describeWork, adjustPlan };
|
||||
describeWork, openWrapUp) {
|
||||
const options = { timer, timerView, todayWork, api, qs, escapeHtml, describeWork, openWrapUp };
|
||||
const recap = createTodayRecap({
|
||||
save:saveTodayRecap, saveAndLog:saveTodayRecapTime,
|
||||
clear:() => timer.clearRecap(), storage:localStorage, getLogin,
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
function createTodayWrapUp({ todayWork, laterWork, todaySync }) {
|
||||
let items = [];
|
||||
const selected = new Set();
|
||||
|
||||
function open(candidates) {
|
||||
const current = new Set(todayWork.read());
|
||||
const byIdentity = new Map((candidates || []).map(item => [todayWork.identity(item), item]));
|
||||
items = todayWork.read().flatMap(identity =>
|
||||
current.has(identity) && byIdentity.has(identity) ? [byIdentity.get(identity)] : []
|
||||
);
|
||||
selected.clear();
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function choose(identity, schedule) {
|
||||
if (!items.some(item => todayWork.identity(item) === identity)) return false;
|
||||
if (schedule) selected.add(identity);
|
||||
else selected.delete(identity);
|
||||
return true;
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return items.map(item => ({
|
||||
item,
|
||||
identity:todayWork.identity(item),
|
||||
schedule:selected.has(todayWork.identity(item)),
|
||||
}));
|
||||
}
|
||||
|
||||
async function finish() {
|
||||
const wake = laterWork.presetUntil('tomorrow');
|
||||
let scheduled = 0;
|
||||
for (const item of items) {
|
||||
const identity = todayWork.identity(item);
|
||||
if (!selected.has(identity) || !todayWork.contains(item)) continue;
|
||||
const deferred = laterWork.defer(item, wake, {handoff:'today'});
|
||||
if (deferred !== 'deferred') throw new Error('Tomorrow could not be saved. Your Today plan is unchanged.');
|
||||
if (!todaySync.enqueue('remove', identity)) {
|
||||
laterWork.restore?.(item);
|
||||
throw new Error('Today sync could not be queued. Your Today plan is unchanged.');
|
||||
}
|
||||
if (todayWork.contains(item) && !todayWork.remove(item)) {
|
||||
throw new Error('Today could not be updated. Retry wrap-up.');
|
||||
}
|
||||
scheduled += 1;
|
||||
}
|
||||
await todaySync.flush();
|
||||
return { scheduled, left:items.length - scheduled, wake_at:wake.toISOString() };
|
||||
}
|
||||
|
||||
return { open, choose, snapshot, finish };
|
||||
}
|
||||
|
||||
function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () => {}, onClose = () => {} }) {
|
||||
let actualMinutes = {};
|
||||
|
||||
function close() {
|
||||
qs('#today-wrap-up-sheet').hidden = true;
|
||||
document.body.classList.remove('task-overlay-open');
|
||||
onClose();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const rows = controller.snapshot();
|
||||
qs('#today-wrap-up-items').innerHTML = rows.map(row => {
|
||||
const title = String(row.item.title || row.identity).slice(0, 180);
|
||||
const context = String(row.item.key || row.item.repository || 'Work item').slice(0, 180);
|
||||
return '<div class="today-wrap-up-item"><span><strong>' + escapeHtml(title) + '</strong>' +
|
||||
'<span class="small muted">' + escapeHtml(context) + '</span></span><label><input type="checkbox" ' +
|
||||
'data-wrap-up-identity="' + escapeHtml(row.identity) + '"' + (row.schedule ? ' checked' : '') +
|
||||
'> Tomorrow 09:00</label></div>';
|
||||
}).join('') || '<p class="small">Nothing unfinished remains in Today.</p>';
|
||||
qs('#today-wrap-up-items').querySelectorAll('[data-wrap-up-identity]').forEach(input => {
|
||||
input.addEventListener('change', () => controller.choose(input.dataset.wrapUpIdentity, input.checked));
|
||||
});
|
||||
}
|
||||
|
||||
function open(items, recapActualMinutes = {}) {
|
||||
actualMinutes = { ...recapActualMinutes };
|
||||
controller.open(items);
|
||||
qs('#today-wrap-up-status').textContent = '';
|
||||
render();
|
||||
qs('#today-wrap-up-sheet').hidden = false;
|
||||
document.body.classList.add('task-overlay-open');
|
||||
requestAnimationFrame(() => (qs('#today-wrap-up-items input') || qs('#finish-today-wrap-up')).focus());
|
||||
}
|
||||
|
||||
async function finish(button) {
|
||||
button.disabled = true;
|
||||
qs('#today-wrap-up-status').textContent = 'Saving tomorrow’s plan…';
|
||||
try {
|
||||
const result = await controller.finish();
|
||||
qs('#my-work-action-status').textContent = result.scheduled + ' scheduled for tomorrow · ' + result.left + ' left in Today.';
|
||||
close();
|
||||
onComplete(result, actualMinutes);
|
||||
} catch (error) {
|
||||
qs('#today-wrap-up-status').textContent = error.message || 'Wrap-up could not be saved. Retry when ready.';
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function bind() {
|
||||
qs('#close-today-wrap-up').addEventListener('click', close);
|
||||
qs('#finish-today-wrap-up').addEventListener('click', event => finish(event.currentTarget));
|
||||
}
|
||||
|
||||
return { open, close, finish, render, bind };
|
||||
}
|
||||
|
||||
function setupTodayWrapUp(options) {
|
||||
const controller = createTodayWrapUp(options);
|
||||
const view = createTodayWrapUpView({ ...options, controller });
|
||||
view.bind();
|
||||
return view;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
createTodayWrapUp.createView = createTodayWrapUpView;
|
||||
createTodayWrapUp.setup = setupTodayWrapUp;
|
||||
module.exports = createTodayWrapUp;
|
||||
}
|
||||
@@ -18,6 +18,14 @@ function unfiledDraftSummary(item) {
|
||||
};
|
||||
}
|
||||
|
||||
function unfiledDraftDisplayTitle(item) {
|
||||
return String(item?.title || '').trim() || (item?.hasAttachment ? 'Untitled photo draft' : 'Untitled draft');
|
||||
}
|
||||
|
||||
function unfiledShouldFocusTitle({mobile = false, continuing = false} = {}) {
|
||||
return !mobile || continuing;
|
||||
}
|
||||
|
||||
function unfiledSavedMessage(item) {
|
||||
return item.repository ? 'Saved planned Draft. Resume on any signed-in device.' :
|
||||
'Saved to Drafts. Choose a repository when you’re ready to file it.';
|
||||
@@ -49,7 +57,8 @@ function createUnfiledCaptures({
|
||||
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
|
||||
return record.items.filter(item =>
|
||||
item && typeof item.id === 'string' && typeof item.ownerLogin === 'string' &&
|
||||
typeof item.title === 'string' && item.title.trim() && typeof item.body === 'string'
|
||||
typeof item.title === 'string' && (item.title.trim() || item.hasAttachment === true) &&
|
||||
typeof item.body === 'string'
|
||||
);
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
@@ -122,9 +131,6 @@ function createUnfiledCaptures({
|
||||
function prepare(note) {
|
||||
const title = String(note?.title || '').trim().slice(0, 255);
|
||||
const body = String(note?.body || '').trim().slice(0, 10000);
|
||||
if (!title) throw new Error('Add a title before saving.');
|
||||
const ownerLogin = String(getCaptureLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.');
|
||||
const attachment = note?.attachment;
|
||||
const attachments = note?.attachments;
|
||||
if (Array.isArray(attachments) && attachments.length > 5) {
|
||||
@@ -139,6 +145,9 @@ function createUnfiledCaptures({
|
||||
const hasAttachment = Boolean(attachment?.blob && attachment?.filename &&
|
||||
['image/png', 'image/jpeg', 'image/webp'].includes(String(attachment?.contentType || '')));
|
||||
if (attachment && !hasAttachment) throw new Error('The screenshot is unavailable. Choose it again before saving.');
|
||||
if (!title && !hasAttachment && !validAttachments) throw new Error('Add a title or photo before saving.');
|
||||
const ownerLogin = String(getCaptureLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.');
|
||||
if ((hasAttachment || validAttachments) && !attachmentStore) {
|
||||
throw new Error('Screenshot storage is unavailable. Your capture is still open; retry after reloading.');
|
||||
}
|
||||
@@ -344,7 +353,7 @@ function createUnfiledCaptures({
|
||||
} : {}),
|
||||
...(evidence.length ? {hasAttachment:true, attachmentCount:evidence.length} : {}),
|
||||
};
|
||||
if (!item.id || !item.title.trim() || !Number.isFinite(item.savedAt)) continue;
|
||||
if (!item.id || (!item.title.trim() && !evidence.length) || !Number.isFinite(item.savedAt)) continue;
|
||||
if (evidence.length) {
|
||||
await attachmentStore.put(item.id, {attachments:evidence.map(entry => ({
|
||||
filename:String(entry.filename), contentType:String(entry.content_type),
|
||||
@@ -382,7 +391,7 @@ function createUnfiledCaptures({
|
||||
} : {}),
|
||||
...(evidence.length ? {hasAttachment:true, attachmentCount:evidence.length} : {}),
|
||||
};
|
||||
if (!item.id || !item.title.trim() || !Number.isFinite(item.savedAt)) continue;
|
||||
if (!item.id || (!item.title.trim() && !evidence.length) || !Number.isFinite(item.savedAt)) continue;
|
||||
if (evidence.length) {
|
||||
stagedEvidence.set(item.id, {attachments:evidence.map(entry => ({
|
||||
filename:String(entry.filename), contentType:String(entry.content_type),
|
||||
@@ -422,4 +431,6 @@ function createUnfiledCaptures({
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = createUnfiledCaptures;
|
||||
module.exports.summary = unfiledDraftSummary;
|
||||
module.exports.displayTitle = unfiledDraftDisplayTitle;
|
||||
module.exports.shouldFocusTitle = unfiledShouldFocusTitle;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
function createVoiceConversationCapture({
|
||||
Recognition, elements, transcriptStore = null, getLogin = () => '',
|
||||
createEvent = name => new Event(name, {bubbles:true}),
|
||||
}) {
|
||||
const supported = typeof Recognition === 'function';
|
||||
let recognition = null;
|
||||
let target = '';
|
||||
let generation = 0;
|
||||
elements.root.hidden = !supported;
|
||||
if (!supported) {
|
||||
elements.status.textContent = 'Voice capture is unavailable; type the reply instead.';
|
||||
}
|
||||
|
||||
const normalize = value => String(value || '').replace(/\s+/g, ' ').trim();
|
||||
const login = () => String(getLogin() || '').trim();
|
||||
|
||||
function showReview(value, recovered = false) {
|
||||
const transcript = normalize(value).slice(0, 10000);
|
||||
elements.transcript.value = transcript;
|
||||
elements.review.hidden = false;
|
||||
elements.append.textContent = elements.draft.value.trim() ? 'Append to draft' : 'Use transcript';
|
||||
elements.replace.hidden = !elements.draft.value.trim();
|
||||
elements.status.textContent = recovered ?
|
||||
'Recovered transcript. Review it before using or discarding it.' :
|
||||
'Transcript ready. Review it before using it.';
|
||||
if (!recovered && transcriptStore && login() && target) {
|
||||
void transcriptStore.save(login(), transcript, target);
|
||||
}
|
||||
}
|
||||
|
||||
async function open(nextTarget) {
|
||||
cancel();
|
||||
target = String(nextTarget || '').trim();
|
||||
const opening = ++generation;
|
||||
elements.transcript.value = '';
|
||||
elements.review.hidden = true;
|
||||
elements.status.textContent = '';
|
||||
if (!supported || !target || !transcriptStore || !login()) return;
|
||||
const recovered = normalize(await transcriptStore.load(login(), target)).slice(0, 10000);
|
||||
if (opening === generation && recovered) showReview(recovered, true);
|
||||
}
|
||||
|
||||
function clearCheckpoint() {
|
||||
if (transcriptStore && login() && target) void transcriptStore.clear(login(), target);
|
||||
}
|
||||
|
||||
function commit(replace) {
|
||||
const transcript = normalize(elements.transcript.value).slice(0, 10000);
|
||||
elements.draft.value = replace ? transcript :
|
||||
[elements.draft.value.trim(), transcript].filter(Boolean).join('\n\n').slice(0, 10000);
|
||||
elements.draft.dispatchEvent(createEvent('input'));
|
||||
clearCheckpoint();
|
||||
elements.review.hidden = true;
|
||||
elements.status.textContent = 'Transcript added. Review the reply before sending.';
|
||||
}
|
||||
|
||||
elements.start.addEventListener('click', () => {
|
||||
if (!supported || !target) return;
|
||||
const sessionGeneration = generation;
|
||||
const currentRecognition = new Recognition();
|
||||
recognition = currentRecognition;
|
||||
recognition.continuous = true;
|
||||
recognition.interimResults = false;
|
||||
recognition.onresult = event => {
|
||||
if (recognition !== currentRecognition || generation !== sessionGeneration) return;
|
||||
const finalText = Array.from(event.results || []).slice(event.resultIndex ?? 0)
|
||||
.filter(result => result.isFinal)
|
||||
.map(result => result[0]?.transcript || '').join(' ');
|
||||
if (normalize(finalText)) {
|
||||
showReview([elements.transcript.value, finalText].filter(Boolean).join(' '));
|
||||
}
|
||||
};
|
||||
recognition.onerror = event => {
|
||||
if (recognition !== currentRecognition || generation !== sessionGeneration) return;
|
||||
elements.start.hidden = false;
|
||||
elements.stop.hidden = true;
|
||||
elements.status.textContent = ['not-allowed', 'service-not-allowed'].includes(event.error) ?
|
||||
'Microphone permission was denied. Your draft and transcript are unchanged.' :
|
||||
'Voice capture stopped. Your draft and transcript are unchanged.';
|
||||
};
|
||||
recognition.onend = () => {
|
||||
if (recognition !== currentRecognition || generation !== sessionGeneration) return;
|
||||
recognition = null;
|
||||
elements.start.hidden = false;
|
||||
elements.stop.hidden = true;
|
||||
};
|
||||
try {
|
||||
recognition.start();
|
||||
} catch (_) {
|
||||
recognition = null;
|
||||
elements.start.hidden = false;
|
||||
elements.stop.hidden = true;
|
||||
elements.status.textContent = 'Voice capture could not start. Your draft and transcript are unchanged.';
|
||||
return;
|
||||
}
|
||||
elements.start.hidden = true;
|
||||
elements.stop.hidden = false;
|
||||
elements.status.textContent = 'Listening… Tap stop when you are finished.';
|
||||
});
|
||||
elements.stop.addEventListener('click', () => {
|
||||
recognition?.stop();
|
||||
elements.status.textContent = 'Finishing transcript…';
|
||||
});
|
||||
elements.append.addEventListener('click', () => commit(false));
|
||||
elements.replace.addEventListener('click', () => commit(true));
|
||||
elements.discard.addEventListener('click', () => {
|
||||
clearCheckpoint();
|
||||
elements.transcript.value = '';
|
||||
elements.review.hidden = true;
|
||||
elements.status.textContent = 'Transcript discarded.';
|
||||
});
|
||||
|
||||
function cancel() {
|
||||
generation += 1;
|
||||
recognition?.abort();
|
||||
recognition = null;
|
||||
elements.start.hidden = false;
|
||||
elements.stop.hidden = true;
|
||||
}
|
||||
|
||||
return {supported, open, cancel};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined') module.exports = {createVoiceConversationCapture};
|
||||
@@ -43,6 +43,7 @@ function createVoiceIssueCapture({
|
||||
recognition.interimResults = false;
|
||||
recognition.onresult = event => {
|
||||
const finalText = Array.from(event.results || [])
|
||||
.slice(event.resultIndex ?? 0)
|
||||
.filter(result => result.isFinal)
|
||||
.map(result => result[0]?.transcript || '').join(' ').replace(/\s+/g, ' ').trim();
|
||||
if (finalText) {
|
||||
|
||||
@@ -58,23 +58,29 @@
|
||||
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, MAX_TRANSCRIPT_LENGTH);
|
||||
}
|
||||
|
||||
function storageKey(login, target = '') {
|
||||
const account = normalizeLogin(login);
|
||||
const scope = String(target || '').trim().toLowerCase();
|
||||
return account && scope ? account + '\u0000' + scope : account;
|
||||
}
|
||||
|
||||
function createVoiceTranscriptStore({transaction = createIndexedDbTransaction(defaultIndexedDB)} = {}) {
|
||||
async function load(login) {
|
||||
const key = normalizeLogin(login);
|
||||
async function load(login, target = '') {
|
||||
const key = storageKey(login, target);
|
||||
if (!key || !transaction) return '';
|
||||
const record = await transaction(store => store.get(key));
|
||||
return normalizeTranscript(record?.transcript);
|
||||
}
|
||||
|
||||
async function save(login, transcript) {
|
||||
const key = normalizeLogin(login);
|
||||
async function save(login, transcript, target = '') {
|
||||
const key = storageKey(login, target);
|
||||
const value = normalizeTranscript(transcript);
|
||||
if (!key || !value || !transaction) return;
|
||||
await transaction(store => store.put({login:key, transcript:value}), 'readwrite');
|
||||
}
|
||||
|
||||
async function clear(login) {
|
||||
const key = normalizeLogin(login);
|
||||
async function clear(login, target = '') {
|
||||
const key = storageKey(login, target);
|
||||
if (!key || !transaction) return;
|
||||
await transaction(store => store.delete(key), 'readwrite');
|
||||
}
|
||||
|
||||
+43
-9
@@ -7,6 +7,13 @@
|
||||
|
||||
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
|
||||
const queueFilters = ['today', 'agenda', 'attention', 'filed', 'update', 'later', 'draft'];
|
||||
const sections = {
|
||||
issue: ['overview', 'conversation', 'reply', 'actions'],
|
||||
filed: ['overview', 'conversation', 'reply', 'actions'],
|
||||
pull: ['overview', 'conversation', 'reply', 'review'],
|
||||
review: ['overview', 'files', 'feedback', 'history'],
|
||||
update: ['activity', 'conversation', 'context', 'reply'],
|
||||
};
|
||||
|
||||
function positiveInteger(value) {
|
||||
const number = Number(value);
|
||||
@@ -23,27 +30,37 @@
|
||||
if (queue === 'agenda' && parts[3] === 'protect-today' && parts.length === 4) {
|
||||
return { kind: 'queue', filter: 'agenda', action: 'protect-today' };
|
||||
}
|
||||
if (parts[2] === 'update' && parts.length === 4) {
|
||||
if (parts[2] === 'update' && [4, 5].includes(parts.length)) {
|
||||
const notificationId = positiveInteger(parts[3]);
|
||||
return notificationId ? { kind: 'update', notification_id: notificationId } : null;
|
||||
const section = parts[4];
|
||||
if (!notificationId || (section && !sections.update.includes(section))) return null;
|
||||
return { kind: 'update', notification_id: notificationId, ...(section ? { section } : {}) };
|
||||
}
|
||||
if (!['issue', 'filed', 'pull', 'review'].includes(parts[2]) || parts.length !== 6) return null;
|
||||
if (!['issue', 'filed', 'pull', 'review'].includes(parts[2]) || ![6, 7].includes(parts.length)) return null;
|
||||
if (!repositoryPart.test(parts[3]) || !repositoryPart.test(parts[4])) return null;
|
||||
const number = positiveInteger(parts[5]);
|
||||
return number ? { kind: parts[2], repository: parts[3] + '/' + parts[4], number } : null;
|
||||
const section = parts[6];
|
||||
if (!number || (section && !sections[parts[2]].includes(section))) return null;
|
||||
return {
|
||||
kind: parts[2], repository: parts[3] + '/' + parts[4], number,
|
||||
...(section ? { section } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function serialize(item) {
|
||||
if (item?.kind === 'update') {
|
||||
const notificationId = positiveInteger(item.notification_id);
|
||||
return notificationId ? '#/my-work/update/' + notificationId : '';
|
||||
if (!notificationId || (item.section && !sections.update.includes(item.section))) return '';
|
||||
return '#/my-work/update/' + notificationId + (item.section ? '/' + item.section : '');
|
||||
}
|
||||
const kind = item?.is_filed && !item?.is_assigned ? 'filed' : item?.kind;
|
||||
if (!['issue', 'filed', 'pull', 'review'].includes(kind)) return '';
|
||||
const repository = String(item.repository || '').split('/');
|
||||
const number = positiveInteger(item.number);
|
||||
if (repository.length !== 2 || !repository.every(part => repositoryPart.test(part)) || !number) return '';
|
||||
return '#/my-work/' + kind + '/' + repository.join('/') + '/' + number;
|
||||
if (item.section && !sections[kind].includes(item.section)) return '';
|
||||
return '#/my-work/' + kind + '/' + repository.join('/') + '/' + number +
|
||||
(item.section ? '/' + item.section : '');
|
||||
}
|
||||
|
||||
function sameRoute(item, route) {
|
||||
@@ -59,7 +76,7 @@
|
||||
|
||||
function createController({
|
||||
location, history, eventTarget, onOpen, onClose, onInvalid,
|
||||
onQueue = function () {},
|
||||
onQueue = function () {}, onSection = function () {},
|
||||
resolve, onResolving = function () {}, onError = function () {},
|
||||
}) {
|
||||
let items = [];
|
||||
@@ -86,7 +103,7 @@
|
||||
return;
|
||||
}
|
||||
active = fragment;
|
||||
onOpen({ ...item, kind: route.kind });
|
||||
onOpen({ ...item, kind: route.kind, ...(route.section ? { section: route.section } : {}) });
|
||||
}).catch(error => {
|
||||
if (request !== resolution || String(location.hash || '') !== fragment) return;
|
||||
resolving = '';
|
||||
@@ -128,10 +145,16 @@
|
||||
return;
|
||||
}
|
||||
if (active === fragment) return;
|
||||
const activeRoute = parse(active);
|
||||
if (activeRoute && sameRoute(item, activeRoute)) {
|
||||
active = fragment;
|
||||
onSection(route.section || sections[route.kind][0], { restore: true });
|
||||
return;
|
||||
}
|
||||
resolution += 1;
|
||||
resolving = '';
|
||||
active = fragment;
|
||||
onOpen({ ...item, kind: route.kind });
|
||||
onOpen({ ...item, kind: route.kind, ...(route.section ? { section: route.section } : {}) });
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -156,6 +179,17 @@
|
||||
onOpen(item);
|
||||
return true;
|
||||
},
|
||||
section(name, options = {}) {
|
||||
const route = parse(location.hash);
|
||||
if (!route || route.kind === 'queue' || !sections[route.kind].includes(name)) return false;
|
||||
if ((route.section || sections[route.kind][0]) === name) return true;
|
||||
const fragment = serialize({ ...route, section: name });
|
||||
const method = options.replace ? 'replaceState' : 'pushState';
|
||||
history[method]({ workRoute: fragment }, '', fragment);
|
||||
active = fragment;
|
||||
onSection(name, { restore: false });
|
||||
return true;
|
||||
},
|
||||
queue(filter) {
|
||||
if (!queueFilters.includes(filter)) return false;
|
||||
const name = filter + (['update', 'draft'].includes(filter) ? 's' : '');
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
async function loadWorkspace({ document, window = null, createLoader = createFeatureLoader }) {
|
||||
let cameOnline = false;
|
||||
let replayed = false;
|
||||
const captureOnline = () => { cameOnline = true; };
|
||||
window?.addEventListener('online', captureOnline);
|
||||
const status = document.querySelector('#my-work-action-status');
|
||||
const url = document.querySelector(
|
||||
'meta[name="stackchain-feature-today-timer"]'
|
||||
)?.content || '';
|
||||
const loader = createLoader({
|
||||
document,
|
||||
urls: { 'today-timer': url },
|
||||
});
|
||||
if (status) status.textContent = 'Starting workspace…';
|
||||
try {
|
||||
await loader.load('today-timer');
|
||||
if (status) status.textContent = '';
|
||||
return {
|
||||
replayOnline(callback) {
|
||||
if (replayed) return;
|
||||
replayed = true;
|
||||
window?.removeEventListener('online', captureOnline);
|
||||
if (cameOnline) callback();
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
window?.removeEventListener('online', captureOnline);
|
||||
if (window?.location?.reload) {
|
||||
if (cameOnline) window.location.reload();
|
||||
else window.addEventListener('online', () => window.location.reload(), { once: true });
|
||||
}
|
||||
if (status) {
|
||||
status.textContent = window
|
||||
? 'Workspace could not load. Reconnect to retry automatically, or reload now.'
|
||||
: 'Workspace could not load. Check your connection, then reload to retry.';
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = loadWorkspace;
|
||||
@@ -0,0 +1 @@
|
||||
pip-audit==2.10.1
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
fastapi==0.133.1
|
||||
httpx==0.28.1
|
||||
pydantic==2.13.4
|
||||
python-multipart==0.0.22
|
||||
Pillow==12.3.0
|
||||
python-multipart==0.0.32
|
||||
pytest==9.1.1
|
||||
pywebpush==2.1.2
|
||||
rjsmin==1.2.5
|
||||
|
||||
+12
-13
@@ -21,22 +21,26 @@ COMMONJS_BROWSER_BRANCH = re.compile(
|
||||
)
|
||||
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
||||
FEATURE_SOURCES = {
|
||||
"comment-actions": ("static/conversation.js", "static/comment-actions.js"),
|
||||
"comment-actions": ("static/comment-actions.js",),
|
||||
"issue-capture": (
|
||||
"static/voice-transcript-store.js", "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/update-follow-up.js", "static/shared-image-capture.js",
|
||||
"static/voice-transcript-store.js", "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/mobile-create-issue-nav.js", "static/update-follow-up.js", "static/shared-image-capture.js",
|
||||
),
|
||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
||||
"push-notifications": ("static/push-notifications.js",),
|
||||
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
||||
"device-setup": (
|
||||
"static/install-app.js", "static/private-data-inventory.js", "static/private-device-data.js",
|
||||
"static/device-storage.js", "static/mobile-device-setup.js",
|
||||
),
|
||||
"security-center": ("static/security-center.js",),
|
||||
"today-timer": (
|
||||
"static/today-completion.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.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/mobile-start-day.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/conversation.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
|
||||
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.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-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.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-wrap-up.js", "static/today-handoff.js",
|
||||
"static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
||||
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.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", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
|
||||
"static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
|
||||
"static/search-batch-plan.js", "static/mention-composer.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
|
||||
"static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/mobile-issue-detail-nav.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
|
||||
),
|
||||
}
|
||||
CACHE_DECLARATION = re.compile(
|
||||
@@ -113,7 +117,6 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||
dashboard_html = dashboard_html.replace("</head>", feature_metadata + "\n</head>")
|
||||
dashboard_html = dashboard_html.replace(
|
||||
"</body>",
|
||||
f'<script src="{feature_bundles["today-timer"].runtime_name}"></script>\n'
|
||||
f'<script src="{core.runtime_name}"></script>\n</body>',
|
||||
)
|
||||
|
||||
@@ -121,15 +124,11 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||
for source in sources:
|
||||
if source != WORKER_RUNTIME_SOURCE:
|
||||
worker = worker.replace(f" BASE + '{source}',\n", "")
|
||||
eager_feature = feature_bundles["today-timer"]
|
||||
optional_features = {
|
||||
name: bundle for name, bundle in feature_bundles.items() if name != "today-timer"
|
||||
}
|
||||
optional_features = feature_bundles
|
||||
worker = worker.replace(
|
||||
" BASE + 'static/dashboard.css',\n",
|
||||
" BASE + 'static/dashboard.css',\n"
|
||||
+ f" BASE + '{core.runtime_name}',\n"
|
||||
+ f" BASE + '{eager_feature.runtime_name}',\n",
|
||||
+ f" BASE + '{core.runtime_name}',\n",
|
||||
)
|
||||
worker = worker.replace(
|
||||
"const OPTIONAL_FEATURES = [\n",
|
||||
|
||||
@@ -1364,6 +1364,32 @@ async def upload_assigned_issue_attachment(
|
||||
return {"name": name, "url": url, "size": size}
|
||||
|
||||
|
||||
async def upload_preview_attachment(
|
||||
repository: str,
|
||||
number: int,
|
||||
filename: str,
|
||||
content_type: str,
|
||||
content: bytes,
|
||||
) -> dict:
|
||||
"""Upload evidence after the caller verifies an exact Search preview target."""
|
||||
response = await _get_client().post(
|
||||
f"/api/v1/repos/{repository}/issues/{number}/assets",
|
||||
headers=_auth(),
|
||||
params={"name": filename},
|
||||
files={"attachment": (filename, content, content_type)},
|
||||
)
|
||||
response.raise_for_status()
|
||||
attachment = response.json()
|
||||
if not isinstance(attachment, dict):
|
||||
raise ValueError("Gitea attachment response was not an object")
|
||||
name = attachment.get("name")
|
||||
url = _safe_web_url(attachment.get("browser_download_url"))
|
||||
size = attachment.get("size")
|
||||
if not isinstance(name, str) or not name or not url or not isinstance(size, int):
|
||||
raise ValueError("Gitea did not confirm the attachment")
|
||||
return {"name": name, "url": url, "size": size}
|
||||
|
||||
|
||||
async def upload_assigned_pull_attachment(
|
||||
repository: str,
|
||||
number: int,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Authoritative image decoding and metadata removal for evidence uploads."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
|
||||
MAX_IMAGE_PIXELS = 12 * 1024 * 1024
|
||||
|
||||
|
||||
def sanitize_image(content_type: str, content: bytes) -> bytes:
|
||||
"""Apply orientation and return metadata-free bytes in the claimed format."""
|
||||
format_names = {
|
||||
"image/png": "PNG",
|
||||
"image/jpeg": "JPEG",
|
||||
"image/webp": "WEBP",
|
||||
}
|
||||
try:
|
||||
with Image.open(io.BytesIO(content)) as source:
|
||||
claimed_format = format_names[content_type]
|
||||
if source.format != claimed_format:
|
||||
raise ValueError("image content does not match the selected image type")
|
||||
if getattr(source, "n_frames", 1) != 1:
|
||||
raise ValueError("animated image evidence is not supported")
|
||||
if source.width * source.height > MAX_IMAGE_PIXELS:
|
||||
raise ValueError("attachment must not exceed 12 megapixels")
|
||||
image = ImageOps.exif_transpose(source)
|
||||
image.load()
|
||||
output = io.BytesIO()
|
||||
save_options = {"quality": 90} if claimed_format in {"JPEG", "WEBP"} else {}
|
||||
image.save(output, format=claimed_format, **save_options)
|
||||
return output.getvalue()
|
||||
except ValueError:
|
||||
raise
|
||||
except (KeyError, OSError) as exc:
|
||||
raise ValueError("attachment must be a valid static image") from exc
|
||||
+8
-2
@@ -131,6 +131,7 @@ class LaterStore:
|
||||
item_id: str,
|
||||
*,
|
||||
wake_at: str | None = None,
|
||||
handoff: str | None = None,
|
||||
base_revision: int | None = None,
|
||||
) -> dict:
|
||||
result = self.apply_batch(login, [{
|
||||
@@ -138,6 +139,7 @@ class LaterStore:
|
||||
"action": action,
|
||||
"item_id": item_id,
|
||||
"wake_at": wake_at,
|
||||
"handoff": handoff,
|
||||
"base_revision": base_revision,
|
||||
}])
|
||||
return {"revision": result["revision"], "records": result["records"]}
|
||||
@@ -181,6 +183,9 @@ class LaterStore:
|
||||
wake_at = operation.get("wake_at")
|
||||
if action == "defer":
|
||||
wake_at = self._validate_wake_at(wake_at)
|
||||
handoff = operation.get("handoff")
|
||||
if handoff not in {None, "today"}:
|
||||
raise ValueError("unsupported Later handoff")
|
||||
duplicate = connection.execute(
|
||||
"SELECT 1 FROM later_operations WHERE login = ? AND operation_id = ?",
|
||||
(login, operation_id),
|
||||
@@ -204,8 +209,9 @@ class LaterStore:
|
||||
|
||||
before = records.get(item_id)
|
||||
if action == "defer":
|
||||
records[item_id] = wake_at
|
||||
changed = before != wake_at
|
||||
record = {"wake_at": wake_at, "handoff": handoff} if handoff else wake_at
|
||||
records[item_id] = record
|
||||
changed = before != record
|
||||
else:
|
||||
changed = item_id in records
|
||||
records.pop(item_id, None)
|
||||
|
||||
+87
-1
@@ -41,6 +41,7 @@ from src.gitea_proxy import (
|
||||
repos,
|
||||
)
|
||||
from src.idempotency import IdempotencyLedger, IdempotencyLedgerBusy
|
||||
from src.image_sanitizer import sanitize_image
|
||||
from src.login_attempt_store import LoginAttemptStore, LoginAttemptStoreError, client_source
|
||||
from src.live_snapshot_store import (
|
||||
LiveSnapshotMetadata,
|
||||
@@ -609,6 +610,7 @@ class LaterOperation(BaseModel):
|
||||
action: Literal["defer", "restore"]
|
||||
item_id: str = Field(min_length=1, max_length=500)
|
||||
wake_at: str | None = Field(default=None, max_length=100)
|
||||
handoff: Literal["today"] | None = None
|
||||
base_revision: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
@@ -697,13 +699,20 @@ class UnfiledDraftFilingPlan(BaseModel):
|
||||
|
||||
class UnfiledDraft(BaseModel):
|
||||
id: str = Field(min_length=1, max_length=100, pattern=r"^[A-Za-z0-9_-]+$")
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
title: str = Field(default="", max_length=255)
|
||||
body: str = Field(default="", max_length=10_000)
|
||||
saved_at: int = Field(ge=0)
|
||||
filing_plan: UnfiledDraftFilingPlan | None = None
|
||||
blockers: list[UnfiledDraftBlocker] = Field(default_factory=list, max_length=5)
|
||||
evidence: list[UnfiledDraftEvidence] = Field(default_factory=list, max_length=5)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_title_or_evidence(self):
|
||||
self.title = self.title.strip()
|
||||
if not self.title and not self.evidence:
|
||||
raise ValueError("title or evidence is required")
|
||||
return self
|
||||
|
||||
|
||||
class UnfiledDraftCollection(BaseModel):
|
||||
revision: int = Field(ge=0)
|
||||
@@ -789,6 +798,13 @@ def _validate_binary_attachment(filename: str, content_type: str, content: bytes
|
||||
return _validate_attachment_content(content_type, content)
|
||||
|
||||
|
||||
async def _sanitize_attachment(content_type: str, content: bytes) -> bytes:
|
||||
sanitized = await asyncio.to_thread(sanitize_image, content_type, content)
|
||||
if len(sanitized) > 2 * 1024 * 1024:
|
||||
raise ValueError("sanitized screenshot must be 2 MB or smaller")
|
||||
return sanitized
|
||||
|
||||
|
||||
class IssueCreation(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
body: str = Field(default="", max_length=10_000)
|
||||
@@ -2613,6 +2629,7 @@ async def update_later_plan(payload: LaterOperation | LaterOperationBatch):
|
||||
payload.action,
|
||||
payload.item_id,
|
||||
wake_at=payload.wake_at,
|
||||
handoff=payload.handoff,
|
||||
base_revision=payload.base_revision,
|
||||
)
|
||||
except ValueError as error:
|
||||
@@ -3129,6 +3146,72 @@ async def comment_on_global_search_preview(
|
||||
return JSONResponse(result, status_code=201)
|
||||
|
||||
|
||||
@app.post(
|
||||
"/api/v1/repos/{owner}/{repo}/issues/{number}/preview/attachments",
|
||||
status_code=201,
|
||||
)
|
||||
async def attach_to_global_search_preview(
|
||||
request: Request,
|
||||
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}"
|
||||
try:
|
||||
form = await request.form()
|
||||
uploaded = form.get("file")
|
||||
if not isinstance(uploaded, UploadFile):
|
||||
raise ValueError("screenshot file is required")
|
||||
filename = str(uploaded.filename or "")
|
||||
content_type = str(uploaded.content_type or "")
|
||||
content = _validate_binary_attachment(filename, content_type, await uploaded.read())
|
||||
content = await _sanitize_attachment(content_type, content)
|
||||
except (ValueError, ValidationError) as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
async def upload_attachment():
|
||||
preview = await gitea_proxy.work_preview(repository, kind, number)
|
||||
if (
|
||||
preview.get("repository") != repository
|
||||
or preview.get("kind") != kind
|
||||
or preview.get("number") != number
|
||||
or preview.get("commentable") is not True
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Search result not found")
|
||||
result = await gitea_proxy.upload_preview_attachment(
|
||||
repository, number, filename, content_type, content
|
||||
)
|
||||
safe_name = (
|
||||
result["name"].replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]")
|
||||
.replace("\r", " ").replace("\n", " ")
|
||||
)
|
||||
safe_url = result["url"].replace("<", "%3C").replace(">", "%3E")
|
||||
result["markdown"] = f""
|
||||
return result
|
||||
|
||||
try:
|
||||
result = await _run_idempotent_authored_action(
|
||||
upload_attachment(),
|
||||
idempotency_key=idempotency_key,
|
||||
fingerprint=(
|
||||
"search-preview-attachment", repository, kind, number, filename,
|
||||
content_type, hashlib.sha256(content).hexdigest(),
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The photo could not be uploaded. Your reply 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", "filed", "pull", "review", "update"] = Query(),
|
||||
@@ -4265,6 +4348,7 @@ async def attach_to_notification(
|
||||
filename = attachment.filename
|
||||
content_type = attachment.content_type
|
||||
content = attachment.content()
|
||||
content = await _sanitize_attachment(content_type, content)
|
||||
except (ValueError, ValidationError) as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
@@ -5248,6 +5332,7 @@ async def attach_to_assigned_issue(
|
||||
filename = attachment.filename
|
||||
content_type = attachment.content_type
|
||||
content = attachment.content()
|
||||
content = await _sanitize_attachment(content_type, content)
|
||||
except (ValueError, ValidationError) as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
async def upload_attachment():
|
||||
@@ -5316,6 +5401,7 @@ async def attach_to_assigned_pull(
|
||||
filename = attachment.filename
|
||||
content_type = attachment.content_type
|
||||
content = attachment.content()
|
||||
content = await _sanitize_attachment(content_type, content)
|
||||
except (ValueError, ValidationError) as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ class UnfiledDraftStore:
|
||||
title = raw.get("title")
|
||||
body = raw.get("body", "")
|
||||
saved_at = raw.get("saved_at")
|
||||
if not isinstance(title, str) or not title.strip() or len(title.strip()) > 255:
|
||||
if not isinstance(title, str) or len(title.strip()) > 255:
|
||||
raise ValueError("title is invalid")
|
||||
if not isinstance(body, str) or len(body) > 10_000:
|
||||
raise ValueError("body is invalid")
|
||||
@@ -214,6 +214,8 @@ class UnfiledDraftStore:
|
||||
**({"note": note} if note else {}),
|
||||
"data": data,
|
||||
})
|
||||
if not title.strip() and not clean_evidence:
|
||||
raise ValueError("title or evidence is required")
|
||||
seen.add(draft_id)
|
||||
normalized.append({
|
||||
"id": draft_id,
|
||||
|
||||
+110
-2
@@ -4,7 +4,8 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlsplit
|
||||
from threading import Event
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
|
||||
REPOSITORY = {
|
||||
@@ -16,6 +17,34 @@ REPOSITORY = {
|
||||
"owner": {"login": "acme"},
|
||||
}
|
||||
USER = {"id": 1, "login": "timmy", "full_name": "Timmy"}
|
||||
AVAILABLE_ISSUES = [
|
||||
{
|
||||
"id": 101,
|
||||
"number": 41,
|
||||
"title": "Ship mobile capture",
|
||||
"body": "Complete the capture flow on a phone.",
|
||||
"state": "open",
|
||||
"html_url": "http://127.0.0.1/acme/mobile/issues/41",
|
||||
"repository": REPOSITORY,
|
||||
"user": USER,
|
||||
"assignees": [],
|
||||
"labels": [{"name": "P1"}],
|
||||
"updated_at": "2026-08-15T12:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": 102,
|
||||
"number": 42,
|
||||
"title": "Polish desktop filters",
|
||||
"body": "Keep the desktop filter controls clear.",
|
||||
"state": "open",
|
||||
"html_url": "http://127.0.0.1/acme/mobile/issues/42",
|
||||
"repository": REPOSITORY,
|
||||
"user": USER,
|
||||
"assignees": [],
|
||||
"labels": [],
|
||||
"updated_at": "2026-08-15T11:00:00Z",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class FakeGiteaServer(ThreadingHTTPServer):
|
||||
@@ -23,6 +52,10 @@ class FakeGiteaServer(ThreadingHTTPServer):
|
||||
super().__init__(address, FakeGiteaHandler)
|
||||
self.created_issues: list[dict] = []
|
||||
self.issue_creation_enabled = False
|
||||
self.issue_creation_ready = Event()
|
||||
self.assigned_issue_numbers = [issue["number"] for issue in AVAILABLE_ISSUES]
|
||||
self.comments: list[tuple[int, str]] = []
|
||||
self.requests: list[tuple[str, str]] = []
|
||||
|
||||
|
||||
class FakeGiteaHandler(BaseHTTPRequestHandler):
|
||||
@@ -42,7 +75,10 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||
path = urlsplit(self.path).path
|
||||
parsed = urlsplit(self.path)
|
||||
path = parsed.path
|
||||
query = parse_qs(parsed.query)
|
||||
self.server.requests.append(("GET", self.path))
|
||||
if path == "/api/v1/user":
|
||||
self._json(200, USER)
|
||||
elif path == "/api/v1/user/repos":
|
||||
@@ -51,6 +87,35 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
|
||||
self._json(200, REPOSITORY)
|
||||
elif path == "/api/v1/repos/search":
|
||||
self._json(200, {"data": [REPOSITORY], "ok": True})
|
||||
elif path == "/api/v1/repos/issues/search":
|
||||
is_assigned_scan = query.get("assigned") == ["true"] and query.get("type") == ["issues"]
|
||||
is_available_scan = not any(
|
||||
name in query for name in ("assigned", "created", "review_requested", "q")
|
||||
) and query.get("type") == ["issues"]
|
||||
assigned = [
|
||||
{**issue, "assignees": [USER]}
|
||||
for issue in AVAILABLE_ISSUES
|
||||
if issue["number"] in self.server.assigned_issue_numbers
|
||||
]
|
||||
available = [
|
||||
issue for issue in AVAILABLE_ISSUES
|
||||
if issue["number"] not in self.server.assigned_issue_numbers
|
||||
]
|
||||
issues = assigned if is_assigned_scan else available if is_available_scan else []
|
||||
self._json(200, issues, **{"X-Total-Count": str(len(issues))})
|
||||
elif path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/comments"):
|
||||
self._json(200, [], **{"X-Total-Count": "0"})
|
||||
elif path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/dependencies"):
|
||||
self._json(200, [])
|
||||
elif path.startswith("/api/v1/repos/acme/mobile/issues/"):
|
||||
try:
|
||||
number = int(path.rsplit("/", 1)[-1])
|
||||
except ValueError:
|
||||
number = 0
|
||||
issue = next((item for item in AVAILABLE_ISSUES if item["number"] == number), None)
|
||||
if issue and number in self.server.assigned_issue_numbers:
|
||||
issue = {**issue, "assignees": [USER]}
|
||||
self._json(200, issue) if issue else self._json(404, {"message": "not found"})
|
||||
elif path.startswith("/api/v1/"):
|
||||
self._json(200, [], **{"X-Total-Count": "0"})
|
||||
else:
|
||||
@@ -58,11 +123,34 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||
path = urlsplit(self.path).path
|
||||
self.server.requests.append(("POST", self.path))
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
payload = json.loads(self.rfile.read(length) or b"{}")
|
||||
if path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/comments"):
|
||||
try:
|
||||
number = int(path.split("/")[-2])
|
||||
except ValueError:
|
||||
self._json(404, {"message": "not found"})
|
||||
return
|
||||
body = str(payload.get("body", ""))
|
||||
self.server.comments.append((number, body))
|
||||
self._json(
|
||||
201,
|
||||
{
|
||||
"id": len(self.server.comments),
|
||||
"body": body,
|
||||
"user": USER,
|
||||
"created_at": "2026-08-16T20:00:00Z",
|
||||
"updated_at": "2026-08-16T20:00:00Z",
|
||||
"html_url": f"http://127.0.0.1/acme/mobile/issues/{number}#issuecomment-1",
|
||||
},
|
||||
)
|
||||
return
|
||||
if path != "/api/v1/repos/acme/mobile/issues":
|
||||
self._json(404, {"message": "not found"})
|
||||
return
|
||||
if not self.server.issue_creation_enabled:
|
||||
self.server.issue_creation_ready.wait(timeout=45)
|
||||
if not self.server.issue_creation_enabled:
|
||||
self._json(503, {"message": "release journey is still offline"})
|
||||
return
|
||||
@@ -84,3 +172,23 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
|
||||
"updated_at": "2026-08-15T12:00:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
def do_PATCH(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||
path = urlsplit(self.path).path
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
payload = json.loads(self.rfile.read(length) or b"{}")
|
||||
if not path.startswith("/api/v1/repos/acme/mobile/issues/"):
|
||||
self._json(404, {"message": "not found"})
|
||||
return
|
||||
try:
|
||||
number = int(path.rsplit("/", 1)[-1])
|
||||
except ValueError:
|
||||
self._json(404, {"message": "not found"})
|
||||
return
|
||||
issue = next((item for item in AVAILABLE_ISSUES if item["number"] == number), None)
|
||||
if issue is None:
|
||||
self._json(404, {"message": "not found"})
|
||||
return
|
||||
assignee = payload.get("assignee")
|
||||
self.server.assigned_issue_numbers.append(number)
|
||||
self._json(200, {**issue, "assignees": [{"login": assignee}] if assignee else []})
|
||||
@@ -0,0 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged Find Work journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
def test_release_artifact_guides_mobile_find_work_through_assignment(tmp_path: Path):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
fake.assigned_issue_numbers = []
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
browser_errors: list[str] = []
|
||||
failed_responses: list[str] = []
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
context = browser.new_context(
|
||||
viewport={"width": 390, "height": 844},
|
||||
ignore_https_errors=True,
|
||||
)
|
||||
page = context.new_page()
|
||||
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
|
||||
page.on("console", lambda message: browser_errors.append(message.text) if message.type == "error" else None)
|
||||
page.on(
|
||||
"response",
|
||||
lambda response: failed_responses.append(f"{response.status} {response.url}")
|
||||
if response.status >= 400
|
||||
else None,
|
||||
)
|
||||
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Find Work release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
|
||||
page.locator('[data-mobile-task="find"]').click()
|
||||
expect(page.locator("#find-work-sheet")).to_have_class("find-work-sheet open")
|
||||
expect(page.locator(".find-work-card")).to_have_count(2)
|
||||
expect(page.locator("#find-work-status")).to_contain_text("2 of 2 available")
|
||||
|
||||
navigation = page.locator(".mobile-find-work-nav")
|
||||
expect(navigation).to_be_visible()
|
||||
for control in navigation.locator("button").all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
page.locator("#select-find-work").click()
|
||||
choices = page.locator("[data-find-work-select]")
|
||||
expect(choices).to_have_count(2)
|
||||
choices.nth(0).check()
|
||||
choices.nth(1).check()
|
||||
page.locator("#claim-selected-work").click()
|
||||
|
||||
expect(navigation.locator('[data-find-work-stage="review"]')).to_have_attribute(
|
||||
"aria-current", "step"
|
||||
)
|
||||
expect(page.locator("#find-work-review-list .find-work-review-item")).to_have_count(2)
|
||||
expect(page.locator("#find-work-review-summary")).to_contain_text("2 selected")
|
||||
page.locator("[data-find-work-review-remove]").nth(1).click()
|
||||
expect(page.locator("#find-work-review-list .find-work-review-item")).to_have_count(1)
|
||||
expect(page.locator("#find-work-review-summary")).to_contain_text("1 selected")
|
||||
|
||||
page.locator("#continue-find-work-fit").click()
|
||||
expect(navigation.locator('[data-find-work-stage="fit"]')).to_have_attribute(
|
||||
"aria-current", "step"
|
||||
)
|
||||
expect(page.locator("#find-work-estimate-list")).to_contain_text("Ship mobile capture")
|
||||
for control in page.locator("#find-work-fit-stage button").all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
|
||||
page.locator("#confirm-find-work-estimates").click()
|
||||
expect(navigation.locator('[data-find-work-stage="discover"]')).to_have_attribute(
|
||||
"aria-current", "step"
|
||||
)
|
||||
page.wait_for_timeout(1_000)
|
||||
status = page.locator("#find-work-status").inner_text()
|
||||
assert "1 queued" in status, {
|
||||
"status": status,
|
||||
"browser_errors": browser_errors,
|
||||
"failed_responses": failed_responses,
|
||||
"assigned": fake.assigned_issue_numbers,
|
||||
"today": page.evaluate("localStorage.getItem('stackchain.today-work.v1.timmy')"),
|
||||
}
|
||||
|
||||
assert fake.assigned_issue_numbers == [41]
|
||||
today = page.evaluate("JSON.parse(localStorage.getItem('stackchain.today-work.v1.timmy') || '[]')")
|
||||
assert today == ["issue:acme/mobile:41:"]
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert browser_errors == []
|
||||
assert failed_responses == []
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
@@ -0,0 +1,252 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged mobile Home bootstrap runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||
tmp_path: Path, width: int, height: int
|
||||
):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
browser_errors: list[str] = []
|
||||
failed_responses: list[str] = []
|
||||
workspace_requests: list[str] = []
|
||||
live_requests: list[str] = []
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
context = browser.new_context(
|
||||
viewport={"width": width, "height": height}, ignore_https_errors=True
|
||||
)
|
||||
page = context.new_page()
|
||||
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
|
||||
page.on(
|
||||
"console",
|
||||
lambda message: browser_errors.append(message.text)
|
||||
if message.type == "error"
|
||||
else None,
|
||||
)
|
||||
page.on(
|
||||
"response",
|
||||
lambda response: failed_responses.append(f"{response.status} {response.url}")
|
||||
if response.status >= 400
|
||||
else None,
|
||||
)
|
||||
page.on(
|
||||
"request",
|
||||
lambda request: workspace_requests.append(request.url)
|
||||
if "feature-today-timer-" in request.url
|
||||
else None,
|
||||
)
|
||||
page.on(
|
||||
"request",
|
||||
lambda request: live_requests.append(request.url)
|
||||
if "/api/v1/live" in request.url
|
||||
else None,
|
||||
)
|
||||
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Home bootstrap release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
|
||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||
initial_live_requests = len(live_requests)
|
||||
page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
window.scrollTo(0, 0);
|
||||
const surface = document.querySelector('#my-work');
|
||||
surface.setPointerCapture = () => {};
|
||||
surface.releasePointerCapture = () => {};
|
||||
const pointer = (type, y) => surface.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles:true, pointerId:41, pointerType:'touch', clientX:100, clientY:y,
|
||||
}));
|
||||
pointer('pointerdown', 10);
|
||||
pointer('pointermove', 90);
|
||||
}
|
||||
"""
|
||||
)
|
||||
expect(page.locator("#mobile-pull-refresh")).to_have_text("Release to refresh")
|
||||
page.evaluate(
|
||||
"document.querySelector('#my-work').dispatchEvent(new PointerEvent('pointerup', "
|
||||
"{bubbles:true, pointerId:41, pointerType:'touch', clientX:100, clientY:90}))"
|
||||
)
|
||||
expect(page.locator("#mobile-pull-refresh")).to_have_text("My Work is up to date")
|
||||
assert len(live_requests) == initial_live_requests + 1
|
||||
dock = page.locator("#mobile-task-dock")
|
||||
expect(dock).to_be_visible()
|
||||
expect(dock.locator("button")).to_have_count(5)
|
||||
for control in dock.locator("button").all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
delivery_queue = page.locator('[data-mobile-queue="delivery"]')
|
||||
expect(delivery_queue).to_be_visible()
|
||||
expect(delivery_queue).to_contain_text("Delivery")
|
||||
delivery_bounds = delivery_queue.bounding_box()
|
||||
assert delivery_bounds and delivery_bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
page.locator("#close-mobile-queues").click()
|
||||
|
||||
page.locator("#app-menu-toggle").click()
|
||||
page.locator("#open-insights").click()
|
||||
expect(page.locator("#insights-sheet")).to_be_visible()
|
||||
expect(page.locator("#insights-heading")).to_have_text("Insights")
|
||||
page.locator("#close-insights").click()
|
||||
expect(page.locator("#insights-sheet")).to_be_hidden()
|
||||
expect(page.locator("#my-work")).to_be_visible()
|
||||
expect(dock).to_be_visible()
|
||||
|
||||
page.locator("#app-menu-toggle").click()
|
||||
page.locator("#open-device-setup").click()
|
||||
expect(page.locator("#device-setup-sheet")).to_be_visible()
|
||||
expect(page.locator("#device-storage-heading")).to_have_text("Private device storage")
|
||||
expect(page.locator("#device-storage-detail")).to_contain_text("private work records")
|
||||
for selector in ("#clear-device-caches", "#clear-private-device-data"):
|
||||
bounds = page.locator(selector).bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
page.locator("#close-device-setup").click()
|
||||
expect(page.locator("#device-setup-sheet")).to_be_hidden()
|
||||
|
||||
page.locator("#active-devices").click()
|
||||
expect(page.locator("#active-devices-sheet")).to_be_visible()
|
||||
expect(page.locator("#security-activity-section")).to_be_visible()
|
||||
security_nav = page.locator(".security-section-nav")
|
||||
expect(security_nav).to_be_visible()
|
||||
for control in security_nav.locator("button").all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
page.locator("#security-section-devices").click()
|
||||
expect(page.locator("#security-section-devices")).to_have_attribute("aria-current", "page")
|
||||
page.go_back()
|
||||
expect(page.locator("#security-section-activity")).to_have_attribute("aria-current", "page")
|
||||
page.go_back()
|
||||
expect(page.locator("#active-devices-sheet")).to_be_hidden()
|
||||
expect(page.locator("#active-devices")).to_be_focused()
|
||||
|
||||
assert len(workspace_requests) == 1, workspace_requests
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert browser_errors == []
|
||||
assert failed_responses == []
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
|
||||
|
||||
def test_release_artifact_keeps_mobile_delivery_recovery_single_flight(tmp_path: Path):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
browser_errors: list[str] = []
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
page = browser.new_page(viewport={"width": 390, "height": 844})
|
||||
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Delivery recovery release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
|
||||
page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
window.__deliveryAttempts = 0;
|
||||
window.__deliverySettled = new Promise(resolve => { window.__finishDelivery = resolve; });
|
||||
window.__releaseRecovery = createMobileDeliveryRecovery({
|
||||
getItems: () => [{outbox_id:'release-retry', delivery_state:'waiting', title:'Post release note'}],
|
||||
activate: async () => {
|
||||
window.__deliveryAttempts += 1;
|
||||
await window.__deliverySettled;
|
||||
},
|
||||
});
|
||||
window.__releaseRecovery.open();
|
||||
window.__firstRecovery = window.__releaseRecovery.activate();
|
||||
window.__secondRecovery = window.__releaseRecovery.activate();
|
||||
}
|
||||
"""
|
||||
)
|
||||
action = page.locator("#mobile-delivery-recovery-action")
|
||||
expect(page.locator("#mobile-delivery-recovery")).to_be_visible()
|
||||
expect(action).to_be_disabled()
|
||||
expect(page.locator("#mobile-delivery-recovery-status")).to_have_text("Working…")
|
||||
assert page.evaluate("window.__deliveryAttempts") == 1
|
||||
bounds = action.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
page.evaluate("window.__finishDelivery()")
|
||||
page.evaluate("Promise.all([window.__firstRecovery, window.__secondRecovery])")
|
||||
expect(action).to_be_enabled()
|
||||
assert page.evaluate("window.__deliveryAttempts") == 1
|
||||
|
||||
page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
const destination = document.createElement('button');
|
||||
destination.type = 'button';
|
||||
destination.className = 'draft-resume';
|
||||
destination.dataset.draftIndex = '0';
|
||||
destination.textContent = 'Open current review';
|
||||
window.__attentionAttempts = 0;
|
||||
window.__attentionSettled = new Promise(resolve => { window.__finishAttention = resolve; });
|
||||
destination.onclick = async () => {
|
||||
window.__attentionAttempts += 1;
|
||||
await window.__attentionSettled;
|
||||
return true;
|
||||
};
|
||||
document.querySelector('#my-work-list').append(destination);
|
||||
window.__attentionRecovery = createMobileDeliveryRecovery({
|
||||
getItems: () => [{outbox_id:'review-attention', status:'attention', title:'Review feedback'}],
|
||||
getIndex: () => 0,
|
||||
});
|
||||
window.__attentionRecovery.open();
|
||||
window.__firstAttention = window.__attentionRecovery.activate();
|
||||
window.__secondAttention = window.__attentionRecovery.activate();
|
||||
}
|
||||
"""
|
||||
)
|
||||
expect(page.locator("#mobile-delivery-recovery")).to_be_hidden()
|
||||
expect(page.locator("#my-work-list .draft-resume")).to_be_focused()
|
||||
assert page.evaluate("window.__attentionAttempts") == 1
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
page.evaluate("window.__finishAttention()")
|
||||
page.evaluate("Promise.all([window.__firstAttention, window.__secondAttention])")
|
||||
assert page.evaluate("window.__attentionAttempts") == 1
|
||||
assert browser_errors == []
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
@@ -186,6 +186,29 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
|
||||
assert page.evaluate("() => navigator.serviceWorker.controller !== null")
|
||||
|
||||
new_action.click()
|
||||
expect(page.locator("#create-issue-sheet")).to_have_class("create-issue-sheet open")
|
||||
assert page.evaluate("document.activeElement?.id") != "create-issue-title"
|
||||
create_navigation = page.locator(".mobile-create-issue-nav")
|
||||
expect(create_navigation).to_be_visible()
|
||||
create_navigation_buttons = create_navigation.locator("button")
|
||||
assert create_navigation_buttons.count() == 3
|
||||
for control in create_navigation_buttons.all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
expect(create_navigation.locator('[data-create-issue-section="describe"]')).to_have_attribute(
|
||||
"aria-current", "location"
|
||||
)
|
||||
expect(create_navigation.locator('[data-create-issue-section="file"]')).to_have_attribute(
|
||||
"aria-disabled", "true"
|
||||
)
|
||||
create_navigation.locator('[data-create-issue-section="evidence"]').click()
|
||||
expect(create_navigation.locator('[data-create-issue-section="evidence"]')).to_have_attribute(
|
||||
"aria-current", "location"
|
||||
)
|
||||
for control in page.locator(".photo-evidence-actions .issue-attachment-trigger").all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
expect(page.locator("#voice-issue-capture")).to_be_visible()
|
||||
page.locator("#start-voice-issue-capture").click()
|
||||
page.evaluate("""([title, body]) => {
|
||||
@@ -200,9 +223,19 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
|
||||
expect(page.locator("#create-issue-body")).to_have_value(BODY)
|
||||
page.locator("#file-new-issue").click()
|
||||
expect(page.locator("#create-issue-filing")).to_be_visible()
|
||||
expect(create_navigation.locator('[data-create-issue-section="file"]')).to_have_attribute(
|
||||
"aria-current", "location"
|
||||
)
|
||||
expect(create_navigation.locator('[data-create-issue-section="file"]')).not_to_have_attribute(
|
||||
"aria-disabled", "true"
|
||||
)
|
||||
page.locator("#create-issue-repository").select_option("acme/mobile")
|
||||
expect(page.locator("#submit-new-issue")).to_be_enabled()
|
||||
|
||||
# Page offline emulation can leave service-worker requests connected.
|
||||
# Abort the mutation at the browser boundary too, matching a real
|
||||
# transport outage without poisoning server-side idempotency state.
|
||||
context.route("**/api/v1/repos/acme/mobile/issues", lambda route: route.abort())
|
||||
context.set_offline(True)
|
||||
page.locator("#submit-new-issue").click()
|
||||
expect(page.locator("#issue-filing-review")).to_be_visible()
|
||||
@@ -238,13 +271,32 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
|
||||
browser_errors.clear() # Chromium reports expected network errors while the context is offline.
|
||||
failed_responses.clear()
|
||||
fake.issue_creation_enabled = True
|
||||
fake.issue_creation_ready.set()
|
||||
context.unroute("**/api/v1/repos/acme/mobile/issues")
|
||||
context.set_offline(False)
|
||||
page.evaluate("window.dispatchEvent(new Event('online'))")
|
||||
for _ in range(80):
|
||||
# Containerized Actions runners can take longer than a local browser to wake the
|
||||
# service worker and complete its identity handshake after reconnect.
|
||||
for _ in range(160):
|
||||
if fake.created_issues:
|
||||
break
|
||||
page.wait_for_timeout(250)
|
||||
assert fake.created_issues == [{"title": TITLE, "body": BODY, "assignee": "timmy"}]
|
||||
local_debug = page.evaluate("localStorage.getItem('stackchain.issue-outbox.v1')")
|
||||
durable_debug = indexed_issue_records(page)
|
||||
assert fake.created_issues == [
|
||||
{"title": TITLE, "body": BODY, "assignee": "timmy"}
|
||||
], (
|
||||
f"url={page.url} ready={page.evaluate('document.readyState')} "
|
||||
f"status={page.locator('#my-work-action-status').inner_text()!r} "
|
||||
f"errors={browser_errors[-5:]!r} responses={failed_responses[-10:]!r} "
|
||||
f"local={local_debug!r} durable={durable_debug!r} "
|
||||
f"requests={fake.requests[-20:]!r}"
|
||||
)
|
||||
# Deferred workspace startup can report its already-issued offline request
|
||||
# after the pre-reconnect clear; retain every non-offline browser error.
|
||||
browser_errors[:] = [
|
||||
error for error in browser_errors if "ERR_INTERNET_DISCONNECTED" not in error
|
||||
]
|
||||
|
||||
for _ in range(40):
|
||||
durable_completion = indexed_issue_records(page)
|
||||
@@ -267,6 +319,7 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
finally:
|
||||
fake.issue_creation_ready.set()
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("rendered mobile Search Preview checks run only in the browser gate", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_rendered_mobile_search_preview_navigation_preserves_reading_space_and_draft(width, height):
|
||||
html = re.sub(r'<script src="static/[^"]+"></script>', "", (FRONTEND / "index.html").read_text())
|
||||
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch()
|
||||
page = browser.new_page(viewport={"width": width, "height": height})
|
||||
page.set_content(html)
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.locator("#search-preview").evaluate("node => node.classList.add('open')")
|
||||
page.locator("#search-preview-reply-workspace").evaluate("node => node.hidden = false")
|
||||
page.locator(".search-preview-navigation").evaluate("node => node.hidden = false")
|
||||
page.locator("#search-preview-overview").evaluate("node => node.style.minHeight = '500px'")
|
||||
page.locator("#search-preview-conversation").evaluate("node => node.style.minHeight = '500px'")
|
||||
page.locator("#search-preview-reply-workspace").evaluate("node => node.style.minHeight = '500px'")
|
||||
page.locator("#search-preview-actions").evaluate("node => node.style.minHeight = '300px'")
|
||||
page.add_script_tag(path=FRONTEND / "mobile-search-preview-nav.js")
|
||||
|
||||
navigation = page.locator(".mobile-search-preview-nav")
|
||||
expect(navigation).to_be_visible()
|
||||
buttons = navigation.locator("button")
|
||||
assert buttons.count() == 4
|
||||
for button in buttons.all():
|
||||
bounds = button.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert page.locator(".search-preview-panel").evaluate("node => node.scrollWidth <= node.clientWidth")
|
||||
|
||||
page.locator("#search-preview-reply").fill("Keep this reply while navigating")
|
||||
navigation.locator('[data-search-preview-section="conversation"]').click()
|
||||
expect(navigation.locator('[data-search-preview-section="conversation"]')).to_have_attribute(
|
||||
"aria-current", "location"
|
||||
)
|
||||
navigation.locator('[data-search-preview-section="reply"]').click()
|
||||
expect(page.locator("#search-preview-reply")).to_be_focused()
|
||||
assert page.locator("#search-preview-reply").input_value() == "Keep this reply while navigating"
|
||||
navigation.locator('[data-search-preview-section="actions"]').click()
|
||||
expect(navigation.locator('[data-search-preview-section="actions"]')).to_have_attribute(
|
||||
"aria-current", "location"
|
||||
)
|
||||
|
||||
continuation = page.locator(".search-preview-navigation")
|
||||
assert continuation.evaluate("node => getComputedStyle(node).position") == "sticky"
|
||||
assert continuation.evaluate("node => getComputedStyle(node).bottom") == "0px"
|
||||
assert page.locator("#search-preview-actions").evaluate("node => getComputedStyle(node).position") == "static"
|
||||
|
||||
page.locator("#next-search-result").click()
|
||||
expect(navigation.locator('[data-search-preview-section="overview"]')).to_have_attribute(
|
||||
"aria-current", "location"
|
||||
)
|
||||
assert page.locator("#search-preview-reply").input_value() == "Keep this reply while navigating"
|
||||
|
||||
page.set_viewport_size({"width": 800, "height": 700})
|
||||
expect(navigation).to_be_hidden()
|
||||
browser.close()
|
||||
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged Plan Today handoff journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path: Path):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
browser_errors: list[str] = []
|
||||
failed_responses: list[str] = []
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
context = browser.new_context(viewport={"width": 390, "height": 844}, ignore_https_errors=True)
|
||||
page = context.new_page()
|
||||
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
|
||||
page.on("console", lambda message: browser_errors.append(message.text) if message.type == "error" else None)
|
||||
page.on(
|
||||
"response",
|
||||
lambda response: failed_responses.append(f"{response.status} {response.url}")
|
||||
if response.status >= 400
|
||||
else None,
|
||||
)
|
||||
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Plan Today release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
|
||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||
page.locator('[data-mobile-task="work"]').click()
|
||||
expect(page.locator("#plan-today-sheet")).to_be_visible()
|
||||
expect(page.locator("#plan-today-candidates .plan-today-item")).to_have_count(2)
|
||||
|
||||
navigation = page.locator(".mobile-plan-today-nav")
|
||||
expect(navigation).to_be_visible()
|
||||
for control in navigation.locator("button").all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
|
||||
page.locator("#plan-today-available").fill("90")
|
||||
page.locator("#plan-today-available").press("Tab")
|
||||
page.locator("#build-today-plan").click()
|
||||
missing_estimates = page.locator("[data-plan-missing-estimate]")
|
||||
expect(missing_estimates).to_have_count(2)
|
||||
for _ in range(2):
|
||||
missing_estimates.nth(0).fill("30")
|
||||
missing_estimates.nth(0).press("Tab")
|
||||
expect(page.locator("#plan-today-list .plan-today-item")).to_have_count(2)
|
||||
estimates = page.locator("[data-plan-estimate]")
|
||||
expect(estimates).to_have_count(2)
|
||||
page.locator("#save-and-start-today").click()
|
||||
|
||||
try:
|
||||
expect(page.locator("#plan-today-sheet")).to_be_hidden(timeout=5_000)
|
||||
except AssertionError as error:
|
||||
raise AssertionError(page.evaluate("({hidden:document.querySelector('#plan-today-sheet').hidden, state:history.state, bodyClass:document.body.className})")) from error
|
||||
expect(page.locator("#issue-sheet")).to_have_class("issue-sheet open")
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
|
||||
expect(page.locator("#send-issue-comment-next")).to_be_visible()
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
expect(page.locator("#plan-today-sheet")).to_be_hidden()
|
||||
|
||||
page.locator('[data-issue-section="reply"]').click()
|
||||
page.locator("#issue-comment").fill("Handoff complete; continuing with the next Today item.")
|
||||
page.locator("#send-issue-comment-next").click()
|
||||
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Polish desktop filters")
|
||||
expect(page.locator("#issue-comment")).to_have_value("")
|
||||
expect(page.locator("[data-mobile-today-hud]")).to_have_attribute("data-overlay-hidden", "true")
|
||||
expect(page.locator("[data-mobile-today-hud]")).to_contain_text("Polish desktop filters")
|
||||
assert fake.comments == [(41, "Handoff complete; continuing with the next Today item.")]
|
||||
today = page.evaluate("JSON.parse(localStorage.getItem('stackchain.today-work.v1.timmy') || '[]')")
|
||||
assert today == ["issue:acme/mobile:42:"]
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert browser_errors == []
|
||||
assert failed_responses == []
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged Today wrap-up journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_release_artifact_renders_and_applies_mobile_today_wrap_up(
|
||||
tmp_path: Path, width: int, height: int
|
||||
):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
browser_errors: list[str] = []
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
context = browser.new_context(
|
||||
viewport={"width": width, "height": height}, ignore_https_errors=True
|
||||
)
|
||||
page = context.new_page()
|
||||
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
|
||||
page.on(
|
||||
"console",
|
||||
lambda message: browser_errors.append(message.text)
|
||||
if message.type == "error"
|
||||
else None,
|
||||
)
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Today wrap-up release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
|
||||
page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
const items=[
|
||||
{kind:'issue',repository:'acme/mobile',number:41,title:'Ship mobile capture',key:'acme/mobile#41'},
|
||||
{kind:'issue',repository:'acme/mobile',number:42,title:'Polish desktop filters',key:'acme/mobile#42'},
|
||||
];
|
||||
const identity=item => `${item.kind}:${item.repository}:${item.number}:`;
|
||||
const today=items.map(identity); const later={}; const operations=[];
|
||||
const todayWork={identity,read:()=>[...today],contains:item=>today.includes(identity(item)),remove:item=>{
|
||||
const index=today.indexOf(identity(item)); if(index<0)return false; today.splice(index,1); return true;
|
||||
}};
|
||||
const laterWork={presetUntil:()=>new Date('2026-08-17T09:00:00Z'),defer:(item,wake)=>{
|
||||
later[identity(item)]=wake.toISOString(); return 'deferred';
|
||||
}};
|
||||
const todaySync={enqueue:(action,id)=>{operations.push([action,id]);return true;},flush:()=>Promise.resolve(true)};
|
||||
const controller=createTodayWrapUp({todayWork,laterWork,todaySync});
|
||||
const view=createTodayWrapUpView({controller,qs:selector=>document.querySelector(selector),escapeHtml:value=>String(value)});
|
||||
window.__wrapTest={view,today,later,operations};
|
||||
view.open(items,{});
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
sheet = page.locator("#today-wrap-up-sheet")
|
||||
expect(sheet).to_be_visible()
|
||||
expect(page.locator(".today-wrap-up-item")).to_have_count(2)
|
||||
for control in sheet.locator("button, label").all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
page.locator('[data-wrap-up-identity="issue:acme/mobile:41:"]').check()
|
||||
page.evaluate("window.__wrapTest.view.finish(document.querySelector('#finish-today-wrap-up'))")
|
||||
expect(sheet).to_be_hidden()
|
||||
result = page.evaluate(
|
||||
"({today:window.__wrapTest.today,later:window.__wrapTest.later,operations:window.__wrapTest.operations})"
|
||||
)
|
||||
assert result == {
|
||||
"today": ["issue:acme/mobile:42:"],
|
||||
"later": {"issue:acme/mobile:41:": "2026-08-17T09:00:00.000Z"},
|
||||
"operations": [["remove", "issue:acme/mobile:41:"]],
|
||||
}
|
||||
assert browser_errors == []
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged next-day Today handoff runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
def test_release_artifact_reviews_wrap_up_commitments_on_a_phone(tmp_path: Path):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
errors: list[str] = []
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
page = browser.new_page(viewport={"width": 390, "height": 844})
|
||||
page.on("pageerror", lambda error: errors.append(error.stack or str(error)))
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Tomorrow review phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
page.evaluate("""localStorage.setItem('stackchain.today-handoff.v1.timmy', JSON.stringify([
|
||||
'issue:acme/mobile:41:', 'issue:acme/mobile:42:'
|
||||
]))""")
|
||||
page.reload(wait_until="networkidle")
|
||||
|
||||
dialog = page.locator("#today-handoff-dialog")
|
||||
expect(dialog).to_be_visible()
|
||||
expect(page.locator("#today-handoff-items .today-handoff-item")).to_have_count(2)
|
||||
for control in dialog.locator("button, input").all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 24
|
||||
buttons = dialog.locator("button")
|
||||
for control in buttons.all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
page.locator('[data-today-handoff="issue:acme/mobile:42:"]').uncheck()
|
||||
page.locator("#confirm-today-handoff").click()
|
||||
expect(dialog).to_be_hidden()
|
||||
assert page.evaluate("JSON.parse(localStorage.getItem('stackchain.today-work.v1.timmy'))") == [
|
||||
"issue:acme/mobile:41:"
|
||||
]
|
||||
assert page.evaluate("JSON.parse(localStorage.getItem('stackchain.today-handoff.v1.timmy'))") == [
|
||||
"issue:acme/mobile:42:"
|
||||
]
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert errors == []
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
thread.join(timeout=5)
|
||||
@@ -77,6 +77,80 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persi
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_durable_conversation_photo_captions_stay_paired_without_local_binary_storage():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); const mirrors = [];
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy',
|
||||
backgroundSync:{{reconcile:async items=>mirrors.push(items),requestSync:async()=>{{}}}},
|
||||
}});
|
||||
const photo=(filename,note)=>({{filename,contentType:'image/png',blob:new Blob([filename]),note}});
|
||||
(async()=>{{
|
||||
await outbox.enqueueDurably({{
|
||||
kind:'issue-comment',repository:'o/r',number:9,body:'Before and after',operationId:'bundle-note',
|
||||
attachments:[photo('before.png','Before repair'),photo('after.png','After *repair*')],
|
||||
}});
|
||||
const metadata=JSON.parse(values.get('stackchain.authored-outbox.v1')).items[0].attachments;
|
||||
const durable=mirrors[0][0].attachments;
|
||||
process.stdout.write(JSON.stringify({{
|
||||
metadata,durable:durable.map(x=>({{filename:x.filename,note:x.note,text:x.data||null,hasBlob:x.blob instanceof Blob}})),
|
||||
}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["metadata"] == [
|
||||
{"filename": "before.png", "contentType": "image/png", "stored": True, "note": "Before repair"},
|
||||
{"filename": "after.png", "contentType": "image/png", "stored": True, "note": "After *repair*"},
|
||||
]
|
||||
assert output["durable"] == [
|
||||
{"filename": "before.png", "note": "Before repair", "text": None, "hasBlob": True},
|
||||
{"filename": "after.png", "note": "After *repair*", "text": None, "hasBlob": True},
|
||||
]
|
||||
|
||||
|
||||
def test_search_preview_reply_is_durably_bound_to_exact_kind_with_upload_checkpoints():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); const mirrors = [];
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy',
|
||||
backgroundSync:{{reconcile:async items=>mirrors.push(items),requestSync:async()=>{{}}}},
|
||||
}});
|
||||
(async()=>{{
|
||||
const result=await outbox.enqueueDurably({{
|
||||
kind:'search-reply',targetKind:'pull',repository:'stackchain/web',number:42,
|
||||
body:'Ready offline.',operationId:'search-42',attachments:[{{
|
||||
filename:'proof.jpg',contentType:'image/jpeg',blob:new Blob(['proof']),note:'After repair',
|
||||
operationId:'photo-stage-1',confirmed:{{markdown:''}},
|
||||
}}],
|
||||
}});
|
||||
const metadata=outbox.list()[0]; const durable=mirrors[0][0];
|
||||
process.stdout.write(JSON.stringify({{
|
||||
item:result.item,metadata,
|
||||
durable:{{targetKind:durable.targetKind,operationId:durable.attachment.operationId,
|
||||
markdown:durable.attachment.confirmed?.markdown,hasBlob:durable.attachment.blob instanceof Blob}},
|
||||
}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["item"]["kind"] == "search-reply"
|
||||
assert output["item"]["targetKind"] == "pull"
|
||||
assert output["metadata"]["attachment"] == {
|
||||
"filename": "proof.jpg", "contentType": "image/jpeg", "stored": True,
|
||||
"note": "After repair", "operationId": "photo-stage-1",
|
||||
"confirmed": {"markdown": ""},
|
||||
}
|
||||
assert output["durable"] == {
|
||||
"targetKind": "pull", "operationId": "photo-stage-1",
|
||||
"markdown": "", "hasBlob": True,
|
||||
}
|
||||
|
||||
|
||||
def test_authored_outbox_persists_and_delivers_revision_checked_issue_content():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
@@ -804,3 +878,35 @@ const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy',background
|
||||
"filename": "proof.webp", "contentType": "image/webp", "stored": True
|
||||
}
|
||||
assert output["durable"] == {"isBlob": True, "text": "PULL-PRIVATE-BYTES"}
|
||||
|
||||
|
||||
def test_conversation_photo_bundle_keeps_only_metadata_in_localstorage_and_all_blobs_in_indexeddb():
|
||||
script = f"""
|
||||
const createAuthoredOutbox=require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();const mirrors=[];
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy',backgroundSync:{{
|
||||
reconcile:async items=>mirrors.push(items),requestSync:async()=>{{}},
|
||||
}}}});
|
||||
const photo=(name,bytes)=>({{filename:name,contentType:'image/jpeg',blob:new Blob([bytes],{{type:'image/jpeg'}})}});
|
||||
(async()=>{{
|
||||
await outbox.enqueueDurably({{kind:'update-reply-read',notificationId:943,body:'Before and after',
|
||||
operationId:'reply-bundle-943',attachments:[photo('before.jpg','PRIVATE-BEFORE'),photo('after.jpg','PRIVATE-AFTER')]}});
|
||||
const local=values.get('stackchain.authored-outbox.v1');
|
||||
const durable=mirrors[0][0].attachments;
|
||||
process.stdout.write(JSON.stringify({{local,metadata:JSON.parse(local).items[0].attachments,
|
||||
durable:await Promise.all(durable.map(async value=>({{filename:value.filename,isBlob:value.blob instanceof Blob,text:await value.blob.text()}})))}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert "PRIVATE-BEFORE" not in output["local"]
|
||||
assert "PRIVATE-AFTER" not in output["local"]
|
||||
assert output["metadata"] == [
|
||||
{"filename": "before.jpg", "contentType": "image/jpeg", "stored": True},
|
||||
{"filename": "after.jpg", "contentType": "image/jpeg", "stored": True},
|
||||
]
|
||||
assert output["durable"] == [
|
||||
{"filename": "before.jpg", "isBlob": True, "text": "PRIVATE-BEFORE"},
|
||||
{"filename": "after.jpg", "isBlob": True, "text": "PRIVATE-AFTER"},
|
||||
]
|
||||
@@ -19,6 +19,55 @@ def run_node(script: str) -> dict:
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_search_reply_reconnect_uses_preview_authorization_and_resumes_photo_checkpoints():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
let item={{id:'search-42',operationId:'search-42',ownerLogin:'timmy',status:'queued',outboxLane:'authored',
|
||||
kind:'search-reply',targetKind:'pull',repository:'stackchain/web',number:42,body:'Ready offline.',attachments:[
|
||||
{{filename:'one.jpg',contentType:'image/jpeg',blob:new Blob(['one']),operationId:'photo-one',confirmed:{{markdown:''}}}},
|
||||
{{filename:'two.jpg',contentType:'image/jpeg',blob:new Blob(['two']),operationId:'photo-two'}},
|
||||
]}};
|
||||
const calls=[];let commentAttempts=0;
|
||||
const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,transform)=>{{item=transform(item);}},
|
||||
complete:async()=>{{item=null;}},release:async()=>{{item={{...item,status:'queued'}};}},
|
||||
fail:async()=>{{}},countBlocked:async()=>0}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
const file=options.body instanceof FormData?options.body.get('file'):null;
|
||||
calls.push({{url,key:options.headers?.['Idempotency-Key'],filename:file?.name||'',
|
||||
body:file?null:JSON.parse(options.body)}});
|
||||
if(file)return{{markdown:''}};
|
||||
if(commentAttempts++===0){{const error=new Error('offline');error.status=503;throw error;}}
|
||||
return{{id:91,body:'Ready offline.'}};
|
||||
}};
|
||||
(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson}});let first='';
|
||||
try{{await sync.flush();}}catch(error){{first=error.message;}}
|
||||
const checkpoint={{...item}};const second=await sync.flush();
|
||||
process.stdout.write(JSON.stringify({{calls,first,checkpoint,second}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["first"] == "offline"
|
||||
assert output["checkpoint"]["attachmentMarkdowns"] == [
|
||||
"", ""
|
||||
]
|
||||
uploads = [call for call in output["calls"] if call["filename"]]
|
||||
assert [(call["filename"], call["key"]) for call in uploads] == [
|
||||
("two.jpg", "photo-two")
|
||||
]
|
||||
comments = [call for call in output["calls"] if not call["filename"]]
|
||||
assert [call["url"] for call in comments] == [
|
||||
"api/v1/repos/stackchain/web/issues/42/preview/comments?kind=pull",
|
||||
"api/v1/repos/stackchain/web/issues/42/preview/comments?kind=pull",
|
||||
]
|
||||
assert [call["key"] for call in comments] == ["search-42:comment"] * 2
|
||||
assert comments[-1]["body"] == {
|
||||
"body": "Ready offline.\n\n\n\n"
|
||||
}
|
||||
assert output["second"]["confirmed"] == [{"id": 91, "body": "Ready offline."}]
|
||||
|
||||
|
||||
def test_closed_app_sync_delivers_matching_issue_once_with_original_idempotency_key():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
@@ -445,6 +494,56 @@ const fetchJson=async(url,options={{}})=>{{
|
||||
assert output["second"]["confirmed"] == [{"id": 91}]
|
||||
|
||||
|
||||
def test_conversation_photo_bundle_resumes_at_first_unconfirmed_upload_and_posts_one_ordered_comment():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
let item={{id:'message-bundle',operationId:'message-bundle',ownerLogin:'timmy',status:'queued',
|
||||
kind:'issue-comment',repository:'stackchain/dashboard',number:943,body:'Before and after',
|
||||
attachments:[
|
||||
{{filename:'before.jpg',contentType:'image/jpeg',blob:new Blob(['before'],{{type:'image/jpeg'}})}},
|
||||
{{filename:'after.jpg',contentType:'image/jpeg',blob:new Blob(['after'],{{type:'image/jpeg'}})}},
|
||||
]}};
|
||||
const calls=[];let afterAttempts=0;
|
||||
const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,transform)=>{{item=transform(item);}},
|
||||
complete:async()=>{{item=null;}},release:async()=>{{item={{...item,status:'queued'}};}},
|
||||
fail:async()=>{{}},countBlocked:async()=>0}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
const file=options.body instanceof FormData?options.body.get('file'):null;
|
||||
calls.push({{url,key:options.headers?.['Idempotency-Key'],filename:file?.name||'',
|
||||
body:options.body instanceof FormData?null:JSON.parse(options.body)}});
|
||||
if(file){{
|
||||
if(file.name==='after.jpg' && afterAttempts++===0){{const error=new Error('offline');error.status=503;throw error;}}
|
||||
return{{markdown:''}};
|
||||
}}
|
||||
return{{id:943}};
|
||||
}};
|
||||
(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson}});let firstError='';
|
||||
try{{await sync.flush();}}catch(error){{firstError=error.message;}}
|
||||
const checkpoint={{...item}};const second=await sync.flush();
|
||||
process.stdout.write(JSON.stringify({{calls,firstError,checkpoint,second}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["firstError"] == "offline"
|
||||
assert output["checkpoint"]["attachmentMarkdowns"] == [
|
||||
""
|
||||
]
|
||||
uploads = [call for call in output["calls"] if call["filename"]]
|
||||
assert [(call["filename"], call["key"]) for call in uploads] == [
|
||||
("before.jpg", "message-bundle:attachment-0"),
|
||||
("after.jpg", "message-bundle:attachment-1"),
|
||||
("after.jpg", "message-bundle:attachment-1"),
|
||||
]
|
||||
comments = [call for call in output["calls"] if call["url"].endswith("/comments")]
|
||||
assert len(comments) == 1
|
||||
assert comments[0]["body"] == {
|
||||
"body": "Before and after\n\n\n\n"
|
||||
}
|
||||
assert output["second"]["confirmed"] == [{"id": 943}]
|
||||
|
||||
|
||||
def test_pull_screenshot_retry_uploads_to_pull_and_reuses_checkpoint_before_one_comment():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
@@ -865,6 +964,34 @@ const transaction=work=>{{const run=tail.then(()=>work({{getAll:async()=>[...rec
|
||||
assert output["attachmentMarkdown"] == ""
|
||||
|
||||
|
||||
def test_foreground_bundle_upsert_preserves_indexeddb_blobs_and_upload_checkpoints():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
const records=new Map();let tail=Promise.resolve();
|
||||
const transaction=work=>{{const run=tail.then(()=>work({{getAll:async()=>[...records.values()].map(value=>({{...value}})),put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id)}}));tail=run.catch(()=>{{}});return run;}};
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
|
||||
await store.upsert({{id:'bundle',operationId:'same',ownerLogin:'timmy',status:'queued',attachments:[
|
||||
{{filename:'a.jpg',contentType:'image/jpeg',blob:new Blob(['a-bytes'])}},
|
||||
{{filename:'b.jpg',contentType:'image/jpeg',blob:new Blob(['b-bytes'])}},
|
||||
]}});
|
||||
await store.update('bundle',item=>({{...item,attachmentMarkdowns:['']}}));
|
||||
const result=await store.upsert({{id:'bundle',operationId:'same',ownerLogin:'timmy',status:'queued',attachments:[
|
||||
{{filename:'a.jpg',contentType:'image/jpeg',stored:true}},
|
||||
{{filename:'b.jpg',contentType:'image/jpeg',stored:true}},
|
||||
]}});
|
||||
process.stdout.write(JSON.stringify({{texts:await Promise.all(result.attachments.map(value=>value.blob?.text())),
|
||||
markdowns:result.attachmentMarkdowns}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"texts": ["a-bytes", "b-bytes"],
|
||||
"markdowns": [""],
|
||||
}
|
||||
|
||||
|
||||
def test_indexeddb_store_closes_on_version_change_and_reopens_afterward():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
|
||||
@@ -9,6 +9,7 @@ HTML = Path(__file__).parents[1] / "frontend" / "index.html"
|
||||
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
||||
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
|
||||
WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
|
||||
FIND_WORK_NAV = Path(__file__).parents[1] / "frontend" / "mobile-find-work-nav.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
@@ -437,14 +438,15 @@ def test_mobile_find_work_exposes_accessible_batch_controls_and_offline_asset():
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
worker = WORKER.read_text()
|
||||
navigation = FIND_WORK_NAV.read_text()
|
||||
|
||||
assert 'id="select-find-work"' in html
|
||||
assert 'id="batch-find-work-actions"' in html
|
||||
assert 'id="claim-selected-work"' in html
|
||||
assert 'aria-live="polite"' in html
|
||||
assert "createBatchFindWork({" in dashboard
|
||||
assert "findWorkController.startSelection()" in dashboard
|
||||
assert "findWorkController.toggleSelection(item)" in dashboard
|
||||
assert "options.controller?.startSelection?.()" in navigation
|
||||
assert "options.controller?.toggleSelection" in navigation
|
||||
assert ".find-work-batch-actions" in css
|
||||
assert "position:sticky" in css
|
||||
assert "env(safe-area-inset-bottom)" in css
|
||||
@@ -472,7 +474,7 @@ def test_mobile_find_work_exposes_fill_today_action_with_capacity_handoff():
|
||||
assert 'id="fill-find-work-today"' in html
|
||||
assert 'aria-describedby="find-work-selection-status"' in html
|
||||
assert "findWorkController.fillSelection(remainingSlots)" in dashboard
|
||||
assert "openFindWorkEstimateReview(findWorkController.selectedItems())" in dashboard
|
||||
assert "openFit:openFindWorkEstimateReview" in dashboard
|
||||
assert ".fill-find-work-today" in css and "min-height:44px" in css
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,23 @@ def test_ci_installs_declared_requirements_before_tests():
|
||||
assert install < tests
|
||||
|
||||
|
||||
def test_ci_audits_pinned_runtime_dependencies_before_tests_and_release_build():
|
||||
text = WORKFLOW.read_text()
|
||||
lint = text[text.index(" lint:") : text.index(" build-release:")]
|
||||
build = text[text.index(" build-release:") : text.index(" browser-journey:")]
|
||||
|
||||
audit_install = lint.index("pip install -r requirements-audit.txt")
|
||||
audit = lint.index("python3 -m pip_audit -r requirements.txt --strict")
|
||||
tests = lint.index("python3 -m pytest tests/ -q")
|
||||
assert audit_install < audit < tests
|
||||
assert "needs: lint" in build
|
||||
|
||||
|
||||
def test_runtime_multipart_parser_uses_advisory_fixed_release():
|
||||
requirements = Path("requirements.txt").read_text().splitlines()
|
||||
assert "python-multipart==0.0.32" in requirements
|
||||
|
||||
|
||||
def test_release_promotion_waits_for_tests_and_bundle():
|
||||
text = WORKFLOW.read_text()
|
||||
release = text[text.index(" release-candidate:") :]
|
||||
@@ -38,5 +55,13 @@ def test_release_promotion_waits_for_artifact_mobile_offline_journey():
|
||||
assert "pip install -r requirements-e2e.txt" in browser
|
||||
assert "python3 -m playwright install --with-deps chromium" in browser
|
||||
assert 'STACKCHAIN_RUN_RELEASE_E2E: "1"' in browser
|
||||
assert "python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py -q" in browser
|
||||
assert (
|
||||
"python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py "
|
||||
"tests/e2e/test_mobile_search_preview_navigation.py "
|
||||
"tests/e2e/test_mobile_find_work_release.py "
|
||||
"tests/e2e/test_mobile_home_bootstrap_release.py "
|
||||
"tests/e2e/test_mobile_today_handoff_release.py "
|
||||
"tests/e2e/test_mobile_today_wrap_up_release.py "
|
||||
"tests/e2e/test_mobile_wrap_up_handoff_release.py -q"
|
||||
) in browser
|
||||
assert "needs: [lint, build-release, browser-journey]" in release
|
||||
@@ -389,6 +389,33 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_queue_and_next_advances_only_after_durable_admission():
|
||||
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 admit;const navigated=[];const states=[];let cleared=0;
|
||||
const preview=createSearchPreview({{
|
||||
fetchJson:async item=>item,mutate:async()=>{{}},
|
||||
queueReply:(item,body,operationId)=>new Promise(resolve=>{{admit=()=>resolve({{id:operationId,queued:true}});}}),
|
||||
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
|
||||
createOperationId:()=> 'offline-search-1',hasAttachments:()=>false,clearAttachments:()=>{{cleared+=1;}},
|
||||
getSession:()=>({{items,more:false}}),onNavigate:item=>navigated.push(item.number),onState:state=>states.push(state),
|
||||
}});
|
||||
await preview.open(items[0]);preview.saveReplyDraft('Queue safely.');
|
||||
const pending=preview.reply({{advance:true}});await Promise.resolve();const before=[...navigated];admit();await pending;
|
||||
process.stdout.write(JSON.stringify({{before,navigated,cleared,draft:values.size,
|
||||
queued:states.some(state=>state.status==='queued')}}));
|
||||
}})().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": [], "navigated": [2], "cleared": 1, "draft": 0, "queued": True
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_reply_retry_keeps_draft_and_operation_until_server_confirmation():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
|
||||
@@ -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-v108" in worker
|
||||
assert "stackchain-dashboard-shell-v117" in worker
|
||||
@@ -0,0 +1,164 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
STORE = ROOT / "frontend" / "conversation-reply-draft-store.js"
|
||||
COORDINATOR = ROOT / "frontend" / "conversation-photo-drafts.js"
|
||||
DASHBOARD = ROOT / "frontend" / "dashboard.js"
|
||||
INDEX = ROOT / "frontend" / "index.html"
|
||||
REGISTRY = ROOT / "frontend" / "private-data-registry.js"
|
||||
WORKER = ROOT / "frontend" / "service-worker.js"
|
||||
BUNDLE = ROOT / "src" / "frontend_bundle.py"
|
||||
|
||||
|
||||
def run_node(script: str) -> str:
|
||||
return subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout
|
||||
|
||||
|
||||
def test_store_isolates_complete_photo_bundles_by_account_and_conversation():
|
||||
script = f"""
|
||||
const createStore = require({json.dumps(str(STORE))});
|
||||
const records = new Map();
|
||||
let login = 'timmy';
|
||||
const transaction = async (operation, key, value) => {{
|
||||
if (operation === 'put') records.set(key, structuredClone(value));
|
||||
if (operation === 'get') return records.has(key) ? structuredClone(records.get(key)) : null;
|
||||
if (operation === 'delete') records.delete(key);
|
||||
}};
|
||||
const store = createStore({{transaction, getOwnerLogin:()=>login}});
|
||||
const issue = {{kind:'issue', repository:'stackchain/dashboard', number:963}};
|
||||
const pull = {{kind:'pull', repository:'stackchain/dashboard', number:963}};
|
||||
const update = {{kind:'update', notificationId:4815}};
|
||||
const photos = [
|
||||
{{filename:'before.webp', contentType:'image/webp', blob:new Blob(['before']), note:'before', operationId:'op-1'}},
|
||||
{{filename:'after.webp', contentType:'image/webp', blob:new Blob(['after']), note:'after', operationId:'op-2', confirmed:{{markdown:''}}}},
|
||||
];
|
||||
(async()=>{{
|
||||
await store.save(issue, photos);
|
||||
await store.save(pull, [photos[1]]);
|
||||
await store.save(update, [photos[0]]);
|
||||
const restored = await store.load(issue);
|
||||
const pullDraft = await store.load(pull);
|
||||
const updateDraft = await store.load(update);
|
||||
login = 'alexander';
|
||||
const wrongAccount = await store.load(issue);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
restored:await Promise.all(restored.map(async photo=>({{...photo,blobText:await photo.blob.text(),blob:undefined}}))),
|
||||
pullCount:pullDraft.length, updateCount:updateDraft.length, wrongAccount, recordCount:records.size,
|
||||
}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
|
||||
assert output == {
|
||||
"restored": [
|
||||
{
|
||||
"filename": "before.webp",
|
||||
"contentType": "image/webp",
|
||||
"note": "before",
|
||||
"operationId": "op-1",
|
||||
"blobText": "before",
|
||||
},
|
||||
{
|
||||
"filename": "after.webp",
|
||||
"contentType": "image/webp",
|
||||
"note": "after",
|
||||
"operationId": "op-2",
|
||||
"confirmed": {"markdown": ""},
|
||||
"blobText": "after",
|
||||
},
|
||||
],
|
||||
"pullCount": 1,
|
||||
"updateCount": 1,
|
||||
"wrongAccount": None,
|
||||
"recordCount": 3,
|
||||
}
|
||||
|
||||
|
||||
def test_coordinator_ignores_stale_restores_and_clears_only_after_durable_completion():
|
||||
script = f"""
|
||||
const createCoordinator = require({json.dumps(str(COORDINATOR))});
|
||||
const waits = new Map();
|
||||
const removed = [];
|
||||
const restored = [];
|
||||
let current = [{{filename:'current.webp',contentType:'image/webp',blob:new Blob(['current'])}}];
|
||||
const store = {{
|
||||
load:target=>new Promise(resolve=>waits.set(target.number, resolve)),
|
||||
save:async()=>null,
|
||||
remove:async target=>removed.push(target.number || target.notificationId),
|
||||
}};
|
||||
const controller = {{
|
||||
serialize:async()=>current,
|
||||
restore:value=>restored.push(value[0].filename),
|
||||
clear:()=>{{current=[];}},
|
||||
}};
|
||||
const drafts = createCoordinator({{store, lanes:{{issue:{{controller,onError:()=>{{}}}}}}}});
|
||||
(async()=>{{
|
||||
const first = drafts.open('issue', {{kind:'issue',repository:'stackchain/dashboard',number:1}});
|
||||
const second = drafts.open('issue', {{kind:'issue',repository:'stackchain/dashboard',number:2}});
|
||||
await Promise.resolve();
|
||||
waits.get(1)([{{filename:'stale.webp',contentType:'image/webp',blob:new Blob(['stale'])}}]);
|
||||
waits.get(2)([{{filename:'fresh.webp',contentType:'image/webp',blob:new Blob(['fresh'])}}]);
|
||||
await Promise.all([first, second]);
|
||||
await drafts.complete('issue');
|
||||
process.stdout.write(JSON.stringify({{restored,removed,hasTarget:drafts.hasTarget('issue')}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
assert output == {"restored": ["fresh.webp"], "removed": [2], "hasTarget": False}
|
||||
|
||||
|
||||
def test_coordinator_checkpoints_previous_target_before_switching_conversations():
|
||||
script = f"""
|
||||
const createCoordinator = require({json.dumps(str(COORDINATOR))});
|
||||
const events = [];
|
||||
let current = [{{filename:'first.webp',contentType:'image/webp',blob:new Blob(['first'])}}];
|
||||
const store = {{
|
||||
load:async target=>target.number === 2 ? [{{filename:'second.webp',contentType:'image/webp',blob:new Blob(['second'])}}] : null,
|
||||
save:async (target,attachments)=>events.push('save:' + target.number + ':' + attachments[0].filename),
|
||||
remove:async()=>null,
|
||||
}};
|
||||
const controller = {{
|
||||
serialize:async()=>current,
|
||||
restore:value=>{{current=value;events.push('restore:' + value[0].filename);}},
|
||||
clear:()=>{{current=[];}},
|
||||
}};
|
||||
const drafts = createCoordinator({{store, lanes:{{issue:{{controller}}}}}});
|
||||
(async()=>{{
|
||||
await drafts.open('issue', {{kind:'issue',repository:'stackchain/dashboard',number:1}});
|
||||
current=[{{filename:'first.webp',contentType:'image/webp',blob:new Blob(['first'])}}];
|
||||
await drafts.switchTo('issue', {{kind:'issue',repository:'stackchain/dashboard',number:2}});
|
||||
process.stdout.write(JSON.stringify(events));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
assert json.loads(run_node(script)) == ["save:1:first.webp", "restore:second.webp"]
|
||||
|
||||
|
||||
def test_my_work_wires_durable_photo_drafts_into_all_conversation_boundaries():
|
||||
dashboard = DASHBOARD.read_text()
|
||||
html = INDEX.read_text()
|
||||
registry = REGISTRY.read_text()
|
||||
worker = WORKER.read_text()
|
||||
bundle = BUNDLE.read_text()
|
||||
|
||||
assert '<script src="static/conversation-reply-draft-store.js"></script>' in html
|
||||
assert '<script src="static/conversation-photo-drafts.js"></script>' in html
|
||||
assert "'stackchain-conversation-reply-drafts-v1'" in registry
|
||||
assert "BASE + 'static/conversation-reply-draft-store.js'" in worker
|
||||
assert "BASE + 'static/conversation-photo-drafts.js'" in worker
|
||||
assert '"static/conversation-reply-draft-store.js"' in bundle
|
||||
assert '"static/conversation-photo-drafts.js"' in bundle
|
||||
assert "conversationPhotoDrafts.switchTo('issue'," in dashboard
|
||||
assert "conversationPhotoDrafts.switchTo('pull'," in dashboard
|
||||
assert "conversationPhotoDrafts.switchTo('update'," in dashboard
|
||||
assert "conversationPhotoDrafts.leave('issue')" in dashboard
|
||||
assert "conversationPhotoDrafts.leave('pull')" in dashboard
|
||||
assert "conversationPhotoDrafts.leave('update')" in dashboard
|
||||
assert "await conversationPhotoDrafts.complete(kind)" in dashboard
|
||||
assert "await conversationPhotoDrafts.complete('issue')" in dashboard
|
||||
assert "await conversationPhotoDrafts.complete('pull')" in dashboard
|
||||
assert "await conversationPhotoDrafts.complete('update')" in dashboard
|
||||
@@ -350,6 +350,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
]
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["replaced"] == [
|
||||
@@ -376,6 +377,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
|
||||
@@ -507,6 +509,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
@@ -530,6 +533,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
@@ -626,6 +630,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["assigned"] == "/dashboard/login"
|
||||
@@ -845,10 +850,22 @@ async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
|
||||
assert 'aria-labelledby="enrolled-passkeys-title"' in html
|
||||
assert 'id="enrolled-passkeys-list"' in html
|
||||
assert 'A recovery token will be required after sign-out if you remove this device’s passkey.' in html
|
||||
assert 'class="security-section-nav" aria-label="Security Center sections"' in html
|
||||
assert 'id="security-section-activity"' in html
|
||||
assert 'id="security-section-devices"' in html
|
||||
assert 'id="security-section-passkeys"' in html
|
||||
assert 'id="security-activity-section"' in html
|
||||
assert 'id="security-devices-section"' in html
|
||||
assert 'id="security-passkeys-section"' in html
|
||||
assert html.index('id="security-activity-section"') < html.index('id="security-devices-section"')
|
||||
assert 'aria-labelledby="security-activity-title"' in html
|
||||
assert 'id="security-activity-list"' in html
|
||||
assert '>Load older activity</button>' in html
|
||||
assert '#sign-out-all { min-height:44px; }' in html
|
||||
assert '.security-section-nav { position:sticky;' in html
|
||||
assert '.security-section-nav button { min-height:44px;' in html
|
||||
assert '.security-event button { min-height:44px;' in html
|
||||
assert '.security-activity, .security-devices, .enrolled-passkeys { scroll-margin-top:' in html
|
||||
|
||||
|
||||
def test_security_activity_names_confirmed_gitea_time_logging():
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE = Path(__file__).parents[1] / "frontend" / "device-storage.js"
|
||||
|
||||
|
||||
def run_scenario(script: str) -> dict:
|
||||
harness = r"""
|
||||
const createDeviceStorage = require(__MODULE__);
|
||||
class Target {
|
||||
constructor() { this.listeners={}; this.textContent=''; this.hidden=false; this.disabled=false; this.dataset={}; }
|
||||
addEventListener(name, callback) { (this.listeners[name] ||= []).push(callback); }
|
||||
async dispatch(name) { for (const callback of this.listeners[name] || []) await callback(); }
|
||||
}
|
||||
const summary=new Target(), detail=new Target(), clearCachesButton=new Target(), clearAllButton=new Target();
|
||||
let purgeCalls=0, purgeError='';
|
||||
let inventory={recordCount:0, unavailable:false};
|
||||
const localStorage={
|
||||
values:new Map([['stackchain.draft.1','private'],['other.preference','keep']]),
|
||||
get length(){return this.values.size;},
|
||||
key(index){return Array.from(this.values.keys())[index] || null;},
|
||||
};
|
||||
const caches={
|
||||
values:new Set(['stackchain-dashboard-shell-a1','other-app']),
|
||||
async keys(){return Array.from(this.values);},
|
||||
async delete(key){return this.values.delete(key);},
|
||||
};
|
||||
const controller=createDeviceStorage({
|
||||
summary, detail, clearCachesButton, clearAllButton, localStorage, caches,
|
||||
privateDatabases:['stackchain-background-outbox-v1','stackchain-offline-work-v2'],
|
||||
inspectPrivateDatabases:async()=>inventory,
|
||||
storageManager:{estimate:async()=>({usage:2 * 1024 * 1024, quota:10 * 1024 * 1024})},
|
||||
clearPrivateDeviceData:async()=>{purgeCalls++;if(purgeError)throw new Error(purgeError);},
|
||||
});
|
||||
(async()=>{ __SCENARIO__ })().catch(error=>{console.error(error);process.exit(1);});
|
||||
""".replace("__MODULE__", json.dumps(str(MODULE))).replace("__SCENARIO__", script)
|
||||
completed = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_inventory_reports_private_categories_and_quota_without_content():
|
||||
result = run_scenario("""
|
||||
await controller.refresh();
|
||||
process.stdout.write(JSON.stringify({summary:summary.textContent,detail:detail.textContent}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"summary": "2 MB of 10 MB browser storage used.",
|
||||
"detail": "1 private browser item · 0 private work records · 1 cached app copy",
|
||||
}
|
||||
|
||||
|
||||
def test_cache_only_cleanup_preserves_private_work_and_other_apps():
|
||||
result = run_scenario("""
|
||||
await controller.start();
|
||||
await clearCachesButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({
|
||||
caches:Array.from(caches.values),
|
||||
storage:Array.from(localStorage.values.keys()),
|
||||
status:summary.textContent,
|
||||
detail:detail.textContent,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"caches": ["other-app"],
|
||||
"storage": ["stackchain.draft.1", "other.preference"],
|
||||
"status": "Cached app copies cleared. Private work was kept.",
|
||||
"detail": "1 private browser item · 0 private work records · 0 cached app copies",
|
||||
}
|
||||
|
||||
|
||||
def test_full_cleanup_requires_explicit_second_press_when_private_work_exists():
|
||||
result = run_scenario("""
|
||||
await controller.start();
|
||||
await clearAllButton.dispatch('click');
|
||||
const warning={calls:purgeCalls,label:clearAllButton.textContent,status:summary.textContent};
|
||||
await clearAllButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({warning,calls:purgeCalls,status:summary.textContent,detail:detail.textContent}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"warning": {
|
||||
"calls": 0,
|
||||
"label": "Confirm: clear private work",
|
||||
"status": "Private drafts or queued work may not be synced. Press confirm to clear them from this device.",
|
||||
},
|
||||
"calls": 1,
|
||||
"status": "All Stackchain private data was cleared from this device.",
|
||||
"detail": "0 private browser items · 0 private work records · 0 cached app copies",
|
||||
}
|
||||
|
||||
|
||||
def test_indexeddb_only_work_requires_confirmation_before_purge():
|
||||
result = run_scenario("""
|
||||
localStorage.values.clear();
|
||||
inventory={recordCount:3, unavailable:false};
|
||||
await controller.start();
|
||||
await clearAllButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({
|
||||
calls:purgeCalls,
|
||||
label:clearAllButton.textContent,
|
||||
status:summary.textContent,
|
||||
detail:detail.textContent,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"calls": 0,
|
||||
"label": "Confirm: clear private work",
|
||||
"status": "Private drafts or queued work may not be synced. Press confirm to clear them from this device.",
|
||||
"detail": "0 private browser items · 3 private work records · 1 cached app copy",
|
||||
}
|
||||
|
||||
|
||||
def test_unavailable_indexeddb_inventory_requires_confirmation_before_purge():
|
||||
result = run_scenario("""
|
||||
localStorage.values.clear();
|
||||
inventory={recordCount:0, unavailable:true};
|
||||
await controller.start();
|
||||
await clearAllButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({
|
||||
calls:purgeCalls,
|
||||
label:clearAllButton.textContent,
|
||||
detail:detail.textContent,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"calls": 0,
|
||||
"label": "Confirm: clear private work",
|
||||
"detail": "0 private browser items · private work status unknown · 1 cached app copy",
|
||||
}
|
||||
|
||||
|
||||
def test_full_cleanup_reports_blocked_deletion_without_claiming_success():
|
||||
result = run_scenario("""
|
||||
purgeError='IndexedDB deletion was blocked.';
|
||||
await controller.start();
|
||||
await clearAllButton.dispatch('click');
|
||||
let escaped='';
|
||||
try { await clearAllButton.dispatch('click'); } catch (error) { escaped=error.message; }
|
||||
process.stdout.write(JSON.stringify({escaped,status:summary.textContent,disabled:clearAllButton.disabled}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"escaped": "",
|
||||
"status": "Private data was not fully cleared: IndexedDB deletion was blocked.",
|
||||
"disabled": False,
|
||||
}
|
||||
@@ -213,6 +213,37 @@ process.stdout.write(JSON.stringify({{item,partition:inbox.partition()}}));
|
||||
assert output["partition"]["retryable"] == []
|
||||
|
||||
|
||||
def test_delivery_partition_counts_human_action_and_orders_it_before_waiting_work():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values = new Map([
|
||||
['stackchain.issue-outbox.v1', JSON.stringify({{version:3,items:[
|
||||
{{id:'waiting',repository:'o/r',title:'Waiting',body:'',ownerLogin:'timmy',status:'queued',queuedAt:500}},
|
||||
{{id:'uncertain',repository:'o/r',title:'Verify',body:'',ownerLogin:'timmy',status:'queued',deliveryState:'uncertain',queuedAt:100}},
|
||||
{{id:'sending',repository:'o/r',title:'Sending',body:'',ownerLogin:'timmy',status:'sending',queuedAt:400}},
|
||||
{{id:'attention',repository:'o/r',title:'Fix me',body:'',ownerLogin:'timmy',status:'attention',queuedAt:200}},
|
||||
]}})],
|
||||
['stackchain.authored-outbox.v1', JSON.stringify({{version:2,items:[
|
||||
{{id:'authorize',kind:'pull-review',repository:'o/r',number:8,body:'Review',ownerLogin:'timmy',status:'authorization',queuedAt:300}},
|
||||
]}})],
|
||||
]);
|
||||
const storage={{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const partition=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).partition();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
actionable:partition.actionable,
|
||||
titles:partition.deliveries.map(item=>item.title),
|
||||
waiting:partition.counts.waiting,
|
||||
sending:partition.counts.sending,
|
||||
}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"actionable": 3,
|
||||
"titles": ["o/r#8", "Fix me", "Verify", "Waiting", "Sending"],
|
||||
"waiting": 2,
|
||||
"sending": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_draft_inbox_exposes_created_issue_waiting_for_create_and_start_continuation():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
@@ -347,6 +378,10 @@ async def test_mobile_dashboard_renders_delivery_center_separately_and_retries_w
|
||||
assert "item.last_attempt_error ? '<span class=\"delivery-attempt small\">Last attempt '" in html
|
||||
assert '.delivery-center { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;' in html
|
||||
assert '.delivery-center button { min-height:44px;' in html
|
||||
assert "counts.delivery = draftInbox.partition(lastDrafts).actionable;" in html
|
||||
assert "openDelivery: () => mobileDeliveryRecovery.open()" in html
|
||||
assert "getItems: () => draftInbox.partition(lastDrafts).deliveries" in html
|
||||
assert "authoritativePhases:['delivery']" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -26,8 +26,8 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
|
||||
assert first.runtime_gzip_bytes == second.runtime_gzip_bytes
|
||||
assert gzip.decompress(first.runtime_gzip_bytes) == first.runtime_bytes
|
||||
assert PAGE_SCRIPT.findall(first.dashboard_html) == []
|
||||
assert first.dashboard_html.count("<script src=") == 2
|
||||
assert f'<script src="{first.feature_bundles["today-timer"].runtime_name}"></script>' in first.dashboard_html
|
||||
assert first.dashboard_html.count("<script src=") == 1
|
||||
assert f'<script src="{first.feature_bundles["today-timer"].runtime_name}"></script>' not in first.dashboard_html
|
||||
assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
|
||||
assert first.runtime_name.startswith("runtime-")
|
||||
assert first.runtime_name.endswith(".js")
|
||||
@@ -51,10 +51,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "device-setup",
|
||||
"today-timer", "security-center",
|
||||
}
|
||||
assert (
|
||||
f'<script src="{first.feature_bundles["today-timer"].runtime_name}"></script>\n'
|
||||
f'<script src="{first.runtime_name}"></script>'
|
||||
) in first.dashboard_html
|
||||
assert first.dashboard_html.count("<script src=") == 1
|
||||
assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
|
||||
capture = first.feature_bundles["issue-capture"]
|
||||
pull_workflow = first.feature_bundles["pull-workflow"]
|
||||
assert b"function createIssueCapture" not in first.runtime_bytes
|
||||
@@ -72,6 +70,10 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
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"function createConversationPager" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"function createConversationPager" not in first.feature_bundles["comment-actions"].runtime_bytes
|
||||
assert b"function createMobileDeliveryRecovery" not in first.runtime_bytes
|
||||
assert b"function createMobileDeliveryRecovery" 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
|
||||
# Core mobile workflows stay below 99 KiB, including synced Search views and Update decisions.
|
||||
@@ -87,13 +89,10 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
"const OPTIONAL_FEATURES = [", 1
|
||||
)
|
||||
optional_block = optional_block.split("];", 1)[0]
|
||||
assert f"BASE + '{first.feature_bundles['today-timer'].runtime_name}'" in shell_block
|
||||
assert f"BASE + '{first.feature_bundles['today-timer'].runtime_name}'" not in shell_block
|
||||
for name, bundle in first.feature_bundles.items():
|
||||
if name == "today-timer":
|
||||
assert f"BASE + '{bundle.runtime_name}'" not in optional_block
|
||||
else:
|
||||
assert f"BASE + '{bundle.runtime_name}'" not in shell_block
|
||||
assert f"BASE + '{bundle.runtime_name}'" in optional_block
|
||||
assert f"BASE + '{bundle.runtime_name}'" not in shell_block
|
||||
assert f"BASE + '{bundle.runtime_name}'" in optional_block
|
||||
|
||||
changed_frontend = tmp_path / "frontend"
|
||||
shutil.copytree(FRONTEND, changed_frontend)
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import asyncio
|
||||
import io
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from src import gitea_proxy, main
|
||||
|
||||
|
||||
def png_bytes(color="red"):
|
||||
output = io.BytesIO()
|
||||
Image.new("RGB", (2, 2), color).save(output, format="PNG")
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_endpoint_scopes_every_page_to_an_exact_repository(monkeypatch):
|
||||
requested = []
|
||||
@@ -325,6 +333,70 @@ async def test_global_search_preview_reply_rejects_a_mismatched_target_before_co
|
||||
assert comments == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_preview_attachment_is_idempotent_for_exact_visible_target(monkeypatch):
|
||||
main._idempotency_ledger.clear()
|
||||
previews = []
|
||||
uploads = []
|
||||
|
||||
async def preview(repository, kind, number):
|
||||
previews.append((repository, kind, number))
|
||||
return {"repository": repository, "kind": kind, "number": number, "commentable": True}
|
||||
|
||||
async def upload(repository, number, filename, content_type, content):
|
||||
uploads.append((repository, number, filename, content_type, content))
|
||||
return {"name": filename, "url": "https://forge.example/evidence/photo.png", "size": len(content)}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||
monkeypatch.setattr(main.gitea_proxy, "upload_preview_attachment", upload)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "search-photo-pull-42"}
|
||||
image = png_bytes()
|
||||
files = {"file": ("photo.png", image, "image/png")}
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
first = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/42/preview/attachments?kind=pull",
|
||||
files=files,
|
||||
headers=headers,
|
||||
)
|
||||
replay = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/42/preview/attachments?kind=pull",
|
||||
files=files,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert first.status_code == replay.status_code == 201
|
||||
assert replay.json() == first.json()
|
||||
assert first.json()["markdown"] == ""
|
||||
assert previews == [("stackchain/api", "pull", 42)]
|
||||
assert uploads == [("stackchain/api", 42, "photo.png", "image/png", image)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_preview_attachment_rejects_kind_mismatch_before_upload(monkeypatch):
|
||||
main._idempotency_ledger.clear()
|
||||
uploads = []
|
||||
|
||||
async def preview(repository, kind, number):
|
||||
return {"repository": repository, "kind": "issue", "number": number}
|
||||
|
||||
async def upload(*args):
|
||||
uploads.append(args)
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||
monkeypatch.setattr(main.gitea_proxy, "upload_preview_attachment", upload)
|
||||
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/attachments?kind=pull",
|
||||
files={"file": ("photo.png", png_bytes(), "image/png")},
|
||||
headers={"Idempotency-Key": "search-photo-mismatch-42"},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert uploads == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results():
|
||||
requests = []
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from src.image_sanitizer import MAX_IMAGE_PIXELS, sanitize_image
|
||||
|
||||
|
||||
def test_runtime_dependencies_install_the_image_decoder():
|
||||
requirements = (Path(__file__).parents[1] / "requirements.txt").read_text().splitlines()
|
||||
assert any(line.startswith("Pillow==") for line in requirements)
|
||||
|
||||
|
||||
def encoded_image(format_name, size=(3, 2), *, exif=None):
|
||||
image = Image.new("RGB", size)
|
||||
image.putdata(
|
||||
[(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 0), (0, 255, 255), (255, 0, 255)]
|
||||
)
|
||||
output = io.BytesIO()
|
||||
image.save(output, format=format_name, exif=exif or b"")
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def test_sanitize_image_applies_orientation_and_removes_jpeg_metadata():
|
||||
exif = Image.Exif()
|
||||
exif[274] = 6
|
||||
exif[ GPS_TAG := 34853 ] = {1: "N", 2: (1, 1, 1)}
|
||||
source = encoded_image("JPEG", exif=exif)
|
||||
|
||||
sanitized = sanitize_image("image/jpeg", source)
|
||||
|
||||
with Image.open(io.BytesIO(sanitized)) as result:
|
||||
assert result.size == (2, 3)
|
||||
assert not result.getexif()
|
||||
assert GPS_TAG not in result.info
|
||||
assert result.format == "JPEG"
|
||||
|
||||
|
||||
def test_sanitize_image_rejects_bytes_that_do_not_match_claimed_type():
|
||||
source = encoded_image("PNG")
|
||||
|
||||
with pytest.raises(ValueError, match="does not match"):
|
||||
sanitize_image("image/jpeg", source)
|
||||
|
||||
|
||||
def test_sanitize_image_rejects_over_twelve_megapixels_before_decoding():
|
||||
image = Image.new("RGB", (4096, 3073))
|
||||
output = io.BytesIO()
|
||||
image.save(output, format="PNG")
|
||||
|
||||
with pytest.raises(ValueError, match="12 megapixels"):
|
||||
sanitize_image("image/png", output.getvalue())
|
||||
|
||||
|
||||
def test_sanitize_image_rejects_animated_webp():
|
||||
first = Image.new("RGB", (2, 2), "red")
|
||||
second = Image.new("RGB", (2, 2), "blue")
|
||||
output = io.BytesIO()
|
||||
first.save(output, format="WEBP", save_all=True, append_images=[second], duration=100)
|
||||
|
||||
with pytest.raises(ValueError, match="animated"):
|
||||
sanitize_image("image/webp", output.getvalue())
|
||||
@@ -89,6 +89,119 @@ controller.select(image('replacement.png'));
|
||||
assert len({key for _, key in output["calls"]}) == 5
|
||||
|
||||
|
||||
def test_mobile_conversation_composers_accept_five_ordered_photos():
|
||||
html = INDEX.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
|
||||
for input_id in (
|
||||
"issue-attachment", "pull-attachment", "update-reply-attachment",
|
||||
):
|
||||
tag = re.search(rf'<input[^>]+id="{input_id}"[^>]*>', html)
|
||||
assert tag, input_id
|
||||
assert " multiple" in tag.group(0), input_id
|
||||
|
||||
for controller in (
|
||||
"issueAttachmentController", "pullAttachmentController",
|
||||
"updateReplyAttachmentController",
|
||||
):
|
||||
mount = re.search(
|
||||
rf"const {controller} = issueAttachment\.mount\(\{{(.*?)\n \}}\);",
|
||||
dashboard,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert mount, controller
|
||||
assert "maxFiles: 5" in mount.group(1), controller
|
||||
|
||||
|
||||
def test_mobile_search_preview_exposes_and_mounts_photo_evidence_controls():
|
||||
html = INDEX.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'for="take-search-reply-photo">Take photo</label>' in html
|
||||
assert 'id="take-search-reply-photo" type="file" accept="image/*" capture="environment"' in html
|
||||
assert 'for="search-reply-attachment">Choose existing</label>' in html
|
||||
assert 'id="search-reply-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple' in html
|
||||
mount = re.search(
|
||||
r"const searchReplyAttachmentController = issueAttachment\.mount\(\{(.*?)\n \}\);",
|
||||
dashboard,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert mount
|
||||
assert "maxFiles: 5" in mount.group(1)
|
||||
assert "'/preview/attachments?kind='" in mount.group(1)
|
||||
assert "queueReply:async (item,body,operationId)" in dashboard
|
||||
assert "kind:'search-reply',targetKind:item.kind" in dashboard
|
||||
assert "authoredOutbox.enqueueDurably" in dashboard
|
||||
assert ".search-preview-reply .conversation-photo-actions" in css
|
||||
|
||||
|
||||
def test_search_preview_can_send_photo_only_and_advances_after_confirmed_comment():
|
||||
search_preview = Path(__file__).parents[1] / "frontend" / "search-preview.js"
|
||||
script = f"""
|
||||
const create = require({json.dumps(str(search_preview))});
|
||||
const posted=[]; const prepared=[]; let cleared=0; let navigated=[]; let attached=true;
|
||||
const item={{kind:'issue',repository:'stackchain/api',number:42}};
|
||||
const next={{kind:'pull',repository:'stackchain/web',number:9}};
|
||||
const api=create({{
|
||||
fetchJson:async value=>({{...value,commentable:true}}),
|
||||
postReply:async (target,body,key)=>{{posted.push([target.number,body,key]);return {{id:91,body}};}},
|
||||
prepareReply:async (target,body)=>{{prepared.push([target.number,body]);return '';}},
|
||||
hasAttachments:()=>attached,
|
||||
clearAttachments:()=>{{cleared += 1;attached=false;}},
|
||||
storage:{{getItem:()=>'',setItem:()=>{{}},removeItem:()=>{{}}}},
|
||||
createOperationId:()=> 'reply-photo-42',
|
||||
getSession:()=>({{items:[item,next],more:false}}),
|
||||
onNavigate:value=>navigated.push(value.number),
|
||||
onState:()=>{{}},
|
||||
}});
|
||||
(async()=>{{
|
||||
await api.open(item);
|
||||
await api.reply({{advance:true}});
|
||||
process.stdout.write(JSON.stringify({{posted,prepared,cleared,navigated}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
assert output == {
|
||||
"posted": [[42, "", "reply-photo-42"]],
|
||||
"prepared": [[42, ""]],
|
||||
"cleared": 1,
|
||||
"navigated": [9],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_conversation_composers_review_reorder_remove_and_caption_every_photo():
|
||||
html = INDEX.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
composers = (
|
||||
("issue-attachment", "issueAttachmentController"),
|
||||
("pull-attachment", "pullAttachmentController"),
|
||||
("update-reply-attachment", "updateReplyAttachmentController"),
|
||||
)
|
||||
for attachment_id, controller in composers:
|
||||
assert f'id="{attachment_id}-tray"' in html
|
||||
assert f'id="{attachment_id}-note" maxlength="240"' in html
|
||||
assert f'id="move-{attachment_id}-earlier"' in html
|
||||
assert f'id="remove-{attachment_id}"' in html
|
||||
assert f'id="move-{attachment_id}-later"' in html
|
||||
mount = re.search(
|
||||
rf"const {controller} = issueAttachment\.mount\(\{{(.*?)\n \}}\);",
|
||||
dashboard,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert mount, controller
|
||||
assert f"tray: qs('#{attachment_id}-tray')" in mount.group(1)
|
||||
assert f"earlier: qs('#move-{attachment_id}-earlier')" in mount.group(1)
|
||||
assert f"later: qs('#move-{attachment_id}-later')" in mount.group(1)
|
||||
assert f"note: qs('#{attachment_id}-note')" in mount.group(1)
|
||||
|
||||
assert ".conversation-evidence-review" in css
|
||||
assert ".conversation-evidence-review .issue-evidence-review-actions button" in css
|
||||
assert "min-height:44px" in css
|
||||
|
||||
|
||||
def test_mobile_evidence_bundle_reorders_selected_image_for_serialization_and_upload():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
@@ -280,6 +393,52 @@ const canvas = {{
|
||||
assert output["name"] == "camera.jpg"
|
||||
|
||||
|
||||
def test_browser_optimizer_reencodes_bounded_images_to_strip_embedded_metadata():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
const results = [];
|
||||
(async()=>{{
|
||||
for (const type of ['image/jpeg', 'image/png', 'image/webp']) {{
|
||||
const original = new Blob(['visible-pixels\\nEXIF GPS device-secret'], {{type}});
|
||||
original.name = 'camera.' + type.split('/')[1];
|
||||
let draws = 0;
|
||||
const canvas = {{
|
||||
width: 0, height: 0,
|
||||
getContext: () => ({{drawImage: () => {{draws += 1;}}}}),
|
||||
toBlob: (callback, outputType) => callback(new Blob(['sanitized-pixels'], {{type:outputType}})),
|
||||
}};
|
||||
const result = await attachment.optimizeImage(original, {{
|
||||
createImageBitmap: async () => ({{width:640,height:480,close:()=>{{}}}}),
|
||||
createCanvas: () => canvas,
|
||||
}});
|
||||
results.push({{
|
||||
type:result.type,name:result.name,text:await result.text(),
|
||||
sameObject:result===original,draws,width:canvas.width,height:canvas.height,
|
||||
}});
|
||||
}}
|
||||
process.stdout.write(JSON.stringify(results));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
|
||||
assert output == [
|
||||
{
|
||||
"type": image_type,
|
||||
"name": filename,
|
||||
"text": "sanitized-pixels",
|
||||
"sameObject": False,
|
||||
"draws": 1,
|
||||
"width": 640,
|
||||
"height": 480,
|
||||
}
|
||||
for image_type, filename in (
|
||||
("image/jpeg", "camera.jpeg"),
|
||||
("image/png", "camera.png"),
|
||||
("image/webp", "camera.webp"),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_browser_optimizer_downscales_small_file_when_decoded_pixels_exceed_budget():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
@@ -543,7 +702,7 @@ def test_new_issue_sheet_captures_screenshot_into_durable_outbox():
|
||||
assert 'id="remove-create-issue-attachment"' in html
|
||||
assert 'id="create-issue-evidence-tray"' in html
|
||||
assert 'role="toolbar"' in html
|
||||
assert 'aria-label="Evidence screenshots"' in html
|
||||
assert 'aria-label="Photo evidence"' in html
|
||||
assert 'id="move-create-issue-attachment-earlier"' in html
|
||||
assert 'id="move-create-issue-attachment-later"' in html
|
||||
assert 'id="create-issue-evidence-note-label"' in html
|
||||
@@ -570,6 +729,75 @@ def test_new_issue_sheet_captures_screenshot_into_durable_outbox():
|
||||
assert "overflow-x:hidden" in css
|
||||
|
||||
|
||||
def test_mobile_new_issue_camera_and_library_render_as_one_photo_evidence_bundle():
|
||||
html = INDEX.read_text()
|
||||
source = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="take-create-issue-photo"' in html
|
||||
assert 'accept="image/*" capture="environment"' in html
|
||||
assert 'for="take-create-issue-photo">Take photo</label>' in html
|
||||
assert 'for="create-issue-attachment">Choose existing</label>' in html
|
||||
assert 'id="create-issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple' in html
|
||||
assert "inputs: [qs('#take-create-issue-photo'), qs('#create-issue-attachment')]" in source
|
||||
controls = css.split(".photo-evidence-actions", 1)[1].split("}", 1)[0]
|
||||
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in controls
|
||||
assert "min-width:0" in controls
|
||||
|
||||
|
||||
def test_mobile_conversation_composers_offer_camera_and_library_through_one_attachment_controller():
|
||||
html = INDEX.read_text()
|
||||
source = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
composers = (
|
||||
("take-issue-comment-photo", "issue-attachment", "issueAttachmentController"),
|
||||
("take-pull-comment-photo", "pull-attachment", "pullAttachmentController"),
|
||||
("take-update-reply-photo", "update-reply-attachment", "updateReplyAttachmentController"),
|
||||
)
|
||||
for camera_id, library_id, controller in composers:
|
||||
assert f'id="{camera_id}" type="file" accept="image/*" capture="environment"' in html
|
||||
assert f'for="{camera_id}">Take photo</label>' in html
|
||||
assert f'for="{library_id}">Choose existing</label>' in html
|
||||
mount = source.split(f"const {controller} = issueAttachment.mount({{", 1)[1].split("});", 1)[0]
|
||||
assert f"inputs: [qs('#{camera_id}'), qs('#{library_id}')]" in mount
|
||||
|
||||
controls = css.split(".conversation-photo-actions", 1)[1].split("}", 1)[0]
|
||||
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in controls
|
||||
assert "min-width:0" in controls
|
||||
assert ".conversation-photo-actions .issue-attachment-trigger" in css
|
||||
assert "width:100%" in css.split(".conversation-photo-actions .issue-attachment-trigger", 1)[1].split("}", 1)[0]
|
||||
|
||||
script = f"""
|
||||
const attachment=require({json.dumps(str(ATTACHMENT))});
|
||||
class Element {{
|
||||
constructor() {{ this.listeners={{}};this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false; }}
|
||||
addEventListener(type,fn) {{ this.listeners[type]=fn; }}
|
||||
dispatch(type) {{ return this.listeners[type]({{target:this}}); }}
|
||||
}}
|
||||
const camera=new Element(),library=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element();
|
||||
library.multiple=false;
|
||||
const controller=attachment.mount({{
|
||||
input:library,inputs:[camera,library],preview,image,meta,remove,status,
|
||||
optimizeImage:async file=>file,
|
||||
createObjectURL:blob=>'blob:'+blob.name,revokeObjectURL:()=>{{}},upload:async()=>{{}},
|
||||
}});
|
||||
const photo=name=>{{const blob=new Blob([name],{{type:'image/jpeg'}});blob.name=name;return blob;}};
|
||||
;(async()=>{{
|
||||
camera.files=[photo('camera-one.jpg')]; await camera.dispatch('change');
|
||||
const afterCamera=controller.state();
|
||||
camera.files=[]; await camera.dispatch('change');
|
||||
const afterCancel=controller.state();
|
||||
library.files=[photo('library-two.jpg')]; await library.dispatch('change');
|
||||
process.stdout.write(JSON.stringify({{afterCamera,afterCancel,afterLibrary:controller.state()}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
assert output["afterCamera"]["name"] == "camera-one.jpg"
|
||||
assert output["afterCancel"] == output["afterCamera"]
|
||||
assert output["afterLibrary"]["name"] == "library-two.jpg"
|
||||
|
||||
|
||||
def test_queued_screenshot_is_hydrated_before_the_issue_editor_opens_and_failure_is_retryable():
|
||||
source = DASHBOARD.read_text()
|
||||
handler = re.search(
|
||||
@@ -751,7 +979,8 @@ def test_issue_comment_actions_upload_binary_multipart_before_posting_and_clear_
|
||||
assert "'/attachments'" in source
|
||||
assert "issueAttachmentController.prepareComment(item, body)" in source
|
||||
assert "issueAttachmentController.prepareComment(selectedIssue, body)" in source
|
||||
assert source.count("issueAttachmentController.clear();") >= 2
|
||||
assert "await conversationPhotoDrafts.complete('issue')" in source
|
||||
assert "await conversationPhotoDrafts.complete(kind)" in source
|
||||
assert "'Idempotency-Key': payload.operation_id" in source
|
||||
assert "body: issueAttachment.multipart(payload)" in source
|
||||
|
||||
@@ -763,7 +992,7 @@ def test_closing_issue_sheet_cannot_carry_a_screenshot_to_another_issue():
|
||||
source,
|
||||
re.DOTALL,
|
||||
).group("body")
|
||||
assert "issueAttachmentController.clear();" in close_body
|
||||
assert "conversationPhotoDrafts.leave('issue')" in close_body
|
||||
|
||||
|
||||
def test_attachment_runtime_is_available_in_the_offline_app_shell():
|
||||
@@ -806,16 +1035,17 @@ def test_assigned_pull_composer_offers_screenshot_preview_remove_and_reuses_opti
|
||||
assert 'id="pull-attachment-image"' in html
|
||||
assert 'id="remove-pull-attachment"' in html
|
||||
assert "const pullAttachmentController = issueAttachment.mount({" in source
|
||||
assert "pullAttachmentController.clear();" in source
|
||||
assert "await conversationPhotoDrafts.complete('pull')" in source
|
||||
|
||||
|
||||
def test_retrying_same_pull_load_preserves_screenshot_but_target_change_clears_it():
|
||||
def test_retrying_same_pull_load_restores_only_the_exact_target_draft():
|
||||
source = DASHBOARD.read_text()
|
||||
open_body = source.split("async function openPullSheet(item, trigger, offlineDetail = null) {", 1)[1].split(
|
||||
"\n function closePullSheet", 1
|
||||
)[0]
|
||||
|
||||
assert "if (!createPullSheet.sameTarget(selectedPull, item)) pullAttachmentController.clear();" in open_body
|
||||
assert "conversationPhotoDrafts.switchTo('pull'," in open_body
|
||||
assert "() => selectedPull === item" in open_body
|
||||
|
||||
|
||||
def test_pull_screenshot_comment_uploads_or_durably_admits_before_clearing_draft():
|
||||
|
||||
@@ -1,13 +1,49 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
from src import gitea_proxy, main
|
||||
|
||||
|
||||
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"mobile screenshot"
|
||||
def make_png(color="red", size=(2, 2)):
|
||||
output = io.BytesIO()
|
||||
Image.new("RGB", size, color).save(output, format="PNG")
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
PNG_BYTES = make_png()
|
||||
|
||||
|
||||
def png_with_private_metadata():
|
||||
output = io.BytesIO()
|
||||
metadata = PngImagePlugin.PngInfo()
|
||||
metadata.add_text("Location", "private coordinates")
|
||||
Image.new("RGB", (2, 2), "red").save(output, format="PNG", pnginfo=metadata)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_attachment_sanitization_keeps_event_loop_responsive(monkeypatch):
|
||||
def slow_sanitizer(_content_type, content):
|
||||
time.sleep(0.1)
|
||||
return content
|
||||
|
||||
monkeypatch.setattr(main, "sanitize_image", slow_sanitizer)
|
||||
started = time.monotonic()
|
||||
ticks = []
|
||||
|
||||
async def tick():
|
||||
await asyncio.sleep(0.01)
|
||||
ticks.append(time.monotonic() - started)
|
||||
|
||||
await asyncio.gather(main._sanitize_attachment("image/png", b"image"), tick())
|
||||
|
||||
assert ticks[0] < 0.05
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -58,6 +94,30 @@ async def test_attachment_endpoint_uploads_valid_screenshot_to_assigned_issue(mo
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_attachment_endpoint_strips_metadata_before_upstream(monkeypatch):
|
||||
uploaded = []
|
||||
source = png_with_private_metadata()
|
||||
|
||||
async def upload(_repository, _number, filename, content_type, content):
|
||||
uploaded.append(content)
|
||||
return {"name": filename, "url": "https://forge.example/a.png", "size": len(content)}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "upload_assigned_issue_attachment", upload)
|
||||
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/17/attachments",
|
||||
files={"file": ("evidence.png", source, "image/png")},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert uploaded and uploaded[0] != source
|
||||
with Image.open(io.BytesIO(uploaded[0])) as sanitized:
|
||||
assert sanitized.info.get("Location") is None
|
||||
assert sanitized.getpixel((0, 0)) == (255, 0, 0)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_attachment_endpoint_accepts_binary_multipart_without_base64_expansion(monkeypatch):
|
||||
calls = []
|
||||
@@ -186,7 +246,7 @@ async def test_attachment_endpoint_rejects_changed_upload_for_used_key(monkeypat
|
||||
json={
|
||||
"filename": "checkout.png",
|
||||
"content_type": "image/png",
|
||||
"data": base64.b64encode(PNG_BYTES + b" changed").decode("ascii"),
|
||||
"data": base64.b64encode(make_png("blue")).decode("ascii"),
|
||||
},
|
||||
headers={"Idempotency-Key": "attachment-conflict-471"},
|
||||
)
|
||||
@@ -198,7 +258,7 @@ async def test_attachment_endpoint_rejects_changed_upload_for_used_key(monkeypat
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_attachment_endpoint_admits_a_normal_phone_screenshot(monkeypatch):
|
||||
screenshot = b"\x89PNG\r\n\x1a\n" + (b"x" * (100 * 1024))
|
||||
screenshot = make_png(size=(390, 844))
|
||||
|
||||
async def upload(_repository, _number, filename, _content_type, content):
|
||||
return {
|
||||
|
||||
@@ -151,3 +151,35 @@ def test_mobile_editor_dialog_is_accessible_touch_sized_and_available_offline():
|
||||
assert "min-height:44px" in css
|
||||
assert "max-width:100%" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
||||
|
||||
def test_mobile_conversation_photo_editor_is_available_in_every_composer():
|
||||
html = INDEX.read_text()
|
||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
composers = (
|
||||
("issue-attachment", "issueAttachmentController"),
|
||||
("pull-attachment", "pullAttachmentController"),
|
||||
("update-reply-attachment", "updateReplyAttachmentController"),
|
||||
)
|
||||
for attachment_id, controller in composers:
|
||||
assert f'id="edit-{attachment_id}" type="button">Edit photo</button>' in html
|
||||
mount = dashboard.split(
|
||||
f"const {controller} = issueAttachment.mount({{", 1
|
||||
)[1].split("\n });", 1)[0]
|
||||
assert f"edit: qs('#edit-{attachment_id}')" in mount
|
||||
assert "dialog: qs('#issue-evidence-editor')" in mount
|
||||
assert "appliedMessage: 'Edited photo flattened and ready to send.'" in mount
|
||||
|
||||
assert '<h3 id="issue-evidence-editor-heading">Edit selected photo</h3>' in html
|
||||
assert "Edits stay on this device until you send or file." in html
|
||||
assert (
|
||||
'</form>\n </section>\n</div>\n'
|
||||
'<section class="issue-evidence-editor" id="issue-evidence-editor"'
|
||||
) in html, "the shared editor must not be hidden inside the New issue sheet"
|
||||
assert (
|
||||
".conversation-evidence-review .issue-evidence-review-actions "
|
||||
"{ grid-template-columns:repeat(4,minmax(0,1fr)); }"
|
||||
) in css
|
||||
assert "@media (max-width:390px)" in css
|
||||
@@ -65,6 +65,30 @@ def test_deferrals_are_durable_revisioned_idempotent_and_account_scoped(tmp_path
|
||||
) == {"revision": 2, "records": {}}
|
||||
|
||||
|
||||
def test_next_day_today_handoff_is_preserved_without_changing_legacy_deferrals(tmp_path):
|
||||
store = LaterStore(tmp_path / "later.sqlite3")
|
||||
|
||||
legacy = store.apply(
|
||||
"timmy", "legacy", "defer", "issue:r:1:",
|
||||
wake_at="2026-08-17T09:00:00.000Z",
|
||||
)
|
||||
handoff = store.apply(
|
||||
"timmy", "wrap-up", "defer", "issue:r:2:",
|
||||
wake_at="2026-08-17T09:00:00.000Z",
|
||||
handoff="today",
|
||||
)
|
||||
|
||||
assert legacy["records"]["issue:r:1:"] == "2026-08-17T09:00:00.000Z"
|
||||
assert handoff["records"] == {
|
||||
"issue:r:1:": "2026-08-17T09:00:00.000Z",
|
||||
"issue:r:2:": {
|
||||
"wake_at": "2026-08-17T09:00:00.000Z",
|
||||
"handoff": "today",
|
||||
},
|
||||
}
|
||||
assert LaterStore(store.path).get("timmy") == handoff
|
||||
|
||||
|
||||
def test_initialized_later_reads_remain_available_during_a_planning_write(tmp_path):
|
||||
path = tmp_path / "later.sqlite3"
|
||||
store = LaterStore(path, timeout=0.05)
|
||||
|
||||
@@ -97,6 +97,35 @@ sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
|
||||
assert result["pending"] == []
|
||||
|
||||
|
||||
def test_today_handoff_intent_replays_through_account_sync():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map(); const requests=[];
|
||||
const sync=createLaterSync({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy',createOperationId:()=> 'wrap-up-op',
|
||||
fetchJson:async(_url,options={{}})=>{{
|
||||
const body=JSON.parse(options.body); requests.push(body);
|
||||
return {{revision:1,records:{{[body.operations[0].item_id]:{{wake_at:body.operations[0].wake_at,handoff:body.operations[0].handoff}}}},accepted_operation_ids:['wrap-up-op'],duplicate_operation_ids:[],rejected_operations:[]}};
|
||||
}},onRemoteRecords:records=>{{globalThis.records=records}},onStatus:()=>{{}},
|
||||
}});
|
||||
sync.enqueue('defer','issue:r:2:','2026-08-17T09:00:00.000Z','today');
|
||||
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{requests,records:globalThis.records}}));}})();
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert result["requests"] == [{"operations": [{
|
||||
"operation_id": "wrap-up-op",
|
||||
"action": "defer",
|
||||
"item_id": "issue:r:2:",
|
||||
"wake_at": "2026-08-17T09:00:00.000Z",
|
||||
"handoff": "today",
|
||||
"base_revision": 0,
|
||||
}]}]
|
||||
assert result["records"] == {"issue:r:2:": {
|
||||
"wake_at": "2026-08-17T09:00:00.000Z", "handoff": "today",
|
||||
}}
|
||||
|
||||
|
||||
def test_later_queue_expires_ancient_edits_before_replay():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
@@ -294,6 +323,26 @@ sync.startLifecycle({{window:{{addEventListener:(n,h)=>handlers[n]=h}},document:
|
||||
}
|
||||
|
||||
|
||||
def test_unsynced_wrap_up_handoff_migrates_after_browser_restart():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map();const requests=[];
|
||||
const sync=createLaterSync({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy',createOperationId:()=> 'migrated-handoff',
|
||||
fetchJson:async(_url,options={{}})=>{{const body=JSON.parse(options.body);requests.push(body);return {{revision:1,records:{{}},accepted_operation_ids:['migrated-handoff'],duplicate_operation_ids:[],rejected_operations:[]}}}},
|
||||
onRemoteRecords:()=>{{}},onStatus:()=>{{}},
|
||||
}});
|
||||
sync.migrate({{'issue:r:1:':{{wake_at:'2026-08-17T09:00:00.000Z',handoff:'today'}}}});
|
||||
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify(requests));}})();
|
||||
"""
|
||||
assert run_node(script) == [{"operations": [{
|
||||
"operation_id": "migrated-handoff", "action": "defer",
|
||||
"item_id": "issue:r:1:", "wake_at": "2026-08-17T09:00:00.000Z",
|
||||
"handoff": "today", "base_revision": 0,
|
||||
}]}]
|
||||
|
||||
|
||||
def test_later_work_emits_local_changes_adopts_remote_truth_and_retires_expiry():
|
||||
script = f"""
|
||||
const createLaterWork=require({json.dumps(str(LATER_WORK))});
|
||||
@@ -324,6 +373,45 @@ process.stdout.write(JSON.stringify({{changes,expired,remote:remote.later,awake:
|
||||
assert result["expired"] == [["issue:stackchain/api:17:"]]
|
||||
|
||||
|
||||
def test_later_work_preserves_synced_today_handoff_until_it_wakes():
|
||||
script = f"""
|
||||
const createLaterWork=require({json.dumps(str(LATER_WORK))});
|
||||
const values=new Map(); const changes=[]; const expired=[];
|
||||
let clock=new Date('2026-08-16T20:00:00Z');
|
||||
const item={{kind:'issue',repository:'stackchain/dashboard',number:992,title:'Commitment'}};
|
||||
const work=createLaterWork({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy',now:()=>clock,setTimer:()=>1,clearTimer:()=>{{}},
|
||||
onChange:(action,id,wakeAt,handoff)=>changes.push([action,id,wakeAt,handoff||null]),
|
||||
onExpire:(ids,handoffs)=>expired.push([ids,handoffs]),
|
||||
}});
|
||||
work.defer(item,new Date('2026-08-17T09:00:00Z'),{{handoff:'today'}});
|
||||
const stored=work.read();
|
||||
clock=new Date('2026-08-17T09:00:01Z');
|
||||
const awake=work.partition([item]);
|
||||
process.stdout.write(JSON.stringify({{stored,changes,expired,awake:awake.active}}));
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert result["stored"] == {
|
||||
"issue:stackchain/dashboard:992:": {
|
||||
"wake_at": "2026-08-17T09:00:00.000Z",
|
||||
"handoff": "today",
|
||||
}
|
||||
}
|
||||
assert result["changes"] == [[
|
||||
"defer", "issue:stackchain/dashboard:992:",
|
||||
"2026-08-17T09:00:00.000Z", "today",
|
||||
]]
|
||||
assert result["expired"] == [[
|
||||
["issue:stackchain/dashboard:992:"],
|
||||
["issue:stackchain/dashboard:992:"],
|
||||
]]
|
||||
assert result["awake"] == [{
|
||||
"kind": "issue", "repository": "stackchain/dashboard",
|
||||
"number": 992, "title": "Commitment",
|
||||
}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
html = await dashboard()
|
||||
@@ -332,9 +420,9 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
assert 'id="later-sync-status"' in html
|
||||
assert "const laterSync = createLaterSync({" in html
|
||||
assert "onRemoteRecords: records =>" in html
|
||||
assert "onChange: (action, itemId, wakeAt) =>" in html
|
||||
assert "onExpire: ids =>" in html
|
||||
assert "laterSync.enqueue(action, itemId, wakeAt)" in html
|
||||
assert "onChange: (action, itemId, wakeAt, handoff) =>" in html
|
||||
assert "onExpire: (ids, handoffs) =>" in html
|
||||
assert "laterSync.enqueue(action, itemId, wakeAt, handoff)" in html
|
||||
assert "ids.map(id => laterSync.enqueue('restore', id)).every(Boolean)" in html
|
||||
assert "laterSync.migrate(laterWork.read());" in html
|
||||
assert "laterSync.flush();" in html
|
||||
@@ -347,5 +435,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-v108" in source
|
||||
assert "stackchain-dashboard-shell-v117" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -569,6 +569,51 @@ async function destination(continuation) {{
|
||||
}
|
||||
|
||||
|
||||
def test_token_and_passkey_login_resume_only_the_private_share_bundle():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
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}},
|
||||
}};
|
||||
async function destination(continuation, authentication) {{
|
||||
let replaced = null;
|
||||
const controller = createLoginController({{
|
||||
form:{{reset:()=>{{}}}}, status:{{textContent:''}}, button:{{disabled:false}},
|
||||
passkeyButton:{{disabled:false}}, credentials:{{get:async()=>credential}},
|
||||
fetchImpl:async url => url.endsWith('/options')
|
||||
? new Response(JSON.stringify({{challenge:'AQID',allowCredentials:[]}}),
|
||||
{{status:200,headers:{{'Content-Type':'application/json'}}}})
|
||||
: new Response('{{}}',{{status:200}}),
|
||||
location:{{replace:value=>replaced=value}}, continuation,
|
||||
}});
|
||||
if (authentication === 'passkey') await controller.signInWithPasskey('Phone');
|
||||
else await controller.submit('operator-token');
|
||||
return replaced;
|
||||
}}
|
||||
(async()=>process.stdout.write(JSON.stringify({{
|
||||
token:await destination('./?launch=new&shared=bundle','token'),
|
||||
passkey:await destination('./?launch=new&shared=bundle','passkey'),
|
||||
extra:await destination('./?launch=new&shared=bundle&title=leak','token'),
|
||||
duplicate:await destination('./?launch=new&shared=bundle&shared=bundle','token'),
|
||||
wrongLaunch:await destination('./?launch=agenda&shared=bundle','token'),
|
||||
}})))().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"token": "./?launch=new&shared=bundle",
|
||||
"passkey": "./?launch=new&shared=bundle",
|
||||
"extra": "./",
|
||||
"duplicate": "./",
|
||||
"wrongLaunch": "./",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_login_page_loads_rate_limit_controller():
|
||||
html = await login()
|
||||
|
||||
@@ -256,4 +256,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-v108" in worker
|
||||
assert "stackchain-dashboard-shell-v117" 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-v108" in worker
|
||||
assert "stackchain-dashboard-shell-v117" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-create-issue-nav.js"
|
||||
FRONTEND = CONTROLLER.parent
|
||||
|
||||
|
||||
def run_navigation(scenario: str) -> dict:
|
||||
script = f"""
|
||||
const createNavigation = require({json.dumps(str(CONTROLLER))});
|
||||
class FakeElement {{
|
||||
constructor(name) {{ this.name=name; this.listeners={{}}; this.attributes={{}}; this.scrolls=[]; this.hidden=false; }}
|
||||
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
|
||||
removeEventListener(name) {{ delete this.listeners[name]; }}
|
||||
click() {{ this.listeners.click?.({{preventDefault() {{}}}}); }}
|
||||
setAttribute(name,value) {{ this.attributes[name]=value; }}
|
||||
removeAttribute(name) {{ delete this.attributes[name]; }}
|
||||
scrollIntoView(options) {{ this.scrolls.push(options); }}
|
||||
}}
|
||||
{scenario}
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_mobile_create_issue_navigation_gates_file_and_navigates_with_reduced_motion():
|
||||
result = run_navigation("""
|
||||
const buttons=Object.fromEntries(['describe','evidence','file'].map(name=>[name,new FakeElement(name)]));
|
||||
const targets=Object.fromEntries(['describe','evidence','file'].map(name=>[name,new FakeElement(name)]));
|
||||
targets.file.hidden=true;
|
||||
const navigation=createNavigation({buttons,targets,filing:targets.file,prefersReducedMotion:()=>true});
|
||||
navigation.start();
|
||||
const initiallyDisabled=buttons.file.attributes['aria-disabled'];
|
||||
buttons.file.click();
|
||||
const beforeReveal=targets.file.scrolls.length;
|
||||
targets.file.hidden=false;
|
||||
navigation.setFilingAvailable(true);
|
||||
buttons.file.click();
|
||||
const selected=Object.fromEntries(Object.entries(buttons).map(([name,button])=>[name,button.attributes['aria-current']||null]));
|
||||
navigation.stop();
|
||||
process.stdout.write(JSON.stringify({initiallyDisabled,beforeReveal,scrolls:targets.file.scrolls,selected,listeners:Object.values(buttons).map(button=>Object.keys(button.listeners).length)}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"initiallyDisabled": "true",
|
||||
"beforeReveal": 0,
|
||||
"scrolls": [{"block": "start", "behavior": "auto"}],
|
||||
"selected": {"describe": None, "evidence": None, "file": "location"},
|
||||
"listeners": [0, 0, 0],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_create_issue_navigation_tracks_visible_panel_sections_and_resets():
|
||||
result = run_navigation("""
|
||||
const buttons=Object.fromEntries(['describe','evidence','file'].map(name=>[name,new FakeElement(name)]));
|
||||
const targets=Object.fromEntries(['describe','evidence','file'].map(name=>[name,new FakeElement(name)]));
|
||||
let callback; let observedRoot; let disconnected=0;
|
||||
const root=new FakeElement('panel');
|
||||
const navigation=createNavigation({buttons,targets,root,filing:targets.file,observe(handler,observed,rootOption){callback=handler; observedRoot=rootOption; return {disconnect(){disconnected++;}};}});
|
||||
navigation.start();
|
||||
callback([{target:targets.describe,isIntersecting:true,intersectionRatio:.2},{target:targets.evidence,isIntersecting:true,intersectionRatio:.8}]);
|
||||
navigation.reset(false);
|
||||
const selected=Object.fromEntries(Object.entries(buttons).map(([name,button])=>[name,button.attributes['aria-current']||null]));
|
||||
navigation.stop();
|
||||
process.stdout.write(JSON.stringify({selected,fileDisabled:buttons.file.attributes['aria-disabled'],observedRoot:observedRoot.name,disconnected}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"selected": {"describe": "location", "evidence": None, "file": None},
|
||||
"fileDisabled": "true",
|
||||
"observedRoot": "panel",
|
||||
"disconnected": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_new_issue_sheet_ships_mobile_navigation_in_lazy_offline_feature():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
dashboard_js = (FRONTEND / "dashboard.js").read_text()
|
||||
navigation_js = CONTROLLER.read_text()
|
||||
bundle = (FRONTEND.parent / "src" / "frontend_bundle.py").read_text()
|
||||
|
||||
assert '<nav class="mobile-create-issue-nav"' in html
|
||||
assert 'aria-label="New issue sections"' in html
|
||||
for name, label in (("describe", "Describe"), ("evidence", "Evidence"), ("file", "File")):
|
||||
assert f'data-create-issue-section="{name}"' in html
|
||||
assert f">{label}</button>" in html
|
||||
assert 'id="create-issue-describe"' in html
|
||||
assert 'id="create-issue-attachment"' in html
|
||||
assert 'id="create-issue-filing"' in html
|
||||
assert '<script src="static/mobile-create-issue-nav.js"></script>' in html
|
||||
assert '"static/mobile-create-issue-nav.js"' in bundle
|
||||
assert "attachMobileCreateIssueNavigation({document, window})" in navigation_js
|
||||
assert "attachMobileCreateIssueNavigation({document, window})" not in dashboard_js
|
||||
assert "attributeFilter:['hidden']" in navigation_js
|
||||
assert "setTimeout(() => navigation.navigate('file'), 0)" in navigation_js
|
||||
assert "navigation.navigate('file')" in navigation_js
|
||||
assert "navigation.reset(" in navigation_js
|
||||
assert ".mobile-create-issue-nav" in css
|
||||
assert "position:sticky" in css
|
||||
assert "min-height:44px" in css
|
||||
assert "grid-template-columns:repeat(3,minmax(0,1fr))" in css
|
||||
assert "env(safe-area-inset-top)" in css
|
||||
@@ -0,0 +1,350 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RECOVERY = ROOT / "frontend" / "mobile-delivery-recovery.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
result = subprocess.run(
|
||||
["node", "-e", script],
|
||||
cwd=ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_recovery_orders_deliveries_by_safest_required_intervention():
|
||||
script = f"""
|
||||
const createRecovery = require({json.dumps(str(RECOVERY))});
|
||||
const recovery = createRecovery({{
|
||||
getItems: () => [
|
||||
{{outbox_id:'waiting', delivery_state:'waiting', title:'Waiting'}},
|
||||
{{outbox_id:'uncertain', delivery_state:'uncertain', title:'Uncertain'}},
|
||||
{{outbox_id:'attention', status:'attention', title:'Attention'}},
|
||||
{{outbox_id:'authorization', status:'authorization', title:'Authorization'}},
|
||||
],
|
||||
}});
|
||||
process.stdout.write(JSON.stringify(recovery.snapshot()));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"count": 4,
|
||||
"current": {
|
||||
"id": "authorization",
|
||||
"state": "authorization",
|
||||
"primary": "Authorize & send",
|
||||
"position": 1,
|
||||
"total": 4,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_recovery_advances_only_after_the_current_delivery_is_resolved():
|
||||
script = f"""
|
||||
const createRecovery = require({json.dumps(str(RECOVERY))});
|
||||
(async () => {{
|
||||
let items = [
|
||||
{{outbox_id:'authorize', status:'authorization', title:'Approve review'}},
|
||||
{{outbox_id:'retry', delivery_state:'waiting', title:'Post comment'}},
|
||||
];
|
||||
let resolves = false;
|
||||
const recovery = createRecovery({{
|
||||
getItems: () => items,
|
||||
activate: async item => {{
|
||||
if (resolves) items = items.filter(candidate => candidate.outbox_id !== item.outbox_id);
|
||||
return resolves;
|
||||
}},
|
||||
}});
|
||||
recovery.open();
|
||||
const retained = await recovery.activate();
|
||||
resolves = true;
|
||||
const advanced = await recovery.activate();
|
||||
process.stdout.write(JSON.stringify({{retained, advanced}}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"retained": {
|
||||
"count": 2,
|
||||
"current": {
|
||||
"id": "authorize",
|
||||
"state": "authorization",
|
||||
"primary": "Authorize & send",
|
||||
"position": 1,
|
||||
"total": 2,
|
||||
},
|
||||
},
|
||||
"advanced": {
|
||||
"count": 1,
|
||||
"current": {
|
||||
"id": "retry",
|
||||
"state": "waiting",
|
||||
"primary": "Retry now",
|
||||
"position": 1,
|
||||
"total": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_recovery_keeps_one_attempt_locked_until_delivery_settles():
|
||||
script = f"""
|
||||
const createRecovery = require({json.dumps(str(RECOVERY))});
|
||||
(async () => {{
|
||||
let finish;
|
||||
let attempts = 0;
|
||||
const settled = new Promise(resolve => {{finish = resolve}});
|
||||
const element = () => ({{textContent:'', disabled:false}});
|
||||
const elements = {{
|
||||
dialog: {{open:false, showModal(){{this.open=true}}, close(){{this.open=false}}}},
|
||||
title:element(), destination:element(), reason:element(), progress:element(),
|
||||
action:element(), status:element(),
|
||||
}};
|
||||
const recovery = createRecovery({{
|
||||
getItems: () => [{{outbox_id:'retry', delivery_state:'waiting', title:'Post comment'}}],
|
||||
activate: async () => {{attempts += 1; await settled; return true}},
|
||||
elements,
|
||||
}});
|
||||
recovery.open();
|
||||
const first = recovery.activate();
|
||||
const second = recovery.activate();
|
||||
await Promise.resolve();
|
||||
const pending = {{attempts, disabled:elements.action.disabled, status:elements.status.textContent}};
|
||||
finish();
|
||||
await Promise.all([first, second]);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
pending, attempts, disabled:elements.action.disabled,
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"pending": {"attempts": 1, "disabled": True, "status": "Working…"},
|
||||
"attempts": 1,
|
||||
"disabled": False,
|
||||
}
|
||||
|
||||
|
||||
def test_attention_handoff_closes_modal_focuses_destination_and_awaits_work():
|
||||
script = f"""
|
||||
const createRecovery = require({json.dumps(str(RECOVERY))});
|
||||
(async () => {{
|
||||
let finish;
|
||||
let handoffs = 0;
|
||||
const settled = new Promise(resolve => {{finish = resolve}});
|
||||
const destination = {{
|
||||
focused:false, scrolled:false,
|
||||
focus(){{this.focused=true}}, scrollIntoView(){{this.scrolled=true}},
|
||||
async onclick(){{handoffs += 1; await settled; return true}},
|
||||
}};
|
||||
global.document = {{
|
||||
getElementById: () => null,
|
||||
querySelector: selector => selector.includes('draft-resume') ? destination : null,
|
||||
}};
|
||||
const element = () => ({{textContent:'', disabled:false}});
|
||||
const elements = {{
|
||||
dialog: {{open:false, showModal(){{this.open=true}}, close(){{this.open=false}}}},
|
||||
title:element(), destination:element(), reason:element(), progress:element(),
|
||||
action:element(), status:element(),
|
||||
}};
|
||||
const recovery = createRecovery({{
|
||||
getItems: () => [{{outbox_id:'review', status:'attention', title:'Review feedback'}}],
|
||||
getIndex: () => 3,
|
||||
elements,
|
||||
}});
|
||||
recovery.open();
|
||||
const first = recovery.activate();
|
||||
const second = recovery.activate();
|
||||
await Promise.resolve();
|
||||
const pending = {{
|
||||
dialogOpen:elements.dialog.open, disabled:elements.action.disabled,
|
||||
focused:destination.focused, scrolled:destination.scrolled, handoffs,
|
||||
}};
|
||||
finish();
|
||||
await Promise.all([first, second]);
|
||||
process.stdout.write(JSON.stringify({{pending, handoffs, dialogOpen:elements.dialog.open}}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"pending": {
|
||||
"dialogOpen": False,
|
||||
"disabled": True,
|
||||
"focused": True,
|
||||
"scrolled": True,
|
||||
"handoffs": 1,
|
||||
},
|
||||
"handoffs": 1,
|
||||
"dialogOpen": False,
|
||||
}
|
||||
|
||||
|
||||
def test_failed_attention_handoff_reopens_the_same_retryable_item():
|
||||
script = f"""
|
||||
const createRecovery = require({json.dumps(str(RECOVERY))});
|
||||
(async () => {{
|
||||
const destination = {{
|
||||
focus(){{}}, scrollIntoView(){{}},
|
||||
async onclick(){{throw new Error('Latest checklist unavailable')}},
|
||||
}};
|
||||
global.document = {{
|
||||
getElementById: () => null,
|
||||
querySelector: () => destination,
|
||||
}};
|
||||
const element = () => ({{textContent:'', disabled:false}});
|
||||
const elements = {{
|
||||
dialog: {{open:false, showModal(){{this.open=true}}, close(){{this.open=false}}}},
|
||||
title:element(), destination:element(), reason:element(), progress:element(),
|
||||
action:element(), status:element(),
|
||||
}};
|
||||
const recovery = createRecovery({{
|
||||
getItems: () => [{{outbox_id:'conflict', checklist_conflict:true, title:'Checklist conflict'}}],
|
||||
getIndex: () => 0,
|
||||
elements,
|
||||
}});
|
||||
recovery.open();
|
||||
const result = await recovery.activate();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
result, dialogOpen:elements.dialog.open, status:elements.status.textContent,
|
||||
action:elements.action.textContent,
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"count": 1,
|
||||
"current": {
|
||||
"id": "conflict",
|
||||
"state": "attention",
|
||||
"primary": "Review changes",
|
||||
"position": 1,
|
||||
"total": 1,
|
||||
},
|
||||
},
|
||||
"dialogOpen": True,
|
||||
"status": "Latest checklist unavailable Try again.",
|
||||
"action": "Review changes",
|
||||
}
|
||||
|
||||
|
||||
def test_recovery_retains_failed_delivery_with_actionable_status():
|
||||
script = f"""
|
||||
const createRecovery = require({json.dumps(str(RECOVERY))});
|
||||
(async () => {{
|
||||
const element = () => ({{textContent:'', disabled:false}});
|
||||
const elements = {{
|
||||
dialog: {{open:false, showModal(){{this.open=true}}, close(){{this.open=false}}}},
|
||||
title:element(), destination:element(), reason:element(), progress:element(),
|
||||
action:element(), status:element(),
|
||||
}};
|
||||
const recovery = createRecovery({{
|
||||
getItems: () => [{{outbox_id:'retry', delivery_state:'waiting', title:'Post comment'}}],
|
||||
activate: async () => {{throw new Error('Network unavailable')}},
|
||||
elements,
|
||||
}});
|
||||
recovery.open();
|
||||
const result = await recovery.activate();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
result, disabled:elements.action.disabled, status:elements.status.textContent,
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"count": 1,
|
||||
"current": {
|
||||
"id": "retry",
|
||||
"state": "waiting",
|
||||
"primary": "Retry now",
|
||||
"position": 1,
|
||||
"total": 1,
|
||||
},
|
||||
},
|
||||
"disabled": False,
|
||||
"status": "Network unavailable Try again.",
|
||||
}
|
||||
|
||||
|
||||
def test_recovery_renders_delivery_context_and_hands_off_when_cleared():
|
||||
script = f"""
|
||||
const createRecovery = require({json.dumps(str(RECOVERY))});
|
||||
(async () => {{
|
||||
let items = [{{
|
||||
outbox_id:'close-7', status:'authorization', outbox_kind:'issue-close',
|
||||
title:'Close resolved incident', repository:'stackchain/ops',
|
||||
details:'#7', last_attempt_error:'Fresh authorization expired',
|
||||
}}];
|
||||
let completed = 0;
|
||||
const element = () => ({{textContent:'', hidden:false}});
|
||||
const elements = {{
|
||||
dialog: {{open:false, showModal(){{this.open=true}}, close(){{this.open=false}}}},
|
||||
title: element(), destination: element(), reason: element(), progress: element(),
|
||||
action: element(), status: element(),
|
||||
}};
|
||||
const recovery = createRecovery({{
|
||||
getItems: () => items,
|
||||
activate: async () => {{items = []; return true}},
|
||||
onComplete: () => {{completed += 1}},
|
||||
elements,
|
||||
}});
|
||||
const opened = recovery.open();
|
||||
const rendered = {{
|
||||
opened, dialogOpen:elements.dialog.open, title:elements.title.textContent,
|
||||
destination:elements.destination.textContent, reason:elements.reason.textContent,
|
||||
progress:elements.progress.textContent, action:elements.action.textContent,
|
||||
}};
|
||||
const cleared = await recovery.activate();
|
||||
process.stdout.write(JSON.stringify({{rendered, cleared, completed, dialogOpen:elements.dialog.open}}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"rendered": {
|
||||
"opened": "opened",
|
||||
"dialogOpen": True,
|
||||
"title": "Close resolved incident",
|
||||
"destination": "stackchain/ops · #7",
|
||||
"reason": "Fresh authorization expired",
|
||||
"progress": "Delivery 1 of 1 · Authorization required",
|
||||
"action": "Authorize & close",
|
||||
},
|
||||
"cleared": {"count": 0, "current": None},
|
||||
"completed": 1,
|
||||
"dialogOpen": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_packages_phone_safe_guided_delivery_recovery():
|
||||
html = await dashboard()
|
||||
service_worker = (ROOT / "frontend" / "service-worker.js").read_text()
|
||||
bundle = (ROOT / "src" / "frontend_bundle.py").read_text()
|
||||
|
||||
assert '<dialog id="mobile-delivery-recovery"' in html
|
||||
assert 'aria-labelledby="mobile-delivery-recovery-title"' in html
|
||||
assert 'id="mobile-delivery-recovery-action"' in html
|
||||
assert '<script src="static/mobile-delivery-recovery.js"></script>' in html
|
||||
assert "const mobileDeliveryRecovery = createMobileDeliveryRecovery({" in html
|
||||
assert "activate: recoverMobileDelivery," in html
|
||||
assert "button.onclick=async()=>" in html
|
||||
assert "await (authored ? authoredOutbox : issueOutbox).retry(item.outbox_id, activeFlushLogin)" in html
|
||||
assert "(authored ? applyAuthoredOutboxResult : applyOutboxResult)(result)" in html
|
||||
assert "openDelivery: () => mobileDeliveryRecovery.open()" in html
|
||||
assert "mobileStartDay.completePhase('delivery')" in html
|
||||
assert "BASE + 'static/mobile-delivery-recovery.js'" in service_worker
|
||||
assert '"static/mobile-delivery-recovery.js"' in bundle
|
||||
assert ".mobile-delivery-recovery-panel" in html
|
||||
assert "min-height:48px" in html
|
||||
assert "env(safe-area-inset-bottom)" in html
|
||||
@@ -197,6 +197,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
root = MODULE.parents[1]
|
||||
html = (root / "frontend" / "index.html").read_text()
|
||||
dashboard = (root / "frontend" / "dashboard.js").read_text()
|
||||
storage_module = (root / "frontend" / "device-storage.js").read_text()
|
||||
worker = (root / "frontend" / "service-worker.js").read_text()
|
||||
css = (root / "frontend" / "dashboard.css").read_text()
|
||||
|
||||
@@ -208,13 +209,21 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
assert 'id="device-setup-sheet"' in html
|
||||
assert 'aria-labelledby="device-setup-heading"' in html
|
||||
assert all(f'id="device-setup-{step}"' in html for step in ("install", "offline", "push", "deadline"))
|
||||
assert 'id="device-storage-summary"' in html
|
||||
assert 'id="device-storage-detail"' in html
|
||||
assert 'id="clear-device-caches"' in html
|
||||
assert 'id="clear-private-device-data"' in html
|
||||
assert '<script src="static/private-device-data.js"></script>' in html
|
||||
assert '<script src="static/device-storage.js"></script>' in html
|
||||
assert 'id="device-setup-deadline-hour"' in html
|
||||
assert '<script src="static/mobile-device-setup.js"></script>' in html
|
||||
assert "createMobileDeviceSetup.mount({" in dashboard
|
||||
assert "createDeviceStorage.mount(document)" in dashboard
|
||||
assert "clearPrivateDeviceData:root.stackchainPrivateDeviceData" in storage_module
|
||||
assert "promptStorage:localStorage" in dashboard
|
||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v108" in worker
|
||||
assert "stackchain-dashboard-shell-v117" in worker
|
||||
assert ".device-setup-panel" in css
|
||||
assert ".device-readiness-card" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-find-work-nav.js"
|
||||
FRONTEND = CONTROLLER.parent
|
||||
|
||||
|
||||
def run_navigation(scenario: str) -> dict:
|
||||
script = f"""
|
||||
const createNavigation = require({json.dumps(str(CONTROLLER))});
|
||||
class FakeElement {{
|
||||
constructor(name) {{ this.name=name; this.listeners={{}}; this.attributes={{}}; this.hidden=false; this.disabled=false; this.focused=0; }}
|
||||
addEventListener(name, callback) {{ (this.listeners[name] ||= []).push(callback); }}
|
||||
removeEventListener(name, callback) {{ this.listeners[name]=(this.listeners[name]||[]).filter(item=>item!==callback); }}
|
||||
click() {{ for (const callback of this.listeners.click||[]) callback({{preventDefault(){{}}}}); }}
|
||||
setAttribute(name,value) {{ this.attributes[name]=value; }}
|
||||
removeAttribute(name) {{ delete this.attributes[name]; }}
|
||||
focus() {{ this.focused++; }}
|
||||
}}
|
||||
class FakeEvents {{
|
||||
constructor() {{ this.listeners={{}}; }}
|
||||
addEventListener(name, callback) {{ (this.listeners[name] ||= []).push(callback); }}
|
||||
removeEventListener(name, callback) {{ this.listeners[name]=(this.listeners[name]||[]).filter(item=>item!==callback); }}
|
||||
emit(name,event) {{ for (const callback of this.listeners[name]||[]) callback(event); }}
|
||||
}}
|
||||
{scenario}
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_find_work_navigation_gates_review_and_fit_until_work_is_selected():
|
||||
result = run_navigation("""
|
||||
const names=['discover','review','fit'];
|
||||
const buttons=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
|
||||
const sections=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
|
||||
const events=new FakeEvents();
|
||||
const history={state:{taskOverlay:'find'}, pushes:[], backs:0,
|
||||
pushState(state){this.state=state;this.pushes.push(state);}, back(){this.backs++;}};
|
||||
const navigation=createNavigation({buttons,sections,history,eventTarget:events,onStageChange:()=>{}});
|
||||
navigation.start();
|
||||
const initial={stage:navigation.stage(),reviewDisabled:buttons.review.disabled,fitDisabled:buttons.fit.disabled,hidden:Object.fromEntries(names.map(name=>[name,sections[name].hidden]))};
|
||||
navigation.sync({selectedCount:2,recovery:false});
|
||||
buttons.review.click();
|
||||
buttons.fit.click();
|
||||
process.stdout.write(JSON.stringify({initial,stage:navigation.stage(),pushes:history.pushes,selected:Object.fromEntries(names.map(name=>[name,buttons[name].attributes['aria-current']||null]))}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"initial": {
|
||||
"stage": "discover",
|
||||
"reviewDisabled": True,
|
||||
"fitDisabled": True,
|
||||
"hidden": {"discover": False, "review": True, "fit": True},
|
||||
},
|
||||
"stage": "fit",
|
||||
"pushes": [
|
||||
{"taskOverlay": "find", "findWorkStage": "review"},
|
||||
{"taskOverlay": "find", "findWorkStage": "fit"},
|
||||
],
|
||||
"selected": {"discover": None, "review": None, "fit": "step"},
|
||||
}
|
||||
|
||||
|
||||
def test_browser_back_moves_fit_to_review_to_discover_without_losing_selection():
|
||||
result = run_navigation("""
|
||||
const names=['discover','review','fit'];
|
||||
const buttons=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
|
||||
const sections=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
|
||||
const events=new FakeEvents(); const changes=[];
|
||||
const history={state:{taskOverlay:'find'},pushState(state){this.state=state;},back(){}};
|
||||
const navigation=createNavigation({buttons,sections,history,eventTarget:events,onStageChange:stage=>changes.push(stage)});
|
||||
navigation.start(); navigation.sync({selectedCount:1,recovery:false}); navigation.go('review'); navigation.go('fit');
|
||||
events.emit('popstate',{state:{taskOverlay:'find',findWorkStage:'review'}});
|
||||
const afterFirst=navigation.stage();
|
||||
events.emit('popstate',{state:{taskOverlay:'find'}});
|
||||
process.stdout.write(JSON.stringify({afterFirst,afterSecond:navigation.stage(),changes,reviewDisabled:buttons.review.disabled,fitDisabled:buttons.fit.disabled}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"afterFirst": "review",
|
||||
"afterSecond": "discover",
|
||||
"changes": ["discover", "review", "fit", "review", "discover"],
|
||||
"reviewDisabled": False,
|
||||
"fitDisabled": False,
|
||||
}
|
||||
|
||||
|
||||
def test_reopening_restores_progress_but_completion_resets_to_discovery():
|
||||
result = run_navigation("""
|
||||
const names=['discover','review','fit'];
|
||||
const buttons=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
|
||||
const sections=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
|
||||
const events=new FakeEvents();
|
||||
const history={state:{taskOverlay:'find',findWorkStage:'review'},replaced:[],pushState(state){this.state=state;},replaceState(state){this.state=state;this.replaced.push(state);},back(){}};
|
||||
const navigation=createNavigation({buttons,sections,history,eventTarget:events,onStageChange:()=>{}});
|
||||
navigation.start(); navigation.sync({selectedCount:1,recovery:false}); navigation.open();
|
||||
const restored=navigation.stage();
|
||||
navigation.complete();
|
||||
process.stdout.write(JSON.stringify({restored,completed:navigation.stage(),state:history.state,replaced:history.replaced}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"restored": "review",
|
||||
"completed": "discover",
|
||||
"state": {"taskOverlay": "find"},
|
||||
"replaced": [{"taskOverlay": "find"}],
|
||||
}
|
||||
|
||||
|
||||
def test_find_work_ships_guided_mobile_flow_in_the_offline_bundle():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
bundle = (FRONTEND.parent / "src" / "frontend_bundle.py").read_text()
|
||||
service_worker = (FRONTEND / "service-worker.js").read_text()
|
||||
dashboard = (FRONTEND / "dashboard.js").read_text()
|
||||
|
||||
assert '<nav class="mobile-find-work-nav"' in html
|
||||
assert 'aria-label="Find Work steps"' in html
|
||||
for name, label in (("discover", "Discover"), ("review", "Review"), ("fit", "Fit & assign")):
|
||||
assert f'data-find-work-stage="{name}"' in html
|
||||
assert f">{label}</button>" in html
|
||||
assert f'id="find-work-{name}-stage"' in html
|
||||
assert 'id="find-work-review-list"' in html
|
||||
assert 'id="continue-find-work-fit"' in html
|
||||
assert '<script src="static/mobile-find-work-nav.js"></script>' in html
|
||||
assert '"static/mobile-find-work-nav.js"' in bundle
|
||||
assert "static/mobile-find-work-nav.js" in service_worker
|
||||
assert ".mobile-find-work-nav" in css
|
||||
assert "min-height:44px" in css
|
||||
assert "createMobileFindWorkNavigation" in dashboard
|
||||
assert "findWorkNavigation?.sync" in dashboard
|
||||
@@ -0,0 +1,178 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-insights.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_mobile_insights_opens_as_addressable_modal_and_hides_home_chrome():
|
||||
script = f"""
|
||||
const createInsights = require({json.dumps(str(CONTROLLER))});
|
||||
const listeners = {{}};
|
||||
const element = () => ({{
|
||||
hidden:false, inert:false, attributes:{{}}, focuses:0, listeners:{{}},
|
||||
setAttribute(name,value) {{ this.attributes[name] = String(value); }},
|
||||
removeAttribute(name) {{ delete this.attributes[name]; }},
|
||||
addEventListener(name,fn) {{ this.listeners[name] = fn; }},
|
||||
focus() {{ this.focuses += 1; }},
|
||||
}});
|
||||
const root = element(); const launcher = element(); const closeButton = element();
|
||||
const dock = element(); const hud = element(); const home = element(); const header = element();
|
||||
const menu = {{open:true}};
|
||||
const location = {{pathname:'/dashboard/', search:'', hash:'#/my-work/today'}};
|
||||
const history = {{state:null, pushes:[], pushState(state,unused,url) {{
|
||||
this.state=state; this.pushes.push(url); location.hash=url.slice(url.indexOf('#'));
|
||||
}}, replaceState() {{}}, back() {{}}}};
|
||||
const controller = createInsights({{
|
||||
root, launcher, closeButton, dock, hud, backgrounds:[header,home], menu, history, location,
|
||||
eventTarget:{{addEventListener(name,fn) {{ listeners[name]=fn; }}}},
|
||||
mediaQuery:{{matches:true, addEventListener() {{}}}},
|
||||
}});
|
||||
controller.start();
|
||||
launcher.listeners.click();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
hash:location.hash, pushes:history.pushes, open:root.attributes['data-mobile-open'],
|
||||
role:root.attributes.role, modal:root.attributes['aria-modal'], rootInert:root.inert,
|
||||
homeInert:home.inert, headerInert:header.inert, dockHidden:dock.hidden,
|
||||
hudHidden:hud.attributes['data-overlay-hidden'], closeFocuses:closeButton.focuses,
|
||||
menuOpen:menu.open,
|
||||
}}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"hash": "#/insights",
|
||||
"pushes": ["/dashboard/#/insights"],
|
||||
"open": "true",
|
||||
"role": "dialog",
|
||||
"modal": "true",
|
||||
"rootInert": False,
|
||||
"homeInert": True,
|
||||
"headerInert": True,
|
||||
"dockHidden": True,
|
||||
"hudHidden": "true",
|
||||
"closeFocuses": 1,
|
||||
"menuOpen": False,
|
||||
}
|
||||
|
||||
|
||||
def test_direct_insights_route_closes_locally_and_restores_launcher_focus():
|
||||
script = f"""
|
||||
const createInsights = require({json.dumps(str(CONTROLLER))});
|
||||
const listeners = {{}};
|
||||
const element = () => ({{
|
||||
hidden:false, inert:false, attributes:{{}}, focuses:0, listeners:{{}},
|
||||
setAttribute(name,value) {{ this.attributes[name] = String(value); }},
|
||||
removeAttribute(name) {{ delete this.attributes[name]; }},
|
||||
addEventListener(name,fn) {{ this.listeners[name] = fn; }},
|
||||
focus() {{ this.focuses += 1; }},
|
||||
}});
|
||||
const root=element(), launcher=element(), closeButton=element(), dock=element(), hud=element(), home=element();
|
||||
const location={{pathname:'/dashboard/', search:'', hash:'#/insights'}};
|
||||
const history={{state:null, backs:0, replacements:[], pushState() {{}}, back() {{ this.backs += 1; }},
|
||||
replaceState(state,unused,url) {{ this.state=state; this.replacements.push(url); location.hash=url.slice(url.indexOf('#')); }}
|
||||
}};
|
||||
const controller=createInsights({{
|
||||
root,launcher,closeButton,dock,hud,backgrounds:[home],history,location,
|
||||
eventTarget:{{addEventListener(name,fn) {{ listeners[name]=fn; }}}},
|
||||
mediaQuery:{{matches:true,addEventListener() {{}}}},
|
||||
}});
|
||||
controller.start();
|
||||
closeButton.listeners.click();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
current:controller.current(), hash:location.hash, backs:history.backs,
|
||||
replacements:history.replacements, rootInert:root.inert, homeInert:home.inert,
|
||||
dockHidden:dock.hidden, hudHidden:hud.attributes['data-overlay-hidden'] || null,
|
||||
launcherFocuses:launcher.focuses,
|
||||
}}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"current": False,
|
||||
"hash": "#/my-work",
|
||||
"backs": 0,
|
||||
"replacements": ["/dashboard/#/my-work"],
|
||||
"rootInert": True,
|
||||
"homeInert": False,
|
||||
"dockHidden": False,
|
||||
"hudHidden": None,
|
||||
"launcherFocuses": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_browser_back_closes_insights_without_resetting_the_work_route():
|
||||
script = f"""
|
||||
const createInsights = require({json.dumps(str(CONTROLLER))});
|
||||
const listeners={{}};
|
||||
const element=()=>({{hidden:false,inert:false,attributes:{{}},focuses:0,listeners:{{}},setAttribute(n,v){{this.attributes[n]=String(v);}},removeAttribute(n){{delete this.attributes[n];}},addEventListener(n,f){{this.listeners[n]=f;}},focus(){{this.focuses+=1;}}}});
|
||||
const root=element(),launcher=element(),closeButton=element(),dock=element(),hud=element(),home=element();
|
||||
const location={{pathname:'/dashboard/',search:'',hash:'#/my-work/update'}};
|
||||
const history={{state:null,pushState(state,unused,url){{this.state=state;location.hash=url.slice(url.indexOf('#'));}},replaceState(){{}},back(){{}}}};
|
||||
const controller=createInsights({{root,launcher,closeButton,dock,hud,backgrounds:[home],history,location,eventTarget:{{addEventListener(n,f){{listeners[n]=f;}}}},mediaQuery:{{matches:true,addEventListener(){{}}}}}});
|
||||
controller.start(); launcher.listeners.click();
|
||||
location.hash='#/my-work/update'; history.state=null; listeners.popstate?.({{state:null}});
|
||||
process.stdout.write(JSON.stringify({{current:controller.current(),hash:location.hash,launcherFocuses:launcher.focuses,homeInert:home.inert,dockHidden:dock.hidden}}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"current": False,
|
||||
"hash": "#/my-work/update",
|
||||
"launcherFocuses": 1,
|
||||
"homeInert": False,
|
||||
"dockHidden": False,
|
||||
}
|
||||
|
||||
|
||||
def test_escape_requests_back_navigation_only_while_insights_is_open():
|
||||
script = f"""
|
||||
const createInsights=require({json.dumps(str(CONTROLLER))});
|
||||
const listeners={{}};
|
||||
const element=()=>({{hidden:false,inert:false,attributes:{{}},listeners:{{}},setAttribute(n,v){{this.attributes[n]=String(v);}},removeAttribute(n){{delete this.attributes[n];}},addEventListener(n,f){{this.listeners[n]=f;}},focus(){{}}}});
|
||||
const root=element(),launcher=element(),closeButton=element(),dock=element(),hud=element();
|
||||
const location={{pathname:'/dashboard/',search:'',hash:'#/my-work'}};
|
||||
const history={{state:null,backs:0,pushState(state,unused,url){{this.state=state;location.hash=url.slice(url.indexOf('#'));}},replaceState(){{}},back(){{this.backs+=1;}}}};
|
||||
const controller=createInsights({{root,launcher,closeButton,dock,hud,backgrounds:[],history,location,eventTarget:{{addEventListener(n,f){{listeners[n]=f;}}}},mediaQuery:{{matches:true,addEventListener(){{}}}}}});
|
||||
controller.start(); launcher.listeners.click();
|
||||
let prevented=0; listeners.keydown?.({{key:'Escape',preventDefault(){{prevented+=1;}}}}); listeners.keydown?.({{key:'Enter',preventDefault(){{prevented+=1;}}}});
|
||||
process.stdout.write(JSON.stringify({{backs:history.backs,prevented}}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {"backs": 1, "prevented": 1}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_home_progressively_discloses_secondary_panels_as_insights():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="open-insights"' in html
|
||||
assert 'id="insights-sheet"' in html
|
||||
assert 'id="close-insights"' in html
|
||||
assert html.index('id="my-work"') < html.index('id="insights-sheet"')
|
||||
assert html.index('id="insights-sheet"') < html.index('data-panel-key="context"')
|
||||
assert html.index('data-panel-key="markdown"') < html.index('</section>\n</main>')
|
||||
assert '<script src="static/mobile-insights.js"></script>' in html
|
||||
assert "createMobileInsights({" in html
|
||||
assert "mediaQuery: window.matchMedia('(max-width: 600px)')" in html
|
||||
assert ".insights-shell { display:contents; }" in html
|
||||
assert "#open-insights { display:none; }" in html
|
||||
assert '.insights-shell[data-mobile-open="true"]' in html
|
||||
assert "position:fixed; inset:0;" in html
|
||||
assert "overflow-x:hidden" in html
|
||||
assert ".insights-header button { min-height:44px;" in html
|
||||
|
||||
|
||||
def test_mobile_insights_rolls_into_the_offline_shell():
|
||||
worker = (CONTROLLER.parent / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v117" in worker
|
||||
assert "BASE + 'static/mobile-insights.js'" in worker
|
||||
@@ -0,0 +1,185 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-issue-detail-nav.js"
|
||||
FRONTEND = CONTROLLER.parent
|
||||
|
||||
|
||||
def test_mobile_issue_navigation_opens_and_focuses_each_destination_without_forced_motion():
|
||||
script = f"""
|
||||
const createNavigation = require({json.dumps(str(CONTROLLER))});
|
||||
class FakeElement {{
|
||||
constructor(name) {{
|
||||
this.name = name;
|
||||
this.listeners = {{}};
|
||||
this.attributes = {{}};
|
||||
this.open = false;
|
||||
this.focuses = 0;
|
||||
this.scrolls = [];
|
||||
}}
|
||||
addEventListener(name, callback) {{ this.listeners[name] = callback; }}
|
||||
removeEventListener(name) {{ delete this.listeners[name]; }}
|
||||
click() {{ this.listeners.click({{currentTarget:this, preventDefault() {{}}}}); }}
|
||||
setAttribute(name, value) {{ this.attributes[name] = value; }}
|
||||
removeAttribute(name) {{ delete this.attributes[name]; }}
|
||||
focus() {{ this.focuses += 1; }}
|
||||
scrollIntoView(options) {{ this.scrolls.push(options); }}
|
||||
}}
|
||||
const buttons = Object.fromEntries(['overview','conversation','reply','actions'].map(name => [name,new FakeElement(name)]));
|
||||
const targets = Object.fromEntries(['overview','conversation','reply','actions'].map(name => [name,new FakeElement(name)]));
|
||||
const navigation = createNavigation({{
|
||||
buttons,
|
||||
targets,
|
||||
planning:targets.actions,
|
||||
prefersReducedMotion:() => true,
|
||||
}});
|
||||
navigation.start();
|
||||
buttons.overview.click();
|
||||
buttons.conversation.click();
|
||||
buttons.reply.click();
|
||||
buttons.actions.click();
|
||||
const currentAfterActions = Object.fromEntries(Object.entries(buttons).map(([name,button]) => [name,button.attributes['aria-current'] || null]));
|
||||
navigation.stop();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
overview:targets.overview.scrolls,
|
||||
conversation:targets.conversation.scrolls,
|
||||
reply:targets.reply.scrolls,
|
||||
replyFocuses:targets.reply.focuses,
|
||||
actions:targets.actions.scrolls,
|
||||
planningOpen:targets.actions.open,
|
||||
currentAfterActions,
|
||||
listenersAfterStop:Object.values(buttons).map(button => Object.keys(button.listeners).length),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"overview": [{"block": "start", "behavior": "auto"}],
|
||||
"conversation": [{"block": "start", "behavior": "auto"}],
|
||||
"reply": [{"block": "start", "behavior": "auto"}],
|
||||
"replyFocuses": 1,
|
||||
"actions": [{"block": "start", "behavior": "auto"}],
|
||||
"planningOpen": True,
|
||||
"currentAfterActions": {
|
||||
"overview": None,
|
||||
"conversation": None,
|
||||
"reply": None,
|
||||
"actions": "location",
|
||||
},
|
||||
"listenersAfterStop": [0, 0, 0, 0],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_issue_navigation_tracks_the_visible_section_and_disconnects_observer():
|
||||
script = f"""
|
||||
const createNavigation = require({json.dumps(str(CONTROLLER))});
|
||||
const makeElement = name => ({{
|
||||
name, attributes:{{}},
|
||||
addEventListener() {{}}, removeEventListener() {{}},
|
||||
setAttribute(key,value) {{ this.attributes[key] = value; }},
|
||||
removeAttribute(key) {{ delete this.attributes[key]; }},
|
||||
}});
|
||||
const buttons = Object.fromEntries(['overview','conversation','reply','actions'].map(name => [name,makeElement(name)]));
|
||||
const targets = Object.fromEntries(['overview','conversation','reply','actions'].map(name => [name,makeElement(name)]));
|
||||
let callback;
|
||||
let disconnected = 0;
|
||||
const navigation = createNavigation({{
|
||||
buttons, targets,
|
||||
observe(handler, observed) {{
|
||||
callback = handler;
|
||||
return {{disconnect() {{ disconnected += 1; }}}};
|
||||
}},
|
||||
}});
|
||||
navigation.start();
|
||||
callback([
|
||||
{{target:targets.overview,isIntersecting:true,intersectionRatio:.2}},
|
||||
{{target:targets.reply,isIntersecting:true,intersectionRatio:.8}},
|
||||
]);
|
||||
const current = Object.fromEntries(Object.entries(buttons).map(([name,button]) => [name,button.attributes['aria-current'] || null]));
|
||||
navigation.stop();
|
||||
process.stdout.write(JSON.stringify({{current,disconnected}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"current": {
|
||||
"overview": None,
|
||||
"conversation": None,
|
||||
"reply": "location",
|
||||
"actions": None,
|
||||
},
|
||||
"disconnected": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_issue_navigation_distinguishes_taps_scrolls_and_keyboard_safe_restore():
|
||||
script = f"""
|
||||
const createNavigation = require({json.dumps(str(CONTROLLER))});
|
||||
class FakeElement {{
|
||||
constructor(name) {{ this.name=name; this.listeners={{}}; this.attributes={{}}; this.focuses=0; }}
|
||||
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
|
||||
removeEventListener() {{}}
|
||||
click() {{ this.listeners.click({{preventDefault() {{}}}}); }}
|
||||
setAttribute(name, value) {{ this.attributes[name]=value; }}
|
||||
removeAttribute(name) {{ delete this.attributes[name]; }}
|
||||
scrollIntoView() {{}}
|
||||
focus() {{ this.focuses += 1; }}
|
||||
}}
|
||||
const names=['overview','conversation','reply','actions'];
|
||||
const buttons=Object.fromEntries(names.map(name => [name,new FakeElement(name)]));
|
||||
const targets=Object.fromEntries(names.map(name => [name,new FakeElement(name)]));
|
||||
const changes=[];
|
||||
let observer;
|
||||
const navigation=createNavigation({{
|
||||
buttons, targets,
|
||||
onSectionChange:(name, options) => changes.push([name, options.replace]),
|
||||
observe(handler) {{ observer=handler; return {{disconnect() {{}}}}; }},
|
||||
}});
|
||||
navigation.start();
|
||||
buttons.reply.click();
|
||||
observer([{{target:targets.actions,isIntersecting:true,intersectionRatio:1}}]);
|
||||
navigation.navigate('reply', {{focus:false}});
|
||||
process.stdout.write(JSON.stringify({{changes,replyFocuses:targets.reply.focuses,current:buttons.reply.attributes['aria-current']}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"changes": [["reply", False], ["actions", True]],
|
||||
"replyFocuses": 1,
|
||||
"current": "location",
|
||||
}
|
||||
|
||||
|
||||
def test_issue_sheet_ships_a_mobile_only_safe_area_navigation_rail():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
dashboard_js = (FRONTEND / "dashboard.js").read_text()
|
||||
|
||||
assert '<nav class="mobile-issue-detail-nav"' in html
|
||||
assert 'aria-label="Issue sections"' in html
|
||||
for name, label in (
|
||||
("overview", "Overview"),
|
||||
("conversation", "Conversation"),
|
||||
("reply", "Reply"),
|
||||
("actions", "Plan & actions"),
|
||||
):
|
||||
assert f'data-issue-section="{name}"' in html
|
||||
assert f">{label}</button>" in html
|
||||
assert 'id="issue-overview"' in html
|
||||
assert 'id="issue-conversation"' in html
|
||||
assert 'id="issue-comment"' in html
|
||||
assert 'id="issue-planning"' in html
|
||||
assert '<script src="static/mobile-issue-detail-nav.js"></script>' in html
|
||||
assert "createMobileIssueDetailNavigation({" in dashboard_js
|
||||
assert "window.matchMedia('(prefers-reduced-motion: reduce)').matches" in dashboard_js
|
||||
assert "@media (max-width:600px)" in css
|
||||
assert ".mobile-issue-detail-nav" in css
|
||||
assert "position:sticky" in css
|
||||
assert "min-height:44px" in css
|
||||
assert "env(safe-area-inset-top)" in css
|
||||
assert "@media (min-width:601px)" in css
|
||||
@@ -0,0 +1,103 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-plan-today-nav.js"
|
||||
FRONTEND = CONTROLLER.parent
|
||||
|
||||
|
||||
def run_navigation(scenario: str) -> dict:
|
||||
script = f"""
|
||||
const createNavigation = require({json.dumps(str(CONTROLLER))});
|
||||
class FakeElement {{
|
||||
constructor(name, offsetTop=0) {{
|
||||
this.name=name; this.offsetTop=offsetTop; this.listeners={{}}; this.attributes={{}};
|
||||
this.scrolls=[]; this.hidden=false;
|
||||
}}
|
||||
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
|
||||
removeEventListener(name) {{ delete this.listeners[name]; }}
|
||||
click() {{ this.listeners.click?.({{preventDefault() {{}}}}); }}
|
||||
setAttribute(name,value) {{ this.attributes[name]=value; }}
|
||||
removeAttribute(name) {{ delete this.attributes[name]; }}
|
||||
scrollTo(options) {{ this.scrolls.push(options); }}
|
||||
}}
|
||||
{scenario}
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_mobile_plan_today_navigation_scrolls_the_planner_and_tracks_sections():
|
||||
result = run_navigation("""
|
||||
const names=['fit','today','available'];
|
||||
const buttons=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
|
||||
const targets={fit:new FakeElement('fit',120),today:new FakeElement('today',620),available:new FakeElement('available',1120)};
|
||||
const root=new FakeElement('panel',100);
|
||||
let callback; let observedRoot; let disconnected=0; let now=1000;
|
||||
const navigation=createNavigation({
|
||||
buttons,targets,root,prefersReducedMotion:()=>true,now:()=>now,
|
||||
observe(handler,observed,rootOption){callback=handler; observedRoot=rootOption; return {disconnect(){disconnected++;}};},
|
||||
});
|
||||
navigation.start();
|
||||
buttons.today.click();
|
||||
now=1600;
|
||||
callback([{target:targets.today,isIntersecting:true,intersectionRatio:.2},{target:targets.available,isIntersecting:true,intersectionRatio:.8}]);
|
||||
const selected=Object.fromEntries(Object.entries(buttons).map(([name,button])=>[name,button.attributes['aria-current']||null]));
|
||||
navigation.stop();
|
||||
process.stdout.write(JSON.stringify({scrolls:root.scrolls,selected,observedRoot:observedRoot.name,disconnected,listeners:Object.values(buttons).map(button=>Object.keys(button.listeners).length)}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"scrolls": [{"top": 520, "behavior": "auto"}],
|
||||
"selected": {"fit": None, "today": None, "available": "location"},
|
||||
"observedRoot": "panel",
|
||||
"disconnected": 1,
|
||||
"listeners": [0, 0, 0],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_plan_today_navigation_preserves_selection_until_a_fresh_open():
|
||||
result = run_navigation("""
|
||||
const names=['fit','today','available'];
|
||||
const buttons=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
|
||||
const targets=Object.fromEntries(names.map((name,index)=>[name,new FakeElement(name,index*400)]));
|
||||
const root=new FakeElement('panel');
|
||||
const navigation=createNavigation({buttons,targets,root,prefersReducedMotion:()=>false,observe:()=>null});
|
||||
navigation.start();
|
||||
buttons.available.click();
|
||||
const afterRerender=Object.fromEntries(Object.entries(buttons).map(([name,button])=>[name,button.attributes['aria-current']||null]));
|
||||
navigation.reset();
|
||||
const afterOpen=Object.fromEntries(Object.entries(buttons).map(([name,button])=>[name,button.attributes['aria-current']||null]));
|
||||
process.stdout.write(JSON.stringify({scrolls:root.scrolls,afterRerender,afterOpen}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"scrolls": [{"top": 800, "behavior": "smooth"}],
|
||||
"afterRerender": {"fit": None, "today": None, "available": "location"},
|
||||
"afterOpen": {"fit": "location", "today": None, "available": None},
|
||||
}
|
||||
|
||||
|
||||
def test_plan_today_ships_mobile_navigation_in_the_offline_bundle():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
bundle = (FRONTEND.parent / "src" / "frontend_bundle.py").read_text()
|
||||
service_worker = (FRONTEND / "service-worker.js").read_text()
|
||||
|
||||
assert '<nav class="mobile-plan-today-nav"' in html
|
||||
assert 'aria-label="Plan Today sections"' in html
|
||||
for name, label in (("fit", "Fit"), ("today", "Today"), ("available", "Available")):
|
||||
assert f'data-plan-today-section="{name}"' in html
|
||||
assert f">{label}</button>" in html
|
||||
assert 'id="plan-today-fit"' in html
|
||||
assert 'id="plan-today-selected"' in html
|
||||
assert 'id="plan-today-available-work"' in html
|
||||
assert '<script src="static/mobile-plan-today-nav.js"></script>' in html
|
||||
assert '"static/mobile-plan-today-nav.js"' in bundle
|
||||
assert "static/mobile-plan-today-nav.js" in service_worker
|
||||
assert ".mobile-plan-today-nav" in css
|
||||
assert "grid-template-columns:repeat(3,minmax(0,1fr))" in css
|
||||
assert "min-height:44px" in css
|
||||
assert "@media (min-width:601px)" in css
|
||||
@@ -0,0 +1,86 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
|
||||
CONTROLLER = FRONTEND / "mobile-issue-detail-nav.js"
|
||||
|
||||
|
||||
def test_pull_navigation_prepares_review_before_scrolling_and_focuses_reply():
|
||||
script = f"""
|
||||
const createNavigation = require({json.dumps(str(CONTROLLER))});
|
||||
class FakeElement {{
|
||||
constructor(name) {{ this.name=name; this.listeners={{}}; this.attributes={{}}; this.open=false; this.focuses=0; this.scrolls=[]; }}
|
||||
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
|
||||
removeEventListener(name) {{ delete this.listeners[name]; }}
|
||||
setAttribute(name, value) {{ this.attributes[name]=value; }}
|
||||
removeAttribute(name) {{ delete this.attributes[name]; }}
|
||||
focus() {{ this.focuses += 1; }}
|
||||
scrollIntoView(options) {{ this.scrolls.push({{...options, openWhenScrolled:this.open}}); }}
|
||||
}}
|
||||
const buttons = Object.fromEntries(['overview','conversation','reply','review'].map(name => [name,new FakeElement(name)]));
|
||||
const targets = Object.fromEntries(['overview','conversation','reply','review'].map(name => [name,new FakeElement(name)]));
|
||||
let prepared = [];
|
||||
const navigation = createNavigation({{
|
||||
buttons, targets,
|
||||
beforeNavigate:{{review(target) {{ prepared.push('review'); target.open=true; }}}},
|
||||
prefersReducedMotion:() => true,
|
||||
}});
|
||||
navigation.start();
|
||||
navigation.navigate('reply');
|
||||
navigation.navigate('review');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
replyFocuses:targets.reply.focuses,
|
||||
reviewScrolls:targets.review.scrolls,
|
||||
prepared,
|
||||
current:Object.fromEntries(Object.entries(buttons).map(([name,button]) => [name,button.attributes['aria-current'] || null])),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"replyFocuses": 1,
|
||||
"reviewScrolls": [{"block": "start", "behavior": "auto", "openWhenScrolled": True}],
|
||||
"prepared": ["review"],
|
||||
"current": {
|
||||
"overview": None,
|
||||
"conversation": None,
|
||||
"reply": None,
|
||||
"review": "location",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_pull_sheet_wires_four_workspace_destinations_to_existing_lazy_review():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
dashboard_js = (FRONTEND / "dashboard.js").read_text()
|
||||
|
||||
assert '<nav class="mobile-detail-nav mobile-pull-detail-nav"' in html
|
||||
assert 'aria-label="Pull request sections"' in html
|
||||
for name, label in (
|
||||
("overview", "Overview"),
|
||||
("conversation", "Conversation"),
|
||||
("reply", "Reply"),
|
||||
("review", "Review & merge"),
|
||||
):
|
||||
assert f'data-pull-section="{name}"' in html
|
||||
assert f">{label}</button>" in html
|
||||
assert 'id="pull-overview"' in html
|
||||
assert 'id="pull-conversation"' in html
|
||||
assert 'id="pull-comment"' in html
|
||||
assert 'id="pull-review"' in html
|
||||
assert "document.querySelectorAll('[data-pull-section]')" in dashboard_js
|
||||
assert "beforeNavigate:{review(target) { target.open = true; }}" in dashboard_js
|
||||
|
||||
|
||||
def test_pull_workspace_rail_has_phone_containment_and_desktop_visibility_contracts():
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert "@media (max-width:600px)" in css
|
||||
assert ".mobile-issue-detail-nav, .mobile-pull-detail-nav" in css
|
||||
assert "position:sticky; top:env(safe-area-inset-top)" in css
|
||||
assert "grid-template-columns:repeat(4,minmax(0,1fr))" in css
|
||||
assert "min-width:0; min-height:44px" in css
|
||||
assert "#pull-overview, #pull-conversation, #pull-comment, #pull-review { scroll-margin-top:72px; }" in css
|
||||
assert "@media (min-width:601px) { .mobile-issue-detail-nav, .mobile-pull-detail-nav { display:none; } }" in css
|
||||
@@ -0,0 +1,188 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from src.frontend_bundle import build_frontend
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-pull-refresh.js"
|
||||
|
||||
|
||||
def run_scenario(source: str) -> dict:
|
||||
script = f"""
|
||||
const createPullRefresh = require({json.dumps(str(CONTROLLER))});
|
||||
class FakeElement {{
|
||||
constructor() {{
|
||||
this.listeners = {{}};
|
||||
this.dataset = {{}};
|
||||
this.hidden = true;
|
||||
this.textContent = '';
|
||||
}}
|
||||
addEventListener(name, callback) {{ this.listeners[name] = callback; }}
|
||||
removeEventListener(name) {{ delete this.listeners[name]; }}
|
||||
setPointerCapture() {{}}
|
||||
releasePointerCapture() {{}}
|
||||
}}
|
||||
const surface = new FakeElement();
|
||||
const indicator = new FakeElement();
|
||||
const plain = {{closest: () => null, parentElement: null, scrollWidth: 100, clientWidth: 100}};
|
||||
const event = (x, y, target=plain, id=1) => ({{
|
||||
pointerId:id, pointerType:'touch', clientX:x, clientY:y, target,
|
||||
preventDefault() {{ this.prevented = true; }},
|
||||
}});
|
||||
{source}
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_mobile_pull_refresh_runs_one_refresh_after_vertical_threshold_and_announces_success():
|
||||
result = run_scenario("""
|
||||
let calls = 0;
|
||||
const controller = createPullRefresh({
|
||||
surface, indicator, threshold:64,
|
||||
refresh: async () => { calls += 1; },
|
||||
isMobile: () => true,
|
||||
getScrollY: () => 0,
|
||||
isBlocked: () => false,
|
||||
});
|
||||
controller.start();
|
||||
surface.listeners.pointerdown(event(100, 10));
|
||||
surface.listeners.pointermove(event(102, 90));
|
||||
const ready = {hidden:indicator.hidden, state:indicator.dataset.state, text:indicator.textContent};
|
||||
const completion = surface.listeners.pointerup(event(102, 90));
|
||||
Promise.resolve(completion).then(() => process.stdout.write(JSON.stringify({
|
||||
calls, ready,
|
||||
final:{hidden:indicator.hidden, state:indicator.dataset.state, text:indicator.textContent},
|
||||
})));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"calls": 1,
|
||||
"ready": {"hidden": False, "state": "ready", "text": "Release to refresh"},
|
||||
"final": {"hidden": False, "state": "success", "text": "My Work is up to date"},
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_pull_refresh_ignores_ineligible_gestures_and_short_pulls():
|
||||
result = run_scenario("""
|
||||
let calls = 0;
|
||||
let mobile = true;
|
||||
let scrollY = 0;
|
||||
let blocked = false;
|
||||
const controller = createPullRefresh({
|
||||
surface, indicator, threshold:64,
|
||||
refresh: async () => { calls += 1; },
|
||||
isMobile: () => mobile,
|
||||
getScrollY: () => scrollY,
|
||||
isBlocked: () => blocked,
|
||||
});
|
||||
controller.start();
|
||||
async function drag(fromX, fromY, toX, toY, target=plain) {
|
||||
surface.listeners.pointerdown(event(fromX, fromY, target));
|
||||
surface.listeners.pointermove(event(toX, toY, target));
|
||||
await surface.listeners.pointerup(event(toX, toY, target));
|
||||
}
|
||||
(async () => {
|
||||
await drag(100, 10, 102, 50);
|
||||
await drag(100, 10, 180, 30);
|
||||
mobile = false; await drag(100, 10, 100, 100);
|
||||
mobile = true; scrollY = 1; await drag(100, 10, 100, 100);
|
||||
scrollY = 0; blocked = true; await drag(100, 10, 100, 100);
|
||||
blocked = false;
|
||||
const interactive = {closest: selector => selector ? interactive : null, parentElement:null, scrollWidth:100, clientWidth:100};
|
||||
await drag(100, 10, 100, 100, interactive);
|
||||
const scroller = {closest: () => null, parentElement:null, scrollWidth:300, clientWidth:100};
|
||||
await drag(100, 10, 100, 100, scroller);
|
||||
process.stdout.write(JSON.stringify({calls}));
|
||||
})();
|
||||
""")
|
||||
|
||||
assert result == {"calls": 0}
|
||||
|
||||
|
||||
def test_mobile_pull_refresh_stays_single_flight_and_keeps_refreshing_status():
|
||||
result = run_scenario("""
|
||||
let calls = 0;
|
||||
let finish;
|
||||
const pending = new Promise(resolve => { finish = resolve; });
|
||||
const controller = createPullRefresh({
|
||||
surface, indicator, threshold:64,
|
||||
refresh: () => { calls += 1; return pending; },
|
||||
isMobile: () => true,
|
||||
getScrollY: () => 0,
|
||||
isBlocked: () => false,
|
||||
});
|
||||
controller.start();
|
||||
function pull(id) {
|
||||
surface.listeners.pointerdown(event(100, 10, plain, id));
|
||||
surface.listeners.pointermove(event(100, 90, plain, id));
|
||||
return surface.listeners.pointerup(event(100, 90, plain, id));
|
||||
}
|
||||
const first = pull(1);
|
||||
Promise.resolve().then(async () => {
|
||||
const second = pull(2);
|
||||
const during = {calls, state:indicator.dataset.state, text:indicator.textContent};
|
||||
finish();
|
||||
await first;
|
||||
await second;
|
||||
process.stdout.write(JSON.stringify({calls, during, final:indicator.dataset.state}));
|
||||
});
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"calls": 1,
|
||||
"during": {"calls": 1, "state": "refreshing", "text": "Refreshing My Work…"},
|
||||
"final": "success",
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_pull_refresh_announces_failure_and_allows_a_retry():
|
||||
result = run_scenario("""
|
||||
let calls = 0;
|
||||
const controller = createPullRefresh({
|
||||
surface, indicator, threshold:64,
|
||||
refresh: async () => { calls += 1; if (calls === 1) throw new Error('offline secret'); },
|
||||
isMobile: () => true,
|
||||
getScrollY: () => 0,
|
||||
isBlocked: () => false,
|
||||
});
|
||||
controller.start();
|
||||
async function pull(id) {
|
||||
surface.listeners.pointerdown(event(100, 10, plain, id));
|
||||
surface.listeners.pointermove(event(100, 90, plain, id));
|
||||
try { await surface.listeners.pointerup(event(100, 90, plain, id)); } catch (_) {}
|
||||
}
|
||||
(async () => {
|
||||
await pull(1);
|
||||
const failed = {state:indicator.dataset.state, text:indicator.textContent};
|
||||
await pull(2);
|
||||
process.stdout.write(JSON.stringify({calls, failed, final:indicator.dataset.state}));
|
||||
})();
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"calls": 2,
|
||||
"failed": {"state": "error", "text": "Could not refresh · pull to retry"},
|
||||
"final": "success",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_pull_refresh_is_wired_to_live_refresh_and_packaged_offline():
|
||||
html = await dashboard()
|
||||
frontend = CONTROLLER.parent
|
||||
build = build_frontend(frontend)
|
||||
today_bundle = build.feature_bundles["today-timer"].runtime_bytes.decode()
|
||||
|
||||
assert 'id="mobile-pull-refresh"' in html
|
||||
assert 'role="status"' in html
|
||||
assert html.index('static/mobile-pull-refresh.js') < html.index('static/dashboard.js')
|
||||
assert "createMobilePullRefresh" in html
|
||||
assert "contextPoller.refresh({ force: true })" in html
|
||||
assert "createMobilePullRefresh" in today_bundle
|
||||
assert "liveDataStatus" in today_bundle
|
||||
assert build.feature_bundles["today-timer"].runtime_name in build.service_worker_source
|
||||
@@ -0,0 +1,178 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
|
||||
CONTROLLER = FRONTEND / "mobile-review-detail-nav.js"
|
||||
|
||||
|
||||
def test_review_navigation_moves_between_sections_and_focuses_feedback():
|
||||
script = f"""
|
||||
const createNavigation = require({json.dumps(str(CONTROLLER))});
|
||||
class FakeElement {{
|
||||
constructor(name) {{
|
||||
this.name=name; this.listeners={{}}; this.attributes={{}}; this.focuses=0; this.scrolls=[];
|
||||
}}
|
||||
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
|
||||
removeEventListener(name) {{ delete this.listeners[name]; }}
|
||||
setAttribute(name, value) {{ this.attributes[name]=value; }}
|
||||
removeAttribute(name) {{ delete this.attributes[name]; }}
|
||||
focus(options) {{ this.focuses += 1; this.focusOptions=options; }}
|
||||
scrollIntoView(options) {{ this.scrolls.push(options); }}
|
||||
}}
|
||||
const names=['overview','files','feedback','history'];
|
||||
const buttons=Object.fromEntries(names.map(name => [name,new FakeElement(name)]));
|
||||
const targets=Object.fromEntries(names.map(name => [name,new FakeElement(name)]));
|
||||
const summaryComposer=new FakeElement('summary');
|
||||
const navigation=createNavigation({{
|
||||
buttons, targets, summaryComposer,
|
||||
prefersReducedMotion:() => true,
|
||||
}});
|
||||
navigation.start();
|
||||
navigation.navigate('files');
|
||||
navigation.navigate('feedback');
|
||||
const currentAfterFeedback=Object.fromEntries(Object.entries(buttons).map(([name,button]) => [name,button.attributes['aria-current'] || null]));
|
||||
navigation.reset();
|
||||
const currentAfterReset=Object.fromEntries(Object.entries(buttons).map(([name,button]) => [name,button.attributes['aria-current'] || null]));
|
||||
navigation.stop();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
files:targets.files.scrolls,
|
||||
feedback:targets.feedback.scrolls,
|
||||
feedbackFocuses:summaryComposer.focuses,
|
||||
focusOptions:summaryComposer.focusOptions,
|
||||
currentAfterFeedback,
|
||||
currentAfterReset,
|
||||
listenersAfterStop:Object.values(buttons).map(button => Object.keys(button.listeners).length),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"files": [{"block": "start", "behavior": "auto"}],
|
||||
"feedback": [{"block": "start", "behavior": "auto"}],
|
||||
"feedbackFocuses": 1,
|
||||
"focusOptions": {"preventScroll": True},
|
||||
"currentAfterFeedback": {
|
||||
"overview": None,
|
||||
"files": None,
|
||||
"feedback": "location",
|
||||
"history": None,
|
||||
},
|
||||
"currentAfterReset": {
|
||||
"overview": "location",
|
||||
"files": None,
|
||||
"feedback": None,
|
||||
"history": None,
|
||||
},
|
||||
"listenersAfterStop": [0, 0, 0, 0],
|
||||
}
|
||||
|
||||
|
||||
def test_review_navigation_tracks_the_visible_section_inside_the_review_panel():
|
||||
script = f"""
|
||||
const createNavigation = require({json.dumps(str(CONTROLLER))});
|
||||
const makeElement=name => ({{
|
||||
name, attributes:{{}},
|
||||
addEventListener() {{}}, removeEventListener() {{}},
|
||||
setAttribute(key,value) {{ this.attributes[key]=value; }},
|
||||
removeAttribute(key) {{ delete this.attributes[key]; }},
|
||||
}});
|
||||
const buttons=Object.fromEntries(['overview','files','feedback','history'].map(name => [name,makeElement(name)]));
|
||||
const targets=Object.fromEntries(['overview','files','feedback','history'].map(name => [name,makeElement(name)]));
|
||||
let callback;
|
||||
let observedRoot=null;
|
||||
let disconnected=0;
|
||||
const panel=makeElement('panel');
|
||||
const navigation=createNavigation({{
|
||||
buttons, targets, root:panel,
|
||||
observe(handler, observedTargets, root) {{
|
||||
callback=handler; observedRoot=root;
|
||||
return {{disconnect() {{ disconnected += 1; }}}};
|
||||
}},
|
||||
}});
|
||||
navigation.start();
|
||||
callback([
|
||||
{{target:targets.files,isIntersecting:true,intersectionRatio:.25}},
|
||||
{{target:targets.feedback,isIntersecting:true,intersectionRatio:.8}},
|
||||
]);
|
||||
const current=Object.fromEntries(Object.entries(buttons).map(([name,button]) => [name,button.attributes['aria-current'] || null]));
|
||||
navigation.stop();
|
||||
process.stdout.write(JSON.stringify({{current,rootMatches:observedRoot === panel,disconnected}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"current": {
|
||||
"overview": None,
|
||||
"files": None,
|
||||
"feedback": "location",
|
||||
"history": None,
|
||||
},
|
||||
"rootMatches": True,
|
||||
"disconnected": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_review_navigation_routes_taps_and_scrolls_without_focusing_on_restore():
|
||||
script = f"""
|
||||
const createNavigation=require({json.dumps(str(CONTROLLER))});
|
||||
const element=name => ({{name,listeners:{{}},attributes:{{}},focuses:0,
|
||||
addEventListener(key,fn) {{ this.listeners[key]=fn; }}, removeEventListener() {{}},
|
||||
setAttribute(key,value) {{ this.attributes[key]=value; }}, removeAttribute(key) {{ delete this.attributes[key]; }},
|
||||
scrollIntoView() {{}}, focus() {{ this.focuses += 1; }},
|
||||
}});
|
||||
const names=['overview','files','feedback','history'];
|
||||
const buttons=Object.fromEntries(names.map(name => [name,element(name)]));
|
||||
const targets=Object.fromEntries(names.map(name => [name,element(name)]));
|
||||
const composer=element('composer'); const changes=[]; let observer;
|
||||
const navigation=createNavigation({{buttons,targets,summaryComposer:composer,
|
||||
onSectionChange:(name,options)=>changes.push([name,options.replace]),
|
||||
observe(handler) {{ observer=handler; return {{disconnect() {{}}}}; }},
|
||||
}});
|
||||
navigation.start();
|
||||
buttons.feedback.listeners.click({{preventDefault() {{}}}});
|
||||
observer([{{target:targets.history,isIntersecting:true,intersectionRatio:1}}]);
|
||||
navigation.navigate('feedback',{{focus:false}});
|
||||
process.stdout.write(JSON.stringify({{changes,focuses:composer.focuses,current:buttons.feedback.attributes['aria-current']}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"changes": [["feedback", False], ["history", True]],
|
||||
"focuses": 1,
|
||||
"current": "location",
|
||||
}
|
||||
|
||||
|
||||
def test_review_sheet_wires_a_mobile_safe_area_navigation_rail_and_offline_asset():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
dashboard_js = (FRONTEND / "dashboard.js").read_text()
|
||||
service_worker = (FRONTEND / "service-worker.js").read_text()
|
||||
|
||||
assert '<nav class="mobile-review-detail-nav" aria-label="Review sections">' in html
|
||||
for name, label in (
|
||||
("overview", "Overview"),
|
||||
("files", "Files"),
|
||||
("feedback", "Feedback"),
|
||||
("history", "History"),
|
||||
):
|
||||
assert f'data-review-section="{name}"' in html
|
||||
assert f">{label}</button>" in html
|
||||
assert 'id="review-overview"' in html
|
||||
assert 'id="review-files-workspace"' in html
|
||||
assert 'id="review-feedback"' in html
|
||||
assert 'id="review-history-workspace"' in html
|
||||
assert '<script src="static/mobile-review-detail-nav.js"></script>' in html
|
||||
assert "createMobileReviewDetailNavigation({" in dashboard_js
|
||||
assert "mobileReviewDetailNavigation.reset();" in dashboard_js
|
||||
assert ".mobile-review-detail-nav" in css
|
||||
assert "position:sticky" in css
|
||||
assert "min-height:44px" in css
|
||||
assert "env(safe-area-inset-top)" in css
|
||||
assert "@media (min-width:601px)" in css
|
||||
assert "BASE + 'static/mobile-review-detail-nav.js'" in service_worker
|
||||
@@ -0,0 +1,135 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-search-preview-nav.js"
|
||||
FRONTEND = CONTROLLER.parent
|
||||
|
||||
|
||||
def run_navigation(scenario: str) -> dict:
|
||||
script = f"""
|
||||
const createNavigation = require({json.dumps(str(CONTROLLER))});
|
||||
class FakeElement {{
|
||||
constructor(name) {{
|
||||
this.name=name; this.listeners={{}}; this.attributes={{}}; this.scrolls=[];
|
||||
this.focuses=0; this.hidden=false;
|
||||
}}
|
||||
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
|
||||
removeEventListener(name) {{ delete this.listeners[name]; }}
|
||||
click() {{ this.listeners.click?.({{preventDefault() {{}}}}); }}
|
||||
setAttribute(name,value) {{ this.attributes[name]=value; }}
|
||||
removeAttribute(name) {{ delete this.attributes[name]; }}
|
||||
scrollIntoView(options) {{ this.scrolls.push(options); }}
|
||||
focus(options) {{ this.focuses += 1; this.focusOptions=options; }}
|
||||
}}
|
||||
{scenario}
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_mobile_search_preview_navigation_completes_the_workflow_and_gates_reply():
|
||||
result = run_navigation("""
|
||||
const names=['overview','conversation','reply','actions'];
|
||||
const buttons=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
|
||||
const targets=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
|
||||
const navigation=createNavigation({buttons,targets,prefersReducedMotion:()=>true});
|
||||
navigation.start();
|
||||
navigation.setReplyAvailable(false);
|
||||
buttons.reply.click();
|
||||
const unavailableScrolls=targets.reply.scrolls.length;
|
||||
navigation.setReplyAvailable(true);
|
||||
buttons.conversation.click();
|
||||
buttons.reply.click();
|
||||
buttons.actions.click();
|
||||
const selected=Object.fromEntries(Object.entries(buttons).map(([name,button])=>[name,button.attributes['aria-current']||null]));
|
||||
navigation.stop();
|
||||
process.stdout.write(JSON.stringify({
|
||||
unavailableScrolls,
|
||||
replyDisabled:buttons.reply.attributes['aria-disabled']||null,
|
||||
conversation:targets.conversation.scrolls,
|
||||
reply:targets.reply.scrolls,
|
||||
replyFocuses:targets.reply.focuses,
|
||||
actions:targets.actions.scrolls,
|
||||
selected,
|
||||
listeners:Object.values(buttons).map(button=>Object.keys(button.listeners).length),
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"unavailableScrolls": 0,
|
||||
"replyDisabled": None,
|
||||
"conversation": [{"block": "start", "behavior": "auto"}],
|
||||
"reply": [{"block": "start", "behavior": "auto"}],
|
||||
"replyFocuses": 1,
|
||||
"actions": [{"block": "start", "behavior": "auto"}],
|
||||
"selected": {
|
||||
"overview": None,
|
||||
"conversation": None,
|
||||
"reply": None,
|
||||
"actions": "location",
|
||||
},
|
||||
"listeners": [0, 0, 0, 0],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_search_preview_navigation_tracks_panel_sections_and_resets_for_next_result():
|
||||
result = run_navigation("""
|
||||
const names=['overview','conversation','reply','actions'];
|
||||
const buttons=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
|
||||
const targets=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
|
||||
const root=new FakeElement('panel');
|
||||
let callback; let observedRoot; let disconnected=0;
|
||||
const navigation=createNavigation({
|
||||
buttons,targets,root,
|
||||
observe(handler,observed,rootOption){callback=handler; observedRoot=rootOption; return {disconnect(){disconnected++;}};},
|
||||
});
|
||||
navigation.start();
|
||||
callback([{target:targets.overview,isIntersecting:true,intersectionRatio:.2},{target:targets.conversation,isIntersecting:true,intersectionRatio:.8}]);
|
||||
navigation.reset(false);
|
||||
const selected=Object.fromEntries(Object.entries(buttons).map(([name,button])=>[name,button.attributes['aria-current']||null]));
|
||||
navigation.stop();
|
||||
process.stdout.write(JSON.stringify({selected,replyDisabled:buttons.reply.attributes['aria-disabled'],observedRoot:observedRoot.name,disconnected}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"selected": {"overview": "location", "conversation": None, "reply": None, "actions": None},
|
||||
"replyDisabled": "true",
|
||||
"observedRoot": "panel",
|
||||
"disconnected": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_ships_mobile_navigation_in_the_offline_bundle():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
navigation_js = CONTROLLER.read_text()
|
||||
bundle = (FRONTEND.parent / "src" / "frontend_bundle.py").read_text()
|
||||
service_worker = (FRONTEND / "service-worker.js").read_text()
|
||||
|
||||
assert '<nav class="mobile-search-preview-nav"' in html
|
||||
assert 'aria-label="Search preview sections"' in html
|
||||
for name, label in (
|
||||
("overview", "Overview"),
|
||||
("conversation", "Conversation"),
|
||||
("reply", "Reply"),
|
||||
("actions", "Act"),
|
||||
):
|
||||
assert f'data-search-preview-section="{name}"' in html
|
||||
assert f">{label}</button>" in html
|
||||
assert 'id="search-preview-overview"' in html
|
||||
assert 'id="search-preview-reply-workspace"' in html
|
||||
assert 'id="search-preview-actions"' in html
|
||||
assert '<script src="static/mobile-search-preview-nav.js"></script>' in html
|
||||
assert '"static/mobile-search-preview-nav.js"' in bundle
|
||||
assert "attachMobileSearchPreviewNavigation({document, window})" in navigation_js
|
||||
assert "navigation.reset(!reply.hidden)" in navigation_js
|
||||
assert "attributeFilter:['hidden']" in navigation_js
|
||||
assert "static/mobile-search-preview-nav.js" in service_worker
|
||||
assert ".mobile-search-preview-nav" in css
|
||||
assert "grid-template-columns:repeat(4,minmax(0,1fr))" in css
|
||||
assert "min-height:44px" in css
|
||||
assert "env(safe-area-inset-top)" in css
|
||||
assert "#search-preview-actions { position:static" in css
|
||||
@@ -72,6 +72,44 @@ process.stdout.write(JSON.stringify({{first, second, ready, opened}}));
|
||||
}
|
||||
|
||||
|
||||
def test_prepare_today_puts_actionable_delivery_recovery_before_other_work():
|
||||
script = f"""
|
||||
const createStartDay = require({json.dumps(str(START_DAY))});
|
||||
let counts = {{delivery:2, agenda:1, attention:0, update:0, filed:0, today:4}};
|
||||
const opened = [];
|
||||
const handoffs = [];
|
||||
const saved = new Map();
|
||||
const controller = createStartDay({{
|
||||
getCounts: () => counts,
|
||||
getLogin: () => 'timmy',
|
||||
getDay: () => '2026-08-17',
|
||||
storage: {{getItem:key=>saved.get(key)||null,setItem:(key,value)=>saved.set(key,value),removeItem:key=>saved.delete(key)}},
|
||||
openQueue: name => opened.push(name),
|
||||
onHandoff: state => handoffs.push(state.next),
|
||||
}});
|
||||
const blocked = controller.briefing();
|
||||
controller.startNext();
|
||||
counts = {{delivery:0, agenda:1, attention:0, update:0, filed:0, today:4}};
|
||||
const handed = controller.reconcile({{authoritative:true}});
|
||||
process.stdout.write(JSON.stringify({{blocked, handed, opened, handoffs}}));
|
||||
"""
|
||||
|
||||
output = run_node(script)
|
||||
assert output["blocked"] == {
|
||||
"total": 3,
|
||||
"next": "delivery",
|
||||
"label": "Review Delivery",
|
||||
"summary": "2 deliveries need action before Today · 1 other item · 4 planned",
|
||||
"phases": [
|
||||
{"name": "delivery", "label": "Delivery recovery", "count": 2},
|
||||
{"name": "agenda", "label": "Agenda", "count": 1},
|
||||
],
|
||||
}
|
||||
assert output["handed"] is True
|
||||
assert output["opened"] == ["delivery"]
|
||||
assert output["handoffs"] == ["agenda"]
|
||||
|
||||
|
||||
def test_start_day_view_renders_refreshed_phases_and_launches_primary_action():
|
||||
script = f"""
|
||||
const createStartDay = require({json.dumps(str(START_DAY))});
|
||||
@@ -273,7 +311,8 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
|
||||
assert "mobileStartDay.completePhase('agenda')" in html
|
||||
assert "mobileStartDay.completePhase('update')" in html
|
||||
assert "mobileStartDay.completePhase('filed')" in html
|
||||
assert "mobileStartDay.reconcile({authoritative:authoritativeMyWorkRefresh})" in html
|
||||
assert "authoritative:authoritativeMyWorkRefresh" in html
|
||||
assert "authoritativePhases:['delivery']" in html
|
||||
assert "mobileStartDay.finish()" in html
|
||||
assert "openQueue: name =>" in html
|
||||
assert "if (sheet.open) sheet.close();" in html
|
||||
@@ -283,4 +322,4 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
|
||||
assert ".mobile-start-day-finish { min-height:44px;" in html
|
||||
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
||||
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
||||
assert "stackchain-dashboard-shell-v108" in service_worker
|
||||
assert "stackchain-dashboard-shell-v117" in service_worker
|
||||
@@ -85,6 +85,30 @@ process.stdout.write(JSON.stringify({{modes, calls}}));
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_work_entry_plans_eligible_work_before_find_fallback():
|
||||
script = f"""
|
||||
const createEntry = require({json.dumps(str(ENTRY))});
|
||||
const calls = [];
|
||||
const entry = createEntry({{
|
||||
isTodayActive: () => false,
|
||||
isTodayResumable: () => false,
|
||||
getTodayCount: () => 0,
|
||||
getEligibleCount: () => 2,
|
||||
queueLauncher: {{recommend: () => ({{name:'find'}}), open: () => {{}}}},
|
||||
continueToday: () => {{}},
|
||||
resumeToday: () => {{}},
|
||||
startToday: () => {{}},
|
||||
planToday: () => calls.push('plan'),
|
||||
findWork: () => calls.push('find'),
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{mode:entry.open(), calls}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {"mode": "plan", "calls": ["plan"]}
|
||||
|
||||
|
||||
def test_mobile_task_dock_routes_actions_hides_for_overlays_and_restores_focus():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
@@ -331,7 +355,7 @@ sheet.close = function () {{ this.open = false; this.listeners.close?.(); }};
|
||||
const close = new FakeElement();
|
||||
const badge = new FakeElement();
|
||||
const deadline = new FakeElement();
|
||||
const rows = Object.fromEntries(['today','agenda','attention','update','filed','later','draft','recaps'].map(name => [name, new FakeElement()]));
|
||||
const rows = Object.fromEntries(['today','agenda','delivery','attention','update','filed','later','draft','recaps'].map(name => [name, new FakeElement()]));
|
||||
const counts = Object.fromEntries(Object.keys(rows).map(name => [name, new FakeElement()]));
|
||||
const selected = [];
|
||||
const utilities = [];
|
||||
@@ -342,7 +366,7 @@ const dock = createDock({{
|
||||
observe() {{}},
|
||||
}});
|
||||
dock.start();
|
||||
dock.updateQueues({{today:2, agenda:5, attention:1, update:5, filed:2, later:3, draft:4}});
|
||||
dock.updateQueues({{today:2, agenda:5, delivery:1, attention:1, update:5, filed:2, later:3, draft:4}});
|
||||
queues.click();
|
||||
const opened = sheet.open;
|
||||
rows.update.click();
|
||||
@@ -387,9 +411,9 @@ process.stdout.write(JSON.stringify({{
|
||||
"utilities": ["recaps:trigger"],
|
||||
"badge": "0 active",
|
||||
"populated": {
|
||||
"badge": "6 active",
|
||||
"badge": "7 active",
|
||||
"badgeHidden": False,
|
||||
"badgeLabel": "Queues: Today 2, Agenda 5 due, Attention 1, Updates 5, Filed 2, Later 3, Drafts 4; 6 active queues",
|
||||
"badgeLabel": "Queues: Today 2, Agenda 5 due, Delivery 1, Attention 1, Updates 5, Filed 2, Later 3, Drafts 4; 7 active queues",
|
||||
"deadline": "5 due",
|
||||
"deadlineHidden": False,
|
||||
"agendaDue": "true",
|
||||
@@ -398,13 +422,13 @@ process.stdout.write(JSON.stringify({{
|
||||
"badge": "1 active",
|
||||
"badgeHidden": False,
|
||||
"deadlineHidden": True,
|
||||
"badgeLabel": "Queues: Today 0, Agenda 0 due, Attention 0, Updates 7, Filed 0, Later 0, Drafts 0; 1 active queue",
|
||||
"badgeLabel": "Queues: Today 0, Agenda 0 due, Delivery 0, Attention 0, Updates 7, Filed 0, Later 0, Drafts 0; 1 active queue",
|
||||
},
|
||||
"clearedBadgeHidden": True,
|
||||
"clearedDeadlineHidden": True,
|
||||
"clearedAgendaDue": None,
|
||||
"clearedBadgeLabel": "Queues: no active queues; no upcoming deadlines",
|
||||
"counts": {"today": "0", "agenda": "0", "attention": "0", "update": "0", "filed": "0", "later": "0", "draft": "0", "recaps": "0"},
|
||||
"counts": {"today": "0", "agenda": "0", "delivery": "0", "attention": "0", "update": "0", "filed": "0", "later": "0", "draft": "0", "recaps": "0"},
|
||||
"updateLabel": "Updates, 0 unread conversations",
|
||||
"queueFocuses": 3,
|
||||
"columnState": None,
|
||||
@@ -461,6 +485,82 @@ process.stdout.write(JSON.stringify({{result, calls}}));
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_queue_launcher_uses_dedicated_delivery_recovery_flow():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
const calls = [];
|
||||
const launcher = createLauncher({{
|
||||
openDelivery: () => {{ calls.push('delivery-recovery'); return 'opened'; }},
|
||||
selectFilter: name => calls.push('generic-filter:' + name),
|
||||
firstAction: () => {{ throw new Error('generic card launch must not run'); }},
|
||||
}});
|
||||
const result = launcher.open('delivery');
|
||||
process.stdout.write(JSON.stringify({{result, calls}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {"result": "opened", "calls": ["delivery-recovery"]}
|
||||
|
||||
|
||||
def test_mobile_work_prioritizes_and_revalidates_actionable_delivery_recovery():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
const calls = [];
|
||||
let counts = {{delivery:2, attention:3, today:1, update:4}};
|
||||
const launcher = createLauncher({{
|
||||
getCounts: () => counts,
|
||||
openDelivery: () => {{ calls.push('delivery-recovery'); return 'opened-delivery'; }},
|
||||
openUpdates: () => {{ calls.push('updates'); return 'opened-updates'; }},
|
||||
selectFilter: name => calls.push('filter:' + name),
|
||||
firstAction: () => null,
|
||||
announce: () => {{}},
|
||||
openFindWork: () => calls.push('find'),
|
||||
}});
|
||||
const delivery = launcher.recommend();
|
||||
const opened = launcher.continueWork();
|
||||
counts = {{delivery:0, attention:0, today:0, update:4}};
|
||||
const afterRecovery = launcher.recommend();
|
||||
process.stdout.write(JSON.stringify({{delivery, opened, afterRecovery, calls}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"delivery": {"name": "delivery", "count": 2, "label": "Recover Delivery (2)"},
|
||||
"opened": "opened-delivery",
|
||||
"afterRecovery": {"name": "update", "count": 4, "label": "Resume Updates (4)"},
|
||||
"calls": ["delivery-recovery"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_work_continues_into_filed_follow_up_before_later_work():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
const calls = [];
|
||||
let counts = {{attention:0, today:0, update:0, agenda:0, filed:2, later:4, draft:3}};
|
||||
const launcher = createLauncher({{
|
||||
getCounts: () => counts,
|
||||
openFiled: () => {{ calls.push('filed-follow-up'); return 'opened-filed'; }},
|
||||
selectFilter: name => calls.push('filter:' + name),
|
||||
firstAction: () => null,
|
||||
announce: () => {{}},
|
||||
openFindWork: () => calls.push('find'),
|
||||
}});
|
||||
const recommendation = launcher.recommend();
|
||||
const opened = launcher.continueWork();
|
||||
process.stdout.write(JSON.stringify({{recommendation, opened, calls}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"recommendation": {"name": "filed", "count": 2, "label": "Review Filed (2)"},
|
||||
"opened": "opened-filed",
|
||||
"calls": ["filed-follow-up"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_queue_launcher_recommends_and_revalidates_cross_queue_continuation():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
@@ -531,7 +631,7 @@ const work = {{attributes: {{}}, setAttribute(name, value) {{ this.attributes[na
|
||||
const workLabel = {{textContent:''}};
|
||||
const dock = createDock({{nav:{{}}, buttons:{{work}}, workLabel}});
|
||||
const labels = {{}};
|
||||
for (const mode of ['attention','update','agenda','later','draft']) {{
|
||||
for (const mode of ['delivery','attention','update','agenda','filed','later','draft']) {{
|
||||
dock.updateWork(mode);
|
||||
labels[mode] = [workLabel.textContent, work.attributes['aria-label']];
|
||||
}}
|
||||
@@ -541,9 +641,11 @@ process.stdout.write(JSON.stringify(labels));
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"delivery": ["Delivery", "Open Delivery"],
|
||||
"attention": ["Attention", "Open Attention"],
|
||||
"update": ["Updates", "Resume Updates"],
|
||||
"agenda": ["Agenda", "Open Agenda"],
|
||||
"filed": ["Filed", "Open Filed"],
|
||||
"later": ["Later", "Open Later"],
|
||||
"draft": ["Drafts", "Open Drafts"],
|
||||
}
|
||||
@@ -576,6 +678,9 @@ async def test_dashboard_renders_and_wires_mobile_queue_switcher():
|
||||
assert "counts.agenda = agendaItems.length;" in html
|
||||
assert 'data-agenda-group' in html
|
||||
assert 'data-mobile-queue="attention"' in html
|
||||
assert 'data-mobile-queue="delivery"' in html
|
||||
assert '<strong>Delivery</strong><small>Needs recovery</small>' in html
|
||||
assert 'data-mobile-queue-count="delivery"' in html
|
||||
assert 'data-mobile-queue="update"' in html
|
||||
assert '<strong>Updates</strong><small>Unread conversations</small>' in html
|
||||
assert 'data-mobile-queue="filed"' in html
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
|
||||
CONTROLLER = FRONTEND / "mobile-update-detail-nav.js"
|
||||
|
||||
|
||||
def test_update_navigation_reuses_new_activity_opens_context_and_focuses_reply():
|
||||
script = f"""
|
||||
const createNavigation = require({json.dumps(str(CONTROLLER))});
|
||||
class FakeElement {{
|
||||
constructor(name) {{
|
||||
this.name=name; this.listeners={{}}; this.attributes={{}}; this.open=false;
|
||||
this.focuses=0; this.scrolls=[];
|
||||
}}
|
||||
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
|
||||
removeEventListener(name) {{ delete this.listeners[name]; }}
|
||||
setAttribute(name, value) {{ this.attributes[name]=value; }}
|
||||
removeAttribute(name) {{ delete this.attributes[name]; }}
|
||||
focus() {{ this.focuses += 1; }}
|
||||
scrollIntoView(options) {{ this.scrolls.push({{...options, openWhenScrolled:this.open}}); }}
|
||||
}}
|
||||
const names=['activity','conversation','context','reply'];
|
||||
const buttons=Object.fromEntries(names.map(name => [name,new FakeElement(name)]));
|
||||
const targets=Object.fromEntries(['conversation','context','reply'].map(name => [name,new FakeElement(name)]));
|
||||
const replyComposer=new FakeElement('composer');
|
||||
let activityJumps=0;
|
||||
const navigation=createNavigation({{
|
||||
buttons, targets, replyComposer,
|
||||
jumpToNewActivity() {{ activityJumps += 1; }},
|
||||
prefersReducedMotion:() => true,
|
||||
}});
|
||||
navigation.start();
|
||||
navigation.navigate('conversation');
|
||||
navigation.navigate('context');
|
||||
const contextOpenAfterNavigation=targets.context.open;
|
||||
navigation.navigate('reply');
|
||||
navigation.navigate('activity');
|
||||
const currentAfterActivity=Object.fromEntries(Object.entries(buttons).map(([name,button]) => [name,button.attributes['aria-current'] || null]));
|
||||
navigation.reset();
|
||||
const contextOpenAfterReset=targets.context.open;
|
||||
const currentAfterReset=Object.fromEntries(Object.entries(buttons).map(([name,button]) => [name,button.attributes['aria-current'] || null]));
|
||||
navigation.stop();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
activityJumps,
|
||||
conversation:targets.conversation.scrolls,
|
||||
context:targets.context.scrolls,
|
||||
contextOpen:contextOpenAfterNavigation,
|
||||
contextOpenAfterReset,
|
||||
reply:targets.reply.scrolls,
|
||||
replyFocuses:replyComposer.focuses,
|
||||
currentAfterActivity,
|
||||
currentAfterReset,
|
||||
listenersAfterStop:Object.values(buttons).map(button => Object.keys(button.listeners).length),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"activityJumps": 1,
|
||||
"conversation": [{"block": "start", "behavior": "auto", "openWhenScrolled": False}],
|
||||
"context": [{"block": "start", "behavior": "auto", "openWhenScrolled": True}],
|
||||
"contextOpen": True,
|
||||
"contextOpenAfterReset": False,
|
||||
"reply": [{"block": "start", "behavior": "auto", "openWhenScrolled": False}],
|
||||
"replyFocuses": 1,
|
||||
"currentAfterActivity": {
|
||||
"activity": "location",
|
||||
"conversation": None,
|
||||
"context": None,
|
||||
"reply": None,
|
||||
},
|
||||
"currentAfterReset": {
|
||||
"activity": "location",
|
||||
"conversation": None,
|
||||
"context": None,
|
||||
"reply": None,
|
||||
},
|
||||
"listenersAfterStop": [0, 0, 0, 0],
|
||||
}
|
||||
|
||||
|
||||
def test_update_navigation_tracks_visible_sections_and_disconnects_observer():
|
||||
script = f"""
|
||||
const createNavigation = require({json.dumps(str(CONTROLLER))});
|
||||
const makeElement=name => ({{
|
||||
name, attributes:{{}},
|
||||
addEventListener() {{}}, removeEventListener() {{}},
|
||||
setAttribute(key,value) {{ this.attributes[key]=value; }},
|
||||
removeAttribute(key) {{ delete this.attributes[key]; }},
|
||||
}});
|
||||
const buttons=Object.fromEntries(['activity','conversation','context','reply'].map(name => [name,makeElement(name)]));
|
||||
const targets=Object.fromEntries(['conversation','context','reply'].map(name => [name,makeElement(name)]));
|
||||
let callback;
|
||||
let disconnected=0;
|
||||
const navigation=createNavigation({{
|
||||
buttons, targets,
|
||||
observe(handler) {{ callback=handler; return {{disconnect() {{ disconnected += 1; }}}}; }},
|
||||
}});
|
||||
navigation.start();
|
||||
callback([
|
||||
{{target:targets.conversation,isIntersecting:true,intersectionRatio:.2}},
|
||||
{{target:targets.reply,isIntersecting:true,intersectionRatio:.8}},
|
||||
]);
|
||||
const current=Object.fromEntries(Object.entries(buttons).map(([name,button]) => [name,button.attributes['aria-current'] || null]));
|
||||
navigation.stop();
|
||||
process.stdout.write(JSON.stringify({{current,disconnected}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"current": {
|
||||
"activity": None,
|
||||
"conversation": None,
|
||||
"context": None,
|
||||
"reply": "location",
|
||||
},
|
||||
"disconnected": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_update_navigation_routes_taps_and_scrolls_without_focusing_on_restore():
|
||||
script = f"""
|
||||
const createNavigation=require({json.dumps(str(CONTROLLER))});
|
||||
const element=name => ({{name,listeners:{{}},attributes:{{}},focuses:0,open:false,
|
||||
addEventListener(key,fn) {{ this.listeners[key]=fn; }}, removeEventListener() {{}},
|
||||
setAttribute(key,value) {{ this.attributes[key]=value; }}, removeAttribute(key) {{ delete this.attributes[key]; }},
|
||||
scrollIntoView() {{}}, focus() {{ this.focuses += 1; }},
|
||||
}});
|
||||
const names=['activity','conversation','context','reply'];
|
||||
const buttons=Object.fromEntries(names.map(name => [name,element(name)]));
|
||||
const targets=Object.fromEntries(['conversation','context','reply'].map(name => [name,element(name)]));
|
||||
const composer=element('composer'); const changes=[]; let observer;
|
||||
const navigation=createNavigation({{buttons,targets,replyComposer:composer,jumpToNewActivity() {{}},
|
||||
onSectionChange:(name,options)=>changes.push([name,options.replace]),
|
||||
observe(handler) {{ observer=handler; return {{disconnect() {{}}}}; }},
|
||||
}});
|
||||
navigation.start();
|
||||
buttons.reply.listeners.click({{preventDefault() {{}}}});
|
||||
observer([{{target:targets.context,isIntersecting:true,intersectionRatio:1}}]);
|
||||
navigation.navigate('reply',{{focus:false}});
|
||||
process.stdout.write(JSON.stringify({{changes,focuses:composer.focuses,current:buttons.reply.attributes['aria-current']}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"changes": [["reply", False], ["context", True]],
|
||||
"focuses": 1,
|
||||
"current": "location",
|
||||
}
|
||||
|
||||
|
||||
def test_update_sheet_wires_a_mobile_only_safe_area_navigation_rail():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
dashboard_js = (FRONTEND / "dashboard.js").read_text()
|
||||
service_worker = (FRONTEND / "service-worker.js").read_text()
|
||||
|
||||
assert '<nav class="mobile-update-detail-nav" aria-label="Update sections">' in html
|
||||
for name, label in (
|
||||
("activity", "New activity"),
|
||||
("conversation", "Conversation"),
|
||||
("context", "Subject context"),
|
||||
("reply", "Reply"),
|
||||
):
|
||||
assert f'data-update-section="{name}"' in html
|
||||
assert f">{label}</button>" in html
|
||||
assert 'id="update-conversation"' in html
|
||||
assert 'id="update-subject-context"' in html
|
||||
assert 'id="update-reply-workspace"' in html
|
||||
assert '<script src="static/mobile-update-detail-nav.js"></script>' in html
|
||||
assert "createMobileUpdateDetailNavigation({" in dashboard_js
|
||||
assert "jumpToNewActivity:() => updateReadPosition.jump()" in dashboard_js
|
||||
assert "mobileUpdateDetailNavigation.reset();" in dashboard_js
|
||||
assert ".mobile-update-detail-nav" in css
|
||||
assert "position:sticky" in css
|
||||
assert "min-height:44px" in css
|
||||
assert "env(safe-area-inset-top)" in css
|
||||
assert "@media (min-width:601px)" in css
|
||||
assert "BASE + 'static/mobile-update-detail-nav.js'" in service_worker
|
||||
+81
-1
@@ -1477,6 +1477,68 @@ process.stdout.write(JSON.stringify({{calls, hash:location.hash}}));
|
||||
assert output["hash"] == "#/my-work/review/stackchain/dashboard/10"
|
||||
|
||||
|
||||
def test_work_route_sections_are_validated_back_safe_and_do_not_reopen_the_item():
|
||||
script = f"""
|
||||
const routes = require({json.dumps(str(WORK_ROUTE))});
|
||||
const listeners = {{}};
|
||||
const location = {{hash:'#/my-work/issue/stackchain/api/17'}};
|
||||
const calls = [];
|
||||
const stack = [location.hash];
|
||||
let cursor = 0;
|
||||
const history = {{
|
||||
pushState(state, _, hash) {{ stack.splice(cursor + 1); stack.push(hash); cursor += 1; location.hash = hash; calls.push(['push', hash]); }},
|
||||
replaceState(state, _, hash) {{ stack[cursor] = hash; location.hash = hash; calls.push(['replace', hash]); }},
|
||||
back() {{ cursor -= 1; location.hash = stack[cursor]; listeners.popstate(); }},
|
||||
}};
|
||||
const controller = routes.createController({{
|
||||
location, history,
|
||||
eventTarget: {{addEventListener(name, fn) {{ listeners[name] = fn; }}}},
|
||||
onOpen: item => calls.push(['open', item.number, item.section || null]),
|
||||
onSection: (section, options) => calls.push(['section', section, options.restore]),
|
||||
onClose() {{}}, onInvalid() {{}},
|
||||
}});
|
||||
controller.start();
|
||||
controller.setItems([{{kind:'issue', repository:'stackchain/api', number:17}}]);
|
||||
controller.section('reply');
|
||||
controller.section('actions', {{replace:true}});
|
||||
history.back();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
calls,
|
||||
hash:location.hash,
|
||||
valid:[
|
||||
routes.parse('#/my-work/issue/stackchain/api/17/reply'),
|
||||
routes.parse('#/my-work/update/42/context'),
|
||||
],
|
||||
invalid:[
|
||||
routes.parse('#/my-work/issue/stackchain/api/17/files'),
|
||||
routes.parse('#/my-work/update/42/overview'),
|
||||
routes.parse('#/my-work/review/stackchain/api/17/feedback/extra'),
|
||||
],
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"calls": [
|
||||
["open", 17, None],
|
||||
["push", "#/my-work/issue/stackchain/api/17/reply"],
|
||||
["section", "reply", False],
|
||||
["replace", "#/my-work/issue/stackchain/api/17/actions"],
|
||||
["section", "actions", False],
|
||||
["section", "overview", True],
|
||||
],
|
||||
"hash": "#/my-work/issue/stackchain/api/17",
|
||||
"valid": [
|
||||
{"kind": "issue", "repository": "stackchain/api", "number": 17, "section": "reply"},
|
||||
{"kind": "update", "notification_id": 42, "section": "context"},
|
||||
],
|
||||
"invalid": [None, None, None],
|
||||
}
|
||||
|
||||
|
||||
def test_updates_inbox_route_survives_hydration_and_detail_back_navigation():
|
||||
script = f"""
|
||||
const routes = require({json.dumps(str(WORK_ROUTE))});
|
||||
@@ -1690,7 +1752,7 @@ const calls = [];
|
||||
async def test_dashboard_opens_delegated_filings_with_follow_up_only_capabilities():
|
||||
html = await dashboard()
|
||||
|
||||
assert "else if (item.kind === 'issue' || item.kind === 'filed') openIssueSheet(item, issueTrigger);" in html
|
||||
assert "else if (item.kind === 'issue' || item.kind === 'filed') await openIssueSheet(item, issueTrigger);" in html
|
||||
assert "const readOnly = issueController.readOnly(item);" in html
|
||||
assert "qs('#issue-sheet').classList.toggle('read-only', readOnly);" in html
|
||||
assert "#issue-sheet.read-only .issue-comment-composer" not in html
|
||||
@@ -1730,6 +1792,24 @@ async def test_dashboard_wires_addressable_work_sheets_back_navigation_and_share
|
||||
assert "openDeliveryReceiptRoute" not in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_wires_mobile_section_routes_to_each_detail_navigation():
|
||||
html = await dashboard()
|
||||
|
||||
assert html.count(
|
||||
"onSectionChange:(section, options) => workRoute.section(section, options)"
|
||||
) == 4
|
||||
assert "function navigateWorkSection(kind, section)" in html
|
||||
assert "mobileIssueDetailNavigation.navigate(section, { focus:false })" in html
|
||||
assert "mobilePullDetailNavigation.navigate(section, { focus:false })" in html
|
||||
assert "mobileUpdateDetailNavigation.navigate(section, { focus:false })" in html
|
||||
assert "mobileReviewDetailNavigation.navigate(section, { focus:false })" in html
|
||||
assert "onSection: (section, options) => {" in html
|
||||
assert "if (options.restore) navigateWorkSection(route?.kind, section);" in html
|
||||
assert "await openRoutedWorkSection(item);" in html
|
||||
assert "navigateWorkSection(item.kind, item.section);" in html
|
||||
|
||||
|
||||
def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity():
|
||||
payload = {
|
||||
"user": {"login": "timmy"},
|
||||
|
||||
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user