Compare commits

...
Author SHA1 Message Date
timmy 966028e0a6 security: sanitize image evidence server-side (Closes #983)
CI / lint (pull_request) Successful in 2m11s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 1m11s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 19:33:46 +00:00
rockachopa 21eb223882 Merge pull request 'Gate releases on the packaged mobile Find Work journey' (#982)
CI / lint (push) Successful in 2m10s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 1m12s
CI / release-candidate (push) Successful in 6s
Closes #981
2026-08-16 19:02:46 +00:00
timmy e10bd2d261 test: gate packaged mobile Find Work journey (Closes #981)
CI / lint (pull_request) Successful in 2m21s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 1m20s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 18:58:13 +00:00
timmy 0728bbdd6a Merge pull request 'Guide mobile Find Work from discovery through assignment' (#980) from timmy/979-mobile-find-work-navigation into main
CI / lint (push) Successful in 2m11s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m6s
CI / release-candidate (push) Successful in 7s
2026-08-16 18:11:41 +00:00
timmy 53e556ddab feat: guide mobile Find Work workflow (Closes #979)
CI / lint (pull_request) Successful in 2m20s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m18s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 18:04:55 +00:00
timmy 611b10b477 Merge pull request 'Preserve offline workflows across PWA activation' (#978) from timmy/977-preserve-offline-feature-cache-activation into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m6s
2026-08-16 17:39:09 +00:00
timmy 85a000422d test: exercise migrated workflow offline
CI / lint (pull_request) Successful in 2m9s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 1m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 17:35:24 +00:00
timmy d80091d244 fix: preserve offline workflows across activation (Closes #977)
CI / lint (pull_request) Successful in 2m3s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 1m43s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 17:25:48 +00:00
rockachopa 5217f6a86f Merge pull request 'Add persistent mobile Plan Today workflow navigation' (#976) from timmy/975-mobile-plan-today-navigation into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m43s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m58s
2026-08-16 16:40:44 +00:00
timmy fc33746d4c feat: add mobile Plan Today navigation (Closes #975)
CI / lint (pull_request) Successful in 2m0s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m2s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 16:36:49 +00:00
rockachopa cc449b3d2f Merge pull request 'Add persistent mobile Search Preview workflow navigation' (#974) from timmy/973-mobile-search-preview-navigation into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m2s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m4s
2026-08-16 16:08:44 +00:00
timmy 8de2318f43 test: isolate Search Preview browser checks
CI / lint (pull_request) Successful in 1m58s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 1m8s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 16:05:06 +00:00
timmy 24f781d398 feat: add mobile Search Preview navigation (Closes #973)
CI / lint (pull_request) Failing after 2m7s
CI / build-release (pull_request) Has been skipped
CI / browser-journey (pull_request) Has been skipped
CI / release-candidate (pull_request) Has been skipped
2026-08-16 15:58:41 +00:00
timmy 25f752339f Merge pull request 'Add mobile navigation to the New issue workflow' (#972) from timmy/971-mobile-new-issue-navigation into main
CI / lint (push) Successful in 2m1s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 59s
CI / release-candidate (push) Successful in 6s
2026-08-16 15:16:04 +00:00
timmy fe95b4e32a fix: keep mobile filing navigation selected
CI / lint (pull_request) Successful in 2m12s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 15:12:02 +00:00
timmy 2b671d91fd feat: add mobile new issue navigation (Closes #971)
CI / lint (pull_request) Successful in 1m50s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 1m0s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 15:02:13 +00:00
timmy dcc3348c12 Merge pull request 'Add persistent mobile requested-review workspace navigation' (#970) from timmy/969-mobile-requested-review-navigation into main
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 55s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m59s
2026-08-16 14:32:36 +00:00
timmy 31fb3fc8ae feat: add mobile requested-review navigation (Closes #969)
CI / lint (pull_request) Successful in 2m10s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 55s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 14:28:25 +00:00
timmy a0b3db6895 Merge pull request 'Add persistent mobile update workspace navigation' (#968) from timmy/967-mobile-update-workspace-navigation into main
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 54s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m5s
2026-08-16 13:46:40 +00:00
timmy 6c7b6ebd4d feat: add mobile update workspace navigation (Closes #967)
CI / lint (pull_request) Successful in 2m25s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m3s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 13:42:19 +00:00
timmy 3c86e02efe Merge pull request 'Add persistent mobile pull request workspace navigation' (#966) from timmy/965-mobile-pull-workspace-navigation into main
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 51s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m59s
2026-08-16 13:04:52 +00:00
timmy c48baac1ea test: keep rendered checks out of unit CI
CI / lint (pull_request) Successful in 1m55s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 54s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 13:01:03 +00:00
timmy 311396cfac feat: add mobile pull workspace navigation (Closes #965)
CI / lint (pull_request) Failing after 1m52s
CI / build-release (pull_request) Has been skipped
CI / browser-journey (pull_request) Has been skipped
CI / release-candidate (pull_request) Has been skipped
2026-08-16 12:55:03 +00:00
timmy 762614b2d3 Merge pull request 'Preserve My Work conversation photo drafts' (#964) from timmy/963-preserve-my-work-photo-drafts into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 56s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m3s
2026-08-16 12:38:07 +00:00
timmy 31f2c71575 feat: preserve My Work photo drafts (Closes #963)
CI / lint (pull_request) Successful in 2m1s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 57s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 12:34:01 +00:00
timmy 50f1af83f6 Merge pull request 'Add persistent mobile issue detail navigation' (#962) from timmy/961-mobile-issue-detail-navigation into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 56s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m59s
2026-08-16 12:05:55 +00:00
timmy 6c200d7c97 feat: add mobile issue detail navigation (Closes #961)
CI / lint (pull_request) Successful in 2m3s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 56s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 12:02:02 +00:00
rockachopa 6954ffc050 Merge pull request 'Queue mobile Search replies for offline delivery' (#960) from timmy/959-offline-search-replies into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 54s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m2s
2026-08-16 11:08:30 +00:00
timmy c8a237aba4 test: tolerate container reconnect latency
CI / lint (pull_request) Successful in 1m59s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 55s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 11:05:04 +00:00
timmy 7fa43a9e50 feat: queue offline Search replies (Closes #959)
CI / lint (pull_request) Successful in 2m7s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Failing after 1m10s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 11:00:11 +00:00
timmy 2e6c20d68d Preserve mobile Search photo reply drafts across interruptions (#958)
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m0s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m1s
Closes #957
2026-08-16 10:12:08 +00:00
timmy 5af45cda57 feat: preserve Search photo reply drafts (Closes #957)
CI / lint (pull_request) Successful in 2m10s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 57s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 10:07:46 +00:00
timmy cab5802547 Merge pull request 'Attach photo evidence to mobile Search preview replies' (#956) from timmy/955-search-preview-photo-evidence into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 51s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m58s
2026-08-16 09:34:05 +00:00
timmy d3a57f222e feat: attach photos from Search preview replies (Closes #955)
CI / lint (pull_request) Successful in 2m0s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 51s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 09:30:18 +00:00
timmy 053f32c428 Merge pull request 'Dictate replies from mobile Search preview' (#954) from timmy/953-dictate-search-preview-replies into main
CI / lint (push) Successful in 2m12s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 58s
CI / release-candidate (push) Successful in 6s
2026-08-16 08:59:30 +00:00
timmy d63166b581 feat: dictate replies from mobile Search preview (Closes #953)
CI / lint (pull_request) Successful in 1m55s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 51s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 08:55:49 +00:00
timmy 51710972eb Merge pull request 'Dictate, review, and send mobile conversation replies' (#952) from timmy/951-dictate-mobile-conversation-replies into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 56s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m2s
2026-08-16 08:39:34 +00:00
timmy 0e13872311 feat: dictate mobile conversation replies (Closes #951)
CI / lint (pull_request) Successful in 1m59s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 59s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 08:35:34 +00:00
timmy eac5342fa5 Merge pull request 'Strip private metadata from mobile photo evidence' (#950) from timmy/949-strip-photo-metadata into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m2s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m2s
2026-08-16 07:58:41 +00:00
timmy a8e931f306 feat: strip metadata from photo evidence (Closes #949)
CI / lint (pull_request) Successful in 1m58s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 57s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 07:55:04 +00:00
timmy 00fd96f4a4 Merge pull request 'Edit and redact mobile conversation photos before sending' (#948) from timmy/947-edit-redact-conversation-photos into main
CI / lint (push) Successful in 2m0s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 55s
CI / release-candidate (push) Successful in 7s
2026-08-16 07:32:05 +00:00
timmy 0d5fa13d65 feat: edit photos in conversation replies (Closes #947)
CI / lint (pull_request) Successful in 2m4s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 57s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 07:28:10 +00:00
rockachopa 6270253c24 Merge pull request 'Review and caption mobile conversation photo bundles before sending' (#946) from timmy/945-review-caption-conversation-photo-bundles into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m0s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m8s
2026-08-16 07:02:09 +00:00
timmy 5da5d7cdc4 feat: review conversation photo bundles before sending (Closes #945)
CI / lint (pull_request) Successful in 2m11s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 1m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 06:56:05 +00:00
timmy 55bd353daf Merge pull request 'Send multi-photo evidence bundles in mobile conversation replies' (#944) from timmy/943-mobile-conversation-photo-bundles into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m0s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m7s
2026-08-16 06:13:37 +00:00
timmy c5d31c1972 feat: send mobile conversation photo bundles (Closes #943)
CI / lint (pull_request) Successful in 2m4s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 57s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 06:06:07 +00:00
timmy 843906208c Capture photo evidence in mobile conversation replies (#942)
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m0s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m3s
Closes #941
2026-08-16 05:37:38 +00:00
timmy 2338a07bab feat: capture photo evidence in conversation replies (Closes #941)
CI / lint (pull_request) Successful in 2m6s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m2s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 05:33:34 +00:00
rockachopa 67d8828aea Merge pull request 'Save photo-only mobile captures as untitled Drafts' (#940) from timmy/939-photo-only-untitled-drafts into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 59s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m2s
2026-08-16 05:06:46 +00:00
timmy caa0fbc2fd feat: save photo-only captures as drafts (Closes #939)
CI / lint (pull_request) Successful in 2m9s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m2s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 04:59:08 +00:00
timmy 8a85b344f8 Merge pull request 'Capture camera-first photo evidence from mobile New issue' (#938) from timmy/937-camera-first-photo-evidence into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 59s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m7s
Merge pull request #938 from timmy/937-camera-first-photo-evidence
2026-08-16 04:33:45 +00:00
timmy b4662a175d feat: capture camera-first photo evidence (Closes #937)
CI / lint (pull_request) Successful in 2m12s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m2s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 04:29:44 +00:00
timmy fb08785ad5 Merge pull request 'Keep multi-segment mobile voice dictation free of repeated phrases' (#936) from timmy/935-deduplicate-mobile-voice-results into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 56s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m7s
2026-08-16 04:00:46 +00:00
timmy 2bb155a9e4 fix: deduplicate cumulative voice results (Closes #935)
CI / lint (pull_request) Successful in 2m3s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 03:56:50 +00:00
timmy c4715fc95c Merge pull request 'Enable same-origin microphone access for voice capture' (#934) from timmy/933-same-origin-microphone into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 58s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m10s
Merge PR #934: enable same-origin microphone access
2026-08-16 03:28:43 +00:00
75 changed files with 5190 additions and 336 deletions

No files matched your search

+2 -2
View File
@@ -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
+11 -6
View File
@@ -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
View File
@@ -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';
}
+99 -59
View File
@@ -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 ?
+81
View File
@@ -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 };
};
});
+122
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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>
+55 -20
View File
@@ -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();
+1
View File
@@ -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;
-1
View File
@@ -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');
+122
View File
@@ -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});
+194
View File
@@ -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 =>
({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' })[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,
};
};
});
+72
View File
@@ -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;
+104
View File
@@ -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});
+76
View File
@@ -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;
+130
View File
@@ -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});
+83
View File
@@ -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;
+2
View File
@@ -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
View File
@@ -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 });
+121
View File
@@ -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 };
};
});
+30 -5
View File
@@ -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();
})());
});
+17 -6
View File
@@ -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;
}
+124
View File
@@ -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};
+1
View File
@@ -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) {
+12 -6
View File
@@ -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
View File
@@ -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
+5 -4
View File
@@ -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(
+26
View File
@@ -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,
+38
View File
@@ -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
View File
@@ -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"![{safe_name}](<{safe_url}>)"
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
+3 -1
View File
@@ -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
View File
@@ -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 []})
+114
View File
@@ -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)
+32 -1
View File
@@ -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()
+106
View File
@@ -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:'![proof](saved)'}},
}}],
}});
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": "![proof](saved)"},
}
assert output["durable"] == {
"targetKind": "pull", "operationId": "photo-stage-1",
"markdown": "![proof](saved)", "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"},
]
+127
View File
@@ -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:'![one](saved)'}}}},
{{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:'![two](uploaded)'}};
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"] == [
"![one](saved)", "![two](uploaded)"
]
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![one](saved)\n\n![two](uploaded)"
}
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:'!['+file.name+'](url/'+file.name+')'}};
}}
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"] == [
"![before.jpg](url/before.jpg)"
]
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![before.jpg](url/before.jpg)\n\n![after.jpg](url/after.jpg)"
}
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"] == "![a](url)"
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:['![a](url/a)']}}));
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": ["![a](url/a)"],
}
def test_indexeddb_store_closes_on_version_change_and_reopens_afterward():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
+5 -3
View File
@@ -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
+5 -1
View File
@@ -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
+27
View File
@@ -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))});
+1 -1
View File
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v108" in worker
assert "stackchain-dashboard-shell-v114" in worker
+164
View File
@@ -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:'![after](saved)'}}}},
];
(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": "![after](saved)"},
"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
+5
View File
@@ -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"
+72
View File
@@ -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"] == "![photo.png](<https://forge.example/evidence/photo.png>)"
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 = []
+63
View File
@@ -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())
+236 -6
View File
@@ -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 '![photo](<safe-url>)';}},
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, "![photo](<safe-url>)", "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():
+63 -3
View File
@@ -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 {
+32
View File
@@ -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
+1 -1
View File
@@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v108" in source
assert "stackchain-dashboard-shell-v114" in source
assert "BASE + 'static/later-sync.js'" in source
+1 -1
View File
@@ -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
+1 -1
View File
@@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-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
+1 -1
View File
@@ -214,7 +214,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert "promptStorage:localStorage" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker
assert "stackchain-dashboard-shell-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
+135
View File
@@ -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 &amp; 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
+103
View File
@@ -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 &amp; 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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-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
+2
View File
@@ -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",
}
+2
View File
@@ -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"}]
+118
View File
@@ -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:'![camera](confirmed-url)'}},
}}]);
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": "![camera](confirmed-url)"},
"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:'![proof](saved-url)'}};}},
}});
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": "![proof](saved-url)"},
},
"comment": "Ready\n\n![proof](saved-url)",
"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
+96 -15
View File
@@ -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"]
+1 -1
View File
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text()
assert "const CACHE = 'stackchain-dashboard-shell-v108';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v114';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
+1 -1
View File
@@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v108" in source
assert "stackchain-dashboard-shell-v114" in source
assert "BASE + 'static/today-sync.js'" in source
+63 -4
View File
@@ -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
+14
View File
@@ -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=[])
+5 -1
View File
@@ -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):
+213
View File
@@ -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
+8 -3
View File
@@ -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);}});