Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
@@ -54,8 +54,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 -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 };
|
||||
};
|
||||
});
|
||||
+111
-2
@@ -201,6 +201,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; }
|
||||
@@ -469,6 +474,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 +557,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 +617,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 +671,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 +687,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 +697,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 +747,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 +782,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; }
|
||||
@@ -832,6 +925,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 +958,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,
|
||||
|
||||
+361
-67
@@ -15,6 +15,34 @@
|
||||
],
|
||||
});
|
||||
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'),
|
||||
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; }},
|
||||
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
});
|
||||
mobilePullDetailNavigation.start();
|
||||
[
|
||||
[qs('.app-menu'), qs('#app-menu-toggle')],
|
||||
[qs('.work-settings'), qs('#work-settings-toggle')],
|
||||
@@ -494,13 +522,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 +673,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 +716,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 +754,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 +1095,7 @@
|
||||
}
|
||||
return offlineToday.warm(confirmedOwnerLogin, todayMyWork);
|
||||
}
|
||||
let findWorkNavigation = null;
|
||||
const findWorkController = createFindWork({
|
||||
fetchJson: fetchReviewJson,
|
||||
onItems: renderAvailableIssues,
|
||||
@@ -877,9 +1110,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 +1365,32 @@
|
||||
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(),
|
||||
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'),
|
||||
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 +1423,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;
|
||||
@@ -1409,6 +1680,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 =>
|
||||
@@ -1930,26 +2204,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) {
|
||||
@@ -2650,7 +2920,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 +
|
||||
@@ -3512,6 +3783,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 +3951,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 +4104,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 +4174,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 +4298,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 +4593,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 +4770,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 +4906,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 +5134,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 +5150,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 +5221,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 +5644,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 +5676,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 +6377,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 +6411,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 +6425,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 +6448,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 +6457,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 +6718,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 +6730,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 +6835,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 +6866,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.';
|
||||
|
||||
+293
-82
@@ -342,6 +342,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 +362,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>
|
||||
@@ -533,9 +540,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 +558,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 +649,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 +665,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 +709,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 +718,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 +843,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 +906,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 +920,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 +952,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 +1055,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 +1076,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 +1138,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 +1151,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 +1256,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 +1370,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 +1386,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 +1408,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 +1433,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>
|
||||
@@ -1317,6 +1517,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>
|
||||
@@ -1372,7 +1575,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>
|
||||
@@ -1404,6 +1609,12 @@
|
||||
<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/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');
|
||||
|
||||
@@ -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,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,72 @@
|
||||
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) {
|
||||
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') 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);
|
||||
};
|
||||
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) select(targetNames.get(visible.target));
|
||||
}, 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,76 @@
|
||||
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) {
|
||||
const target = targets[name];
|
||||
if (!target) return false;
|
||||
target.scrollIntoView({
|
||||
block:'start',
|
||||
behavior:prefersReducedMotion() ? 'auto' : 'smooth',
|
||||
});
|
||||
if (name === 'feedback' && options.summaryComposer) {
|
||||
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);
|
||||
};
|
||||
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) 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,
|
||||
};
|
||||
}
|
||||
|
||||
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});
|
||||
@@ -0,0 +1,83 @@
|
||||
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) {
|
||||
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) {
|
||||
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);
|
||||
};
|
||||
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) select(targetNames.get(visible.target));
|
||||
}, 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;
|
||||
@@ -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;
|
||||
|
||||
+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 };
|
||||
};
|
||||
});
|
||||
@@ -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-v114';
|
||||
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;
|
||||
@@ -22,6 +22,9 @@ const SHELL = [
|
||||
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',
|
||||
@@ -74,10 +77,18 @@ 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/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',
|
||||
@@ -356,15 +367,29 @@ 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);
|
||||
}
|
||||
|
||||
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();
|
||||
})());
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
fastapi==0.133.1
|
||||
httpx==0.28.1
|
||||
pydantic==2.13.4
|
||||
Pillow==12.3.0
|
||||
python-multipart==0.0.22
|
||||
pytest==9.1.1
|
||||
pywebpush==2.1.2
|
||||
|
||||
@@ -23,20 +23,21 @@ WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
||||
FEATURE_SOURCES = {
|
||||
"comment-actions": ("static/conversation.js", "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"),
|
||||
"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/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js",
|
||||
"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/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-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/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(
|
||||
|
||||
@@ -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
|
||||
+85
-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,
|
||||
@@ -697,13 +698,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 +797,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)
|
||||
@@ -3129,6 +3144,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 +4346,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 +5330,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 +5399,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,
|
||||
|
||||
+65
-2
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlsplit
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
|
||||
REPOSITORY = {
|
||||
@@ -16,6 +16,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):
|
||||
@@ -42,7 +70,9 @@ 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)
|
||||
if path == "/api/v1/user":
|
||||
self._json(200, USER)
|
||||
elif path == "/api/v1/user/repos":
|
||||
@@ -51,6 +81,19 @@ 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_available_scan = not any(
|
||||
name in query for name in ("assigned", "created", "review_requested", "q")
|
||||
) and query.get("type") == ["issues"]
|
||||
issues = AVAILABLE_ISSUES if is_available_scan else []
|
||||
self._json(200, issues, **{"X-Total-Count": str(len(issues))})
|
||||
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)
|
||||
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:
|
||||
@@ -84,3 +127,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)
|
||||
@@ -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,6 +223,12 @@ 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()
|
||||
|
||||
@@ -240,7 +269,9 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
|
||||
fake.issue_creation_enabled = True
|
||||
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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -38,5 +38,9 @@ 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 -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-v114" 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"
|
||||
|
||||
@@ -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
|
||||
@@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -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-v114" 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-v114" 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
|
||||
@@ -214,7 +214,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
assert "promptStorage:localStorage" in dashboard
|
||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v108" in worker
|
||||
assert "stackchain-dashboard-shell-v114" 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,146 @@
|
||||
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_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,146 @@
|
||||
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_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
|
||||
@@ -283,4 +283,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-v114" in service_worker
|
||||
@@ -0,0 +1,153 @@
|
||||
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_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
|
||||
@@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-readiness.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
@@ -31,6 +31,8 @@ process.stdout.write(JSON.stringify(databases));
|
||||
"stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
"stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1",
|
||||
"stackchain-conversation-reply-drafts-v1",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -48,6 +48,8 @@ const clear = createPrivateDeviceDataPurger({{
|
||||
"stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
"stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1",
|
||||
"stackchain-conversation-reply-drafts-v1",
|
||||
]
|
||||
assert state["caches"] == ["stackchain-dashboard-shell-v37"]
|
||||
assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
STORE = ROOT / "frontend" / "search-reply-draft-store.js"
|
||||
ATTACHMENT = ROOT / "frontend" / "issue-attachment.js"
|
||||
INDEX = ROOT / "frontend" / "index.html"
|
||||
DASHBOARD = ROOT / "frontend" / "dashboard.js"
|
||||
REGISTRY = ROOT / "frontend" / "private-data-registry.js"
|
||||
WORKER = ROOT / "frontend" / "service-worker.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> str:
|
||||
return subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout
|
||||
|
||||
|
||||
def test_search_reply_photo_drafts_restore_only_for_the_confirmed_account_and_exact_result():
|
||||
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 target = {{kind:'issue', repository:'stackchain/dashboard', number:957}};
|
||||
const other = {{kind:'issue', repository:'stackchain/dashboard', number:958}};
|
||||
const photo = new Blob(['field-evidence'], {{type:'image/webp'}});
|
||||
(async()=>{{
|
||||
await store.save(target, [{{
|
||||
filename:'camera.webp', contentType:'image/webp', blob:photo,
|
||||
note:'rack label', operationId:'upload-stable-1',
|
||||
confirmed:{{markdown:''}},
|
||||
}}]);
|
||||
const restored = await store.load(target);
|
||||
const wrongTarget = await store.load(other);
|
||||
login = 'alexander';
|
||||
const wrongAccount = await store.load(target);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
restored:{{...restored[0], blobText:await restored[0].blob.text(), blob:undefined}},
|
||||
wrongTarget, wrongAccount, recordCount:records.size,
|
||||
}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
|
||||
assert output == {
|
||||
"restored": {
|
||||
"filename": "camera.webp",
|
||||
"contentType": "image/webp",
|
||||
"note": "rack label",
|
||||
"operationId": "upload-stable-1",
|
||||
"confirmed": {"markdown": ""},
|
||||
"blobText": "field-evidence",
|
||||
},
|
||||
"wrongTarget": None,
|
||||
"wrongAccount": None,
|
||||
"recordCount": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_photo_bundle_checkpoint_restores_stable_uploads_without_repeating_them():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
const photo = new Blob(['proof'], {{type:'image/png'}}); photo.name = 'proof.png';
|
||||
const uploads = [];
|
||||
const first = attachment.create({{
|
||||
createOperationId:()=> 'stable-upload-957',
|
||||
upload:async payload=>{{uploads.push(payload.operation_id);return {{markdown:''}};}},
|
||||
}});
|
||||
first.select(photo);
|
||||
(async()=>{{
|
||||
await first.prepareComment({{repository:'stackchain/dashboard',number:957}}, 'Ready');
|
||||
const checkpoint = await first.serialize();
|
||||
const restored = attachment.create({{
|
||||
createOperationId:()=> 'must-not-replace-operation',
|
||||
upload:async payload=>{{uploads.push(payload.operation_id);throw new Error('must not upload twice');}},
|
||||
}});
|
||||
restored.restore(checkpoint);
|
||||
const comment = await restored.prepareComment({{repository:'stackchain/dashboard',number:957}}, 'Ready');
|
||||
process.stdout.write(JSON.stringify({{checkpoint:{{...checkpoint,blob:undefined}},comment,uploads}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
|
||||
assert output == {
|
||||
"checkpoint": {
|
||||
"filename": "proof.png",
|
||||
"contentType": "image/png",
|
||||
"operationId": "stable-upload-957",
|
||||
"confirmed": {"markdown": ""},
|
||||
},
|
||||
"comment": "Ready\n\n",
|
||||
"uploads": ["stable-upload-957"],
|
||||
}
|
||||
|
||||
|
||||
def test_dashboard_checkpoints_restores_and_clears_search_photo_drafts_at_user_boundaries():
|
||||
html = INDEX.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
registry = REGISTRY.read_text()
|
||||
worker = WORKER.read_text()
|
||||
|
||||
assert '<script src="static/search-reply-draft-store.js"></script>' in html
|
||||
assert html.index('static/search-reply-draft-store.js') < html.index('static/dashboard.js')
|
||||
assert "'stackchain-search-reply-drafts-v1'" in registry
|
||||
assert "BASE + 'static/search-reply-draft-store.js'" in worker
|
||||
assert "createSearchReplyDraftStore({" in dashboard
|
||||
assert "getOwnerLogin:() => confirmedOwnerLogin" in dashboard
|
||||
assert "onCheckpoint:() => persistSearchReplyPhotos()" in dashboard
|
||||
assert "searchReplyDraftStore.load(target)" in dashboard
|
||||
assert "searchReplyDraftStore.remove(item)" in dashboard
|
||||
@@ -14,7 +14,7 @@ def run_worker_scenario(scenario: str) -> dict:
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const listeners = {{}};
|
||||
const state = {{ added: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], sharedRecords: {{}}, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
||||
const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
||||
const storedResponses = new Map();
|
||||
storedResponses.set(
|
||||
'https://forge.example/dashboard/__offline-session-lease',
|
||||
@@ -26,6 +26,7 @@ storedResponses.set(
|
||||
const cache = {{
|
||||
addAll: async urls => {{ state.added = urls; }},
|
||||
add: async url => {{
|
||||
state.addAttempts.push(url);
|
||||
if (state.failedAdds.includes(url)) throw new Error('optional asset unavailable');
|
||||
state.individuallyAdded.push(url);
|
||||
}},
|
||||
@@ -37,7 +38,16 @@ const cache = {{
|
||||
put: async (request, response) => {{
|
||||
const key = String(request.url || request);
|
||||
state.puts.push(key);
|
||||
storedResponses.set(key, response.clone());
|
||||
state.migrated.push(key);
|
||||
state.activationOrder.push('put:' + key);
|
||||
storedResponses.set(new URL(key, 'https://forge.example').href, response.clone());
|
||||
}},
|
||||
}};
|
||||
const oldCache = {{
|
||||
match: async request => {{
|
||||
const key = String(request.url || request);
|
||||
const body = state.oldCachedAssets[key];
|
||||
return body === undefined ? null : new Response(body);
|
||||
}},
|
||||
}};
|
||||
const context = {{
|
||||
@@ -79,9 +89,9 @@ const context = {{
|
||||
return request;
|
||||
}}}},
|
||||
caches: {{
|
||||
open: async () => cache,
|
||||
open: async key => key === 'stackchain-dashboard-old' ? oldCache : cache,
|
||||
keys: async () => ['stackchain-dashboard-old', 'another-app-cache'],
|
||||
delete: async key => {{ state.deleted.push(key); return true; }},
|
||||
delete: async key => {{ state.deleted.push(key); state.activationOrder.push('delete:' + key); return true; }},
|
||||
match: async request => cache.match(request),
|
||||
}},
|
||||
fetch: async (request, options = {{}}) => {{
|
||||
@@ -152,26 +162,49 @@ async function dispatchPush(payload) {{
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_offline_activation_migration_rolls_the_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
|
||||
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
|
||||
|
||||
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/authored-outbox.js'" in source
|
||||
assert "BASE + 'static/background-issue-sync.js'" in source
|
||||
|
||||
|
||||
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "BASE + 'static/issue-evidence-review.js'" in source
|
||||
assert "BASE + 'static/issue-attachment.js'" in source
|
||||
|
||||
|
||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
||||
def test_offline_review_next_ships_today_completion_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -179,7 +212,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -187,7 +220,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "BASE + 'static/issue-sheet.js'" in source
|
||||
assert "BASE + 'static/checklist-conflict.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
@@ -197,14 +230,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -213,21 +246,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -413,6 +446,8 @@ def test_revoked_background_session_purges_worker_data_and_notifies_dashboard_cl
|
||||
"stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
"stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1",
|
||||
"stackchain-conversation-reply-drafts-v1",
|
||||
]
|
||||
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
|
||||
assert result["state"]["clientMessages"] == [
|
||||
@@ -506,6 +541,8 @@ def test_device_purge_message_stops_worker_outbox_and_acknowledges_completion():
|
||||
"stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
"stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1",
|
||||
"stackchain-conversation-reply-drafts-v1",
|
||||
]
|
||||
assert result["replies"] == [{"ok": True}]
|
||||
|
||||
@@ -893,7 +930,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
@@ -922,6 +959,9 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/commands.js",
|
||||
"/dashboard/static/saved-searches.js",
|
||||
"/dashboard/static/search-preview.js",
|
||||
"/dashboard/static/search-reply-draft-store.js",
|
||||
"/dashboard/static/conversation-reply-draft-store.js",
|
||||
"/dashboard/static/conversation-photo-drafts.js",
|
||||
"/dashboard/static/search-defer.js",
|
||||
"/dashboard/static/widgets.js",
|
||||
"/dashboard/static/drafts.js",
|
||||
@@ -974,10 +1014,18 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/issue-attachment.js",
|
||||
"/dashboard/static/issue-filing-review.js",
|
||||
"/dashboard/static/issue-sheet.js",
|
||||
"/dashboard/static/mobile-issue-detail-nav.js",
|
||||
"/dashboard/static/mobile-update-detail-nav.js",
|
||||
"/dashboard/static/mobile-review-detail-nav.js",
|
||||
"/dashboard/static/mobile-search-preview-nav.js",
|
||||
"/dashboard/static/mobile-plan-today-nav.js",
|
||||
"/dashboard/static/mobile-find-work-nav.js",
|
||||
"/dashboard/static/checklist-conflict.js",
|
||||
"/dashboard/static/voice-transcript-store.js",
|
||||
"/dashboard/static/voice-issue-capture.js",
|
||||
"/dashboard/static/voice-conversation-capture.js",
|
||||
"/dashboard/static/create-issue-sheet.js",
|
||||
"/dashboard/static/mobile-create-issue-nav.js",
|
||||
"/dashboard/static/create-and-start.js",
|
||||
"/dashboard/static/assign-and-start.js",
|
||||
"/dashboard/static/filed-claim.js",
|
||||
@@ -1025,6 +1073,37 @@ def test_activate_deletes_only_stale_stackchain_caches():
|
||||
assert result["deleted"] == ["stackchain-dashboard-old"]
|
||||
|
||||
|
||||
def test_offline_activation_migrates_cached_optional_feature_before_deleting_old_cache():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
const asset = '/dashboard/feature-issue-capture-test.js';
|
||||
state.oldCachedAssets[asset] = 'previous cached feature';
|
||||
state.failedAdds = [asset];
|
||||
context.self.__testOptionalFeatures.push(asset);
|
||||
await dispatch('activate');
|
||||
state.failFetch = true;
|
||||
const response = await dispatch('fetch', {
|
||||
method: 'GET', mode: 'cors',
|
||||
url: 'https://forge.example/dashboard/feature-issue-capture-test.js',
|
||||
});
|
||||
process.stdout.write(JSON.stringify({ body: await response.text(), state }));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["body"] == "previous cached feature"
|
||||
state = result["state"]
|
||||
assert state["migrated"] == [
|
||||
"/dashboard/feature-issue-capture-test.js"
|
||||
]
|
||||
assert state["individuallyAdded"] == []
|
||||
assert "/dashboard/feature-issue-capture-test.js" not in state["addAttempts"]
|
||||
assert state["activationOrder"] == [
|
||||
"put:/dashboard/feature-issue-capture-test.js",
|
||||
"delete:stackchain-dashboard-old",
|
||||
]
|
||||
assert state["claimed"] is True
|
||||
|
||||
|
||||
def test_activate_warms_optional_features_without_blocking_siblings_or_claim():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
@@ -1108,6 +1187,8 @@ def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_she
|
||||
"stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
"stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1",
|
||||
"stackchain-conversation-reply-drafts-v1",
|
||||
]
|
||||
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
|
||||
assert "private cached dashboard" not in result["body"]
|
||||
|
||||
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
||||
def test_readiness_runtime_is_available_in_offline_shell():
|
||||
service_worker = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v108';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v114';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
|
||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
@@ -68,7 +68,11 @@ const planned=createUnfiledCaptures.summary({{
|
||||
attachmentCount:2,
|
||||
}});
|
||||
const minimal=createUnfiledCaptures.summary({{}});
|
||||
process.stdout.write(JSON.stringify({{planned,minimal}}));
|
||||
const titles=[
|
||||
createUnfiledCaptures.displayTitle({{title:'Pump failure',hasAttachment:true}}),
|
||||
createUnfiledCaptures.displayTitle({{title:'',hasAttachment:true}}),
|
||||
];
|
||||
process.stdout.write(JSON.stringify({{planned,minimal,titles}}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
@@ -86,6 +90,24 @@ process.stdout.write(JSON.stringify({{planned,minimal}}));
|
||||
"action": "Choose repository",
|
||||
"details": "Create issue · No screenshots",
|
||||
},
|
||||
"titles": ["Pump failure", "Untitled photo draft"],
|
||||
}
|
||||
|
||||
|
||||
def test_capture_title_focus_defers_phone_keyboard_until_draft_continuation():
|
||||
script = f"""
|
||||
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
|
||||
const shouldFocus=createUnfiledCaptures.shouldFocusTitle;
|
||||
process.stdout.write(JSON.stringify({{
|
||||
freshPhone:shouldFocus({{mobile:true,continuing:false}}),
|
||||
resumedPhoto:shouldFocus({{mobile:true,continuing:true}}),
|
||||
freshDesktop:shouldFocus({{mobile:false,continuing:false}}),
|
||||
}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"freshPhone": False,
|
||||
"resumedPhoto": True,
|
||||
"freshDesktop": True,
|
||||
}
|
||||
|
||||
|
||||
@@ -198,6 +220,41 @@ process.stdout.write(JSON.stringify({{listed:captures.list()[0],names:resumed.at
|
||||
assert output["stored"] == output["notes"]
|
||||
|
||||
|
||||
def test_unfiled_capture_saves_untitled_photo_only_draft_but_rejects_empty_capture():
|
||||
script = f"""
|
||||
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
|
||||
const values=new Map(),blobs=new Map();
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const attachmentStore={{put:async(id,value)=>blobs.set(id,value),get:async id=>blobs.get(id),delete:async id=>blobs.delete(id)}};
|
||||
const photo={{filename:'field.jpg',contentType:'image/jpeg',blob:new Blob(['photo'],{{type:'image/jpeg'}}),note:'Pump label'}};
|
||||
(async()=>{{
|
||||
const captures=createUnfiledCaptures({{storage,attachmentStore,getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'photo-only'}});
|
||||
const saved=await captures.save({{title:' ',body:'',attachments:[photo]}});
|
||||
const listed=captures.list()[0];
|
||||
const resumed=await captures.resume(saved.id,'timmy');
|
||||
const remoteValues=new Map(),remoteBlobs=new Map();
|
||||
const remote=createUnfiledCaptures({{
|
||||
storage:{{getItem:k=>remoteValues.get(k)||null,setItem:(k,v)=>remoteValues.set(k,v)}},
|
||||
attachmentStore:{{put:async(id,value)=>remoteBlobs.set(id,value),get:async id=>remoteBlobs.get(id),delete:async id=>remoteBlobs.delete(id)}},
|
||||
getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'unused'
|
||||
}});
|
||||
await remote.mergeRemote(await captures.exportOwned('timmy'),'timmy');
|
||||
const remoteResumed=await remote.resume(saved.id,'timmy');
|
||||
let emptyError='';
|
||||
try {{ await captures.save({{title:' ',body:''}}); }} catch(error) {{ emptyError=error.message; }}
|
||||
process.stdout.write(JSON.stringify({{saved,listed,resumedTitle:resumed.title,resumedEvidence:resumed.attachments.map(item=>item.filename),remoteTitle:remoteResumed.title,remoteEvidence:remoteResumed.attachments.map(item=>item.filename),emptyError}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1)}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["saved"]["title"] == ""
|
||||
assert output["listed"]["title"] == ""
|
||||
assert output["resumedTitle"] == ""
|
||||
assert output["resumedEvidence"] == ["field.jpg"]
|
||||
assert output["remoteTitle"] == ""
|
||||
assert output["remoteEvidence"] == ["field.jpg"]
|
||||
assert output["emptyError"] == "Add a title or photo before saving."
|
||||
|
||||
|
||||
def test_unfiled_captures_export_and_import_ordered_evidence_between_devices():
|
||||
script = f"""
|
||||
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
|
||||
@@ -643,7 +700,7 @@ process.stdout.write(JSON.stringify({{errors,size:values.size}}));
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"errors": ["Add a title before saving.", "Offline identity is unavailable. Reconnect once before saving private work."],
|
||||
"errors": ["Add a title or photo before saving.", "Offline identity is unavailable. Reconnect once before saving private work."],
|
||||
"size": 0,
|
||||
}
|
||||
|
||||
@@ -670,7 +727,8 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume
|
||||
assert "dependencies.attachment[evidence ? 'restore' : 'clear'](evidence)" in DRAFT_SESSION.read_text()
|
||||
assert "await unfiledCaptures.completeResume(rUC)" in html
|
||||
assert "...(rUC ? { sourceCaptureId: rUC } : {})" in html
|
||||
assert "item.hasAttachment ? ' · Screenshot attached' : ''" in html
|
||||
assert "title:unfiledDraftDisplayTitle(item)" in html
|
||||
assert "item.hasAttachment ? 'Photo evidence attached' : ''" in html
|
||||
assert "issueCapture.saveDraft(resumed)" in DRAFT_SESSION.read_text()
|
||||
assert "item.kind === 'unfiled-issue'" in html
|
||||
assert '.create-issue-actions button { min-height:44px;' in html
|
||||
@@ -759,7 +817,8 @@ async def test_mobile_new_opens_capture_first_and_progressively_reveals_filing_f
|
||||
assert 'id="file-new-issue" type="button">File now' in html
|
||||
assert "function setIssueFilingMode(enabled)" in html
|
||||
assert "setIssueFilingMode(Boolean(captureDraft.repository))" in html
|
||||
assert "qs('#create-issue-title').focus();" in html
|
||||
assert "window.matchMedia?.('(max-width: 600px)').matches === true" in html
|
||||
assert "unfiledShouldFocusTitle({mobile:mobileCapture, continuing:continuingCapture})" in html
|
||||
assert "qs('#file-new-issue').addEventListener('click'" in html
|
||||
assert '.create-issue-capture-actions button { min-height:44px;' in html
|
||||
assert '.create-issue-capture-actions[hidden] { display:none;' in html
|
||||
|
||||
@@ -51,6 +51,20 @@ def test_unfiled_drafts_bound_collection_and_decoded_evidence(tmp_path):
|
||||
}])])
|
||||
|
||||
|
||||
def test_unfiled_drafts_allow_untitled_photo_evidence_but_reject_empty_records(tmp_path):
|
||||
store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3")
|
||||
photo_only = draft(title="")
|
||||
|
||||
validated = main.UnfiledDraft.model_validate(photo_only).model_dump()
|
||||
assert store.replace("timmy", 0, [validated])["drafts"][0]["title"] == ""
|
||||
|
||||
empty = draft(title="", evidence=[])
|
||||
with pytest.raises(ValueError, match="title or evidence is required"):
|
||||
main.UnfiledDraft.model_validate(empty)
|
||||
with pytest.raises(ValueError, match="title or evidence is required"):
|
||||
store.replace("timmy", 1, [empty])
|
||||
|
||||
|
||||
def test_unfiled_drafts_validate_and_round_trip_complete_filing_plan(tmp_path):
|
||||
store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3")
|
||||
planned = draft(evidence=[])
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import io
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from src import gitea_proxy, main
|
||||
from tests.dashboard_bundle import dashboard
|
||||
@@ -12,7 +14,9 @@ from tests.dashboard_bundle import dashboard
|
||||
ROOT = Path(__file__).parents[1]
|
||||
OUTBOX = ROOT / "frontend" / "authored-outbox.js"
|
||||
SYNC = ROOT / "frontend" / "background-issue-sync.js"
|
||||
PNG = b"\x89PNG\r\n\x1a\nmobile-update"
|
||||
_png = io.BytesIO()
|
||||
Image.new("RGB", (2, 2), "red").save(_png, format="PNG")
|
||||
PNG = _png.getvalue()
|
||||
|
||||
|
||||
def run_node(script: str):
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
FRONTEND = Path(__file__).parents[1] / "frontend"
|
||||
VOICE_CAPTURE = FRONTEND / "voice-conversation-capture.js"
|
||||
VOICE_STORE = FRONTEND / "voice-transcript-store.js"
|
||||
|
||||
|
||||
def run_node(script: str):
|
||||
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_dictated_reply_waits_for_review_then_appends_to_the_current_conversation():
|
||||
script = f"""
|
||||
const {{createVoiceConversationCapture}} = require({json.dumps(str(VOICE_CAPTURE))});
|
||||
class Element {{
|
||||
constructor() {{ this.hidden=false; this.value=''; this.textContent=''; this.listeners={{}}; this.events=[]; }}
|
||||
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
|
||||
click() {{ this.listeners.click?.(); }}
|
||||
dispatchEvent(event) {{ this.events.push(event.type); }}
|
||||
}}
|
||||
let recognition;
|
||||
class Recognition {{
|
||||
constructor() {{ recognition=this; this.aborts=0; }}
|
||||
start() {{}} stop() {{}} abort() {{ this.aborts += 1; }}
|
||||
}}
|
||||
const elements=Object.fromEntries(
|
||||
['root','start','stop','review','transcript','append','replace','discard','status','draft'].map(key=>[key,new Element()])
|
||||
);
|
||||
elements.review.hidden=true;
|
||||
elements.draft.value='Typed opening';
|
||||
const saved=[]; const cleared=[];
|
||||
const store={{
|
||||
async load(login, target) {{ return ''; }},
|
||||
async save(login, transcript, target) {{ saved.push({{login, transcript, target}}); }},
|
||||
async clear(login, target) {{ cleared.push({{login, target}}); }},
|
||||
}};
|
||||
(async()=>{{
|
||||
const controller=createVoiceConversationCapture({{
|
||||
Recognition,elements,transcriptStore:store,getLogin:()=> 'timmy',createEvent:name=>({{type:name}})
|
||||
}});
|
||||
await controller.open('issue:stackchain/stackchain-dashboard#951');
|
||||
elements.start.click();
|
||||
const first=Object.assign([{{transcript:'Please test on mobile.'}}],{{isFinal:true}});
|
||||
recognition.onresult({{resultIndex:0,results:[first]}});
|
||||
const before={{draft:elements.draft.value,reviewHidden:elements.review.hidden,transcript:elements.transcript.value}};
|
||||
elements.append.click(); await Promise.resolve();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
before,draft:elements.draft.value,events:elements.draft.events,saved,cleared,
|
||||
reviewHidden:elements.review.hidden,status:elements.status.textContent
|
||||
}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"before": {
|
||||
"draft": "Typed opening",
|
||||
"reviewHidden": False,
|
||||
"transcript": "Please test on mobile.",
|
||||
},
|
||||
"draft": "Typed opening\n\nPlease test on mobile.",
|
||||
"events": ["input"],
|
||||
"saved": [
|
||||
{
|
||||
"login": "timmy",
|
||||
"transcript": "Please test on mobile.",
|
||||
"target": "issue:stackchain/stackchain-dashboard#951",
|
||||
}
|
||||
],
|
||||
"cleared": [
|
||||
{"login": "timmy", "target": "issue:stackchain/stackchain-dashboard#951"}
|
||||
],
|
||||
"reviewHidden": True,
|
||||
"status": "Transcript added. Review the reply before sending.",
|
||||
}
|
||||
|
||||
|
||||
def test_private_store_isolates_conversation_transcripts_by_account_and_target():
|
||||
script = f"""
|
||||
const createVoiceTranscriptStore=require({json.dumps(str(VOICE_STORE))});
|
||||
const records=new Map();
|
||||
const transaction=async work=>work({{
|
||||
get:async key=>records.get(key),
|
||||
put:async value=>records.set(value.login,{{...value}}),
|
||||
delete:async key=>records.delete(key),
|
||||
}});
|
||||
(async()=>{{
|
||||
const store=createVoiceTranscriptStore({{transaction}});
|
||||
await store.save('timmy','Issue reply','issue:org/repo#1');
|
||||
await store.save('timmy','Pull reply','pull:org/repo#2');
|
||||
await store.save('alexander','Other account','issue:org/repo#1');
|
||||
const before={{
|
||||
issue:await store.load('TIMMY','issue:org/repo#1'),
|
||||
pull:await store.load('timmy','pull:org/repo#2'),
|
||||
other:await store.load('alexander','issue:org/repo#1'),
|
||||
}};
|
||||
await store.clear('timmy','issue:org/repo#1');
|
||||
process.stdout.write(JSON.stringify({{before,cleared:await store.load('timmy','issue:org/repo#1')}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"before": {"issue": "Issue reply", "pull": "Pull reply", "other": "Other account"},
|
||||
"cleared": "",
|
||||
}
|
||||
|
||||
|
||||
def test_switching_conversations_aborts_listening_and_recovers_only_the_new_target():
|
||||
script = f"""
|
||||
const {{createVoiceConversationCapture}} = require({json.dumps(str(VOICE_CAPTURE))});
|
||||
class Element {{
|
||||
constructor() {{ this.hidden=false;this.value='';this.textContent='';this.listeners={{}}; }}
|
||||
addEventListener(name,callback) {{ this.listeners[name]=callback; }}
|
||||
click() {{ this.listeners.click?.(); }} dispatchEvent() {{}}
|
||||
}}
|
||||
const instances=[];
|
||||
class Recognition {{
|
||||
constructor() {{ this.aborts=0; instances.push(this); }} start() {{}} stop() {{}} abort() {{ this.aborts+=1; }}
|
||||
}}
|
||||
const elements=Object.fromEntries(
|
||||
['root','start','stop','review','transcript','append','replace','discard','status','draft'].map(key=>[key,new Element()])
|
||||
);
|
||||
const loads=[]; const saved=[];
|
||||
const store={{
|
||||
async load(login,target) {{ loads.push(target); return target.endsWith('#2') ? 'Recovered pull reply.' : ''; }},
|
||||
async save(login,text,target) {{ saved.push({{text,target}}); }}, async clear() {{}},
|
||||
}};
|
||||
(async()=>{{
|
||||
const controller=createVoiceConversationCapture({{Recognition,elements,transcriptStore:store,getLogin:()=> 'timmy'}});
|
||||
await controller.open('issue:org/repo#1'); elements.start.click();
|
||||
await controller.open('pull:org/repo#2');
|
||||
instances[0].onresult({{results:[Object.assign([{{transcript:'Stale issue words.'}}],{{isFinal:true}})]}});
|
||||
instances[0].onerror({{error:'not-allowed'}});
|
||||
instances[0].onend();
|
||||
await Promise.resolve();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
loads,saved,aborts:instances[0].aborts,transcript:elements.transcript.value,
|
||||
reviewHidden:elements.review.hidden,status:elements.status.textContent,
|
||||
startHidden:elements.start.hidden,stopHidden:elements.stop.hidden
|
||||
}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"loads": ["issue:org/repo#1", "pull:org/repo#2"],
|
||||
"saved": [],
|
||||
"aborts": 1,
|
||||
"transcript": "Recovered pull reply.",
|
||||
"reviewHidden": False,
|
||||
"status": "Recovered transcript. Review it before using or discarding it.",
|
||||
"startHidden": False,
|
||||
"stopHidden": True,
|
||||
}
|
||||
|
||||
|
||||
def test_all_mobile_conversation_composers_ship_accessible_voice_review_controls():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
dashboard = (FRONTEND / "dashboard.js").read_text()
|
||||
bundle = (Path(__file__).parents[1] / "src" / "frontend_bundle.py").read_text()
|
||||
|
||||
for kind, draft in (
|
||||
("issue-comment", "issue-comment"),
|
||||
("pull-comment", "pull-comment"),
|
||||
("update-reply", "update-reply"),
|
||||
):
|
||||
assert f'id="voice-{kind}"' in html
|
||||
assert f'id="start-voice-{kind}"' in html
|
||||
assert f'id="voice-{kind}-transcript"' in html
|
||||
assert f'for="voice-{kind}-transcript"' in html
|
||||
assert f'id="append-voice-{kind}"' in html
|
||||
assert f'id="replace-with-voice-{kind}"' in html
|
||||
assert f'id="discard-voice-{kind}"' in html
|
||||
|
||||
assert "mountConversationVoice('issue-comment', '#issue-comment')" in dashboard
|
||||
assert "mountConversationVoice('pull-comment', '#pull-comment')" in dashboard
|
||||
assert "mountConversationVoice('update-reply', '#update-reply')" in dashboard
|
||||
assert ".voice-conversation-controls button" in css
|
||||
assert "min-height:44px" in css
|
||||
assert '"static/voice-conversation-capture.js"' in bundle
|
||||
assert "issueVoiceReply.open(conversationVoiceTarget('issue', item))" in dashboard
|
||||
assert "pullVoiceReply.open(conversationVoiceTarget('pull', item))" in dashboard
|
||||
assert "updateVoiceReply.open('update:' + item.notification_id)" in dashboard
|
||||
assert "issueVoiceReply.cancel();" in dashboard
|
||||
assert "pullVoiceReply.cancel();" in dashboard
|
||||
assert "updateVoiceReply.cancel();" in dashboard
|
||||
|
||||
|
||||
def test_mobile_search_reply_ships_target_scoped_voice_review_and_cancels_on_close():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
dashboard = (FRONTEND / "dashboard.js").read_text()
|
||||
|
||||
for control in (
|
||||
"voice-search-reply",
|
||||
"start-voice-search-reply",
|
||||
"stop-voice-search-reply",
|
||||
"voice-search-reply-review",
|
||||
"voice-search-reply-transcript",
|
||||
"append-voice-search-reply",
|
||||
"replace-with-voice-search-reply",
|
||||
"discard-voice-search-reply",
|
||||
"voice-search-reply-status",
|
||||
):
|
||||
assert f'id="{control}"' in html
|
||||
|
||||
assert 'for="voice-search-reply-transcript"' in html
|
||||
assert "mountConversationVoice('search-reply', '#search-preview-reply')" in dashboard
|
||||
assert "searchVoiceReply.open(searchConversationVoiceTarget(state.item))" in dashboard
|
||||
assert "searchVoiceReply.cancel();" in dashboard
|
||||
assert ".search-preview-reply .voice-conversation-controls button" in css
|
||||
assert "min-height:44px" in css
|
||||
@@ -157,7 +157,7 @@ const loads=[];
|
||||
}
|
||||
|
||||
|
||||
def test_multiple_final_segments_accumulate_in_one_bounded_checkpoint():
|
||||
def test_cumulative_recognition_events_append_each_new_final_segment_once():
|
||||
script = f"""
|
||||
const {{createVoiceIssueCapture}} = require({json.dumps(str(VOICE_CAPTURE))});
|
||||
class Element {{
|
||||
@@ -165,6 +165,7 @@ class Element {{
|
||||
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
|
||||
click() {{ this.listeners.click?.(); }} dispatchEvent() {{}}
|
||||
}}
|
||||
const result=(transcript,isFinal)=>Object.assign([{{transcript}}],{{isFinal}});
|
||||
let recognition;
|
||||
class Recognition {{ constructor() {{ recognition=this; }} start() {{}} stop() {{}} abort() {{}} }}
|
||||
const elements=Object.fromEntries(
|
||||
@@ -176,8 +177,12 @@ const saved=[];
|
||||
async load() {{ return ''; }}, async save(login,text) {{ saved.push(text); }}, async clear() {{}},
|
||||
}}}});
|
||||
await controller.ready; elements.start.click();
|
||||
recognition.onresult({{results:[Object.assign([{{transcript:'Lift outage.'}}],{{isFinal:true}})]}});
|
||||
recognition.onresult({{results:[Object.assign([{{transcript:'Elevator B is trapped.'}}],{{isFinal:true}})]}});
|
||||
const first=result('Lift outage.',true);
|
||||
recognition.onresult({{resultIndex:0,results:[first]}});
|
||||
recognition.onresult({{
|
||||
resultIndex:1,
|
||||
results:[first,result('Elevator B',false),result('Elevator B is trapped.',true)],
|
||||
}});
|
||||
await Promise.resolve();
|
||||
process.stdout.write(JSON.stringify({{transcript:elements.transcript.value,saved}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
|
||||
Reference in new issue
Block a user