Compare commits

...
Author SHA1 Message Date
timmy c04cba29d9 feat: batch-plan mobile search results into Today (Closes #809)
CI / lint (pull_request) Successful in 1m37s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 08:06:03 +00:00
timmy cacecac865 Merge pull request 'Make partial mobile Search pagination retryable and lossless' (#808) from timmy/807-lossless-partial-search into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m34s
2026-08-14 07:35:02 +00:00
timmy 10f7cc832a fix: make partial search pagination lossless (Closes #807)
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 07:32:45 +00:00
timmy e5f37a866c Merge pull request 'Reply and continue from mobile Search' (#806) from timmy/805-reply-continue-mobile-search into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m39s
2026-08-14 06:44:29 +00:00
timmy 5a4fbd1564 feat: reply and continue from mobile search (Closes #805)
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 06:41:34 +00:00
timmy 7bcab03b8a Merge pull request 'Read current conversations inside mobile Search preview' (#804)
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m37s
Closes #803
2026-08-14 06:07:12 +00:00
timmy 51fe9e5af2 feat: show conversations in mobile search preview (Closes #803)
CI / lint (pull_request) Successful in 1m45s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 06:04:25 +00:00
timmy 2406d24cb8 Merge pull request 'Continue through mobile Search results while planning work' (#802) from timmy/801-sequential-mobile-search into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m33s
2026-08-14 05:13:09 +00:00
timmy 745d2529e5 feat: continue through mobile search results (Closes #801)
CI / lint (pull_request) Successful in 1m39s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 05:08:56 +00:00
timmy af9699e362 Merge pull request 'Schedule mobile Search results into Later' (#800)
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m38s
Closes #799
2026-08-14 04:32:45 +00:00
timmy 71b38687c4 feat: schedule search results into Later (Closes #799)
CI / lint (pull_request) Successful in 1m42s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 04:30:02 +00:00
timmy 0a8f33dc83 Merge pull request 'Queue mobile Search results into Today' (#798) from timmy/797-queue-mobile-search-results into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m45s
2026-08-14 04:08:20 +00:00
timmy 0f45b7aead feat: queue mobile search results into Today (Closes #797)
CI / lint (pull_request) Successful in 1m41s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 04:05:49 +00:00
timmy a44bf46bef Merge pull request 'Scope mobile Search to an accessible repository' (#796) from timmy/795-repository-scoped-mobile-search into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m38s
2026-08-14 03:14:05 +00:00
timmy ec86bf55a8 Scope mobile Search by repository
CI / lint (pull_request) Successful in 1m37s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 03:11:40 +00:00
timmy 7636e30482 Merge pull request 'Scope mobile Search by type and status' (#794) from timmy/793-scope-mobile-search into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m42s
Merge pull request 'Scope mobile Search by type and status' (#794) from timmy/793-scope-mobile-search into main
2026-08-14 02:37:38 +00:00
timmy bd054998b2 feat: scope mobile search by type and status (Closes #793)
CI / lint (pull_request) Successful in 1m45s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 02:34:44 +00:00
timmy 3c65bc1b31 Merge pull request 'Open requested pull reviews from mobile Search' (#792) from timmy/791-open-requested-reviews-from-search into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m36s
2026-08-14 01:47:39 +00:00
timmy bc082f2bd9 feat: open requested reviews from mobile search (Closes #791)
CI / lint (pull_request) Successful in 1m45s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 01:44:51 +00:00
timmy 9ae0a89970 Merge pull request 'Share resumable mobile Search previews' (#790) from timmy/787-share-resumable-search-previews into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m37s
2026-08-14 01:07:05 +00:00
timmy 43163ed926 feat: share resumable mobile search previews (Closes #787)
CI / lint (pull_request) Successful in 1m41s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 01:04:20 +00:00
timmy 66a2b78860 Merge pull request 'Open assigned pull requests from mobile Search' (#786) from timmy/785-open-assigned-pulls-from-search into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m31s
2026-08-14 00:27:40 +00:00
timmy 20d4f1ef7e feat: open assigned pulls from mobile search (Closes #785)
CI / lint (pull_request) Successful in 1m31s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 00:25:07 +00:00
timmy 70281bb56a Merge pull request 'Load complete, balanced results in mobile Search' (#784) from timmy/783-paginated-balanced-mobile-search into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m32s
2026-08-14 00:06:24 +00:00
timmy 7b736bc4c6 feat: paginate balanced mobile search results (Closes #783)
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 00:04:06 +00:00
timmy fa3e5e3bf0 Merge pull request 'Resume addressable mobile Search sessions' (#782) from timmy/781-resumable-mobile-search into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m32s
Merge pull request Resume addressable mobile Search sessions (#782)
2026-08-13 23:27:44 +00:00
timmy bc0d47e8e7 feat: resume addressable mobile search sessions (Closes #781)
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 23:25:35 +00:00
timmy 741c27eacf Merge pull request 'Authorize and audit permanent comment deletion' (#780) from timmy/779-authorize-audit-comment-deletion into main
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m26s
2026-08-13 22:58:01 +00:00
timmy 3896f17a58 feat: authorize and audit comment deletion (Closes #779)
CI / lint (pull_request) Successful in 1m28s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 22:55:47 +00:00
timmy 9d14fa5ffb Merge pull request 'Reset mobile work details when advancing to another item' (#778) from timmy/777-reset-mobile-work-detail-position into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m34s
2026-08-13 22:30:11 +00:00
timmy 83be8fcdbe fix: reset mobile work detail position (Closes #777)
CI / lint (pull_request) Successful in 1m25s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 22:28:08 +00:00
timmy 35f3999787 Merge pull request 'Complete review requests without leaving mobile Updates triage' (#776) from timmy/775-update-review-handoff into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m35s
2026-08-13 22:01:54 +00:00
timmy 378291d3c8 feat: review pull requests from update triage (Closes #775)
CI / lint (pull_request) Successful in 1m42s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 21:59:35 +00:00
timmy 584752ed24 Merge pull request 'Prioritize actionable work in mobile Updates triage' (#774) from timmy/773-actionable-update-triage into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m31s
2026-08-13 21:26:58 +00:00
timmy d54f58bf36 feat: prioritize actionable update triage (Closes #773)
CI / lint (pull_request) Successful in 1m37s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 21:24:34 +00:00
timmy 84302d6f1e Merge pull request 'Make mobile Update decisions transaction-safe' (#772) from timmy/771-transaction-safe-update-decisions into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m39s
2026-08-13 21:01:00 +00:00
timmy 2e0d64907b fix: make update decisions transaction-safe (Closes #771)
CI / lint (pull_request) Successful in 1m36s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 20:58:43 +00:00
timmy 80042ce561 Merge pull request 'Add one-handed swipe decisions to mobile Updates triage' (#770) from timmy/769-mobile-update-swipe-triage into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m34s
2026-08-13 20:32:44 +00:00
timmy 931f656f78 feat: add mobile update swipe triage (Closes #769)
CI / lint (pull_request) Successful in 1m41s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 20:30:15 +00:00
timmy 18f56583ba Merge pull request 'Recover mobile sign-in from stalled requests' (#768) from timmy/767-stalled-login-recovery into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m35s
2026-08-13 19:54:59 +00:00
timmy 6e2f5d1120 fix: recover stalled mobile sign-in (Closes #767)
CI / lint (pull_request) Successful in 1m32s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 19:52:44 +00:00
timmy 9bd755f521 Merge pull request 'Review kept-unread outcomes after mobile Updates triage' (#766) from timmy/765-update-outcome-review into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m33s
2026-08-13 19:23:52 +00:00
timmy fd2f801f3e feat: review mobile update triage outcomes (Closes #765)
CI / lint (pull_request) Successful in 1m30s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 19:21:36 +00:00
timmy e21cc58cca Merge pull request 'Keep mobile Update decisions within thumb reach' (#764) from timmy/763-mobile-update-decision-bar into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m31s
2026-08-13 18:56:13 +00:00
timmy d6212b2812 feat: keep mobile update decisions within reach (Closes #763)
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 18:53:50 +00:00
timmy 6528b2e73f Merge pull request 'Load mobile update conversations without blocking core detail' (#762) from timmy/761-progressive-update-conversation into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m36s
2026-08-13 18:27:41 +00:00
timmy a9d586e689 feat: load update conversations progressively (Closes #761)
CI / lint (pull_request) Successful in 1m38s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 18:25:21 +00:00
timmy 7c86f28346 Merge pull request 'Open each mobile update at its newest unread activity' (#760) from timmy/759-newest-update-activity into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m33s
2026-08-13 17:58:03 +00:00
timmy 707ee4c1bc feat: open updates at newest activity (Closes #759)
CI / lint (pull_request) Successful in 1m41s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 17:55:33 +00:00
timmy 82982e5741 Merge pull request 'Prefetch the next mobile update during triage' (#758) from timmy/757-prefetch-next-mobile-update into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m32s
2026-08-13 17:33:37 +00:00
timmy db08c7e75f feat: prefetch the next mobile update (Closes #757)
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 17:31:20 +00:00
timmy 1a3df5d7a7 Merge pull request 'Make the mobile Work action launch the highest-priority queue' (#756) from timmy/755-priority-mobile-work into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m31s
2026-08-13 17:02:12 +00:00
timmy fdba0554df feat: prioritize the mobile Work action (Closes #755)
CI / lint (pull_request) Successful in 1m39s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 16:59:36 +00:00
timmy d2a85cd5ea Merge pull request 'Bound mobile Updates discovery with one atomic snapshot' (#754) from timmy/753-atomic-updates-snapshot into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m40s
2026-08-13 16:27:32 +00:00
timmy 1f034a6f64 feat: make Updates discovery atomic (Closes #753)
CI / lint (pull_request) Successful in 1m43s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 16:25:03 +00:00
timmy de6d6d0e61 Merge pull request 'Mute future updates during mobile triage' (#752) from timmy/751-mute-future-updates into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m32s
2026-08-13 15:57:37 +00:00
timmy 8dc3880167 feat: mute future updates during triage (Closes #751)
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 15:55:02 +00:00
timmy b93e63ab70 Merge pull request 'Complete unread discovery before mobile Updates triage' (#750) from timmy/749-complete-updates-discovery into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m32s
2026-08-13 15:34:15 +00:00
timmy 8c82644515 feat: complete Updates discovery before triage (Closes #749)
CI / lint (pull_request) Successful in 1m37s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 15:28:17 +00:00
timmy 6c6cd3c71b Merge pull request 'Resume interrupted mobile Updates triage' (#748) from timmy/747-resumable-updates-triage into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m40s
2026-08-13 15:00:45 +00:00
timmy 4f6cebae7c feat: resume mobile Updates triage (Closes #747)
CI / lint (pull_request) Successful in 1m37s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 14:58:19 +00:00
timmy 341a3283ea Merge pull request 'Make live data status truthful and actionable' (#746) from timmy/745-live-data-status into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m35s
Merge pull request 'Make live data status truthful and actionable' (#746) from timmy/745-live-data-status into main
2026-08-13 14:28:33 +00:00
timmy 9c276fa251 feat: make live data status actionable (Closes #745)
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 14:26:09 +00:00
timmy 940f7c05d2 Merge pull request 'Make the mobile Queues summary truthful' (#744) from timmy/743-truthful-mobile-queue-summary into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m36s
2026-08-13 13:55:28 +00:00
timmy d34c082da2 fix: make mobile queue summary truthful (Closes #743)
CI / lint (pull_request) Successful in 1m37s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 13:53:04 +00:00
timmy 1c086be09b Merge pull request 'Preserve shared screenshots through operator sign-in' (#742) from timmy/741-preserve-shared-screenshot-signin into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m31s
2026-08-13 13:27:39 +00:00
timmy 4cfcb92eec fix: preserve shared screenshots through sign-in (Closes #741)
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 13:25:26 +00:00
timmy b484bbef03 Merge pull request 'Share screenshots directly into mobile capture' (#740)
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m29s
Closes #739
2026-08-13 12:58:07 +00:00
timmy 623817eb7d feat: share screenshots into mobile capture (Closes #739)
CI / lint (pull_request) Successful in 1m27s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 12:55:47 +00:00
timmy 11347e5fe0 Merge pull request 'Launch installed mobile work from task-specific app shortcuts' (#738) from timmy/737-pwa-task-shortcuts into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m35s
2026-08-13 12:39:20 +00:00
timmy bb1712279c feat: launch mobile work from PWA shortcuts (Closes #737)
CI / lint (pull_request) Successful in 1m31s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 12:26:27 +00:00
timmy e7d5196987 Merge pull request 'Wait for complete Agenda discovery before starting a session' (#736)
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 1m26s
Closes #735
2026-08-13 11:58:30 +00:00
timmy 64ff5127d8 fix: wait for complete Agenda discovery (Closes #735)
CI / lint (pull_request) Successful in 1m26s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 11:56:23 +00:00
timmy 4ac5a0f499 Merge pull request 'Make mobile Agenda sessions directly launchable and resumable' (#734) from timmy/733-resumable-mobile-agenda into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m32s
Merge pull request 'Make mobile Agenda sessions directly launchable and resumable' (#734)
2026-08-13 11:29:15 +00:00
timmy 715df380e0 feat: resume mobile Agenda sessions (Closes #733)
CI / lint (pull_request) Successful in 1m25s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 11:27:13 +00:00
timmy 1fc70f6890 Merge pull request 'Run the mobile Agenda as a sequential work session' (#732)
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m24s
Closes #731
2026-08-13 10:57:28 +00:00
timmy 523968f5a3 feat: run Agenda as a work session (Closes #731)
CI / lint (pull_request) Successful in 1m27s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 10:54:58 +00:00
timmy af27bb06e0 Merge pull request 'Bound assigned-issue reminder snapshots' (#730) from timmy/729-bounded-assigned-snapshot into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m29s
2026-08-13 10:26:37 +00:00
timmy 4410af09a6 Bound assigned-issue reminder snapshots
CI / lint (pull_request) Successful in 1m30s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 10:24:23 +00:00
timmy 880f60831a Merge pull request 'Configure the deadline reminder horizon per device' (#728) from timmy/727-configurable-deadline-horizon into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m26s
2026-08-13 09:57:25 +00:00
timmy e860d16d29 feat: configure deadline reminder horizon (Closes #727)
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 09:55:09 +00:00
timmy abf8e23685 Merge pull request 'Bulk-authorize push devices before notification fan-out' (#726) from timmy/725-bulk-push-authorization into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m27s
2026-08-13 09:31:45 +00:00
76 changed files with 6804 additions and 358 deletions

No files matched your search

+27 -4
View File
@@ -62,7 +62,17 @@ and durable delivery flow. A different or unconfirmed account can only copy or d
the private content. Issue capture and authored mobile actions (issue
comments, pull-request comments, notification replies, and reviews) persist per-draft
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
another worker replays a confirmed result instead of posting duplicate content. The ordered,
another worker replays a confirmed result instead of posting duplicate content. Mobile Search previews
let operators assign an eligible issue directly into Later at an exact local return time without filling
Today or interrupting active work. Cancel and browser Back preserve the Search preview without assigning;
confirmation claims only when needed, syncs the Later plan across devices, and returns to the preserved
query, filters, results, and scroll position. If assignment succeeds but Later storage fails, the issue
remains recoverable in My Work and the dashboard reports the partial outcome instead of claiming success.
Search previews also
let operators assign an eligible issue and add it to Today without starting or replacing active work.
The queue action keeps the Search query, filters, results, and scroll position available for continued
planning, reports an existing Today item without duplicating it, and uses the same capacity, sync, and
offline-warm path as other Today admission flows. The ordered,
five-item Today plan syncs across the operator's devices. **Plan Today** also stores available minutes
and a per-item estimate with the account-scoped plan, continuously showing planned/free or over-capacity
time. An over-capacity plan requires a second explicit save, legacy plans migrate with unestimated work,
@@ -81,7 +91,11 @@ an open, unassigned issue update also offers **Take ownership & start**, which c
before assignment, preserves the unread update, adds and syncs the owned issue to Today, checkpoints
the session, and opens the issue. The adjacent **Take ownership** action remains available for
claim-only triage, and a local start failure opens the now-owned issue with truthful recovery guidance.
**Mark read & next** remains the explicit acknowledgement path. Delivery or local-admission
**Mark read & next** remains the explicit acknowledgement path. During an online Updates pass,
Stackchain preloads at most the next surviving conversation from the fixed snapshot while the
current one is being read. Advancing consumes that account-bound result without another detail
request; failures fall back to the normal foreground retry path, and offline triage never speculates.
Delivery or local-admission
failure preserves both the reply draft and checkpoint. Finishing or choosing **End session** clears only the checkpoint and leaves the Today plan
unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
@@ -285,8 +299,9 @@ remain compatible. The bounded **Passkey counter anomaly** Security activity ent
only the device label and attempted action; repeated alerts should prompt removal and
re-enrollment of that passkey.
High-impact actions—merging a pull request, closing an assigned issue, revoking a
remote device, or signing out every device—require a passkey assertion or the
High-impact actions—merging a pull request, closing an assigned issue, permanently
deleting an authored comment, revoking a remote device, or signing out every device—
require a passkey assertion or the
operator access token again.
The server issues a random 90-second grant bound to the active session, exact action,
and exact target. Only its digest is stored, and the grant is consumed atomically on
@@ -403,6 +418,14 @@ filter, preserves the selected release lane, shows the current draft count, resp
the device safe area, and moves out of the way while a full-screen task is open.
Desktop layout is unchanged.
Mobile **Search** provides touch-sized **Repository**, **Type**, and **Status** controls.
Operators can find and select a repository visible to their Gitea account, then scope results
to that exact repository, issues, pull requests, open work, closed work, or all accessible work;
the server applies every scope before pagination. Clearing Repository returns to organization-wide
Search. The selected scope is bounded and addressable, survives preview/back, reload, sharing,
and sign-in continuation, and a scope change cancels obsolete requests before restarting at the
first page. Repository lookup failure leaves organization-wide Search usable.
My Work also has an account-synced **Later** queue. **Later today** defers an item for four
hours, while **Tomorrow** returns it at 09:00 in the device's local timezone. **Choose date & time**
accepts a valid future local date and time and returns the item at that exact instant; the picker
+33
View File
@@ -0,0 +1,33 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createAgendaSessionLauncher = factory;
})(typeof self !== 'undefined' ? self : this, function createAgendaSessionLauncher(options) {
let pending = null;
function open() {
if (pending) return pending;
options.selectAgenda();
pending = Promise.resolve()
.then(() => options.discover())
.then(complete => {
if (!options.isAgendaSelected()) return 'cancelled';
if (complete === false || options.hasMore()) {
options.announce('Agenda check paused. Retry to check older assigned deadlines.');
return 'incomplete';
}
const opened = options.hasCheckpoint() ? options.resume() : options.start();
if (!opened) options.announce('No deadlines are ready in Agenda.');
return opened ? 'opened' : 'empty';
})
.catch(() => {
if (options.isAgendaSelected()) {
options.announce('Agenda check paused. Retry to check older assigned deadlines.');
}
return 'incomplete';
})
.finally(() => { pending = null; });
return pending;
}
return {open};
});
+11 -3
View File
@@ -7,9 +7,12 @@ function createBatchFindWork({
onProgress = () => {},
storage = typeof localStorage === 'undefined' ? null : localStorage,
owner = () => '',
journalName = 'find-work-batch',
autoMount = true,
}) {
let request = null;
const journalKey = () => 'stackchain.find-work-batch.v1.' + encodeURIComponent(String(owner() || ''));
const journalKey = () => 'stackchain.' + journalName + '.v1.' +
encodeURIComponent(String(owner() || ''));
function readJournal() {
if (!storage || !String(owner() || '')) return null;
@@ -125,6 +128,11 @@ function createBatchFindWork({
return request;
}
function pending() {
const journal = readJournal();
return journal ? journal.items.filter(item => item.state !== 'queued').length : 0;
}
function mountRecovery(button, opener) {
const show = () => {
const journal = readJournal();
@@ -144,10 +152,10 @@ function createBatchFindWork({
opener.addEventListener('click', show);
}
if (typeof document !== 'undefined') mountRecovery(
if (autoMount && typeof document !== 'undefined') mountRecovery(
document.getElementById('resume-find-work-batch'), document.getElementById('find-work')
);
return { run, resume, mountRecovery };
return { run, resume, pending, mountRecovery };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createBatchFindWork;
+73 -17
View File
@@ -15,6 +15,15 @@
return current;
};
filterCommands.searchUrl = function searchUrl(query, page, scope, continuation) {
const streamPages = continuation ?
(continuation.issues ? '&issues_page=' + encodeURIComponent(continuation.issues) : '') +
(continuation.pulls ? '&pulls_page=' + encodeURIComponent(continuation.pulls) : '') : '';
return 'api/v1/search?q=' + encodeURIComponent(query) + '&limit=10&page=' + page +
'&kind=' + encodeURIComponent(scope.kind) + '&state=' + encodeURIComponent(scope.state) +
(scope.repository ? '&repository=' + encodeURIComponent(scope.repository) : '') + streamPages;
};
filterCommands.createGlobalSearchController = function createGlobalSearchController(options) {
const search = options.search;
const onState = options.onState;
@@ -22,6 +31,46 @@
let timer = null;
let generation = 0;
let activeController = null;
let scope = { kind:'all', state:'all' };
let state = { status: 'idle', query: '', items: [], more: false, next: 1, scope };
function publish(next) {
state = next;
onState(next);
return state;
}
async function requestPage(query, page, current, append, continuation) {
const requestController = new AbortController();
activeController = requestController;
try {
const requestScope = { ...scope };
const result = await search(
query, requestController.signal, page, requestScope, continuation
);
const partial = result.partial === true;
const incoming = result.items || result;
const combined = append ? state.items.concat(incoming) : incoming;
const items = [...new Map((combined || []).map(item =>
[`${item.kind}:${item.repository}:${item.number}`, item]
)).values()];
if (current === generation) return publish({
status: 'ready', query, items, partial,
more: !!result.has_more,
next: result.next_page,
continuation:result.continuation,
failedStreams:result.failed_streams || [],
scope:requestScope,
});
} catch (error) {
if (error && error.name === 'AbortError') return;
if (current === generation) return publish(append
? { ...state, status: 'ready' }
: { status: 'error', query, items: [], error, scope:{ ...scope } });
} finally {
if (activeController === requestController) activeController = null;
}
}
return {
setQuery(value) {
@@ -32,25 +81,32 @@
if (activeController !== null) activeController.abort();
activeController = null;
if (query.length < 2) {
onState({ status: 'idle', query, items: [] });
publish({ status: 'idle', query, items: [], more: false, next: 1, scope:{ ...scope } });
return;
}
onState({ status: 'loading', query, items: [] });
timer = setTimeout(async () => {
const requestController = new AbortController();
activeController = requestController;
try {
const result = await search(query, requestController.signal);
const items = Array.isArray(result) ? result : result.items;
const partial = !Array.isArray(result) && result.partial === true;
if (current === generation) onState({ status: 'ready', query, items, partial });
} catch (error) {
if (error && error.name === 'AbortError') return;
if (current === generation) onState({ status: 'error', query, items: [], error });
} finally {
if (activeController === requestController) activeController = null;
}
}, delay);
publish({ status: 'loading', query, items: [], more: false, next: 1, scope:{ ...scope } });
timer = setTimeout(() => requestPage(query, 1, current, false), delay);
},
setScope(value) {
const repository = typeof value?.repository === 'string' &&
/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value.repository.trim())
? value.repository.trim() : '';
const next = {
kind:['all', 'issue', 'pull'].includes(value?.kind) ? value.kind : 'all',
state:['all', 'open', 'closed'].includes(value?.state) ? value.state : 'all',
...(repository ? { repository } : {}),
};
if (next.kind === scope.kind && next.state === scope.state &&
(next.repository || '') === (scope.repository || '')) return;
const query = state.query;
scope = next;
this.setQuery(query);
},
loadMore() {
if (state.status !== 'ready' || !state.more || activeController) return Promise.resolve(state);
const current = generation;
const page = state.next;
return requestPage(state.query, page, current, true, state.continuation);
},
};
};
+13
View File
@@ -37,6 +37,8 @@ function createContextPoller({
let retainedSnapshot = null;
let nextDelayMs = intervalMs;
let failureStreak = 0;
let nextRetryAt = null;
let lastSuccessAt = null;
function snapshotDelay(snapshot) {
const freshness = snapshot && snapshot.freshness;
@@ -69,8 +71,10 @@ function createContextPoller({
function schedule(delayMs = intervalMs) {
cancelTimer();
if (stopped || isHidden()) return;
nextRetryAt = Date.now() + delayMs;
timer = setTimer(() => {
timer = null;
nextRetryAt = null;
refresh();
}, delayMs);
}
@@ -125,6 +129,7 @@ function createContextPoller({
.then((snapshot) => {
if (activeRequest !== requestState) return retainedSnapshot;
failureStreak = 0;
lastSuccessAt = Date.now();
nextDelayMs = snapshotDelay(snapshot);
const changedSections = ['context', 'events', 'notifications'].filter(
(section) => Object.prototype.hasOwnProperty.call(snapshot, section)
@@ -165,6 +170,14 @@ function createContextPoller({
start: refresh,
refresh,
setVisible,
getState() {
return {
refreshing: Boolean(activeRequest),
nextRetryAt,
lastSuccessAt,
failureStreak,
};
},
stop() {
stopped = true;
cancelTimer();
+70 -4
View File
@@ -5,7 +5,17 @@ html, body { height: 100%; margin: 0; background: var(--bg); color: var(--text);
header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex; gap:16px; align-items:center; justify-content:space-between; background: linear-gradient(180deg, rgba(11,21,38,.95), rgba(11,21,38,.55), transparent); backdrop-filter: blur(4px); border-bottom: 1px solid #1b2d45; }
.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
.app-brand { display:flex; align-items:center; gap:6px; white-space:nowrap; }
.app-live-status { display:inline-flex; gap:6px; align-items:center; }
.app-live-status { display:inline-flex; gap:6px; align-items:center; min-height:44px; padding:6px 10px; background:transparent; border-color:transparent; }
.live-data-status-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.live-data-status-sheet[hidden] { display:none; }
.live-data-status-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
.live-data-status-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.live-data-status-header h2, .live-data-status-header p { margin-top:0; }
.live-data-status-header button, .live-data-status-actions button { min-height:44px; }
.live-data-status-feeds { display:grid; gap:8px; margin:14px 0; }
.live-data-status-feed { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
.live-data-status-feed strong, .live-data-status-feed span { overflow-wrap:anywhere; }
.live-data-status-actions { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }
.app-menu { margin-left:auto; }
.app-menu > summary { display:none; }
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
@@ -40,7 +50,7 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
.device-setup-step p { margin:5px 0 0; }
.device-setup-deadline-controls { display:flex; align-items:end; gap:8px; flex-wrap:wrap; }
.device-setup-deadline-controls label { font-size:12px; color:#bfdbfe; }
.device-setup-deadline-controls select, .device-setup-deadline-controls button, #push-deadline-hour { min-height:44px; }
.device-setup-deadline-controls select, .device-setup-deadline-controls button, #push-deadline-hour, #push-deadline-days { min-height:44px; }
.device-setup-ready { margin:0; padding:12px; border-radius:10px; background:#0f2237; color:#bfdbfe; font-weight:700; }
.device-readiness-card { display:none; min-width:0; margin:10px 0; padding:12px; border:1px solid #3b82b8; border-radius:12px; background:#102b46; overflow-x:hidden; }
.device-readiness-card p { margin:5px 0 0; overflow-wrap:anywhere; }
@@ -104,11 +114,27 @@ textarea { resize: vertical; min-height: 120px; }
#cmd-palette { position: fixed; left: 50%; top: 10%; transform: translateX(-50%); width: min(900px, 94vw); background: rgba(11,21,38,.96); border: 1px solid #2a496e; border-radius: 12px; box-shadow: 0 20px 70px rgba(0,0,0,.55); padding: 10px; z-index: 30; display: none; backdrop-filter: blur(12px); }
#cmd-palette.open { display: block; }
.cmd-palette-header { display:none; align-items:center; justify-content:space-between; gap:10px; }
.cmd-palette-header-actions { display:flex; gap:8px; }
.cmd-search-scope { display:grid; grid-template-columns:auto minmax(120px,1fr) auto minmax(120px,1fr); gap:6px 10px; align-items:center; margin:8px 0 0; padding:8px; border:1px solid #1f3a5f; border-radius:8px; }
.cmd-search-scope legend { padding:0 4px; color:#93a4b8; font-size:12px; }
.cmd-search-scope label { font-size:12px; color:#cbd5e1; }
.cmd-search-scope select { min-width:0; padding:7px; border-radius:8px; border:1px solid #31577f; background:#0b1526; color:#e5e7eb; }
.cmd-search-scope input { min-width:0; padding:7px; border-radius:8px; border:1px solid #31577f; background:#0b1526; color:#e5e7eb; }
.cmd-repository-status { grid-column:2 / -1; min-height:16px; color:#93a4b8; font-size:12px; }
#cmd-results { margin-top:8px; max-height:min(65vh,520px); overflow-y:auto; }
.cmd-item { padding: 10px; min-height:44px; cursor: pointer; border-radius: 10px; color:#e5e7eb; display:flex; gap:10px; align-items:center; justify-content:space-between; }
.cmd-item:hover, .cmd-item.selected { background: #10233a; outline:1px solid #31577f; }
.cmd-meta { color:#93a4b8; font-size:12px; text-align:right; }
.cmd-status { padding:10px; color:#93a4b8; font-size:13px; }
.cmd-select-result { min-height:44px; width:100%; display:grid; grid-template-columns:28px 1fr auto; gap:10px; align-items:center; text-align:left; }
.cmd-select-result input { width:22px; height:22px; }
.cmd-select-result[disabled] { cursor:not-allowed; opacity:.62; }
.search-batch-actions { position:sticky; bottom:0; z-index:3; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0 calc(10px + env(safe-area-inset-bottom)); background:#0b1526; border-top:1px solid #2a496e; }
.search-batch-actions[hidden] { display:none; }
.search-batch-actions span { grid-column:1 / -1; }
.search-batch-actions button, .search-batch-recovery { min-height:44px; }
.search-batch-recovery { width:100%; font-weight:700; }
.cmd-group { padding:8px 10px 3px; color:#60a5fa; font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; }
#whiteboard-modal, #markdown-modal { position: fixed; inset: 0; background: rgba(5,12,21,.55); display: none; align-items: center; justify-content: center; z-index: 40; backdrop-filter: blur(6px); }
#whiteboard-modal.open, #markdown-modal.open { display: flex; }
@@ -373,7 +399,11 @@ textarea { resize: vertical; min-height: 120px; }
.update-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
.update-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.update-sheet-header button { min-height:44px; }
.update-triage-progress { margin:10px 0; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
.update-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
.update-new-activity { scroll-margin-block:18px 96px; border-color:#60a5fa; box-shadow:0 0 0 2px rgba(96,165,250,.25); }
.update-new-activity::before { content:'New activity'; display:inline-block; margin-bottom:6px; padding:2px 7px; border-radius:999px; background:#1d4ed8; color:#fff; font-size:12px; font-weight:700; }
.update-new-activity-jump { position:sticky; bottom:92px; z-index:4; min-height:44px; width:100%; margin:10px 0; border-color:#60a5fa; background:#17345a; }
.update-reply { display:grid; gap:8px; margin-top:16px; }
.update-reply textarea { width:100%; min-height:112px; resize:vertical; }
.update-reply button { min-height:44px; width:100%; }
@@ -390,7 +420,12 @@ textarea { resize: vertical; min-height: 120px; }
.update-ownership-actions button { min-width:0; width:100%; }
.update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
.update-sheet-actions #acknowledge-update-next { min-height:44px; width:100%; }
.update-sheet-actions #mute-update-next { min-height:44px; width:100%; }
.update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.update-more-actions { margin-top:14px; }
.update-more-actions > summary { min-height:44px; display:flex; align-items:center; justify-content:center; cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.update-decision-bar { display:grid; grid-template-columns:repeat(auto-fit,minmax(76px,1fr)); gap:6px; margin-top:14px; padding:8px; background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.update-decision-bar button, .update-decision-bar summary { min-height:44px; min-width:0; padding-inline:6px; }
.update-retry { min-height:44px; width:100%; margin-top:10px; }
.issue-sheet { position:fixed; inset:0; z-index:56; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.issue-sheet.open { display:flex; }
@@ -520,8 +555,23 @@ textarea { resize: vertical; min-height: 120px; }
.search-preview-header { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; }
.search-preview-header button, .search-preview-actions button, .search-preview-actions a { min-height:44px; }
.search-preview-body { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
.search-preview-conversation { display:grid; gap:8px; padding-top:8px; border-top:1px solid #2a496e; }
.search-preview-conversation h2 { margin:0; font-size:1rem; }
.search-preview-comment { min-width:0; padding:10px 0; border-bottom:1px solid #1b2d45; overflow-wrap:anywhere; }
.search-preview-conversation button { min-height:44px; width:100%; }
.search-preview-reply { display:grid; gap:8px; padding-top:8px; border-top:1px solid #2a496e; }
.search-preview-reply[hidden] { display:none; }
.search-preview-reply h2 { margin:0; font-size:1rem; }
.search-preview-reply textarea { box-sizing:border-box; width:100%; min-height:110px; resize:vertical; scroll-margin-bottom:calc(190px + env(safe-area-inset-bottom)); }
.search-preview-reply-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.search-preview-reply-actions button { min-height:44px; }
.search-preview-navigation { display:grid; grid-template-columns:minmax(0,1fr) auto minmax(0,1fr); align-items:center; gap:8px; }
.search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
.search-preview-actions button, .search-preview-actions a { min-height:44px; box-sizing:border-box; display:flex; align-items:center; justify-content:center; }
.search-preview-primary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
@media (max-width:420px) {
.search-preview-primary-actions { grid-template-columns:1fr; }
}
.search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere; white-space:normal; }
.markdown-content > :first-child { margin-top:0; }
@@ -605,7 +655,7 @@ textarea { resize: vertical; min-height: 120px; }
.mobile-task-action { min-width:0; min-height:44px; padding:6px 2px; border:0; border-radius:8px; background:transparent; display:grid; place-items:center; gap:2px; font-size:12px; }
.mobile-task-action[hidden] { display:none; }
.mobile-task-action[aria-current="page"] { color:#bfdbfe; background:#17365a; outline:1px solid #31577f; }
.mobile-task-count { min-width:18px; min-height:18px; padding:1px 5px; border-radius:999px; background:#31577f; font-size:11px; line-height:16px; }
.mobile-task-count { min-width:18px; min-height:18px; max-width:100%; padding:1px 5px; border-radius:999px; background:#31577f; font-size:10px; line-height:16px; white-space:nowrap; }
.mobile-task-deadline { padding:1px 5px; border-radius:999px; color:#fef3c7; background:#92400e; font-size:10px; line-height:14px; }
.attention-interruption:not([hidden]) { max-width:100%; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:0 0 12px; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#102641; }
.attention-interruption button { min-height:44px; flex:0 0 auto; }
@@ -649,6 +699,12 @@ textarea { resize: vertical; min-height: 120px; }
.work-filter { flex:1 1 calc(50% - 8px); }
.review-sheet-panel { width:100%; border-left:0; padding:14px; }
.update-sheet-panel { width:100%; border-left:0; padding:14px; overflow-x:hidden; }
.update-sheet-panel { padding-bottom:calc(82px + env(safe-area-inset-bottom)); }
.update-gesture-status { display:block; padding:6px 10px; border-radius:999px; text-align:center; background:#102641; }
.update-sheet-panel[data-triage-gesture="keep"] { box-shadow:inset 8px 0 #60a5fa; }
.update-sheet-panel[data-triage-gesture="read"] { box-shadow:inset -8px 0 #34d399; }
.update-decision-bar { position:fixed; inset:auto 0 0; z-index:57; margin:0; padding-bottom:calc(8px + env(safe-area-inset-bottom)); }
.update-more-actions > summary { display:none; }
.issue-sheet-panel { width:100%; border-left:0; padding:14px; }
.issue-blocker-row { grid-template-columns:1fr; }
.issue-blocker-remove { width:100%; }
@@ -656,8 +712,11 @@ textarea { resize: vertical; min-height: 120px; }
#cmd-palette { left:0; top:var(--search-viewport-top,0px); transform:none; width:100%; height:var(--search-viewport-height,100dvh); border:0; border-radius:0; padding:12px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); z-index:46; }
#cmd-palette.open { display:flex; flex-direction:column; }
.cmd-palette-header { display:flex; flex:0 0 auto; min-height:44px; }
#close-command-palette { min-height:44px; }
#close-command-palette, #cmd-load-more { min-height:44px; }
#cmd-input { flex:0 0 auto; min-height:44px; }
.cmd-search-scope { grid-template-columns:auto minmax(0,1fr); }
#cmd-search-kind, #cmd-search-state { min-height:44px; width:100%; }
#cmd-search-repository { min-height:44px; width:100%; }
#cmd-results { flex:1; min-height:0; overflow-y:auto; max-height:none; overscroll-behavior:contain; padding-bottom:env(safe-area-inset-bottom); }
.create-issue-panel { width:100%; border-left:0; padding:14px; }
.pull-sheet-panel { width:100%; border-left:0; padding:14px; }
@@ -686,6 +745,9 @@ textarea { resize: vertical; min-height: 120px; }
.mobile-queue-list [data-mobile-queue-count] { min-width:28px; padding:3px 8px; border-radius:999px; text-align:center; background:#1d426d; }
.mobile-queue-list [data-mobile-queue="agenda"][data-deadlines="true"] { border-color:#f59e0b; background:#30240f; box-shadow:inset 3px 0 #f59e0b; }
.mobile-queue-list [data-recommended="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px #60a5fa; }
.mobile-update-outcome { margin-top:12px; padding:12px; border:1px solid #31577f; border-radius:12px; background:#0b1b30; }
.mobile-update-outcome p { margin:0 0 10px; }
.mobile-update-outcome button { width:100%; min-height:44px; }
.mobile-today-hud { position:fixed; left:8px; right:8px; bottom:calc(56px + env(safe-area-inset-bottom)); z-index:44; display:grid; grid-template-columns:minmax(0,1fr) minmax(112px,auto); grid-template-areas:"summary complete" "progress toggle"; gap:4px 8px; max-width:100%; padding:8px; border:1px solid #31577f; border-radius:12px 12px 0 0; background:rgba(16,38,65,.98); box-shadow:0 -8px 24px rgba(0,0,0,.28); }
.mobile-today-summary { grid-area:summary; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; font-weight:700; }
.mobile-today-hud [data-work-session-progress] { grid-area:progress; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
@@ -694,6 +756,10 @@ textarea { resize: vertical; min-height: 120px; }
.mobile-today-hud button { min-height:44px; max-width:100%; }
.mobile-today-hud [data-work-session-adjust-plan] { grid-column:1 / -1; }
}
@media (min-width:701px) { .update-gesture-status { display:none; } }
@media (prefers-reduced-motion: reduce) {
.update-sheet-panel[data-triage-gesture] { transition:none; }
}
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
+603 -95
View File
File diff suppressed because it is too large. Load diff
+117 -16
View File
@@ -12,7 +12,7 @@
<canvas id="bg"></canvas>
<header>
<div class="app-brand"><span class="obi"></span><strong>Stackchain</strong><span class="muted">Dashboard</span></div>
<div class="app-live-status"><span class="dot"></span><span class="small" id="status">Live</span></div>
<button class="app-live-status" id="open-live-data-status" type="button" aria-controls="live-data-status-sheet" aria-expanded="false"><span class="dot"></span><span class="small" id="status">Live</span></button>
<details class="app-menu">
<summary id="app-menu-toggle" aria-label="Open dashboard menu">Menu</summary>
<div class="app-menu-panel">
@@ -26,6 +26,20 @@
</div>
</details>
</header>
<div id="live-data-status-sheet" class="live-data-status-sheet" hidden>
<section class="live-data-status-panel" role="dialog" aria-modal="true" aria-labelledby="live-data-status-heading">
<div class="live-data-status-header">
<div><h2 id="live-data-status-heading">Live data status</h2><p class="small muted">Check which dashboard feeds are current before acting.</p></div>
<button id="close-live-data-status" type="button" aria-label="Close live data status">Close</button>
</div>
<div id="live-data-status-feeds" class="live-data-status-feeds"></div>
<p id="live-data-status-retry" class="small muted"></p>
<div class="live-data-status-actions">
<button id="refresh-live-data" type="button">Refresh now</button>
<span id="live-data-status-result" class="small" role="status" aria-live="polite"></span>
</div>
</section>
</div>
<div class="offline-status" id="offline-status" role="status" aria-live="polite" hidden>
Offline · live Gitea data is unavailable. Saved drafts remain available on this device.
</div>
@@ -87,6 +101,8 @@
<div class="device-setup-deadline-controls">
<label for="device-setup-deadline-hour">Local hour</label>
<select id="device-setup-deadline-hour" aria-label="Deadline reminder local hour"></select>
<label for="device-setup-deadline-days">Warn me</label>
<select id="device-setup-deadline-days" aria-label="Deadline reminder horizon"><option value="0">Due today</option><option value="2" selected>Next 2 days</option><option value="7">Next 7 days</option></select>
<button class="device-setup-action" id="device-setup-deadline" type="button">Enable</button>
</div>
</li>
@@ -139,6 +155,7 @@
<span class="small" id="push-update-status" role="status" aria-live="polite"></span>
<label class="push-update-control" for="push-deadlines"><input id="push-deadlines" type="checkbox" /> Notify me about deadlines</label>
<label for="push-deadline-hour">Reminder hour <select id="push-deadline-hour" aria-label="Deadline reminder local hour"></select></label>
<label for="push-deadline-days">Warn me <select id="push-deadline-days" aria-label="Deadline reminder horizon"><option value="0">Due today</option><option value="2" selected>Next 2 days</option><option value="7">Next 7 days</option></select></label>
<span class="small" id="push-deadline-status" role="status" aria-live="polite"></span>
<button id="clear-offline-work" type="button">Clear offline work data</button>
<button id="retry-offline-storage" type="button" hidden>Retry offline saving</button>
@@ -409,10 +426,40 @@
<div id="cmd-palette" role="dialog" aria-label="Command palette">
<div class="cmd-palette-header">
<strong>Search work</strong>
<button id="close-command-palette" type="button">Close</button>
<div class="cmd-palette-header-actions">
<button id="select-search-results" type="button">Select</button>
<button id="close-command-palette" type="button">Close</button>
</div>
</div>
<input id="cmd-input" type="text" role="combobox" aria-autocomplete="list" aria-controls="cmd-results" aria-expanded="false" placeholder="Search commands, issues, and pull requests..." />
<fieldset class="cmd-search-scope">
<legend>Filter search results</legend>
<label for="cmd-search-kind">Type</label>
<select id="cmd-search-kind">
<option value="all">All work</option>
<option value="issue">Issues</option>
<option value="pull">Pull requests</option>
</select>
<label for="cmd-search-state">Status</label>
<select id="cmd-search-state">
<option value="all">Any status</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
<label for="cmd-search-repository">Repository</label>
<input id="cmd-search-repository" type="search" list="cmd-search-repositories" autocomplete="off" placeholder="All repositories" aria-describedby="cmd-repository-status" />
<datalist id="cmd-search-repositories"></datalist>
<span id="cmd-repository-status" class="cmd-repository-status" aria-live="polite"></span>
</fieldset>
<div id="cmd-search-action-status" class="small" aria-live="polite"></div>
<button id="resume-search-batch" class="search-batch-recovery" type="button" hidden>Resume batch</button>
<div id="cmd-results" class="stack" role="listbox" aria-label="Commands and work search results"></div>
<button id="cmd-load-more" hidden>More results</button>
<div class="search-batch-actions" id="search-batch-actions" hidden>
<span id="search-selection-status" class="small" aria-live="polite">No issues selected.</span>
<button id="cancel-search-selection" type="button">Cancel</button>
<button id="queue-selected-search-results" type="button" disabled>Assign &amp; add to Today</button>
</div>
</div>
<div class="search-preview" id="search-preview" role="dialog" aria-modal="true" aria-labelledby="search-preview-title">
@@ -427,12 +474,36 @@
<div id="search-preview-status" class="small" aria-live="polite">Choose a search result.</div>
<div id="search-preview-meta" class="row"></div>
<div id="search-preview-body" class="search-preview-body markdown-content"></div>
<section id="search-preview-conversation" class="search-preview-conversation" aria-labelledby="search-preview-conversation-title">
<h2 id="search-preview-conversation-title">Current conversation</h2>
<div id="search-preview-comments"></div>
<div id="search-preview-conversation-status" class="small" aria-live="polite"></div>
<button id="retry-search-preview-conversation" type="button" hidden>Retry conversation</button>
<button id="load-older-search-preview-comments" type="button" hidden>Load older messages</button>
</section>
<section class="search-preview-reply" aria-labelledby="search-preview-reply-title" hidden>
<h2 id="search-preview-reply-title">Reply</h2>
<textarea id="search-preview-reply" maxlength="10000" placeholder="Write a reply"></textarea>
<div id="search-preview-reply-status" class="small" aria-live="polite"></div>
<div class="search-preview-reply-actions">
<button id="send-search-preview-reply" type="button">Send</button>
<button id="send-search-preview-reply-next" type="button">Send &amp; next</button>
</div>
</section>
<div class="search-preview-actions">
<nav class="search-preview-navigation" aria-label="Search result navigation" hidden>
<button id="previous-search-result" type="button">Previous</button>
<span id="search-preview-position" aria-live="polite"></span>
<button id="next-search-result" type="button">Next</button>
</nav>
<div class="search-preview-primary-actions">
<button id="claim-search-result" type="button" hidden>Assign to me</button>
<button id="defer-search-result" type="button" hidden>Assign &amp; defer</button>
<button id="queue-search-result" type="button" hidden>Assign &amp; add to Today</button>
<button id="start-search-result" type="button" hidden>Assign &amp; start</button>
</div>
<button id="share-search-result" type="button" disabled>Share result</button>
<a id="open-search-result-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
</div>
</section>
@@ -726,14 +797,15 @@
</div>
<div class="update-sheet" id="update-sheet" role="dialog" aria-modal="true" aria-labelledby="update-sheet-title">
<section class="update-sheet-panel">
<section class="update-sheet-panel" data-triage-gesture="">
<div class="update-sheet-header">
<div>
<div class="small" id="update-sheet-key"></div>
<h3 id="update-sheet-title">Unread update</h3>
</div>
<button id="keep-update-unread" type="button">Keep unread</button>
</div>
<div id="update-triage-progress" class="update-triage-progress small" aria-live="polite" hidden></div>
<div id="update-gesture-status" class="update-gesture-status small" aria-live="polite">Swipe right to keep unread or left to mark read. Buttons remain available.</div>
<div id="update-sheet-status" class="small" aria-live="polite">Choose an update.</div>
<button class="update-retry" id="retry-update-load" type="button" hidden>Retry loading update</button>
<div class="row">
@@ -742,8 +814,10 @@
</div>
<h2>Full conversation</h2>
<div id="update-comments"></div>
<button class="update-new-activity-jump" id="jump-update-new-activity" type="button" hidden>Jump to new activity</button>
<button class="conversation-more" id="load-older-update-comments" type="button" hidden>Load older messages</button>
<div id="update-conversation-status" class="small" aria-live="assertive"></div>
<button class="update-retry" id="retry-update-conversation" type="button" hidden>Retry conversation</button>
<details>
<summary><h2>Subject context</h2></summary>
<div class="update-sheet-content muted markdown-content" id="update-subject-body"></div>
@@ -769,18 +843,21 @@
</div>
<div id="update-reply-status" class="small" aria-live="assertive"></div>
</section>
<div class="update-sheet-actions">
<div class="update-ownership-actions">
<button id="update-ownership-action" type="button" hidden aria-describedby="update-sheet-status">Take ownership</button>
<button id="update-ownership-start" type="button" hidden aria-describedby="update-sheet-status">Take ownership &amp; start</button>
<details class="update-more-actions">
<summary>More actions</summary>
<div class="update-sheet-actions">
<div class="update-ownership-actions">
<button id="update-ownership-action" type="button" hidden aria-describedby="update-sheet-status">Take ownership</button>
<button id="update-ownership-start" type="button" hidden aria-describedby="update-sheet-status">Take ownership &amp; start</button>
</div>
<button class="share-work-route" type="button">Share</button>
<button id="acknowledge-update-next" type="button" hidden aria-label="Acknowledge and open next update">👍 Acknowledge &amp; next</button>
<button id="mute-update-next" type="button" hidden>Mute future updates &amp; next</button>
<button id="create-update-follow-up" type="button" hidden>Create follow-up</button>
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date &amp; time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
</div>
<button class="share-work-route" type="button">Share</button>
<button id="acknowledge-update-next" type="button" hidden aria-label="Acknowledge and open next update">👍 Acknowledge &amp; next</button>
<button id="create-update-follow-up" type="button" hidden>Create follow-up</button>
<button id="mark-update-read-next" type="button">Mark read &amp; next</button>
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date &amp; time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
</div>
</details>
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
<button type="button" data-work-session-previous>Previous</button>
@@ -788,6 +865,13 @@
<button type="button" data-work-session-complete="update" hidden>Done for Today &amp; next</button>
<button type="button" data-work-session-next>Next work item</button>
</nav>
<div class="update-decision-bar" aria-label="Update decisions">
<button id="keep-update-unread" type="button" aria-label="Keep unread and open next update">Keep unread</button>
<button id="mark-update-read-next" type="button" aria-label="Mark read and open next update">Mark read</button>
<button id="review-update-now" type="button" hidden>Review now</button>
<button id="focus-update-reply" type="button">Reply</button>
<button id="toggle-update-more" type="button" aria-expanded="false">More</button>
</div>
</section>
</div>
@@ -965,6 +1049,10 @@
<dialog class="mobile-queue-sheet" id="mobile-queue-sheet" aria-labelledby="mobile-queue-heading">
<section class="mobile-queue-panel">
<header><h2 id="mobile-queue-heading">Work queues</h2><button id="close-mobile-queues" type="button">Close</button></header>
<div id="mobile-update-outcome" class="mobile-update-outcome" role="status" aria-live="polite" hidden>
<p id="mobile-update-outcome-summary"></p>
<button id="review-kept-updates" type="button" hidden>Review kept unread</button>
</div>
<p class="small muted">Choose what to work through next.</p>
<div class="mobile-queue-list">
<button data-mobile-queue="today" type="button"><span><strong>Today</strong><small>Planned work</small></span><span data-mobile-queue-count="today">0</span></button>
@@ -984,7 +1072,7 @@
<button class="mobile-task-action" data-mobile-task="find" type="button">Find</button>
<button class="mobile-task-action" data-mobile-task="new" type="button">New</button>
<button class="mobile-task-action" data-mobile-task="search" type="button">Search</button>
<button class="mobile-task-action" data-mobile-task="queues" type="button" aria-label="Queues, 0 items"><span>Queues <span class="mobile-task-count" id="mobile-queue-count" hidden>0</span></span><span class="mobile-task-deadline" id="mobile-deadline-count" hidden>0 due</span></button>
<button class="mobile-task-action" data-mobile-task="queues" type="button" aria-label="Queues: no active queues; no upcoming deadlines"><span>Queues <span class="mobile-task-count" id="mobile-queue-count" hidden>0 active</span></span><span class="mobile-task-deadline" id="mobile-deadline-count" hidden>0 due</span></button>
</nav>
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
@@ -995,9 +1083,11 @@
<script src="static/markdown.js"></script>
<script src="static/commands.js"></script>
<script src="static/search-preview.js"></script>
<script src="static/search-defer.js"></script>
<script src="static/widgets.js"></script>
<script src="static/drafts.js"></script>
<script src="static/unfiled-captures.js"></script>
<script src="static/shared-image-capture.js"></script>
<script src="static/draft-filing-session.js"></script>
<script src="static/draft-capacity-dialog.js"></script>
<script src="static/outbox-coordinator.js"></script>
@@ -1036,6 +1126,7 @@
<script src="static/later-picker.js"></script>
<script src="static/pick-work.js"></script>
<script src="static/batch-find-work.js"></script>
<script src="static/search-batch-plan.js"></script>
<script src="static/conversation.js"></script>
<script src="static/comment-actions.js"></script>
<script src="static/issue-attachment.js"></script>
@@ -1049,10 +1140,20 @@
<script src="static/work-route.js"></script>
<script src="static/task-overlay-history.js"></script>
<script src="static/context-poller.js"></script>
<script src="static/live-data-status.js"></script>
<script src="static/mobile-task-dock.js"></script>
<script src="static/mobile-work-entry.js"></script>
<script src="static/mobile-queue-launcher.js"></script>
<script src="static/update-triage-session.js"></script>
<script src="static/update-review-handoff.js"></script>
<script src="static/update-read-position.js"></script>
<script src="static/work-detail-position.js"></script>
<script src="static/update-triage-launcher.js"></script>
<script src="static/update-triage-gesture.js"></script>
<script src="static/update-decision-transaction.js"></script>
<script src="static/agenda-session-launcher.js"></script>
<script src="static/mobile-launch.js"></script>
<script src="static/mobile-app-shortcuts.js"></script>
<script src="static/install-app.js"></script>
<script src="static/mobile-device-setup.js"></script>
<script src="static/mobile-search-viewport.js"></script>
+78
View File
@@ -0,0 +1,78 @@
(function (root, factory) {
const api = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else root.liveDataStatus = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
const feeds = [
['context', 'Work'],
['notifications', 'Updates'],
['events', 'Activity'],
];
function boundedSeconds(value) {
const seconds = Number(value);
return Number.isFinite(seconds) && seconds >= 0 ? Math.min(Math.round(seconds), 86400) : null;
}
function describe(freshness = {}) {
const sections = freshness.sections || {};
const hasSectionData = feeds.some(([key]) => Object.prototype.hasOwnProperty.call(sections, key));
const described = feeds.map(([key, label]) => {
const section = sections[key] || {};
const state = section.revalidating ? 'refreshing' :
(section.stale || section.degraded ? 'delayed' : 'live');
return { key, label, state, ageSeconds: boundedSeconds(section.age_seconds) };
});
const delayed = described.filter(feed => feed.state === 'delayed');
const refreshing = described.filter(feed => feed.state === 'refreshing');
let summary = hasSectionData ? 'Live' : 'Live data unavailable';
if (delayed.length === 1) summary = delayed[0].label + ' delayed';
else if (delayed.length > 1) summary = delayed.length + ' data feeds delayed';
else if (refreshing.length === 1) summary = refreshing[0].label + ' refreshing';
else if (refreshing.length > 1) summary = 'Refreshing live data';
const retryValues = described.map(feed => {
const section = sections[feed.key] || {};
return boundedSeconds(section.retry_in_seconds);
}).filter(value => value !== null && value > 0);
const aggregateRetry = boundedSeconds(freshness.retry_in_seconds);
if (aggregateRetry !== null && aggregateRetry > 0) retryValues.push(aggregateRetry);
return {
summary,
feeds: described,
nextRetrySeconds: retryValues.length ? Math.min(...retryValues) : null,
};
}
function createRefreshController({ button, output, refresh, onState = () => {} }) {
let pending = null;
function run() {
if (pending) return pending;
button.disabled = true;
output.textContent = 'Refreshing live data…';
onState('refreshing');
let request;
try {
request = refresh();
} catch (error) {
request = Promise.reject(error);
}
pending = Promise.resolve(request).then(result => {
if (!result) throw new Error('Live data remains unavailable.');
output.textContent = 'Live data refreshed.';
onState('success');
return result;
}).catch(error => {
output.textContent = error && error.message ? error.message : 'Live data refresh failed.';
onState('error');
return null;
}).finally(() => {
button.disabled = false;
pending = null;
});
return pending;
}
return { run };
}
return { describe, createRefreshController };
});
+73 -10
View File
@@ -4,7 +4,8 @@
}(typeof self !== 'undefined' ? self : this, function createLoginController(options) {
function validShareContinuation(value) {
if (typeof value !== 'string' || !value.startsWith('./?') || value.includes('#')) return './';
const limits = { title: 200, text: 8000, url: 2048 };
const limits = { title: 200, text: 8000, url: 2048, launch: 8, shared: 5, search: 200,
preview: 200, search_kind: 5, search_state: 6, search_repository: 161 };
const params = new URLSearchParams(value.slice(3));
const entries = Array.from(params.entries());
if (!entries.length) return './';
@@ -14,6 +15,27 @@
|| content.length > limits[name]
));
if (invalid) return './';
if (Object.keys(limits).some(name => params.getAll(name).length > 1)) return './';
const search = params.get('search');
const preview = params.get('preview');
if (search !== null || preview !== null) {
if (entries.some(([name]) => ![
'search', 'preview', 'search_kind', 'search_state', 'search_repository'
].includes(name))) return './';
if (search === null || preview === null) return './';
if (!/^(issue|pull):[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+:[1-9]\d*$/.test(preview)) return './';
const kind = params.get('search_kind');
const state = params.get('search_state');
const repository = params.get('search_repository');
if (kind !== null && !['all', 'issue', 'pull'].includes(kind)) return './';
if (state !== null && !['all', 'open', 'closed'].includes(state)) return './';
if (repository !== null && !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) return './';
return value;
}
const launch = params.get('launch');
const shared = params.get('shared');
if (launch && !['continue', 'new', 'agenda'].includes(launch)) return './';
if (shared !== null && (shared !== 'image' || launch !== 'new')) return './';
return value;
}
@@ -28,7 +50,31 @@
const continuation = validShareContinuation(options.continuation);
const setIntervalImpl = options.setIntervalImpl || setInterval;
const clearIntervalImpl = options.clearIntervalImpl || clearInterval;
const setTimeoutImpl = options.setTimeoutImpl || setTimeout;
const clearTimeoutImpl = options.clearTimeoutImpl || clearTimeout;
const requestTimeoutMs = options.requestTimeoutMs || 15000;
let timer = null;
let activeAttempt = false;
function setAttemptActive(active) {
activeAttempt = active;
button.disabled = active;
if (passkeyButton) passkeyButton.disabled = active || !credentials?.get;
}
async function requestWithDeadline(url, init) {
const controller = new AbortController();
const timeout = setTimeoutImpl(() => {
const error = new Error('Sign-in request timed out');
error.name = 'TimeoutError';
controller.abort(error);
}, requestTimeoutMs);
try {
return await fetchImpl(url, { ...init, signal: controller.signal });
} finally {
clearTimeoutImpl(timeout);
}
}
function decodeBase64Url(value) {
const padded = String(value).replaceAll('-', '+').replaceAll('_', '/')
@@ -58,7 +104,12 @@
};
}
if (continuation !== './') status.textContent = 'Sign in to continue your shared capture.';
if (continuation !== './') {
const continuationParams = new URLSearchParams(continuation.slice(3));
status.textContent = continuationParams.has('preview')
? 'Sign in to open the shared Search result.'
: 'Sign in to continue your shared capture.';
}
async function showReason(reason) {
if (reason === 'session-expired') {
@@ -100,18 +151,24 @@
}
async function submit(accessToken, deviceLabel = 'This device') {
if (activeAttempt) return false;
setAttemptActive(true);
status.textContent = 'Signing in…';
let response;
try {
response = await fetchImpl('api/v1/session', {
response = await requestWithDeadline('api/v1/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ access_token: accessToken, device_label: deviceLabel }),
});
} catch (_error) {
} catch (error) {
form.reset();
status.textContent = 'Sign-in failed. Check your connection and try again.';
return;
status.textContent = error?.name === 'TimeoutError'
? 'Sign-in timed out. Check your connection and try again.'
: 'Sign-in failed. Check your connection and try again.';
return false;
} finally {
setAttemptActive(false);
}
form.reset();
if (response.ok) {
@@ -130,9 +187,11 @@
status.textContent = 'Passkeys are not supported in this browser. Use the access token.';
return false;
}
if (activeAttempt) return false;
setAttemptActive(true);
status.textContent = 'Waiting for your passkey…';
try {
const optionsResponse = await fetchImpl('api/v1/passkeys/authentication/options', {
const optionsResponse = await requestWithDeadline('api/v1/passkeys/authentication/options', {
method: 'POST', headers: { Accept: 'application/json' },
});
if (!optionsResponse.ok) throw new Error('No enrolled passkey');
@@ -143,7 +202,7 @@
...item, id: decodeBase64Url(item.id),
}));
const credential = await credentials.get({ publicKey });
const response = await fetchImpl('api/v1/passkeys/authentication/verify', {
const response = await requestWithDeadline('api/v1/passkeys/authentication/verify', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -157,9 +216,13 @@
if (!response.ok) throw new Error('Passkey verification failed');
location.replace(continuation);
return true;
} catch (_error) {
status.textContent = 'Passkey sign-in was not completed. Try again or use the access token.';
} catch (error) {
status.textContent = error?.name === 'TimeoutError'
? 'Passkey sign-in timed out. Check your connection and try again.'
: 'Passkey sign-in was not completed. Try again or use the access token.';
return false;
} finally {
setAttemptActive(false);
}
}
+31 -3
View File
@@ -12,10 +12,38 @@
{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"},
{"src": "static/icons/stackchain-512.png", "sizes": "512x512", "type": "image/png"}
],
"shortcuts": [
{
"name": "Continue work",
"short_name": "Continue",
"description": "Resume the highest-priority mobile work flow.",
"url": "./?launch=continue",
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}]
},
{
"name": "New issue",
"short_name": "New",
"description": "Capture work now and file it online or offline.",
"url": "./?launch=new",
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}]
},
{
"name": "Open Agenda",
"short_name": "Agenda",
"description": "Check every assigned deadline and continue the Agenda session.",
"url": "./?launch=agenda",
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}]
}
],
"share_target": {
"action": "./",
"method": "GET",
"enctype": "application/x-www-form-urlencoded",
"params": {"title": "title", "text": "text", "url": "url"}
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [{"name": "image", "accept": ["image/png", "image/jpeg", "image/webp"]}]
}
}
}
+31
View File
@@ -0,0 +1,31 @@
(function (root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.mobileAppShortcuts = api;
})(typeof self !== 'undefined' ? self : this, function () {
const ACTIONS = new Set(['continue', 'new', 'agenda']);
function parse(search) {
const action = new URLSearchParams(search || '').get('launch');
return ACTIONS.has(action) ? action : null;
}
function createController(options) {
const launchAction = parse(options.search);
let handled = false;
function action() { return launchAction; }
function run() {
if (!launchAction || handled) return null;
handled = true;
if (launchAction === 'continue') return options.continueWork();
if (launchAction === 'new') return options.newIssue();
return options.agenda();
}
return {action, run};
}
return {parse, createController};
});
+3
View File
@@ -11,6 +11,7 @@
const continuation = [
['attention', 'Start Attention'],
['today', 'Continue Today'],
['update', 'Resume Updates'],
['agenda', 'Open Agenda'],
['later', 'Start Later'],
['draft', 'Open Drafts'],
@@ -27,6 +28,8 @@
function open(name) {
if (name === 'today') return options.openToday();
if (name === 'agenda') return options.openAgenda();
if (name === 'update' && options.openUpdates) return options.openUpdates();
options.selectFilter(name);
const action = options.firstAction(name);
if (!action) {
+23 -20
View File
@@ -64,19 +64,15 @@
}
function updateWork(mode) {
const labels = {
continue: ['Continue', 'Continue Today'],
resume: ['Resume', 'Resume Today'],
start: ['Start', 'Start Today'],
plan: ['Plan', 'Plan Today'],
find: ['Find', 'Find work'],
work: ['Work', 'Work'],
};
const workMode = labels[mode] ? mode : 'work';
if (options.workLabel) options.workLabel.textContent = labels[workMode][0];
if (buttons.work) {
buttons.work.setAttribute('aria-label', labels[workMode][1]);
}
const text = {
continue:'Continue', resume:'Resume', start:'Start', plan:'Plan', find:'Find',
attention:'Attention', update:'Updates', agenda:'Agenda', later:'Later', draft:'Drafts',
}[mode] || 'Work';
const queue = ['Attention', 'Updates', 'Agenda', 'Later', 'Drafts'].includes(text);
if (options.workLabel) options.workLabel.textContent = text;
if (buttons.work) buttons.work.setAttribute('aria-label', queue
? (mode === 'update' ? 'Resume ' : 'Open ') + text
: mode === 'find' ? 'Find work' : text + (mode === 'work' ? '' : ' Today'));
}
function updateAttention(count) {
@@ -97,7 +93,8 @@
function updateQueues(counts) {
const names = 'today agenda attention update later draft'.split(' ');
const normalized = Object.fromEntries(names.map(name => [name, Math.max(0, Number(counts?.[name]) || 0)]));
const total = ['today', 'attention', 'later', 'draft'].reduce((sum, name) => sum + normalized[name], 0);
const actionableNames = ['today', 'attention', 'update', 'later', 'draft'];
const active = actionableNames.reduce((total, name) => total + (normalized[name] > 0 ? 1 : 0), 0);
Object.entries(options.queueCounts || {}).forEach(([name, element]) => {
element.textContent = String(normalized[name] || 0);
});
@@ -105,8 +102,8 @@
'aria-label', 'Updates, ' + normalized.update + ' unread conversations'
);
if (options.queueBadge) {
options.queueBadge.textContent = String(total);
options.queueBadge.hidden = total === 0;
options.queueBadge.textContent = active + ' active';
options.queueBadge.hidden = active === 0;
}
if (options.deadlineBadge) {
options.deadlineBadge.textContent = normalized.agenda + ' due';
@@ -116,10 +113,16 @@
if (normalized.agenda > 0) options.queueRows.agenda.setAttribute('data-deadlines', 'true');
else options.queueRows.agenda.removeAttribute('data-deadlines');
}
const deadlineLabel = normalized.agenda > 0
? ', ' + normalized.agenda + ' upcoming deadlines'
: '';
buttons.queues?.setAttribute('aria-label', 'Queues, ' + total + ' items' + deadlineLabel);
const label = active === 0 && normalized.agenda === 0
? 'Queues: no active queues; no upcoming deadlines'
: 'Queues: Today ' + normalized.today
+ ', Agenda ' + normalized.agenda + ' due'
+ ', Attention ' + normalized.attention
+ ', Updates ' + normalized.update
+ ', Later ' + normalized.later
+ ', Drafts ' + normalized.draft
+ '; ' + active + ' active ' + (active === 1 ? 'queue' : 'queues');
buttons.queues?.setAttribute('aria-label', label);
}
return {start, select, refreshVisibility, updateAttention, updateQueues, updateWork};
+4 -1
View File
@@ -4,6 +4,8 @@
})(typeof self !== 'undefined' ? self : this, function createMobileWorkEntry(options) {
function mode() {
if (options.isTodayActive()) return 'continue';
const recommended = options.queueLauncher && options.queueLauncher.recommend();
if (recommended && recommended.name !== 'today') return recommended.name;
if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume';
if (options.getTodayCount() > 0) return 'start';
if (options.getEligibleCount() > 0) return 'plan';
@@ -16,7 +18,8 @@
else if (current === 'resume') options.resumeToday();
else if (current === 'start') options.startToday();
else if (current === 'plan') options.planToday();
else options.findWork();
else if (current === 'find') options.findWork();
else options.queueLauncher.open(current);
return current;
}
+104 -18
View File
@@ -44,6 +44,19 @@ function needsAttention(item) {
return Boolean(item && (item.has_update || item.is_review || urgencyReason(item)));
}
function updateActionability(item) {
const priorityLabels = ['p0', 'priority-high', 'critical'];
if ((item?.labels || []).some(label => priorityLabels.includes(String(label).toLowerCase()))) {
return { priority: 0, reason: 'Critical' };
}
if (item?.is_review) return { priority: 1, reason: 'Review requested' };
if (item?.is_assigned && ['Overdue', 'Due today'].includes(item?.due_label)) {
return { priority: 2, reason: item.due_label };
}
if (item?.is_assigned) return { priority: 3, reason: 'Assigned to you' };
return { priority: 4, reason: '' };
}
function buildMyWork(data, now = new Date()) {
const login = data.user?.login || '';
const issues = (data.issues || []).map((item) => ({ ...item, kind: 'issue' }));
@@ -82,13 +95,15 @@ function buildMyWork(data, now = new Date()) {
const subjectKind = String(update.subject_type || '').toLowerCase().includes('pull') ? 'pull' : 'issue';
const existing = byKey.get(subjectKind + ':' + key);
if (existing) {
const actionability = updateActionability(existing);
existing.has_update = true;
existing.needs_attention = true;
existing.attention_reason = 'Unread update';
existing.notification_id = update.id;
existing.url = update.url || existing.url;
existing.updated_at = update.updated_at || existing.updated_at;
existing._priority = Math.min(existing._priority, 1);
existing.update_reason = actionability.reason;
existing._priority = actionability.priority;
return;
}
if (!update.url) return;
@@ -103,7 +118,8 @@ function buildMyWork(data, now = new Date()) {
attention_reason: 'Unread update',
notification_id: update.id,
reason: 'Unread update',
_priority: 1,
update_reason: '',
_priority: 4,
};
work.push(item);
byKey.set(subjectKind + ':' + key, item);
@@ -248,7 +264,8 @@ function createNotificationSelection({ limit = 50, onChange = () => {} } = {}) {
function createNotificationPager({ load, onNotifications, onPagination, onStatus }) {
let pagination = { page: 1, total: 0, has_more: false };
let pending = false;
return {
let completing = null;
const pager = {
reset(next) {
pagination = { ...pagination, ...(next || {}) };
onPagination(pagination);
@@ -282,7 +299,19 @@ function createNotificationPager({ load, onNotifications, onPagination, onStatus
pending = false;
}
},
loadAll(getExisting) {
if (completing) return completing;
completing = (async () => {
while (pagination.has_more) {
const loaded = await pager.loadMore(getExisting());
if (!loaded) return false;
}
return true;
})().finally(() => { completing = null; });
return completing;
},
};
return pager;
}
function createWorkPager({ load, onItems, onPagination, onStatus }) {
@@ -366,13 +395,52 @@ function createNotificationReader({
loadSaved = () => null,
loadConversation = null,
onConversation = () => {},
onConversationStatus = () => {},
createPager = typeof createConversationPager === 'function' ? createConversationPager : null,
getScope = () => '',
}) {
let selected = null;
let loadVersion = 0;
let marking = false;
let conversationPager = null;
let offlineHydrated = false;
let prefetched = null;
let prefetchKey = '';
async function hydrateConversation(item, version) {
if (!loadConversation || !createPager || !item || offlineHydrated) return false;
onConversationStatus('Loading conversation…');
try {
const initial = await loadConversation(item.notification_id, null);
if (selected !== item || version !== loadVersion) return false;
conversationPager = createPager({
loadPage: page => loadConversation(item.notification_id, page),
});
const state = conversationPager.reset(initial);
onConversation(state);
onConversationStatus(state.comments.length + ' of ' +
Math.max(state.total || 0, state.comments.length) + ' messages loaded.');
return true;
} catch (_error) {
if (selected === item && version === loadVersion) {
conversationPager = null;
onConversationStatus('Conversation temporarily unavailable. Retry.');
}
return false;
}
}
function prefetch(item) {
if (!item) { prefetched = null; prefetchKey = ''; return false; }
const notificationId = item?.notification_id;
if (!Number.isInteger(notificationId) || offlineHydrated) return false;
const key = getScope() + notificationId;
if (prefetchKey === key) return prefetched;
if (prefetched) return false;
prefetchKey = key;
prefetched = load(notificationId);
return prefetched;
}
async function advanceAfterRead(items, current, queueing = false) {
const updated = acknowledgeNotification(items, current.notification_id);
@@ -394,10 +462,19 @@ function createNotificationReader({
selected = item;
offlineHydrated = Boolean(savedDetail && !Array.isArray(savedDetail));
const version = ++loadVersion;
const preload = !offlineHydrated && prefetchKey === getScope() + item.notification_id ? prefetched : null;
prefetched = null;
prefetchKey = '';
onOpen(item);
onStatus('Loading update…');
if (!preload) onStatus('Loading update…');
try {
const detail = offlineHydrated ? savedDetail : await load(item.notification_id);
let detail;
if (offlineHydrated) detail = savedDetail;
else if (preload) detail = await preload.catch(() => {
onStatus('Loading update…');
return load(item.notification_id);
});
else detail = await load(item.notification_id);
if (selected !== item || version !== loadVersion) return false;
onDetail(detail);
if (createPager && loadConversation && detail.conversation) {
@@ -409,6 +486,9 @@ function createNotificationReader({
conversationPager = null;
}
onStatus('Update ready.');
if (!offlineHydrated && detail.conversation_available) {
void hydrateConversation(item, version);
}
return true;
} catch (_error) {
if (selected === item && version === loadVersion) {
@@ -420,6 +500,11 @@ function createNotificationReader({
return {
open,
prefetch,
retryConversation() {
if (!selected || offlineHydrated) return Promise.resolve(false);
return hydrateConversation(selected, loadVersion);
},
commentPager() {
return conversationPager;
},
@@ -503,7 +588,7 @@ function createNotificationReplier({
if ((storage.getItem(keyFor(item)) || '') !== body) storage.removeItem(operationKeyFor(item));
storage.setItem(keyFor(item), body);
}
catch (_error) { /* Keep the editable textarea as the fallback. */ }
catch (_error) {}
},
async submit(item, body, attachment = null) {
if (pending) return false;
@@ -529,15 +614,15 @@ function createNotificationReplier({
return { queued:true };
}
try { storage.removeItem(keyFor(item)); storage.removeItem(operationKeyFor(item)); }
catch (_error) { /* Confirmed delivery is authoritative. */ }
catch (_error) {}
onStatus('Reply posted. You can mark this update read when ready.');
return delivery.confirmed[0];
}
const result = await post(item.notification_id, body, operationId);
try { storage.removeItem(keyFor(item)); }
catch (_error) { /* The posted reply is still authoritative. */ }
catch (_error) {}
try { storage.removeItem(operationKeyFor(item)); }
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
catch (_error) {}
onStatus('Reply posted. You can mark this update read when ready.');
return result;
} catch (error) {
@@ -675,13 +760,14 @@ function createWorkSessionCheckpoint({
function createWorkSession({
getItems, getFilter, getMilestone = () => 'all', checkpoint = null,
checkpointEnabled = () => true,
checkpointEnabled = () => true, checkpointedEnabled = checkpointEnabled,
onOpen, onProgress, onFinish,
}) {
let currentIdentity = '';
let currentIndex = -1;
let running = false;
let durable = false;
const activeCheckpoint = () => typeof checkpoint === 'function' ? checkpoint() : checkpoint;
const queue = () => filterMyWork(getItems() || [], getFilter(), getMilestone());
const report = (items, index) => onProgress({
@@ -696,7 +782,7 @@ function createWorkSession({
durable = false;
currentIdentity = '';
currentIndex = -1;
if (clearCheckpoint) checkpoint?.clear();
if (clearCheckpoint) activeCheckpoint()?.clear();
onFinish();
return false;
};
@@ -704,7 +790,7 @@ function createWorkSession({
if (!items.length || index < 0 || index >= items.length) return finish();
currentIndex = index;
currentIdentity = workIdentity(items[index]);
if (durable) checkpoint?.save(currentIdentity, currentIndex);
if (durable) activeCheckpoint()?.save(currentIdentity, currentIndex);
report(items, index);
onOpen(items[index]);
return true;
@@ -712,9 +798,9 @@ function createWorkSession({
return {
active: () => running,
checkpointed: item => running && durable && (!item || workIdentity(item) === currentIdentity),
checkpointed: item => running && durable && checkpointedEnabled() && (!item || workIdentity(item) === currentIdentity),
end: () => finish(),
resumable: () => Boolean(checkpoint?.read()),
resumable: () => Boolean(activeCheckpoint()?.read()),
reopen(requested = null) {
if (!running) return false;
const items = queue();
@@ -723,13 +809,13 @@ function createWorkSession({
if (index < 0) return false;
currentIdentity = requestedIdentity;
currentIndex = index;
if (durable && requested) checkpoint?.save(currentIdentity, currentIndex);
if (durable && requested) activeCheckpoint()?.save(currentIdentity, currentIndex);
report(items, index);
onOpen(items[index]);
return true;
},
resume(requested = null) {
const saved = checkpoint?.read();
const saved = activeCheckpoint()?.read();
if (!saved) return false;
durable = true;
const items = queue();
@@ -743,7 +829,7 @@ function createWorkSession({
const items = queue();
if (!items.length) return finish();
running = true;
durable = Boolean(checkpoint && checkpointEnabled());
durable = Boolean(activeCheckpoint() && checkpointEnabled());
const requested = item ? items.findIndex(candidate => workIdentity(candidate) === workIdentity(item)) : 0;
return openAt(items, requested >= 0 ? requested : 0);
},
@@ -791,7 +877,7 @@ function createWorkSession({
if (!items.length) return null;
if (action === 'start') return items[0];
if (action === 'resume') {
const saved = checkpoint?.read();
const saved = activeCheckpoint()?.read();
if (!saved) return null;
const exact = items.findIndex(item => workIdentity(item) === saved.identity);
return items[exact >= 0 ? exact : Math.min(saved.index, items.length - 1)];
+16 -5
View File
@@ -2,7 +2,7 @@
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createPushNotifications = factory;
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({
control, status, deadlineControl, deadlineStatus, deadlineHour, notification, serviceWorker, fetchJson,
control, status, deadlineControl, deadlineStatus, deadlineHour, deadlineDays, notification, serviceWorker, fetchJson,
}) {
let configuration = null;
@@ -10,12 +10,18 @@
return `${String(Number(value)).padStart(2, '0')}:00`;
}
function enabledDeadlineText(hour, days) {
const value = Number(days ?? 2);
const horizon = value === 0 ? 'due today' : `next ${value} days`;
return `Deadline reminders enabled for ${formattedHour(hour)} local time, ${horizon}.`;
}
function deadlineReadiness() {
if (!configuration?.available || deadlineControl?.disabled) {
return {state:'unavailable', detail:deadlineStatus?.textContent || 'Deadline reminders are unavailable.'};
}
return configuration.deadline_enabled
? {state:'complete', detail:`Deadline reminders enabled for ${formattedHour(configuration.reminder_hour)} local time.`}
? {state:'complete', detail:enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)}
: {state:'incomplete', detail:deadlineStatus?.textContent || 'Choose when to receive deadline reminders.'};
}
@@ -84,6 +90,7 @@
async function changeDeadline() {
deadlineControl.disabled = true;
if (deadlineHour) deadlineHour.disabled = true;
if (deadlineDays) deadlineDays.disabled = true;
try {
const registration = await serviceWorker.ready;
let subscription = await registration.pushManager.getSubscription();
@@ -95,16 +102,18 @@
}
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
const reminderHour = Number(deadlineHour?.value ?? configuration?.reminder_hour ?? 9);
const reminderDays = Number(deadlineDays?.value ?? configuration?.reminder_days ?? 2);
await fetchJson('api/v1/push-subscription/deadlines', {
method:'PUT',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({enabled:deadlineControl.checked, timezone, reminder_hour:reminderHour}),
body:JSON.stringify({enabled:deadlineControl.checked, timezone, reminder_hour:reminderHour, reminder_days:reminderDays}),
});
configuration.deadline_enabled = deadlineControl.checked;
configuration.reminder_hour = reminderHour;
configuration.reminder_days = reminderDays;
configuration.timezone = timezone;
deadlineStatus.textContent = deadlineControl.checked
? `Deadline reminders enabled for ${formattedHour(reminderHour)} local time.`
? enabledDeadlineText(reminderHour, reminderDays)
: 'Deadline reminders are off for this device.';
return true;
} catch (error) {
@@ -114,6 +123,7 @@
} finally {
deadlineControl.disabled = false;
if (deadlineHour) deadlineHour.disabled = false;
if (deadlineDays) deadlineDays.disabled = false;
}
}
@@ -136,11 +146,12 @@
control.checked = Boolean(configuration.subscribed);
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
if (deadlineHour) deadlineHour.value = String(configuration.reminder_hour ?? 9);
if (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2);
status.textContent = configuration.subscribed
? 'New update notifications enabled for this device.'
: 'New update notifications are off for this device.';
if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled
? `Deadline reminders enabled for ${formattedHour(configuration.reminder_hour)} local time.`
? enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)
: 'Deadline reminders are off for this device.';
}
+138
View File
@@ -0,0 +1,138 @@
(function (root, factory) {
const createSearchBatchPlan = factory();
if (typeof module === 'object' && module.exports) module.exports = createSearchBatchPlan;
if (root) {
root.createSearchBatchPlan = createSearchBatchPlan;
root.mountSearchBatchPlanning = createSearchBatchPlan.mount;
}
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
function createSearchBatchPlan({
limit = 50,
resolve = item => Promise.resolve(item),
claim = item => Promise.resolve(item),
onChange = () => {},
} = {}) {
const maximum = Number.isInteger(limit) && limit > 0 ? limit : 50;
const selected = new Map();
let active = false;
const identity = item => [item?.kind || '', item?.repository || '', item?.number || ''].join(':');
const eligible = item => item?.kind === 'issue' && item?.state === 'open' &&
Boolean(item.repository) && Number.isInteger(item.number);
function snapshot() {
return { active, count:selected.size, items:Array.from(selected.values()) };
}
function changed() {
const state = snapshot();
onChange(state);
return state;
}
function start() {
active = true;
selected.clear();
return changed();
}
function cancel() {
active = false;
selected.clear();
return changed();
}
function toggle(item) {
if (!active) return 'inactive';
if (!eligible(item)) return 'ineligible';
const key = identity(item);
if (selected.has(key)) {
selected.delete(key);
changed();
return 'removed';
}
if (selected.size >= maximum) return 'limit';
selected.set(key, { ...item });
changed();
return 'selected';
}
async function prepare(item) {
const detail = await resolve(item);
if (detail?.assigned_to_me) return detail;
if (detail?.claimable) return claim(detail);
throw new Error('This issue is no longer available to assign.');
}
return { identity, eligible, start, cancel, toggle, snapshot, prepare, limit:maximum };
}
createSearchBatchPlan.mount = function mountSearchBatchPlanning(
document, batchFactory, todayWork, getOwner, fetchJson, previewPath, queueToday,
acceptIssue, lookup, render, escapeHtml, escapeAttribute
) {
const get = selector => document.querySelector(selector);
let processor;
const plan = createSearchBatchPlan({
resolve:item => fetchJson(previewPath(item), {headers:{Accept:'application/json'}}),
claim:detail => fetchJson(
'api/v1/repos/' + detail.repository.split('/').map(encodeURIComponent).join('/') +
'/issues/' + encodeURIComponent(detail.number) + '/claim',
{method:'PATCH', headers:{Accept:'application/json'}}
),
onChange:state => {
get('#search-batch-actions').hidden = !state.active;
get('#select-search-results').hidden = state.active;
get('#queue-selected-search-results').disabled = state.count === 0;
get('#search-selection-status').textContent = state.count ?
state.count + ' issue' + (state.count === 1 ? '' : 's') + ' selected.' : 'No issues selected.';
render();
},
});
processor = batchFactory({
capacity:() => Math.max(0, todayWork.limit - todayWork.read().length),
owner:getOwner,
journalName:'search-today-batch',
autoMount:false,
claim:item => plan.prepare(item),
queue:confirmed => queueToday(acceptIssue(confirmed)),
onProgress:progress => {
get('#queue-selected-search-results').disabled = progress.status === 'running';
if (progress.status === 'running') {
get('#search-selection-status').textContent = 'Planning ' + progress.processed + ' of ' + progress.selected + '…';
} else if (progress.status === 'full') {
get('#search-selection-status').textContent = 'Today has ' + progress.available + ' remaining slot' +
(progress.available === 1 ? '.' : 's.');
} else if (progress.status === 'complete') {
get('#cmd-search-action-status').textContent = progress.failed.length ?
progress.queued.length + ' queued · ' + progress.failed.length + ' need retry.' :
progress.queued.length + ' added to Today.';
get('#resume-search-batch').hidden = processor.pending() === 0;
if (!progress.failed.length) plan.cancel();
}
},
});
get('#select-search-results').addEventListener('click', () => plan.start());
get('#cancel-search-selection').addEventListener('click', () => plan.cancel());
get('#queue-selected-search-results').addEventListener('click', () => processor.run(plan.snapshot().items));
get('#resume-search-batch').addEventListener('click', () => processor.resume());
get('#open-palette').addEventListener('click', () => restore());
get('#cmd-results').addEventListener('change', event => {
const index = event.target?.dataset?.searchSelect;
if (index === undefined) return;
const result = lookup(Number(index));
if (result) plan.toggle(result);
});
function restore() {
const pending = processor.pending();
get('#resume-search-batch').hidden = pending === 0;
get('#resume-search-batch').textContent = 'Resume ' + pending + ' interrupted';
}
function resultHtml(result, index) {
const allowed = result.kind === 'issue' && result.state === 'open';
const selected = plan.snapshot().items.some(item => plan.identity(item) === plan.identity(result));
return '<label class="cmd-select-result"' + (allowed ? '' : ' aria-disabled="true"') + '>' +
'<input type="checkbox" data-search-select="' + index + '" ' + (selected ? 'checked ' : '') +
(allowed ? '' : 'disabled ') + 'aria-label="Select ' + escapeAttribute(result.title) + '" />' +
'<span>' + escapeHtml(result.title) + '</span><span class="cmd-meta">' +
escapeHtml(result.repository) + ' #' + escapeHtml(result.number) + ' · ' +
(allowed ? 'Open issue' : 'Not eligible') + '</span></label>';
}
return { plan, processor, restore, resultHtml };
};
return createSearchBatchPlan;
});
+28
View File
@@ -0,0 +1,28 @@
function createSearchDefer({ claim, accept, defer, refresh, announce, formatTime }) {
let request = null;
function run(detail, until) {
if (request) return request;
const wasClaimable = Boolean(detail?.claimable);
request = Promise.resolve(wasClaimable ? claim(detail) : detail)
.then(confirmed => {
const item = accept(confirmed);
const outcome = defer(item, until);
refresh();
if (outcome === 'deferred') {
announce((wasClaimable ? 'Assigned and deferred until ' : 'Deferred until ') + formatTime(until) + '.');
} else {
announce(wasClaimable
? 'Assignment succeeded, but Later could not be saved. Open this issue in My Work to retry.'
: 'Could not save Later on this device. Retry scheduling.');
}
return outcome;
})
.finally(() => { request = null; });
return request;
}
return { run, pending:() => Boolean(request) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createSearchDefer;
+304 -9
View File
@@ -1,40 +1,225 @@
(function (root, factory) {
const createSearchPreview = factory();
if (typeof module === 'object' && module.exports) module.exports = createSearchPreview;
if (root) root.createSearchPreview = createSearchPreview;
if (root) {
root.createSearchPreview = createSearchPreview;
root.safeSearchUrl = value => {
try {
const url = new URL(value);
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
} catch (_) { return ''; }
};
root.searchPreviewUrl = (state, defaultScope, location) => {
const { query, preview, scope = defaultScope } = state;
const params = new URLSearchParams({
search:query || '', preview:preview.kind + ':' + preview.repository + ':' + preview.number,
search_kind:scope.kind, search_state:scope.state,
});
if (scope.repository) params.set('search_repository', scope.repository);
return new URL('?' + params, location.origin + location.pathname).href;
};
root.searchPreviewPath = item => {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
'/preview?kind=' + encodeURIComponent(item.kind);
};
root.searchPreviewConversationPath = (item, page) => {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
const query = new URLSearchParams({ kind:item.kind, limit:'20' });
if (page) query.set('page', String(page));
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
'/preview/conversation?' + query.toString();
};
root.searchPreviewReplyPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
'/comments?kind=' + encodeURIComponent(item.kind);
root.searchPreviewReplyOptions = (fetchJson, storage, crypto) => ({
storage,
createOperationId:() => crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
postReply:(item,body,operationId) => fetchJson(root.searchPreviewReplyPath(item), {
method:'POST', headers:{Accept:'application/json','Content-Type':'application/json','Idempotency-Key':operationId},
body:JSON.stringify({body}),
}),
});
root.renderSearchPreviewConversation = (conversation, document, escapeHtml, formatTime, renderMarkdown) => {
const comments = document.querySelector('#search-preview-comments');
const status = document.querySelector('#search-preview-conversation-status');
const retry = document.querySelector('#retry-search-preview-conversation');
const older = document.querySelector('#load-older-search-preview-comments');
if (!conversation) {
comments.textContent = status.textContent = '';
retry.hidden = older.hidden = true;
return;
}
comments.innerHTML = (conversation.comments || []).map(comment =>
'<article class="search-preview-comment" data-comment-id="' + Number(comment.id || 0) + '">' +
'<div class="small">' + escapeHtml(comment.author || 'Unknown author') +
(comment.created_at ? ' · ' + escapeHtml(formatTime(comment.created_at)) : '') + '</div>' +
'<div class="markdown-content">' + renderMarkdown(comment.body || '') + '</div></article>'
).join('');
retry.hidden = conversation.status !== 'error';
older.hidden = conversation.status !== 'ready' || !conversation.olderPage;
older.disabled = conversation.status === 'loading';
if (conversation.status === 'loading') status.textContent = conversation.comments?.length
? 'Loading older messages…' : 'Loading current conversation…';
else if (conversation.status === 'error') status.textContent =
'Conversation unavailable. Preview and planning actions still work.';
else if (!conversation.comments?.length) status.textContent = 'No conversation yet.';
else status.textContent = conversation.comments.length +
(conversation.comments.length === 1 ? ' message.' : ' messages.');
};
root.renderSearchPreviewReply = (state, detail, preview, document) => {
const section = document.querySelector('.search-preview-reply');
const input = document.querySelector('#search-preview-reply');
const status = document.querySelector('#search-preview-reply-status');
const buttons = ['#send-search-preview-reply', '#send-search-preview-reply-next']
.map(selector => document.querySelector(selector));
section.hidden = detail?.commentable !== true;
status.textContent = '';
if (section.hidden) {
buttons.forEach(button => { button.disabled = true; });
return;
}
input.value = preview.replyDraft();
const replying = state.status === 'replying';
buttons.forEach(button => { button.disabled = replying || !input.value.trim(); });
if (replying) status.textContent = 'Sending reply…';
else if (state.status === 'replied') status.textContent = 'Reply posted.';
else if (state.status === 'reply-error') status.textContent =
state.error?.message || 'Reply failed. Your draft is safe; retry when ready.';
};
}
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
return function createSearchPreview({ fetchJson, mutate, onState }) {
return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
if (Array.isArray(session)) {
getSession = session[0];
loadMore = () => session[1].loadMore();
onNavigate = session[2];
navigationRoot = document;
}
let generation = 0;
let current = null;
let mutationRequest = null;
let shareRequest = null;
let moveRequest = null;
let replyRequest = null;
let conversation = null;
function sameItem(left, right) {
return left && right && left.kind === right.kind && left.repository === right.repository &&
Number(left.number) === Number(right.number);
}
function replyKey(item, suffix) {
return 'stackchain.search-reply.' + [item?.kind, item?.repository, item?.number]
.map(value => encodeURIComponent(String(value || ''))).join('.') + '.' + suffix;
}
function stored(key) {
try { return storage?.getItem(key) || ''; }
catch (_) { return ''; }
}
function save(key, value) {
try {
if (value) storage?.setItem(key, value);
else storage?.removeItem(key);
} catch (_) { /* Volatile drafts still remain in the textarea. */ }
}
function navigation(item) {
const session = typeof getSession === 'function' ? getSession() : null;
const items = Array.isArray(session?.items) ? session.items : [];
const index = items.findIndex(candidate => sameItem(candidate, item));
if (index < 0) return null;
return {
position:index + 1,
total:items.length,
hasPrevious:index > 0,
hasNext:index < items.length - 1 || session.more === true,
};
}
function publish(state) {
const position = navigation(state.item || current);
if (navigationRoot) {
const bar = navigationRoot.querySelector('.search-preview-navigation');
bar.hidden = !position;
if (position) {
navigationRoot.querySelector('#search-preview-position').textContent = position.position + ' of ' + position.total;
navigationRoot.querySelector('#previous-search-result').disabled = !position.hasPrevious;
navigationRoot.querySelector('#next-search-result').disabled = !position.hasNext;
}
}
onState(position ? { ...state, navigation:position } : state);
if (state.complete && navigationRoot) {
navigationRoot.querySelector('#search-preview-status').textContent = 'Search pass complete.';
}
}
function run(action, pending, success, detail) {
if (mutationRequest) return mutationRequest;
onState({ status: pending, item: current, detail });
publish({ status: pending, item: current, detail });
mutationRequest = mutate(detail, action).then(result => {
onState({ status: success, item: current, detail, result });
publish({ status: success, item: current, detail, result });
return result;
}).catch(error => {
onState({ status: 'ready', item: current, detail, error });
publish({ status: 'ready', item: current, detail, error });
throw error;
}).finally(() => { mutationRequest = null; });
return mutationRequest;
}
return {
function loadConversation(detail, requestGeneration, page) {
if (typeof fetchConversation !== 'function') return Promise.resolve(null);
const previousComments = page && Array.isArray(conversation?.comments)
? conversation.comments : [];
conversation = { status:'loading', comments:previousComments, olderPage:page ?? null };
publish({ status:'ready', item:current, detail, conversation });
return fetchConversation(detail, page).then(result => {
if (requestGeneration !== generation) return result;
const incoming = Array.isArray(result?.comments) ? result.comments : [];
const liveComments = Array.isArray(conversation?.comments) ? conversation.comments : previousComments;
const comments = [...incoming, ...liveComments].filter((comment, index, all) =>
all.findIndex(candidate => candidate?.id === comment?.id) === index);
conversation = {
status:'ready',
comments,
olderPage:result?.older_page ?? null,
};
publish({ status:'ready', item:current, detail:current, conversation });
return result;
}).catch(error => {
if (requestGeneration === generation) {
conversation = {
status:'error', comments:previousComments,
olderPage:page ?? conversation?.olderPage ?? null, error,
};
publish({ status:'ready', item:current, detail:current, conversation });
}
return null;
});
}
const api = {
open(item) {
generation += 1;
const requestGeneration = generation;
current = { ...item };
onState({ status: 'loading', item: current });
conversation = null;
publish({ status: 'loading', item: current });
return fetchJson(current).then(detail => {
if (requestGeneration === generation) {
onState({ status: 'ready', item: current, detail });
current = { ...current, ...detail };
if (typeof fetchConversation === 'function') {
loadConversation(current, requestGeneration);
} else {
publish({ status: 'ready', item: current, detail });
}
}
return detail;
}).catch(error => {
if (requestGeneration === generation) {
onState({ status: 'error', item: current, error });
publish({ status: 'error', item: current, error });
}
throw error;
});
@@ -44,6 +229,98 @@
current = null;
onState({ status: 'closed' });
},
previous() {
const session = typeof getSession === 'function' ? getSession() : null;
const items = Array.isArray(session?.items) ? session.items : [];
const index = items.findIndex(item => sameItem(item, current));
if (index <= 0) return Promise.resolve(current);
const item = items[index - 1];
if (typeof onNavigate === 'function') onNavigate(item);
return api.open(item);
},
next() {
if (moveRequest) return moveRequest;
moveRequest = (async () => {
let session = typeof getSession === 'function' ? getSession() : null;
let items = Array.isArray(session?.items) ? session.items : [];
let index = items.findIndex(item => sameItem(item, current));
if (index < 0) return current;
if (index === items.length - 1 && session?.more === true && typeof loadMore === 'function') {
await loadMore();
session = getSession();
items = Array.isArray(session?.items) ? session.items : [];
index = items.findIndex(item => sameItem(item, current));
}
if (index >= 0 && index < items.length - 1) {
const item = items[index + 1];
if (typeof onNavigate === 'function') onNavigate(item);
return api.open(item);
}
publish({ status:'ready', item:current, detail:current, complete:true });
return current;
})().finally(() => { moveRequest = null; });
return moveRequest;
},
retryConversation() {
if (!current) return Promise.resolve(null);
return loadConversation(current, generation);
},
loadOlderConversation() {
if (!current || !conversation?.olderPage) return Promise.resolve(null);
return loadConversation(current, generation, conversation.olderPage);
},
saveReplyDraft(body) {
if (!current) return '';
const key = replyKey(current, 'draft');
const next = String(body || '');
if (stored(key) !== next) save(replyKey(current, 'operation'), '');
save(key, next);
return next;
},
replyDraft() {
return current ? stored(replyKey(current, 'draft')) : '';
},
reply({ advance = false } = {}) {
if (replyRequest) return replyRequest;
if (!current || typeof postReply !== 'function') return Promise.reject(new Error('Replying is unavailable.'));
const body = api.replyDraft().trim();
if (!body) return Promise.reject(new Error('Write a reply first.'));
const item = { ...current };
const operationKey = replyKey(item, 'operation');
let operationId = stored(operationKey);
if (!operationId) {
operationId = String(createOperationId?.() || Date.now()).slice(0, 128);
save(operationKey, operationId);
}
publish({ status:'replying', item:current, detail:current, conversation });
replyRequest = postReply(item, body, operationId).then(comment => {
const comments = [...(conversation?.comments || [])];
if (!comments.some(candidate => candidate?.id === comment?.id)) comments.push(comment);
conversation = { status:'ready', comments, olderPage:conversation?.olderPage ?? null };
save(replyKey(item, 'draft'), '');
save(operationKey, '');
publish({ status:'replied', item:current, detail:current, conversation, result:comment });
return advance ? api.next().then(() => comment) : comment;
}).catch(error => {
publish({ status:'reply-error', item:current, detail:current, conversation, error });
throw error;
}).finally(() => { replyRequest = null; });
return replyRequest;
},
share(url) {
if (shareRequest) return shareRequest;
if (!current || typeof share !== 'function') return Promise.reject(new Error('Sharing is unavailable.'));
const detail = current;
publish({ status:'sharing', item:current, detail });
shareRequest = share(url).then(result => {
publish({ status:result, item:current, detail });
return result;
}).catch(error => {
publish({ status:error?.name === 'AbortError' ? 'share-canceled' : 'share-error', item:current, detail, error });
throw error;
}).finally(() => { shareRequest = null; });
return shareRequest;
},
claim(detail) {
return run('claim', 'claiming', 'claimed', detail);
},
@@ -51,5 +328,23 @@
return run('reopen', 'reopening', 'reopened', detail);
},
};
if (navigationRoot) {
navigationRoot.querySelector('#previous-search-result').addEventListener('click', () => api.previous().catch(() => {}));
navigationRoot.querySelector('#next-search-result').addEventListener('click', () => api.next().catch(() => {}));
navigationRoot.querySelector('#retry-search-preview-conversation').addEventListener('click', () => api.retryConversation());
navigationRoot.querySelector('#load-older-search-preview-comments').addEventListener('click', () => api.loadOlderConversation());
const reply = navigationRoot.querySelector('#search-preview-reply');
reply?.addEventListener('input', event => {
api.saveReplyDraft(event.target.value);
const disabled = !event.target.value.trim();
navigationRoot.querySelector('#send-search-preview-reply').disabled = disabled;
navigationRoot.querySelector('#send-search-preview-reply-next').disabled = disabled;
});
reply?.addEventListener('focus', event => event.target.scrollIntoView({ block:'center', behavior:'smooth' }));
navigationRoot.querySelector('#send-search-preview-reply')?.addEventListener('click', () => api.reply().catch(() => {}));
navigationRoot.querySelector('#send-search-preview-reply-next')?.addEventListener('click', () =>
api.reply({ advance:true }).catch(() => {}));
}
return api;
};
});
+1
View File
@@ -131,6 +131,7 @@
passkey_counter_anomaly: 'Passkey counter anomaly',
device_revoked: 'Device access revoked', all_sessions_revoked: 'All device access revoked',
issue_closed: 'Issue closed', pull_merged: 'Pull request merged',
comment_deleted: 'Comment deleted',
pull_review_approved: 'Pull request approved',
pull_review_changes_requested: 'Changes requested', gitea_time_logged: 'Gitea time logged',
};
+60 -1
View File
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v99';
const CACHE = 'stackchain-dashboard-shell-v101';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
@@ -18,9 +18,11 @@ const SHELL = [
BASE + 'static/markdown.js',
BASE + 'static/commands.js',
BASE + 'static/search-preview.js',
BASE + 'static/search-defer.js',
BASE + 'static/widgets.js',
BASE + 'static/drafts.js',
BASE + 'static/unfiled-captures.js',
BASE + 'static/shared-image-capture.js',
BASE + 'static/draft-filing-session.js',
BASE + 'static/draft-capacity-dialog.js',
BASE + 'static/outbox-coordinator.js',
@@ -58,6 +60,7 @@ const SHELL = [
BASE + 'static/later-picker.js',
BASE + 'static/pick-work.js',
BASE + 'static/batch-find-work.js',
BASE + 'static/search-batch-plan.js',
BASE + 'static/conversation.js',
BASE + 'static/comment-actions.js',
BASE + 'static/issue-attachment.js',
@@ -71,10 +74,20 @@ const SHELL = [
BASE + 'static/work-route.js',
BASE + 'static/task-overlay-history.js',
BASE + 'static/context-poller.js',
BASE + 'static/live-data-status.js',
BASE + 'static/mobile-task-dock.js',
BASE + 'static/mobile-work-entry.js',
BASE + 'static/mobile-queue-launcher.js',
BASE + 'static/update-triage-session.js',
BASE + 'static/update-review-handoff.js',
BASE + 'static/update-read-position.js',
BASE + 'static/work-detail-position.js',
BASE + 'static/update-triage-launcher.js',
BASE + 'static/update-triage-gesture.js',
BASE + 'static/update-decision-transaction.js',
BASE + 'static/agenda-session-launcher.js',
BASE + 'static/mobile-launch.js',
BASE + 'static/mobile-app-shortcuts.js',
BASE + 'static/install-app.js',
BASE + 'static/mobile-device-setup.js',
BASE + 'static/mobile-search-viewport.js',
@@ -86,6 +99,44 @@ const SHELL = [
const OPTIONAL_FEATURES = [
];
const SHARED_IMAGE_ID = 'shared-image';
const SHARED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
const MAX_SHARED_IMAGE_BYTES = 12 * 1024 * 1024;
const sharedAttachmentStore = self.__STACKCHAIN_SHARED_ATTACHMENT_STORE || createUnfiledAttachmentStore();
function boundedShareField(form, name, limit) {
const value = form.get(name);
return typeof value === 'string' ? value.trim().slice(0, limit) : '';
}
async function acceptSharedContent(request) {
const form = await request.formData();
const images = form.getAll('image').filter(value => typeof value !== 'string' && value?.size > 0);
let marker = '';
await sharedAttachmentStore.delete(SHARED_IMAGE_ID).catch(() => {});
if (images.length > 1) marker = 'multiple';
else if (images.length === 1) {
const image = images[0];
if (!SHARED_IMAGE_TYPES.has(String(image.type || '')) || image.size > MAX_SHARED_IMAGE_BYTES) {
marker = 'unsupported';
} else {
await sharedAttachmentStore.put(SHARED_IMAGE_ID, {
filename:String(image.name || 'shared-screenshot').slice(0, 255),
contentType:String(image.type), blob:image,
});
marker = 'image';
}
}
const target = new URL(BASE, self.location.origin);
target.searchParams.set('launch', 'new');
if (marker) target.searchParams.set('shared', marker);
for (const [name, limit] of [['title', 255], ['text', 10000], ['url', 2048]]) {
const value = boundedShareField(form, name, limit);
if (value) target.searchParams.set(name, value);
}
return new Response(null, {status:303, headers:{Location:target.pathname + target.search}});
}
async function fetchNavigation(request) {
const controller = new AbortController();
let timeout;
@@ -462,6 +513,14 @@ self.addEventListener('notificationclick', event => {
self.addEventListener('fetch', event => {
const request = event.request;
const requestUrl = new URL(request.url);
if (
request.method === 'POST' && request.mode === 'navigate'
&& requestUrl.origin === self.location.origin && requestUrl.pathname === BASE
) {
event.respondWith(acceptSharedContent(request));
return;
}
if (request.method !== 'GET' || request.url.includes('/api/')) return;
const url = new URL(request.url);
if (url.origin !== self.location.origin || !url.pathname.startsWith(BASE)) return;
+29
View File
@@ -0,0 +1,29 @@
(function(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.sharedImageCapture = api;
})(typeof self !== 'undefined' ? self : this, function() {
'use strict';
const RECORD_ID = 'shared-image';
const TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
async function consume({marker, store, restore, status = () => {}}) {
if (!marker) return false;
if (marker !== 'image') {
status('Share one PNG, JPEG, or WebP screenshot.');
return false;
}
const value = await store?.get(RECORD_ID);
if (!value?.blob || !value.filename || !TYPES.has(String(value.contentType || ''))) {
status('The shared screenshot is unavailable. Share it again.');
return false;
}
restore(value);
await store.delete(RECORD_ID);
status('Shared screenshot ready to file with this issue.');
return true;
}
return {consume, RECORD_ID};
});
+154 -22
View File
@@ -6,21 +6,132 @@
'use strict';
const allowed = new Set(['new', 'find', 'search', 'search-preview', 'plan-today', 'plan-today-preview', 'today-readiness']);
const searchKinds = new Set(['search', 'search-preview']);
const maxQueryLength = 200;
return function createTaskOverlayHistory({ history, eventTarget, onChange }) {
let active = allowed.has(history.state?.taskOverlay) ? history.state.taskOverlay : null;
function cleanQuery(value) {
const query = typeof value === 'string' ? value.trim() : '';
return query.length <= maxQueryLength ? query : '';
}
function cleanScope(value) {
const repository = typeof value?.repository === 'string' &&
/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value.repository.trim())
? value.repository.trim() : '';
return {
kind:['all', 'issue', 'pull'].includes(value?.kind) ? value.kind : 'all',
state:['all', 'open', 'closed'].includes(value?.state) ? value.state : 'all',
repository,
};
}
function cleanPreview(value) {
if (!value || !['issue', 'pull'].includes(value.kind)) return null;
const repository = typeof value.repository === 'string' ? value.repository : '';
const parts = repository.split('/');
const validPart = part => /^[A-Za-z0-9_.-]+$/.test(part);
const number = Number(value.number);
if (parts.length !== 2 || !parts.every(validPart) || !Number.isInteger(number) || number < 1) return null;
return { kind:value.kind, repository, number };
}
function previewToken(preview) {
return preview ? `${preview.kind}:${preview.repository}:${preview.number}` : '';
}
function parsePreview(value) {
const match = /^(issue|pull):([^/:]+\/[^/:]+):(\d+)$/.exec(value || '');
return match ? cleanPreview({ kind:match[1], repository:match[2], number:Number(match[3]) }) : null;
}
return function createTaskOverlayHistory({ history, location, eventTarget, onChange }) {
let started = false;
let restoredFromUrl = false;
function stateKind(state = history.state) {
return allowed.has(state?.taskOverlay) ? state.taskOverlay : null;
function stateDetail(state = history.state) {
const kind = allowed.has(state?.taskOverlay) ? state.taskOverlay : null;
if (!searchKinds.has(kind)) return { kind };
const query = cleanQuery(state.searchQuery);
const scope = state.searchScope === undefined ? null : cleanScope(state.searchScope);
const preview = kind === 'search-preview' ? cleanPreview(state.searchPreview) : null;
if (kind === 'search-preview' && state.searchPreview !== undefined && !preview) {
return { kind:'search', ...(query ? { query } : {}) };
}
return { kind, ...(query ? { query } : {}), ...(scope ? { scope } : {}), ...(preview ? { preview } : {}) };
}
function urlDetail() {
if (!location || typeof URLSearchParams === 'undefined') return { kind:null };
const params = new URLSearchParams(location.search || '');
const rawQuery = params.get('search');
if (rawQuery === null) return { kind:null };
const query = cleanQuery(rawQuery);
if (rawQuery.trim() && !query) return { kind:null };
const preview = parsePreview(params.get('preview'));
const hasScope = params.has('search_kind') || params.has('search_state') || params.has('search_repository');
const scope = hasScope ? cleanScope({
kind:params.get('search_kind'), state:params.get('search_state'),
repository:params.get('search_repository'),
}) : null;
return {
kind:preview ? 'search-preview' : 'search',
...(query ? { query } : {}),
...(scope ? { scope } : {}),
...(preview ? { preview } : {}),
};
}
function toState(detail, base = history.state) {
const state = { ...(base || {}), taskOverlay:detail.kind };
delete state.searchQuery;
delete state.searchPreview;
delete state.searchScope;
if (searchKinds.has(detail.kind)) {
if (detail.query) state.searchQuery = detail.query;
if (detail.scope) state.searchScope = cleanScope(detail.scope);
if (detail.kind === 'search-preview' && detail.preview) state.searchPreview = detail.preview;
}
return state;
}
function urlFor(detail) {
if (!location || typeof URLSearchParams === 'undefined') return undefined;
const params = new URLSearchParams(location.search || '');
params.delete('search');
params.delete('preview');
params.delete('search_kind');
params.delete('search_state');
params.delete('search_repository');
if (searchKinds.has(detail.kind)) {
params.set('search', detail.query || '');
if (detail.scope) {
params.set('search_kind', detail.scope.kind);
params.set('search_state', detail.scope.state);
if (detail.scope.repository) params.set('search_repository', detail.scope.repository);
}
if (detail.kind === 'search-preview' && detail.preview) params.set('preview', previewToken(detail.preview));
}
const query = params.toString();
return `${location.pathname || ''}${query ? `?${query}` : ''}${location.hash || ''}`;
}
let initial = stateDetail();
if (!initial.kind) {
const fromUrl = urlDetail();
if (fromUrl.kind) {
initial = fromUrl;
restoredFromUrl = true;
history.replaceState(toState(fromUrl), '', urlFor(fromUrl));
}
}
let active = initial;
function apply(state) {
const next = stateKind(state);
if (next === active) return;
const previous = active;
const next = stateDetail(state);
if (JSON.stringify(next) === JSON.stringify(active)) return;
const previous = active.kind;
active = next;
onChange(next, previous);
onChange(next.kind, previous, next);
}
return {
@@ -28,33 +139,54 @@
if (started) return;
started = true;
eventTarget.addEventListener('popstate', event => apply(event.state));
if (restoredFromUrl) onChange(active.kind, null, active);
},
open(kind) {
open(kind, detail = {}) {
if (!allowed.has(kind)) return false;
if (active === kind) return true;
const previous = active;
const state = { ...(history.state || {}), taskOverlay: kind };
history.pushState(state, '');
active = kind;
onChange(kind, previous);
const next = stateDetail(toState({
kind, query:cleanQuery(detail.query),
scope:detail.scope === undefined ? undefined : cleanScope(detail.scope),
preview:cleanPreview(detail.preview),
}));
if (active.kind === next.kind && JSON.stringify(active) === JSON.stringify(next)) return true;
const previous = active.kind;
history.pushState(toState(next), '', urlFor(next));
active = next;
onChange(next.kind, previous, next);
return true;
},
update(detail = {}) {
if (!searchKinds.has(active.kind)) return false;
const next = stateDetail(toState({
kind:active.kind,
query:detail.query === undefined ? active.query : cleanQuery(detail.query),
scope:detail.scope === undefined ? active.scope : cleanScope(detail.scope),
preview:detail.preview === undefined ? active.preview : cleanPreview(detail.preview),
}));
history.replaceState(toState(next), '', urlFor(next));
active = next;
return true;
},
close() {
if (!active) return false;
if (!active.kind) return false;
history.back();
return true;
},
leave() {
if (!active) return false;
const previous = active;
if (!active.kind) return false;
const previous = active.kind;
const state = { ...(history.state || {}) };
delete state.taskOverlay;
history.replaceState(state, '');
active = null;
onChange(null, previous);
delete state.searchQuery;
delete state.searchPreview;
delete state.searchScope;
active = { kind:null };
history.replaceState(state, '', urlFor(active));
onChange(null, previous, active);
return true;
},
current() { return active; },
current() { return active.kind; },
currentState() { return { ...active, ...(active.preview ? { preview:{ ...active.preview } } : {}) }; },
};
};
});
+38
View File
@@ -0,0 +1,38 @@
(function(r){
function transaction({controls=[],status}) {
let busy=false, disabled=[];
async function run(message, action, {origin,failure}={}) {
if (busy) return false;
busy=true;
disabled=controls.map(control=>control.disabled);
controls.forEach(control=>{ control.disabled=true; });
status.textContent=message;
let completed=false;
try { completed=Boolean(await action()); }
catch (error) { failure=failure || error.message || 'Could not complete decision. Retry.'; }
if (!completed && failure) status.textContent=failure;
busy=false;
controls.forEach((control,index)=>{ control.disabled=disabled[index]; });
if (!completed) origin?.focus?.();
return completed;
}
return {run,busy:()=>busy};
}
function actions(o) {
return {
keepUnread:()=>o.transaction.run('Keeping unread…',()=>{
if(o.triage.active()) o.triage.keepUnreadAndNext(); else o.close();
return true;
},{origin:o.keepControl,failure:'Could not keep this update unread. Retry.'}),
markRead:()=>o.transaction.run('Marking read…',async()=>{
const result=await o.reader.markReadAndNext(o.items());
if(!result) return false;
o.undo.offer(result.item,result.items);
if(o.triage.active()) o.triage.acceptCompleted();
return true;
},{origin:o.readControl,failure:'Could not mark update read. Retry.'}),
};
}
if(typeof module==='object'&&module.exports){module.exports=transaction;module.exports.createActions=actions;}
else {r.createUpdateDecisionTransaction=transaction;r.createUpdateDecisionActions=actions;}
})(typeof self!=='undefined'?self:this);
+49
View File
@@ -0,0 +1,49 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createUpdateReadPosition = factory;
})(typeof self !== 'undefined' ? self : this, function createUpdateReadPosition(options) {
const panel = options.panel;
const jump = options.jump;
let identity = '';
let openingTop = 0;
let movedByUser = false;
let positioned = false;
let target = null;
function moveToTarget() {
if (!target) return false;
target.classList?.add('update-new-activity');
target.scrollIntoView({ block:'end' });
positioned = true;
jump.hidden = true;
return true;
}
panel.addEventListener('scroll', () => {
if (identity && !positioned && panel.scrollTop !== openingTop) movedByUser = true;
}, { passive:true });
return {
open(nextIdentity) {
identity = String(nextIdentity || '');
openingTop = panel.scrollTop;
movedByUser = false;
positioned = false;
target = null;
jump.hidden = true;
},
ready(readyIdentity, newest) {
if (!identity || String(readyIdentity || '') !== identity || positioned || !newest) return false;
target = newest;
target.classList?.add('update-new-activity');
if (movedByUser) {
jump.hidden = false;
return false;
}
return moveToTarget();
},
jump() {
return moveToTarget();
},
};
});
+62
View File
@@ -0,0 +1,62 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createUpdateReviewHandoff = factory;
})(typeof self !== 'undefined' ? self : this, function createUpdateReviewHandoff(options) {
let source = null;
let completing = null;
function eligible(detail, item = detail) {
const merged = { ...(item || {}), ...(detail || {}) };
return Boolean(
merged.is_review && merged.update_reason === 'Review requested' &&
merged.subject_type === 'Pull' && /^[^/]+\/[^/]+$/.test(String(merged.repository || '')) &&
Number.isInteger(Number(merged.number)) && Number(merged.number) > 0 &&
Number.isInteger(Number(merged.notification_id))
);
}
function reviewItem(item) {
return {
...item,
kind: 'review',
is_review: true,
key: item.key || item.repository + '#' + item.number,
};
}
function begin(item) {
if (!eligible(item) || source) return false;
source = item;
options.openReview(reviewItem(item));
return true;
}
function cancel() {
if (!source || completing) return false;
const item = source;
source = null;
options.restoreUpdate?.(item);
return true;
}
function complete() {
if (!source) return Promise.resolve(false);
if (completing) return completing;
const item = source;
completing = Promise.resolve(options.admitRead(item.notification_id)).then(() => {
options.advance(item);
source = null;
return true;
}).finally(() => { completing = null; });
return completing;
}
return {
eligible,
begin,
cancel,
complete,
active: () => Boolean(source),
source: () => source,
};
});
+92
View File
@@ -0,0 +1,92 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createUpdateTriageGesture = factory;
})(typeof self !== 'undefined' ? self : this, function createUpdateTriageGesture(options) {
const viewportWidth = options.viewportWidth || (() => root.innerWidth);
const surface = options.surface;
const threshold = options.threshold || 72;
const edgeInset = options.edgeInset || 24;
const interactive = 'a,button,input,textarea,select,label,summary,[contenteditable="true"],[role="button"]';
let active = null;
let busy = false;
function horizontalScroller(target) {
for (let node = target; node && node !== surface; node = node.parentElement) {
if (Number(node.scrollWidth) > Number(node.clientWidth) + 1) return true;
}
return false;
}
function ignored(event) {
return event.pointerType === 'mouse' || event.clientX <= edgeInset ||
event.clientX >= viewportWidth() - edgeInset ||
Boolean(event.target?.closest?.(interactive)) || horizontalScroller(event.target);
}
function reset() {
active = null;
delete surface.dataset.triageGesture;
}
function start(event) {
if (busy || !options.enabled() || viewportWidth() > 700 || ignored(event) || active) return;
active = {id:event.pointerId, x:event.clientX, y:event.clientY, cancelled:false};
surface.setPointerCapture?.(event.pointerId);
}
function move(event) {
if (!active || active.id !== event.pointerId) return;
const dx = event.clientX - active.x;
const dy = event.clientY - active.y;
if (Math.abs(dy) > Math.abs(dx) || Math.abs(dy) > 36) {
active.cancelled = true;
delete surface.dataset.triageGesture;
return;
}
if (Math.abs(dx) < 12) return;
event.preventDefault?.();
surface.dataset.triageGesture = dx > 0 ? 'keep' : 'read';
}
function decide(callback) {
busy = true;
let outcome;
try {
outcome = callback();
} catch (error) {
busy = false;
throw error;
}
if (outcome && typeof outcome.then === 'function') {
Promise.resolve(outcome).finally(() => { busy = false; });
} else {
busy = false;
}
}
function finish(event) {
if (!active || active.id !== event.pointerId) return;
const gesture = active;
const dx = event.clientX - gesture.x;
const dy = event.clientY - gesture.y;
surface.releasePointerCapture?.(event.pointerId);
reset();
if (gesture.cancelled || Math.abs(dx) < threshold || Math.abs(dx) <= Math.abs(dy)) return;
decide(dx > 0 ? options.keepUnread : options.markRead);
}
surface.addEventListener('pointerdown', start);
surface.addEventListener('pointermove', move);
surface.addEventListener('pointerup', finish);
surface.addEventListener('pointercancel', reset);
function destroy() {
surface.removeEventListener('pointerdown', start);
surface.removeEventListener('pointermove', move);
surface.removeEventListener('pointerup', finish);
surface.removeEventListener('pointercancel', reset);
reset();
}
return {destroy};
});
+39
View File
@@ -0,0 +1,39 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createUpdateTriageLauncher = factory;
})(typeof self !== 'undefined' ? self : this, function createUpdateTriageLauncher(options) {
let pending = null;
function open() {
if (pending) return pending;
options.selectUpdates();
options.announce('Checking all unread updates…');
pending = Promise.resolve()
.then(() => options.discover())
.then(discovery => {
if (!options.isUpdatesSelected()) return 'cancelled';
if (discovery === false || discovery?.complete === false) {
options.announce('Updates check paused. Retry to check older unread updates.');
return 'incomplete';
}
if (discovery?.complete) options.applySnapshot?.(discovery);
if (options.hasMore()) {
options.announce('Updates check paused. Retry to check older unread updates.');
return 'incomplete';
}
const opened = options.hasCheckpoint() ? options.resume() : options.start();
if (!opened) options.announce('No unread updates are ready.');
return opened ? 'opened' : 'empty';
})
.catch(() => {
if (options.isUpdatesSelected()) {
options.announce('Updates check paused. Retry to check older unread updates.');
}
return 'incomplete';
})
.finally(() => { pending = null; });
return pending;
}
return {open};
});
+137
View File
@@ -0,0 +1,137 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createUpdateTriageSession = factory;
})(typeof self !== 'undefined' ? self : this, function createUpdateTriageSession(options) {
const key = options.key || 'stackchain.update-triage.v1';
const identity = item => Number.isInteger(item?.notification_id) ? String(item.notification_id) : '';
const login = () => String(options.getLogin() || '').trim();
let state = null;
let running = false;
let lastOutcome = null;
function read() {
const owner = login();
if (!owner) return null;
try {
const value = JSON.parse(options.storage.getItem(key) || 'null');
if (![1, 2].includes(value?.version) || value.login !== owner || !Array.isArray(value.identities) ||
!value.identities.length || typeof value.current !== 'string' || !Array.isArray(value.completed)) return null;
const identities = value.identities.filter(value => typeof value === 'string' && value);
const completed = value.completed.filter(value => identities.includes(value));
const kept = Array.isArray(value.kept) ? value.kept.filter(value => identities.includes(value)) : [];
if (!identities.length || !identities.includes(value.current)) return null;
return { version:2, login:owner, identities, current:value.current, completed, kept };
} catch (_) {
return null;
}
}
function persist() {
if (!state) return;
options.storage.setItem(key, JSON.stringify(state));
}
function available() {
const byIdentity = new Map((options.getItems() || []).map(item => [identity(item), item]));
return state.identities.filter(id => !state.completed.includes(id) && byIdentity.has(id))
.map(id => byIdentity.get(id));
}
function nextAvailable() {
if (!running || !state) return null;
const candidates = available().filter(item => identity(item) !== state.current);
return candidates.find(item => state.identities.indexOf(identity(item)) > state.identities.indexOf(state.current)) || candidates[0] || null;
}
function finish() {
const unread = new Set((options.getItems() || []).map(identity).filter(Boolean));
const keptIdentities = state ? state.kept.filter(id => unread.has(id)) : [];
const reviewed = state ? state.completed.length : 0;
const outcome = { reviewed, resolved:reviewed - keptIdentities.length, kept:keptIdentities.length, keptIdentities };
lastOutcome = outcome;
running = false;
state = null;
options.storage.removeItem(key);
options.onFinish(outcome);
return false;
}
function openCurrent(preferredIndex = null) {
const items = available();
if (!items.length) return finish();
let index = items.findIndex(item => identity(item) === state.current);
if (index < 0) index = Math.min(preferredIndex ?? 0, items.length - 1);
state.current = identity(items[index]);
persist();
const originalIndex = state.identities.indexOf(state.current);
const progress = {
index:originalIndex + 1,
total:state.identities.length,
};
if (items[index]?.update_reason) progress.reason = String(items[index].update_reason);
options.onProgress(progress);
options.onOpen(items[index]);
return true;
}
function advance(openNext = true) {
if (!running || !state) return false;
const originalIndex = state.identities.indexOf(state.current);
if (!state.completed.includes(state.current)) state.completed.push(state.current);
const candidates = available();
const next = candidates.find(item => state.identities.indexOf(identity(item)) > originalIndex) || candidates[0];
if (!next) return finish();
state.current = identity(next);
if (openNext) return openCurrent();
persist();
const progress = {
index:state.identities.indexOf(state.current) + 1,
total:state.identities.length,
};
if (next?.update_reason) progress.reason = String(next.update_reason);
options.onProgress(progress);
return true;
}
return {
active: () => running,
resumable: () => Boolean(read()),
start() {
const identities = (options.getItems() || []).map(identity).filter(Boolean);
if (!identities.length) return finish();
state = { version:2, login:login(), identities, current:identities[0], completed:[], kept:[] };
if (!state.login) return false;
running = true;
return openCurrent();
},
resume() {
state = read();
if (!state) return false;
running = true;
return openCurrent(state.identities.indexOf(state.current));
},
reconcile() {
if (!running || !state) return false;
return openCurrent(state.identities.indexOf(state.current));
},
completeAndNext: advance,
acceptCompleted: () => advance(false),
keepUnreadAndNext() {
if (!running || !state) return false;
if (!state.kept.includes(state.current)) state.kept.push(state.current);
return advance();
},
reviewKept(identities = lastOutcome?.keptIdentities || []) {
const unread = new Set((options.getItems() || []).map(identity).filter(Boolean));
const kept = (identities || []).filter(id => unread.has(id));
if (!kept.length) return false;
state = { version:2, login:login(), identities:kept, current:kept[0], completed:[], kept:[] };
if (!state.login) return false;
running = true;
return openCurrent();
},
next: nextAvailable,
items: () => state ? available().slice() : [],
end: finish,
};
});
+1
View File
@@ -0,0 +1 @@
(function(r,f){if(typeof module==='object'&&module.exports)module.exports=f;else r.createWorkDetailPosition=f})(typeof self!=='undefined'?self:this,function(o){let i='';return{open(n){n=String(n||'');if(n&&n===i)return false;i=n;o.panel.scrollTop=0;return true}}});
+3 -1
View File
@@ -28,10 +28,12 @@ FEATURE_SOURCES = {
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
"security-center": ("static/security-center.js",),
"today-timer": (
"static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
"static/commands.js", "static/task-overlay-history.js", "static/search-preview.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
"static/assign-and-start.js", "static/queue-today.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
"static/search-batch-plan.js",
),
}
CACHE_DECLARATION = re.compile(
+155 -28
View File
@@ -4,7 +4,7 @@ import re
import shlex
from contextlib import asynccontextmanager
from typing import Any
from urllib.parse import urljoin, urlsplit
from urllib.parse import quote, urljoin, urlsplit
import httpx
@@ -403,15 +403,39 @@ async def work_page(stream: str, page: int = 1, limit: int = 50) -> dict:
}
async def assigned_issue_snapshot(*, limit: int = 50, max_pages: int = 20) -> dict:
async def assigned_issue_snapshot(
*,
limit: int = 50,
max_pages: int = 20,
max_concurrency: int = 4,
deadline_seconds: float = 5.0,
) -> dict:
"""Load a complete, bounded assigned-issue snapshot for deadline dispatch."""
items = []
for page in range(1, max_pages + 1):
result = await work_page("issue", page, limit)
items.extend(result["items"])
if not result["has_more"]:
return {"items": items, "complete": True}
return {"items": [], "complete": False}
async with asyncio.timeout(deadline_seconds):
first = await work_page("issue", 1, limit)
page_count = max(1, (first["total"] + limit - 1) // limit)
if page_count > max_pages:
raise ValueError("Assigned issue snapshot exceeds the scan limit")
semaphore = asyncio.Semaphore(max(1, max_concurrency))
async def load(page: int) -> dict:
async with semaphore:
return await work_page("issue", page, limit)
remaining = await asyncio.gather(
*(load(page) for page in range(2, page_count + 1))
)
pages = [first, *remaining]
if any(page.get("total") != first["total"] for page in pages[1:]):
raise ValueError("Assigned issue pagination changed during the scan")
items = [item for page in pages for item in page["items"]]
issue_ids = [
item.get("id") for item in items
if isinstance(item, dict) and isinstance(item.get("id"), int) and item["id"] > 0
]
if len(items) != first["total"] or len(set(issue_ids)) != first["total"]:
raise ValueError("Assigned issue snapshot has an incomplete issue set")
return {"items": items, "complete": True}
def _normalize_global_search_item(item: Any, kind: str) -> dict | None:
@@ -439,13 +463,46 @@ def _normalize_global_search_item(item: Any, kind: str) -> dict | None:
}
async def global_search(query: str, limit: int = 10) -> dict:
"""Search accessible issues and pulls concurrently with a bounded result set."""
async def global_search(
query: str,
limit: int = 10,
page: int = 1,
kind: str = "all",
state: str = "all",
repository: str | None = None,
continuation: dict[str, int | None] | None = None,
) -> dict:
"""Search accessible work with lossless, independently retryable streams."""
item_types = ("issues", "pulls") if kind == "all" else (
"issues" if kind == "issue" else "pulls",
)
stream_limits = {
item_type: (
(limit + 1) // 2 if item_type == "issues" else limit // 2
) if kind == "all" else limit
for item_type in item_types
}
stream_pages = {
item_type: continuation.get(item_type) if continuation is not None else page
for item_type in item_types
}
active_types = tuple(
item_type for item_type in item_types
if stream_pages[item_type] is not None and stream_limits[item_type] > 0
)
async def load(item_type: str) -> Any:
params = {
"q": query, "type": item_type, "state": state,
"limit": stream_limits[item_type], "page": stream_pages[item_type],
}
if repository:
owner, repo = repository.split("/", 1)
params.update({"owner": owner, "repo": repo})
response = await _get_client().get(
"/api/v1/repos/issues/search",
headers=_auth(),
params={"q": query, "type": item_type, "limit": limit, "page": 1},
params=params,
)
response.raise_for_status()
payload = response.json()
@@ -454,30 +511,58 @@ async def global_search(query: str, limit: int = 10) -> dict:
return payload
outcomes = await asyncio.gather(
load("issues"), load("pulls"), return_exceptions=True
*(load(item_type) for item_type in active_types), return_exceptions=True
)
for outcome in outcomes:
if isinstance(outcome, asyncio.CancelledError):
raise outcome
if all(isinstance(outcome, BaseException) for outcome in outcomes):
if outcomes and all(isinstance(outcome, BaseException) for outcome in outcomes):
raise outcomes[0]
results = []
streams: list[list[dict]] = []
seen: set[tuple[str, str, int]] = set()
for outcome, kind in zip(outcomes, ("issue", "pull"), strict=True):
result_kinds = tuple(
"issue" if item_type == "issues" else "pull" for item_type in active_types
)
for outcome, result_kind in zip(outcomes, result_kinds, strict=True):
normalized_stream = []
if isinstance(outcome, BaseException):
streams.append(normalized_stream)
continue
for item in outcome:
normalized = _normalize_global_search_item(item, kind)
normalized = _normalize_global_search_item(item, result_kind)
if normalized is not None:
identity = (kind, normalized["repository"], normalized["number"])
identity = (result_kind, normalized["repository"], normalized["number"])
if identity in seen:
continue
seen.add(identity)
results.append(normalized)
normalized_stream.append(normalized)
streams.append(normalized_stream)
results = []
for index in range(max((len(stream) for stream in streams), default=0)):
for stream in streams:
if index < len(stream):
results.append(stream[index])
failed_streams = [
"issue" if item_type == "issues" else "pull"
for item_type, outcome in zip(active_types, outcomes, strict=True)
if isinstance(outcome, BaseException)
]
next_continuation = {item_type: None for item_type in item_types}
for item_type, outcome in zip(active_types, outcomes, strict=True):
if isinstance(outcome, BaseException):
next_continuation[item_type] = stream_pages[item_type]
elif len(outcome) >= stream_limits[item_type]:
next_continuation[item_type] = int(stream_pages[item_type]) + 1
has_more = any(value is not None for value in next_continuation.values())
return {
"items": results[:limit],
"partial": any(isinstance(outcome, BaseException) for outcome in outcomes),
"partial": bool(failed_streams),
"has_more": has_more,
"next_page": page + 1,
"continuation": next_continuation,
"failed_streams": failed_streams,
}
@@ -501,6 +586,19 @@ async def work_preview(repository: str, kind: str, number: int) -> dict:
login = user.get("login") if isinstance(user, dict) else ""
state = issue.get("state") if isinstance(issue.get("state"), str) else ""
actual_kind = "pull" if isinstance(issue.get("pull_request"), dict) else "issue"
reviewable = False
if actual_kind == "pull" and state == "open" and login:
try:
pull = await fetch(f"repos/{repository}/pulls/{number}")
except Exception:
pull = None
requested_reviewers = pull.get("requested_reviewers") if isinstance(pull, dict) else []
if not isinstance(requested_reviewers, list):
requested_reviewers = []
reviewable = any(
isinstance(reviewer, dict) and reviewer.get("login") == login
for reviewer in requested_reviewers
)
return {
"kind": actual_kind,
"repository": repository,
@@ -518,6 +616,8 @@ async def work_preview(repository: str, kind: str, number: int) -> dict:
"claimable": actual_kind == "issue" and state == "open" and not assignee_names,
"reopenable": actual_kind == "issue" and state == "closed",
"assigned_to_me": bool(login and login in assignee_names),
"reviewable": reviewable,
"commentable": bool(login),
}
@@ -892,6 +992,14 @@ def _gitea_api_path(value: Any) -> str:
return parsed.path[len(prefix):] + (("?" + parsed.query) if parsed.query else "")
async def _notification_subscription(repository: str, number: str) -> dict:
try:
value = await fetch(f"repos/{repository}/issues/{number}/subscriptions/check")
except Exception:
return {}
return value if isinstance(value, dict) else {}
async def notification_detail(thread_id: int) -> dict:
thread = await fetch(f"notifications/threads/{thread_id}")
if not isinstance(thread, dict):
@@ -916,18 +1024,16 @@ async def notification_detail(thread_id: int) -> dict:
)
if supported_conversation:
assert conversation_match is not None
subject_detail, comment, conversation = await asyncio.gather(
subject_detail, comment, subscription = await asyncio.gather(
fetch(subject_path),
fetch(comment_path) if comment_path else asyncio.sleep(0, result={}),
issue_conversation_page(
conversation_match.group(1), int(conversation_match.group(3))
),
_notification_subscription(conversation_match.group(1), conversation_match.group(3)),
)
else:
subject_detail, comment, conversation = await asyncio.gather(
subject_detail, comment, subscription = await asyncio.gather(
fetch(subject_path) if subject_path else asyncio.sleep(0, result={}),
fetch(comment_path) if comment_path else asyncio.sleep(0, result={}),
asyncio.sleep(0, result={"comments": [], "page": 1, "older_page": None, "total": 0}),
asyncio.sleep(0, result={}),
)
subject_detail = subject_detail if isinstance(subject_detail, dict) else {}
comment = comment if isinstance(comment, dict) else {}
@@ -988,12 +1094,18 @@ async def notification_detail(thread_id: int) -> dict:
and comment_match
and comment_match.group(1) == repository_name
),
"conversation": conversation,
"mute_supported": bool(
supported_conversation
and isinstance(subscription, dict)
and subscription.get("subscribed") is True
and subscription.get("ignored") is not True
),
"conversation_available": bool(supported_conversation),
}
async def notification_conversation_page(
thread_id: int, page: int, limit: int = 20
thread_id: int, page: int | None, limit: int = 20
) -> dict:
thread = await fetch(f"notifications/threads/{thread_id}")
if not isinstance(thread, dict):
@@ -1034,6 +1146,21 @@ async def notification_conversation_target(thread_id: int) -> tuple[str, int]:
return match.group(1), int(match.group(3))
async def mute_notification(thread_id: int) -> dict:
"""Unsubscribe the current operator from the trusted notification conversation."""
repository, number = await notification_conversation_target(thread_id)
user = await fetch("user")
login = user.get("login") if isinstance(user, dict) else None
if not isinstance(login, str) or not login:
raise ValueError("Gitea did not identify the current operator")
response = await _get_client().delete(
f"/api/v1/repos/{repository}/issues/{number}/subscriptions/{quote(login, safe='')}",
headers=_auth(),
)
response.raise_for_status()
return {"id": thread_id, "repository": repository, "number": number, "muted": True}
async def reply_to_notification(thread_id: int, body: str) -> dict:
thread = await fetch(f"notifications/threads/{thread_id}")
if not isinstance(thread, dict):
+261 -7
View File
@@ -5,6 +5,7 @@ import hashlib
import hmac
import math
import os
import re
import secrets
import sqlite3
import time
@@ -394,6 +395,7 @@ class DeadlineReminderPayload(BaseModel):
enabled: bool
timezone: str = Field(min_length=1, max_length=64)
reminder_hour: int = Field(default=9, ge=0, le=23)
reminder_days: Literal[0, 2, 7] = 2
@field_validator("timezone")
@classmethod
@@ -411,6 +413,7 @@ StepUpAction = Literal[
"merge_pull",
"submit_pull_review",
"close_issue",
"delete_comment",
"log_recap_time",
"revoke_device",
"revoke_all_sessions",
@@ -1000,6 +1003,41 @@ app.include_router(frontend_router)
def _share_target_login_redirect(request: Request) -> str:
search_values = request.query_params.getlist("search")
preview_values = request.query_params.getlist("preview")
if search_values or preview_values:
allowed = {"search", "preview", "search_kind", "search_state", "search_repository"}
kind_values = request.query_params.getlist("search_kind")
state_values = request.query_params.getlist("search_state")
repository_values = request.query_params.getlist("search_repository")
if (
set(request.query_params.keys()) - allowed
or len(search_values) != 1
or len(preview_values) != 1
or len(kind_values) > 1
or len(state_values) > 1
or len(repository_values) > 1
or len(search_values[0]) > 200
or len(preview_values[0]) > 200
or not re.fullmatch(
r"(?:issue|pull):[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+:[1-9]\d*",
preview_values[0],
)
):
return "login"
continuation_values = [
("search", search_values[0].strip()), ("preview", preview_values[0]),
]
if kind_values and kind_values[0] in {"all", "issue", "pull"}:
continuation_values.append(("search_kind", kind_values[0]))
if state_values and state_values[0] in {"all", "open", "closed"}:
continuation_values.append(("search_state", state_values[0]))
if repository_values and re.fullmatch(
r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository_values[0]
):
continuation_values.append(("search_repository", repository_values[0]))
continuation = urlencode(continuation_values)
return f"login?{urlencode({'continue': f'./?{continuation}'})}"
limits = {"title": 200, "text": 8000, "url": 2048}
if any(
len(request.query_params.get(name, "")) > limit
@@ -1011,6 +1049,17 @@ def _share_target_login_redirect(request: Request) -> str:
for name in limits
if request.query_params.get(name)
]
launch = request.query_params.get("launch", "")
if launch in {"continue", "new", "agenda"}:
shared.append(("launch", launch))
image_markers = request.query_params.getlist("shared")
if image_markers:
if (
image_markers != ["image"]
or request.query_params.getlist("launch") != ["new"]
):
return "login"
shared.append(("shared", "image"))
if dashboard_auth.application_path(request) != "/" or not shared:
return "login"
continuation = f"./?{urlencode(shared)}"
@@ -1939,6 +1988,7 @@ async def push_status(request: Request):
"deadline_enabled": preferences["enabled"],
"timezone": preferences["timezone"],
"reminder_hour": preferences["reminder_hour"],
"reminder_days": preferences["reminder_days"],
}
@@ -2007,11 +2057,13 @@ async def update_deadline_reminders(payload: DeadlineReminderPayload, request: R
enabled=payload.enabled,
timezone=payload.timezone,
reminder_hour=payload.reminder_hour,
reminder_days=payload.reminder_days,
)
return {
"deadline_enabled": payload.enabled,
"timezone": payload.timezone,
"reminder_hour": payload.reminder_hour,
"reminder_days": payload.reminder_days,
}
@@ -2674,13 +2726,33 @@ async def background_identity() -> JSONResponse:
async def global_search(
q: str = Query(min_length=2, max_length=100),
limit: int = Query(default=10, ge=1, le=25),
page: int = Query(default=1, ge=1, le=100),
kind: Literal["all", "issue", "pull"] = Query(default="all"),
state: Literal["all", "open", "closed"] = Query(default="all"),
repository: str | None = Query(
default=None, pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", max_length=161
),
issues_page: int | None = Query(default=None, ge=1, le=100),
pulls_page: int | None = Query(default=None, ge=1, le=100),
) -> JSONResponse:
query = q.strip()
if len(query) < 2:
raise HTTPException(status_code=422, detail="Search query must contain at least 2 characters")
try:
arguments = (query, limit, page, kind, state)
continuation = None
if issues_page is not None or pulls_page is not None:
continuation = {"issues": issues_page, "pulls": pulls_page}
if continuation is not None:
operation = gitea_proxy.global_search(
*arguments, repository, continuation
)
elif repository:
operation = gitea_proxy.global_search(*arguments, repository)
else:
operation = gitea_proxy.global_search(*arguments)
result = await asyncio.wait_for(
gitea_proxy.global_search(query, limit),
operation,
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
)
except Exception:
@@ -2689,7 +2761,10 @@ async def global_search(
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse({"query": query, **result})
scope = {"kind": kind, "state": state}
if repository:
scope["repository"] = repository
return JSONResponse({"query": query, "scope": scope, **result})
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview")
@@ -2713,6 +2788,74 @@ async def global_search_preview(
return JSONResponse(preview)
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview/conversation")
async def global_search_preview_conversation(
owner: str,
repo: str,
number: int = PathParam(gt=0),
kind: Literal["issue", "pull"] = Query(),
page: int | None = Query(default=None, ge=1, le=100),
limit: int = Query(default=20, ge=1, le=50),
) -> JSONResponse:
del kind # Issue and pull-request conversations share Gitea's issue-comments API.
try:
conversation = await asyncio.wait_for(
gitea_proxy.issue_conversation_page(
f"{owner}/{repo}", number, page, limit
),
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
)
except Exception:
return JSONResponse(
{"error": "This conversation is temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(conversation)
@app.post(
"/api/v1/repos/{owner}/{repo}/issues/{number}/preview/comments",
status_code=201,
)
async def comment_on_global_search_preview(
comment: IssueComment,
owner: str,
repo: str,
number: int = PathParam(gt=0),
kind: Literal["issue", "pull"] = Query(),
idempotency_key: str | None = Header(default=None, max_length=128),
) -> JSONResponse:
repository = f"{owner}/{repo}"
async def post_comment():
preview = await gitea_proxy.work_preview(repository, kind, number)
if (
preview.get("repository") != repository
or preview.get("kind") != kind
or preview.get("number") != number
):
raise HTTPException(status_code=404, detail="Search result not found")
return await gitea_proxy.comment_on_issue(repository, number, comment.body)
try:
result = await _run_idempotent_authored_action(
post_comment(),
idempotency_key=idempotency_key,
fingerprint=("search-preview-comment", repository, kind, number, comment.body),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "The reply could not be posted. Your draft is safe; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result, status_code=201)
@app.get("/api/v1/work-route")
async def resolve_work_route(
kind: Literal["issue", "pull", "review", "update"] = Query(),
@@ -3577,6 +3720,21 @@ async def notification_page(page: int = Query(default=1, ge=1)) -> JSONResponse:
return JSONResponse(result)
@app.get("/api/v1/notifications/snapshot")
async def notification_snapshot() -> JSONResponse:
try:
result = await gitea_proxy.unread_notification_snapshot(
deadline_seconds=NOTIFICATION_PAGE_TIMEOUT_SECONDS
)
except Exception:
return JSONResponse(
{"error": "Complete unread updates are temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.get("/api/v1/notifications/{thread_id}")
async def notification_thread_detail(
thread_id: int = PathParam(gt=0),
@@ -3604,7 +3762,7 @@ async def notification_thread_detail(
@app.get("/api/v1/notifications/{thread_id}/conversation")
async def notification_thread_conversation(
thread_id: int = PathParam(gt=0),
page: int = Query(ge=1),
page: int | None = Query(default=None, ge=1),
limit: int = Query(default=20, ge=1, le=50),
) -> JSONResponse:
try:
@@ -3670,6 +3828,43 @@ async def unread_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
return JSONResponse({"id": thread_id, "status": "unread"})
@app.post("/api/v1/notifications/{thread_id}/mute")
async def mute_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
try:
await asyncio.wait_for(
gitea_proxy.mute_notification(thread_id),
timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
)
except TimeoutError:
return JSONResponse(
{"error": "Muting future updates timed out. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
except Exception:
return JSONResponse(
{"error": "Future updates could not be muted. Please retry."},
status_code=503,
)
try:
await asyncio.wait_for(
mark_notification_read(thread_id),
timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
)
except Exception:
return JSONResponse(
{
"id": thread_id,
"muted": True,
"status": "unread",
"error": "Future updates are muted; current item is still unread. Retry mark read & next.",
},
status_code=409,
)
await _remove_notifications_from_live_snapshot([thread_id])
return JSONResponse({"id": thread_id, "muted": True, "status": "read"})
@app.patch("/api/v1/notifications/{thread_id}/later")
async def defer_notification(
payload: NotificationLaterRequest,
@@ -4385,25 +4580,66 @@ async def _edit_conversation_comment(
async def _delete_conversation_comment(
repository: str, number: int, comment_id: int
request: Request,
step_up_grant: str | None,
repository: str,
number: int,
comment_id: int,
) -> JSONResponse:
target = f"{repository}#{number}:{comment_id}"
await _require_step_up(
request,
step_up_grant,
action="delete_comment",
target=target,
)
try:
journal = _security_event_store()
operation_id = await asyncio.to_thread(
journal.reserve,
"comment_deleted",
target=target,
)
except SecurityEventStoreError:
return JSONResponse(
{"error": "Security activity is temporarily unavailable. The comment was not deleted."},
status_code=503,
headers={"Retry-After": "1"},
)
try:
result = await asyncio.wait_for(
gitea_proxy.delete_owned_comment(repository, number, comment_id),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.CommentMutationForbiddenError as exc:
try:
await asyncio.to_thread(journal.discard, operation_id)
except SecurityEventStoreError:
pass
raise HTTPException(
status_code=403, detail="You can only change your own comments"
) from exc
except HTTPException:
try:
await asyncio.to_thread(journal.discard, operation_id)
except SecurityEventStoreError:
pass
raise
except Exception:
try:
await asyncio.to_thread(journal.discard, operation_id)
except SecurityEventStoreError:
pass
return JSONResponse(
{"error": "The comment deletion could not be confirmed. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
try:
await asyncio.to_thread(journal.finalize, operation_id)
except SecurityEventStoreError:
# Upstream deletion is authoritative; pending evidence remains truthful.
pass
return JSONResponse(result)
@@ -4423,15 +4659,21 @@ async def edit_assigned_issue_comment(
@app.delete("/api/v1/repos/{owner}/{repo}/issues/{number}/comments/{comment_id}")
async def delete_assigned_issue_comment(
request: Request,
owner: str,
repo: str,
number: int = PathParam(gt=0),
comment_id: int = PathParam(gt=0),
step_up_grant: str | None = Header(
default=None, alias="X-Step-Up-Grant", max_length=128
),
):
repository = f"{owner}/{repo}"
if not await gitea_proxy.is_assigned_issue(repository, number):
raise HTTPException(status_code=404, detail="Assigned issue not found")
return await _delete_conversation_comment(repository, number, comment_id)
return await _delete_conversation_comment(
request, step_up_grant, repository, number, comment_id
)
@app.patch("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments/{comment_id}")
@@ -4450,15 +4692,21 @@ async def edit_assigned_pull_comment(
@app.delete("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments/{comment_id}")
async def delete_assigned_pull_comment(
request: Request,
owner: str,
repo: str,
number: int = PathParam(gt=0),
comment_id: int = PathParam(gt=0),
step_up_grant: str | None = Header(
default=None, alias="X-Step-Up-Grant", max_length=128
),
):
repository = f"{owner}/{repo}"
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
return await _delete_conversation_comment(repository, number, comment_id)
return await _delete_conversation_comment(
request, step_up_grant, repository, number, comment_id
)
@app.patch("/api/v1/notifications/{thread_id}/comments/{comment_id}")
@@ -4476,14 +4724,20 @@ async def edit_notification_comment(
@app.delete("/api/v1/notifications/{thread_id}/comments/{comment_id}")
async def delete_notification_comment(
request: Request,
thread_id: int = PathParam(gt=0),
comment_id: int = PathParam(gt=0),
step_up_grant: str | None = Header(
default=None, alias="X-Step-Up-Grant", max_length=128
),
):
try:
repository, number = await gitea_proxy.notification_conversation_target(thread_id)
except Exception as exc:
raise HTTPException(status_code=404, detail="Notification conversation not found") from exc
return await _delete_conversation_comment(repository, number, comment_id)
return await _delete_conversation_comment(
request, step_up_grant, repository, number, comment_id
)
@app.post("/api/v1/repos/{owner}/{repo}/issues/{number}/attachments", status_code=201)
+1 -1
View File
@@ -323,7 +323,7 @@ async def _dispatch_deadline_reminders_unlocked(
due_counts = {}
for device in eligible_devices:
local_now = current.astimezone(ZoneInfo(device.timezone))
local_cutoff = local_now.date() + timedelta(days=2)
local_cutoff = local_now.date() + timedelta(days=device.reminder_days)
due_count = sum(due_day <= local_cutoff for due_day in due_days)
if due_count:
due_counts[device.session_id] = due_count
+21 -8
View File
@@ -28,6 +28,7 @@ class DeadlineReminderDevice:
subscription: dict
timezone: str
reminder_hour: int
reminder_days: int
delivered_local_day: str | None
@@ -92,6 +93,7 @@ class PushSubscriptionStore:
enabled INTEGER NOT NULL DEFAULT 0,
timezone TEXT NOT NULL DEFAULT 'UTC',
reminder_hour INTEGER NOT NULL DEFAULT 9,
reminder_days INTEGER NOT NULL DEFAULT 2,
delivered_local_day TEXT,
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
ON DELETE CASCADE
@@ -131,6 +133,14 @@ class PushSubscriptionStore:
DROP TABLE push_dispatch_lease_legacy;
"""
)
preference_columns = {
row[1]
for row in connection.execute("PRAGMA table_info(push_deadline_preferences)")
}
if "reminder_days" not in preference_columns:
connection.execute(
"ALTER TABLE push_deadline_preferences ADD COLUMN reminder_days INTEGER NOT NULL DEFAULT 2"
)
os.chmod(self.path, 0o600)
def _connect(self):
@@ -192,24 +202,26 @@ class PushSubscriptionStore:
).fetchone() is not None
def set_deadline_preferences(
self, session_id: str, *, enabled: bool, timezone: str, reminder_hour: int
self, session_id: str, *, enabled: bool, timezone: str, reminder_hour: int,
reminder_days: int = 2,
) -> None:
with self._connect() as connection:
connection.execute(
"""INSERT INTO push_deadline_preferences(
session_id, enabled, timezone, reminder_hour
) VALUES (?, ?, ?, ?)
session_id, enabled, timezone, reminder_hour, reminder_days
) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(session_id) DO UPDATE SET
enabled = excluded.enabled,
timezone = excluded.timezone,
reminder_hour = excluded.reminder_hour""",
(session_id, int(enabled), timezone, reminder_hour),
reminder_hour = excluded.reminder_hour,
reminder_days = excluded.reminder_days""",
(session_id, int(enabled), timezone, reminder_hour, reminder_days),
)
def deadline_preferences(self, session_id: str) -> dict:
with self._connect() as connection:
row = connection.execute(
"""SELECT enabled, timezone, reminder_hour
"""SELECT enabled, timezone, reminder_hour, reminder_days
FROM push_deadline_preferences WHERE session_id = ?""",
(session_id,),
).fetchone()
@@ -217,19 +229,20 @@ class PushSubscriptionStore:
"enabled": bool(row[0]) if row else False,
"timezone": row[1] if row else "UTC",
"reminder_hour": row[2] if row else 9,
"reminder_days": row[3] if row else 2,
}
def deadline_reminder_devices(self) -> list[DeadlineReminderDevice]:
with self._connect() as connection:
rows = connection.execute(
"""SELECT s.session_id, s.subscription_json, p.timezone,
p.reminder_hour, p.delivered_local_day
p.reminder_hour, p.reminder_days, p.delivered_local_day
FROM push_subscriptions s
JOIN push_deadline_preferences p ON p.session_id = s.session_id
WHERE p.enabled = 1 ORDER BY s.session_id"""
).fetchall()
return [
DeadlineReminderDevice(row[0], json.loads(row[1]), row[2], row[3], row[4])
DeadlineReminderDevice(row[0], json.loads(row[1]), row[2], row[3], row[4], row[5])
for row in rows
]
+3
View File
@@ -8,6 +8,8 @@ from tests.dashboard_bundle import dashboard_bundle_text
def test_api_requests_resolve_inside_dashboard_subpath():
html = dashboard_bundle_text()
api_paths = re.findall(r"fetch\(['\"]([^'\"]*api/v1/[^'\"]*)['\"]", html)
commands = (Path(__file__).parents[1] / "frontend" / "commands.js").read_text()
api_paths += re.findall(r"return ['\"]([^'\"]*api/v1/[^'\"]*)['\"]", commands)
assert api_paths
assert {
@@ -18,6 +20,7 @@ def test_api_requests_resolve_inside_dashboard_subpath():
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications?page=",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/read",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/repositories/search?q=",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/search?q=",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/work/",
}
+35
View File
@@ -185,6 +185,41 @@ flow.resume().then(result=>process.stdout.write(JSON.stringify({{result,calls}})
}
def test_search_batch_uses_an_isolated_owner_journal_and_can_resume_without_dom_mounting():
script = f"""
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
const values={{}};
const calls=[];
const storage={{
getItem:key=>values[key] || null,
setItem:(key,value)=>{{values[key]=value;}},
removeItem:key=>{{delete values[key];}},
}};
const flow=createBatchFindWork({{
capacity:()=>1, owner:()=> 'timmy', storage,
journalName:'search-today-batch', autoMount:false,
claim:item=>Promise.resolve({{...item,assigned_to_me:true}}),
queue:()=>{{calls.push('queue');return 'sync-unavailable';}},
}});
flow.run([{{repository:'stackchain/dashboard',number:809}}]).then(result=>
process.stdout.write(JSON.stringify({{result,keys:Object.keys(values),calls,pending:flow.pending()}})));
"""
assert run_node(script) == {
"result": {
"status": "complete", "selected": 1, "available": 1, "queued": [],
"failed": [{
"key": "stackchain/dashboard#809",
"reason": "assigned but Today sync is unavailable",
"assigned": True,
}],
},
"keys": ["stackchain.search-today-batch.v1.timmy"],
"calls": ["queue"],
"pending": 1,
}
def test_time_budget_blocks_claims_until_every_estimate_fits():
script = f"""
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
+782 -5
View File
@@ -11,6 +11,7 @@ FRONTEND = Path(__file__).parents[1] / "frontend"
COMMANDS = FRONTEND / "commands.js"
SEARCH_PREVIEW = FRONTEND / "search-preview.js"
MOBILE_SEARCH_VIEWPORT = FRONTEND / "mobile-search-viewport.js"
SEARCH_DEFER = FRONTEND / "search-defer.js"
class ScriptSourceParser(HTMLParser):
@@ -52,6 +53,80 @@ def test_command_script_resolves_inside_dashboard_subpath():
) == "https://forge.alexanderwhitestone.com/dashboard/static/commands.js"
def test_mobile_search_renders_touch_sized_type_and_status_scope_controls():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
commands = COMMANDS.read_text()
assert '<fieldset class="cmd-search-scope"' in html
assert '<legend>Filter search results</legend>' in html
assert '<label for="cmd-search-kind">Type</label>' in html
assert '<select id="cmd-search-kind"' in html
assert '<option value="pull">Pull requests</option>' in html
assert '<label for="cmd-search-state">Status</label>' in html
assert '<select id="cmd-search-state"' in html
assert '.cmd-search-scope' in css
assert '#cmd-search-kind, #cmd-search-state { min-height:44px;' in css
assert "commandSearch.setScope(scope)" in html
assert "taskOverlayHistory.update({ scope })" in html
assert "'&kind=' + encodeURIComponent(scope.kind)" in commands
assert "'&state=' + encodeURIComponent(scope.state)" in commands
def test_mobile_search_exposes_accessible_repository_scope_and_forwards_it():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
commands = COMMANDS.read_text()
assert '<label for="cmd-search-repository">Repository</label>' in html
assert '<input id="cmd-search-repository"' in html
assert 'list="cmd-search-repositories"' in html
assert '<datalist id="cmd-search-repositories">' in html
assert "api/v1/repositories/search?q=" in html
assert "repository:qs('#cmd-search-repository').value" in html
assert "'&repository=' + encodeURIComponent(scope.repository)" in commands
assert '#cmd-search-repository' in css
def test_mobile_search_preview_exposes_a_keyboard_safe_reply_and_continue_composer():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
preview = SEARCH_PREVIEW.read_text()
assert '<section class="search-preview-reply"' in html
assert '<textarea id="search-preview-reply"' in html
assert '<button id="send-search-preview-reply"' in html
assert '<button id="send-search-preview-reply-next"' in html
assert "searchPreviewReplyOptions(fetchReviewJson" in html
assert "root.searchPreviewReplyPath(item)" in preview
assert "api.saveReplyDraft" in preview
assert "api.reply({ advance:true })" in preview
assert ".search-preview-reply textarea" in css
assert "scroll-margin-bottom:calc(190px + env(safe-area-inset-bottom))" in css
def test_remote_command_search_repository_change_aborts_and_restarts_scoped_search():
script = f"""
const filterCommands = require({json.dumps(str(COMMANDS))});
(async () => {{
const pending = [];
const controller = filterCommands.createGlobalSearchController({{
delay: 0,
search: (query, signal, page, scope) => new Promise(resolve => pending.push({{signal, scope, resolve}})),
onState() {{}},
}});
controller.setQuery('mobile');
await new Promise(resolve => setTimeout(resolve, 0));
controller.setScope({{kind:'all', state:'all', repository:'stackchain/api'}});
await new Promise(resolve => setTimeout(resolve, 0));
if (!pending[0].signal.aborted) throw new Error('repository change did not abort old request');
if (pending[1].scope.repository !== 'stackchain/api') throw new Error('repository was not forwarded');
pending[1].resolve({{items:[],has_more:false,next_page:2}});
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_remote_command_search_ignores_stale_responses():
script = f"""
const filterCommands = require({json.dumps(str(COMMANDS))});
@@ -85,6 +160,37 @@ if (ready[0].query !== 'release' || ready[0].items[0].title !== 'Current result'
)
def test_remote_command_search_scope_change_aborts_and_restarts_without_stale_results():
script = f"""
const filterCommands = require({json.dumps(str(COMMANDS))});
(async () => {{
const pending = [];
const states = [];
const controller = filterCommands.createGlobalSearchController({{
delay: 0,
search: (query, signal, page, scope) => new Promise(resolve => pending.push({{query, signal, page, scope, resolve}})),
onState: state => states.push(state),
}});
controller.setQuery('mobile');
await new Promise(resolve => setTimeout(resolve, 0));
controller.setScope({{kind:'pull', state:'open'}});
await new Promise(resolve => setTimeout(resolve, 0));
if (!pending[0].signal.aborted) throw new Error('scope change did not abort old request');
if (JSON.stringify(pending[1].scope) !== JSON.stringify({{kind:'pull', state:'open'}})) {{
throw new Error('new request did not receive scope');
}}
pending[1].resolve({{items:[{{kind:'pull',repository:'a/b',number:2}}],has_more:false,next_page:2}});
pending[0].resolve({{items:[{{kind:'issue',repository:'a/b',number:1}}],has_more:false,next_page:2}});
await new Promise(resolve => setTimeout(resolve, 0));
const ready = states.filter(state => state.status === 'ready').at(-1);
if (ready.items.length !== 1 || ready.items[0].kind !== 'pull') throw new Error('stale scope result entered list');
if (ready.scope.kind !== 'pull' || ready.scope.state !== 'open') throw new Error('scope missing from state');
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_remote_command_search_aborts_superseded_and_cleared_queries():
script = f"""
const filterCommands = require({json.dumps(str(COMMANDS))});
@@ -142,9 +248,83 @@ if (!ready || ready.items.length !== 1 || ready.partial !== true) {{
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_remote_command_search_retries_partial_continuation_without_losing_results():
script = f"""
const filterCommands = require({json.dumps(str(COMMANDS))});
(async () => {{
const requests = [];
const states = [];
const controller = filterCommands.createGlobalSearchController({{
delay: 0,
search: (query, signal, page, scope, continuation) => {{
requests.push({{page, continuation}});
if (requests.length === 1) return Promise.resolve({{
items:[{{kind:'pull',repository:'a/b',number:2}}], partial:true,
has_more:true, next_page:2, continuation:{{issues:1,pulls:null}}, failed_streams:['issue']
}});
return Promise.resolve({{
items:[{{kind:'issue',repository:'a/b',number:1}}], partial:false,
has_more:false, next_page:2, continuation:{{issues:null,pulls:null}}, failed_streams:[]
}});
}},
onState: state => states.push(state),
}});
controller.setQuery('mobile');
await new Promise(resolve => setTimeout(resolve, 0));
await new Promise(resolve => setTimeout(resolve, 0));
await controller.loadMore();
const ready = states.filter(state => state.status === 'ready').at(-1);
if (JSON.stringify(requests[1].continuation) !== JSON.stringify({{issues:1,pulls:null}})) {{
throw new Error('retry did not preserve failed stream page: ' + JSON.stringify(requests));
}}
if (ready.partial || ready.items.length !== 2 || ready.items[0].kind !== 'pull' || ready.items[1].kind !== 'issue') {{
throw new Error('recovered results were not appended: ' + JSON.stringify(ready));
}}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_remote_command_search_appends_deduplicated_pages_and_ignores_stale_load_more():
script = f"""
const filterCommands = require({json.dumps(str(COMMANDS))});
(async () => {{
const pending = new Map();
const states = [];
const controller = filterCommands.createGlobalSearchController({{
delay: 0,
search: (query, signal, page) => new Promise(resolve => pending.set(query + ':' + page, resolve)),
onState: state => states.push(state),
}});
controller.setQuery('mobile');
await new Promise(resolve => setTimeout(resolve, 0));
pending.get('mobile:1')({{ items:[{{kind:'issue',repository:'a/b',number:1}}], has_more:true, next_page:2 }});
await new Promise(resolve => setTimeout(resolve, 0));
controller.loadMore();
await new Promise(resolve => setTimeout(resolve, 0));
controller.setQuery('release');
await new Promise(resolve => setTimeout(resolve, 0));
pending.get('mobile:2')({{ items:[{{kind:'issue',repository:'a/b',number:1}},{{kind:'pull',repository:'a/b',number:2}}], has_more:false, next_page:3 }});
pending.get('release:1')({{ items:[{{kind:'issue',repository:'a/b',number:3}}], has_more:false, next_page:2 }});
await new Promise(resolve => setTimeout(resolve, 0));
const ready = states.filter(state => state.status === 'ready').at(-1);
if (ready.query !== 'release' || ready.items.length !== 1 || ready.items[0].number !== 3) {{
throw new Error('stale page entered current query: ' + JSON.stringify(states));
}}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_command_selection_wraps_for_arrow_keys():
script = f"""
const commands = require({json.dumps(str(COMMANDS))});
if (commands.searchUrl('mobile', 2, {{kind:'all',state:'open'}}, {{issues:1,pulls:3}}) !==
'api/v1/search?q=mobile&limit=10&page=2&kind=all&state=open&issues_page=1&pulls_page=3') {{
throw new Error('independent continuation query was not encoded');
}}
if (commands.nextSelection(-1, 'ArrowDown', 3) !== 0) throw new Error('should select first');
if (commands.nextSelection(2, 'ArrowDown', 3) !== 0) throw new Error('should wrap down');
if (commands.nextSelection(0, 'ArrowUp', 3) !== 2) throw new Error('should wrap up');
@@ -160,16 +340,287 @@ def test_palette_exposes_accessible_global_work_search_under_dashboard_subpath()
assert 'role="combobox"' in html
assert 'aria-controls="cmd-results"' in html
assert 'role="listbox"' in html
assert "fetch('api/v1/search?q='" in html
assert "async function searchGlobalWork(query, signal)" in html
assert "fetch(filterCommands.searchUrl(" in html
assert "async function searchGlobalWork(query, signal, page = 1, scope" in html
assert "signal," in html
assert "Some results are temporarily unavailable." in html
assert "Retry missing results" in html
assert 'id="cmd-load-more"' in html
assert urljoin(
"https://forge.alexanderwhitestone.com/dashboard/",
"api/v1/search?q=mobile",
) == "https://forge.alexanderwhitestone.com/dashboard/api/v1/search?q=mobile"
def test_search_preview_navigates_a_loaded_result_session_and_reports_position():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const items = [1, 2, 3].map(number => ({{repository:'stackchain/api', number, kind:'issue'}}));
const states = [];
const navigated = [];
const preview = createSearchPreview({{
fetchJson: item => Promise.resolve({{...item, title:'Issue ' + item.number}}),
mutate: () => Promise.resolve(),
getSession: () => ({{items, more:false}}),
loadMore: () => Promise.resolve(),
onNavigate: item => navigated.push(item.number),
onState: state => states.push(state),
}});
await preview.open(items[1]);
await preview.previous();
await preview.next();
const ready = states.filter(state => state.status === 'ready');
process.stdout.write(JSON.stringify({{
navigated,
positions:ready.map(state => state.navigation && [state.navigation.position, state.navigation.total,
state.navigation.hasPrevious, state.navigation.hasNext]),
current:ready.at(-1).detail.number,
}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"navigated": [1, 2],
"positions": [[2, 3, True, True], [1, 3, False, True], [2, 3, True, True]],
"current": 2,
}
def test_search_preview_reply_retry_keeps_draft_and_operation_until_server_confirmation():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const item = {{repository:'stackchain/api', number:42, kind:'pull'}};
const values = new Map();
const storage = {{
getItem:key => values.has(key) ? values.get(key) : null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
const calls = [];
const states = [];
let attempt = 0;
const preview = createSearchPreview({{
fetchJson: candidate => Promise.resolve({{...candidate, title:'Review reply'}}),
mutate: () => Promise.resolve(),
postReply: (candidate, body, operationId) => {{
calls.push([candidate.number, body, operationId]);
attempt += 1;
return attempt === 1 ? Promise.reject(new Error('offline')) : Promise.resolve({{
id:91, author:'timmy', body, created_at:'2026-08-14T12:30:00Z'
}});
}},
storage,
createOperationId: () => 'reply-operation-42',
onState: state => states.push(state),
}});
await preview.open(item);
preview.saveReplyDraft('Ready to merge.');
try {{ await preview.reply(); }} catch (_) {{}}
const draftAfterFailure = preview.replyDraft();
await preview.reply();
process.stdout.write(JSON.stringify({{
calls,
draftAfterFailure,
draftAfterSuccess:preview.replyDraft(),
comments:states.filter(state => state.conversation).at(-1).conversation.comments,
}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"calls": [
[42, "Ready to merge.", "reply-operation-42"],
[42, "Ready to merge.", "reply-operation-42"],
],
"draftAfterFailure": "Ready to merge.",
"draftAfterSuccess": "",
"comments": [{
"id": 91,
"author": "timmy",
"body": "Ready to merge.",
"created_at": "2026-08-14T12:30:00Z",
}],
}
def test_search_preview_reply_and_next_advances_only_after_server_confirmation():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const items = [1, 2].map(number => ({{repository:'stackchain/api', number, kind:'issue'}}));
const values = new Map();
let confirm;
const navigated = [];
const preview = createSearchPreview({{
fetchJson:item => Promise.resolve(item), mutate:() => Promise.resolve(),
postReply:() => new Promise(resolve => {{ confirm = resolve; }}),
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
createOperationId:() => 'reply-next-1',
getSession:() => ({{items,more:false}}), loadMore:() => Promise.resolve(),
onNavigate:item => navigated.push(item.number), onState:() => {{}},
}});
await preview.open(items[0]);
preview.saveReplyDraft('Done.');
const pending = preview.reply({{advance:true}});
await Promise.resolve();
const before = [...navigated];
confirm({{id:7,author:'timmy',body:'Done.'}});
await pending;
process.stdout.write(JSON.stringify({{before,after:navigated,draft:preview.replyDraft()}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"before": [],
"after": [2],
"draft": "",
}
def test_search_preview_keeps_confirmed_reply_when_earlier_conversation_load_finishes_late():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const values = new Map();
let finishConversation;
const states = [];
const preview = createSearchPreview({{
fetchJson:item => Promise.resolve({{...item,title:'Race'}}),
fetchConversation:() => new Promise(resolve => {{ finishConversation = resolve; }}),
mutate:() => Promise.resolve(),
postReply:(_item,body) => Promise.resolve({{id:9,author:'timmy',body}}),
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
createOperationId:() => 'reply-race-9', onState:state => states.push(state),
}});
await preview.open({{repository:'stackchain/api',number:9,kind:'issue'}});
preview.saveReplyDraft('Confirmed reply');
await preview.reply();
finishConversation({{comments:[{{id:3,author:'alex',body:'Earlier'}}],older_page:null}});
await Promise.resolve();
const comments = states.filter(state => state.conversation?.status === 'ready').at(-1).conversation.comments;
process.stdout.write(JSON.stringify(comments.map(comment => comment.id)));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == [3, 9]
def test_search_preview_next_loads_one_page_at_boundary_then_announces_completion():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
let session = {{items:[1, 2].map(number => ({{repository:'stackchain/api', number, kind:'issue'}})), more:true}};
let loads = 0;
const states = [];
const preview = createSearchPreview({{
fetchJson: item => Promise.resolve(item), mutate: () => Promise.resolve(),
getSession: () => session,
loadMore: async () => {{
loads += 1;
session = {{items:[...session.items, {{repository:'stackchain/api', number:3, kind:'issue'}}], more:false}};
}},
onNavigate() {{}}, onState: state => states.push(state),
}});
await preview.open(session.items[1]);
await preview.next();
await preview.next();
const final = states.at(-1);
process.stdout.write(JSON.stringify({{loads, current:final.detail.number, complete:final.complete,
navigation:final.navigation}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"loads": 1,
"current": 3,
"complete": True,
"navigation": {"position": 3, "total": 3, "hasPrevious": True, "hasNext": False},
}
def test_remote_search_load_more_is_awaitable_for_preview_boundary_navigation():
script = f"""
const commands = require({json.dumps(str(COMMANDS))});
(async () => {{
const states = [];
const controller = commands.createGlobalSearchController({{
delay:0,
search: async (_query, _signal, page) => page === 1
? {{items:[{{kind:'issue',repository:'a/b',number:1}}], has_more:true, next_page:2}}
: {{items:[{{kind:'issue',repository:'a/b',number:2}}], has_more:false, next_page:3}},
onState: state => states.push(state),
}});
controller.setQuery('mobile');
await new Promise(resolve => setTimeout(resolve, 5));
const loaded = await controller.loadMore();
process.stdout.write(JSON.stringify({{numbers:loaded.items.map(item => item.number), more:loaded.more}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {"numbers": [1, 2], "more": False}
def test_mobile_search_preview_progressively_renders_a_retryable_paged_conversation():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
preview_source = SEARCH_PREVIEW.read_text()
assert 'id="search-preview-conversation"' in html
assert 'id="search-preview-comments"' in html
assert 'id="search-preview-conversation-status"' in html
assert 'id="retry-search-preview-conversation"' in html
assert 'id="load-older-search-preview-comments"' in html
assert 'aria-live="polite"' in html
assert "fetchConversation:" in html
assert "api.retryConversation()" in preview_source
assert "api.loadOlderConversation()" in preview_source
assert "renderMarkdown(comment.body || '')" in preview_source
assert ".search-preview-conversation" in css
assert ".search-preview-comment" in css
assert "min-height:44px" in css
def test_mobile_search_preview_renders_session_navigation_and_advances_after_planning():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
assert 'id="previous-search-result"' in html
assert 'id="search-preview-position"' in html
assert 'id="next-search-result"' in html
assert 'aria-label="Search result navigation"' in html
assert ".search-preview-navigation" in css
assert ".search-preview-header button, .search-preview-actions button" in css
assert "session:[()=>commandSearchState, commandSearch, item=>taskOverlayHistory.update({preview:item})]" in html
preview_source = SEARCH_PREVIEW.read_text()
assert "api.previous()" in preview_source
assert "api.next()" in preview_source
defer_flow = html.split("if (context === 'search')", 1)[1].split(
"if (context === 'detail')", 1
)[0]
queue_flow = html.split(
"qs('#queue-search-result').addEventListener('click'", 1
)[1].split("qs('#start-search-result')", 1)[0]
assert "next()" in defer_flow
assert "next()" in queue_flow
assert "closeSearchPreview()" not in defer_flow
assert "if (outcome === 'queued') await next()" in queue_flow
def test_search_preview_ignores_stale_result_details():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
@@ -197,6 +648,85 @@ if (ready.length !== 1 || ready[0].detail.number !== 2) {{
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_search_preview_publishes_detail_before_progressive_conversation_and_ignores_stale_comments():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const pending = new Map();
const states = [];
const preview = createSearchPreview({{
fetchJson: item => Promise.resolve({{...item, title:'Issue ' + item.number}}),
fetchConversation: item => new Promise(resolve => pending.set(item.number, resolve)),
mutate: () => Promise.resolve(),
onState: state => states.push(state),
}});
await preview.open({{repository:'stackchain/api', number:1, kind:'issue'}});
const firstReady = states.find(state => state.status === 'ready' && state.detail?.number === 1);
if (!firstReady || firstReady.conversation?.status !== 'loading') throw new Error('detail waited for conversation');
await preview.open({{repository:'stackchain/api', number:2, kind:'issue'}});
pending.get(1)({{comments:[{{id:1,body:'stale'}}], page:1, older_page:null}});
pending.get(2)({{comments:[{{id:2,body:'current'}}], page:1, older_page:null}});
await new Promise(resolve => setTimeout(resolve, 0));
const final = states.at(-1);
process.stdout.write(JSON.stringify({{
number:final.detail.number,
status:final.conversation.status,
comments:final.conversation.comments.map(comment => comment.body),
}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"number": 2,
"status": "ready",
"comments": ["current"],
}
def test_search_preview_retries_failed_conversation_and_prepends_older_messages_without_duplicates():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
let calls = 0;
const states = [];
const preview = createSearchPreview({{
fetchJson: item => Promise.resolve({{...item, title:'Issue'}}),
fetchConversation: (_item, page) => {{
calls += 1;
if (calls === 1) return Promise.reject(new Error('offline'));
if (page === 1) return Promise.resolve({{comments:[{{id:1,body:'old'}},{{id:3,body:'duplicate'}}], older_page:null}});
return Promise.resolve({{comments:[{{id:3,body:'newer'}},{{id:4,body:'newest'}}], older_page:1}});
}},
mutate: () => Promise.resolve(),
onState: state => states.push(state),
}});
await preview.open({{repository:'stackchain/api', number:42, kind:'issue'}});
await new Promise(resolve => setTimeout(resolve, 0));
if (states.at(-1).conversation.status !== 'error') throw new Error('failure unavailable');
await preview.retryConversation();
await preview.loadOlderConversation();
const final = states.at(-1).conversation;
process.stdout.write(JSON.stringify({{
calls,
status:final.status,
ids:final.comments.map(comment => comment.id),
olderPage:final.olderPage,
}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"calls": 3,
"status": "ready",
"ids": [1, 3, 4],
"olderPage": None,
}
def test_search_preview_claim_is_single_flight_and_reports_confirmation():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
@@ -249,6 +779,71 @@ if (!states.some(state => state.status === 'reopened')) throw new Error('reopen
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_search_preview_shares_canonical_url_without_closing_the_preview():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const calls = [];
const states = [];
const preview = createSearchPreview({{
fetchJson: item => Promise.resolve(item),
mutate: () => Promise.resolve(),
share: async url => {{ calls.push(url); return 'shared'; }},
onState: state => states.push(state),
}});
const detail = {{ repository:'stackchain/api', number:42, kind:'issue' }};
await preview.open(detail);
const result = await preview.share('https://forge.example/dashboard/?search=release&preview=issue%3Astackchain%2Fapi%3A42');
process.stdout.write(JSON.stringify({{ result, calls, states }}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
assert payload["result"] == "shared"
assert payload["calls"] == [
"https://forge.example/dashboard/?search=release&preview=issue%3Astackchain%2Fapi%3A42"
]
assert payload["states"][-1]["status"] == "shared"
assert payload["states"][-1]["detail"]["number"] == 42
def test_search_preview_share_is_single_flight_and_cancellation_restores_ready_context():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
let calls = 0;
let rejectShare;
const states = [];
const preview = createSearchPreview({{
fetchJson: item => Promise.resolve(item), mutate: () => Promise.resolve(),
share: () => {{ calls += 1; return new Promise((_resolve, reject) => rejectShare = reject); }},
onState: state => states.push(state),
}});
await preview.open({{ repository:'stackchain/api', number:42, kind:'issue' }});
const first = preview.share('https://forge.example/dashboard/?search=x');
const second = preview.share('https://forge.example/dashboard/?search=x');
const canceled = new Error('canceled'); canceled.name = 'AbortError'; rejectShare(canceled);
await Promise.allSettled([first, second]);
const state = states.at(-1);
process.stdout.write(JSON.stringify({{ calls, same:first === second, status:state.status,
item:state.item, detail:state.detail, errorName:state.error?.name }}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"calls": 1,
"same": True,
"status": "share-canceled",
"item": {"repository": "stackchain/api", "number": 42, "kind": "issue"},
"detail": {"repository": "stackchain/api", "number": 42, "kind": "issue"},
"errorName": "AbortError",
}
def test_remote_search_selection_opens_native_preview_without_navigation():
html = dashboard_bundle_text()
@@ -261,12 +856,60 @@ def test_remote_search_selection_opens_native_preview_without_navigation():
assert "qs('#cmd-input').value = ''" not in remote_branch
def test_search_preview_share_action_uses_canonical_stackchain_url_and_keeps_context():
html = dashboard_bundle_text()
assert "share: url => createWorkRoute.share(url, navigator, navigator.clipboard)" in html
handler = html.split("qs('#share-search-result').addEventListener('click'", 1)[1].split(
"qs('#claim-search-result')", 1
)[0]
assert "canonicalSearchPreviewUrl()" in handler
assert "searchPreview.share" in handler
assert "closeSearchPreview" not in handler
assert "taskOverlayHistory.close" not in handler
def test_assigned_issue_preview_hands_off_to_existing_my_work_sheet():
html = dashboard_bundle_text()
assert "detail.claimable || (detail.assigned_to_me && detail.kind === 'issue')" in html
assert "claimButton.hidden = !(detail.claimable || detail.assigned_to_me)" in html
assert "claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me'" in html
assert "openPreviewIssueInMyWork" in html
assert "openPreviewWorkInMyWork" in html
def test_assigned_pull_preview_hands_off_to_existing_my_work_sheet_without_claiming():
html = dashboard_bundle_text()
assert "claimButton.hidden = !(detail.claimable || detail.assigned_to_me)" in html
assert "async function openPreviewWorkInMyWork(detail)" in html
handoff = html.split("async function openPreviewWorkInMyWork(detail)", 1)[1].split(
"function runCommandItem", 1
)[0]
assert "candidate.kind === detail.kind" in handoff
assert "candidate.key === detail.repository + '#' + detail.number" in handoff
click_handler = html.split(
"qs('#claim-search-result').addEventListener('click'", 1
)[1].split("qs('#start-search-result')", 1)[0]
assert "if (claimed.claimable) await searchPreview.claim(claimed)" in click_handler
assert "await openPreviewWorkInMyWork(claimed)" in click_handler
def test_requested_pull_preview_opens_native_review_without_assignment_mutation():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
assert "startButton.hidden = !(detail.reviewable" in html
assert "detail.reviewable ? 'Review now'" in html
handler = html.split(
"qs('#start-search-result').addEventListener('click'", 1
)[1].split("qs('#close-whiteboard')", 1)[0]
assert "detail.kind = 'review'" in handler
assert "openRoutedWork" in handler
assert "{ replace:true }" in handler
assert "searchPreview.claim" not in handler
assert "fetchReviewJson" not in handler
assert ".search-preview-actions button, .search-preview-actions a { min-height:44px;" in css
def test_search_preview_offers_assign_and_start_for_eligible_issues():
@@ -281,10 +924,144 @@ def test_search_preview_offers_assign_and_start_for_eligible_issues():
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css
def test_search_preview_queues_eligible_issue_and_continues_preserved_search():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
assert 'id="queue-search-result"' in html
assert "queueButton.hidden = !(detail.kind === 'issue'" in html
assert "detail.assigned_to_me ? 'Add to Today' : 'Assign & add to Today'" in html
orchestrator = html.split("function createSearchStart(claim)", 1)[1].split(
"const searchAssignAndStart", 1
)[0]
assert "queue: confirmed => queueToday(acceptClaimedIssue(confirmed))" in orchestrator
handler = html.split("qs('#queue-search-result').addEventListener('click'", 1)[1].split(
"qs('#start-search-result')", 1
)[0]
assert "destination: 'queue'" in handler
assert "alreadyOwned: detail.assigned_to_me" in handler
assert "next()" in handler
assert "closeSearchPreview()" not in handler
assert "mobileSearchViewport.restoreScroll()" in html
assert "@media (max-width:420px)" in css
assert ".search-preview-primary-actions { grid-template-columns:1fr; }" in css
def test_search_queue_confirmation_is_accessible_and_existing_today_item_is_not_readded():
html = dashboard_bundle_text()
assert 'id="cmd-search-action-status"' in html
assert 'aria-live="polite"' in html
orchestrator = html.split("function createSearchStart(claim)", 1)[1].split(
"const searchAssignAndStart", 1
)[0]
assert "qs('#cmd-search-action-status').textContent = message" in orchestrator
handler = html.split("qs('#queue-search-result').addEventListener('click'", 1)[1].split(
"qs('#start-search-result')", 1
)[0]
assert "todayWork.contains(detail)" in handler
assert "Already in Today." in handler
assert handler.index("todayWork.contains(detail)") < handler.index("searchAssignAndStart.run")
def test_search_defer_claims_once_then_schedules_the_canonical_issue_without_touching_today():
script = f"""
const createSearchDefer = require({json.dumps(str(SEARCH_DEFER))});
(async () => {{
const calls = [];
let releaseClaim;
const flow = createSearchDefer({{
claim: detail => {{
calls.push(['claim', detail.number]);
return new Promise(resolve => releaseClaim = resolve);
}},
accept: confirmed => {{
calls.push(['accept', confirmed.key]);
return {{kind:'issue', repository:confirmed.repository, number:confirmed.number, key:confirmed.key}};
}},
defer: (item, until) => {{ calls.push(['defer', item.key, until.toISOString()]); return 'deferred'; }},
refresh: () => calls.push(['refresh']),
announce: message => calls.push(['announce', message]),
formatTime: value => value.toISOString(),
}});
const detail = {{kind:'issue', repository:'stackchain/api', number:42, claimable:true}};
const until = new Date('2026-08-15T09:00:00.000Z');
const first = flow.run(detail, until);
const second = flow.run(detail, until);
if (first !== second) throw new Error('confirmation was not single-flight');
releaseClaim({{kind:'issue', repository:'stackchain/api', number:42, key:'stackchain/api#42'}});
const outcome = await first;
process.stdout.write(JSON.stringify({{outcome, calls}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"outcome": "deferred",
"calls": [
["claim", 42],
["accept", "stackchain/api#42"],
["defer", "stackchain/api#42", "2026-08-15T09:00:00.000Z"],
["refresh"],
["announce", "Assigned and deferred until 2026-08-15T09:00:00.000Z."],
],
}
def test_search_preview_opens_later_picker_before_claiming_an_eligible_issue():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
assert 'id="defer-search-result"' in html
assert "const deferButton = qs('#defer-search-result')" in html
assert "deferButton.hidden = !(detail.kind === 'issue'" in html
assert "detail.assigned_to_me ? 'Defer' : 'Assign & defer'" in html
handler = html.split("qs('#defer-search-result').addEventListener('click'", 1)[1].split(
"qs('#queue-search-result')", 1
)[0]
assert "laterPicker.open(detail, event.currentTarget, 'search')" in handler
assert "searchPreview.claim" not in handler
assert ".search-preview-actions button, .search-preview-actions a { min-height:44px;" in css
assert "@media (max-width:420px)" in css
assert ".search-preview-primary-actions { grid-template-columns:1fr; }" in css
def test_search_defer_reports_recoverable_partial_outcome_when_later_storage_fails_after_claim():
script = f"""
const createSearchDefer = require({json.dumps(str(SEARCH_DEFER))});
(async () => {{
const messages = [];
let refreshes = 0;
const flow = createSearchDefer({{
claim: () => Promise.resolve({{kind:'issue', repository:'stackchain/api', number:42}}),
accept: issue => issue,
defer: () => 'unavailable',
refresh: () => refreshes += 1,
announce: message => messages.push(message),
formatTime: value => value.toISOString(),
}});
const outcome = await flow.run({{kind:'issue', repository:'stackchain/api', number:42, claimable:true}},
new Date('2026-08-15T09:00:00.000Z'));
process.stdout.write(JSON.stringify({{outcome, refreshes, messages}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"outcome": "unavailable",
"refreshes": 1,
"messages": [
"Assignment succeeded, but Later could not be saved. Open this issue in My Work to retry."
],
}
def test_closed_issue_preview_reopens_then_resumes_through_capacity_guard():
html = dashboard_bundle_text()
assert "startButton.textContent = detail.reopenable ? 'Reopen & resume'" in html
assert "detail.reopenable ? 'Reopen & resume'" in html
assert "mutate: (detail, action) => fetchReviewJson(" in html
handler = html.split("qs('#start-search-result').addEventListener('click'", 1)[1].split(
"qs('#close-whiteboard')", 1
+1 -1
View File
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v99" in worker
assert "stackchain-dashboard-shell-v101" in worker
+101
View File
@@ -876,6 +876,107 @@ async def test_anonymous_share_target_redirect_preserves_only_bounded_capture_fi
assert response.headers["cache-control"] == "no-store"
@pytest.mark.anyio
async def test_anonymous_app_shortcut_preserves_only_a_known_launch_action(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
valid = await client.get("/", params={"launch": "agenda", "next": "https://evil.example"})
invalid = await client.get("/", params={"launch": "delete-account"})
assert valid.status_code == 303
assert parse_qs(urlsplit(valid.headers["location"]).query) == {
"continue": ["./?launch=agenda"]
}
assert invalid.status_code == 303
assert invalid.headers["location"] == "login"
@pytest.mark.anyio
async def test_anonymous_search_preview_preserves_only_bounded_canonical_continuation(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
valid = await client.get(
"/", params={"search": "release blocker", "preview": "issue:stackchain/api:42"}
)
malformed = await client.get(
"/", params={"search": "release blocker", "preview": "issue:stackchain/api:0"}
)
duplicate = await client.get(
"/?search=release&search=other&preview=issue%3Astackchain%2Fapi%3A42"
)
oversized = await client.get(
"/", params={"search": "release", "preview": f"issue:{'x' * 201}/api:42"}
)
assert parse_qs(urlsplit(valid.headers["location"]).query) == {
"continue": [
"./?search=release+blocker&preview=issue%3Astackchain%2Fapi%3A42"
]
}
assert malformed.headers["location"] == "login"
assert duplicate.headers["location"] == "login"
assert oversized.headers["location"] == "login"
@pytest.mark.anyio
async def test_anonymous_search_preview_preserves_only_valid_search_scope(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
valid = await client.get("/", params={
"search": "release blocker", "preview": "pull:stackchain/api:42",
"search_kind": "pull", "search_state": "open",
"search_repository": "stackchain/api",
})
invalid = await client.get("/", params={
"search": "release blocker", "preview": "pull:stackchain/api:42",
"search_kind": "script", "search_state": "secret",
})
valid_continue = parse_qs(urlsplit(valid.headers["location"]).query)["continue"][0]
invalid_continue = parse_qs(urlsplit(invalid.headers["location"]).query)["continue"][0]
assert valid_continue.endswith(
"search=release+blocker&preview=pull%3Astackchain%2Fapi%3A42&"
"search_kind=pull&search_state=open&search_repository=stackchain%2Fapi"
)
assert invalid_continue == (
"./?search=release+blocker&preview=pull%3Astackchain%2Fapi%3A42"
)
@pytest.mark.anyio
async def test_anonymous_shared_screenshot_preserves_bounded_sign_in_continuation(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
valid = await client.get(
"/",
params={
"launch": "new",
"shared": "image",
"title": "Broken checkout",
"text": "Steps to reproduce",
},
)
wrong_launch = await client.get(
"/", params={"launch": "agenda", "shared": "image"}
)
wrong_marker = await client.get(
"/", params={"launch": "new", "shared": "document"}
)
duplicate_marker = await client.get(
"/?launch=new&shared=image&shared=image"
)
assert valid.status_code == 303
assert parse_qs(urlsplit(valid.headers["location"]).query) == {
"continue": [
"./?title=Broken+checkout&text=Steps+to+reproduce&launch=new&shared=image"
]
}
assert wrong_launch.headers["location"] == "login"
assert wrong_marker.headers["location"] == "login"
assert duplicate_marker.headers["location"] == "login"
@pytest.mark.anyio
async def test_oversized_share_target_is_not_carried_through_login(access_control):
transport = httpx.ASGITransport(app=main.app)
+6
View File
@@ -777,6 +777,12 @@ def test_security_activity_explains_pending_outcome_confirmation():
assert "event.status === 'pending'" in source
def test_security_activity_labels_permanent_comment_deletion():
source = SECURITY_CENTER_JS.read_text()
assert "comment_deleted: 'Comment deleted'" in source
def test_security_activity_labels_passkey_enrollment_without_html_rendering():
source = SECURITY_CENTER_JS.read_text()
+143 -4
View File
@@ -85,6 +85,48 @@ async def test_deadline_reminder_counts_calendar_days_per_device_timezone(tmp_pa
assert sent == {"tokyo": 2, "la": 1}
@pytest.mark.anyio
async def test_deadline_reminder_uses_each_devices_confirmed_horizon(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
for device, reminder_days in (("today", 0), ("week", 7)):
store.upsert(device, {
"endpoint": f"https://push.example/{device}",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
store.set_deadline_preferences(
device,
enabled=True,
timezone="UTC",
reminder_hour=9,
reminder_days=reminder_days,
)
async def assigned():
return {"complete": True, "items": [
{"id": 1, "due_date": "2026-08-13"},
{"id": 2, "due_date": "2026-08-15"},
{"id": 3, "due_date": "2026-08-20"},
{"id": 4, "due_date": "2026-08-21"},
]}
sent = {}
async def send(subscription, payload):
device = subscription["endpoint"].rsplit("/", 1)[-1]
sent[device] = json.loads(payload)["deadline_count"]
delivered = await dispatch_deadline_reminders(
store,
PushConfiguration("public", "private", "mailto:ops@example.com"),
assigned,
send,
now=datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc),
)
assert delivered == 2
assert sent == {"today": 1, "week": 3}
@pytest.mark.anyio
async def test_deadline_reminder_fails_closed_for_incomplete_snapshot_and_before_local_hour(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
@@ -194,29 +236,126 @@ def test_deadline_preferences_persist_on_the_existing_device_subscription(tmp_pa
"enabled": True,
"timezone": "Europe/London",
"reminder_hour": 8,
"reminder_days": 2,
}
def test_existing_deadline_preferences_migrate_to_two_day_horizon(tmp_path):
database = tmp_path / "push.sqlite3"
import sqlite3
with sqlite3.connect(database) as connection:
connection.executescript("""
CREATE TABLE push_subscriptions (
session_id TEXT PRIMARY KEY,
endpoint TEXT NOT NULL UNIQUE,
subscription_json TEXT NOT NULL
);
CREATE TABLE push_deadline_preferences (
session_id TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 0,
timezone TEXT NOT NULL DEFAULT 'UTC',
reminder_hour INTEGER NOT NULL DEFAULT 9,
delivered_local_day TEXT
);
INSERT INTO push_subscriptions VALUES ('device-a', 'https://push.example/a', '{}');
INSERT INTO push_deadline_preferences VALUES ('device-a', 1, 'UTC', 8, NULL);
""")
store = PushSubscriptionStore(database)
assert store.deadline_preferences("device-a")["reminder_days"] == 2
@pytest.mark.anyio
async def test_assigned_deadline_snapshot_is_pagination_complete(monkeypatch):
pages = {
1: {"items": [{"id": 1}], "has_more": True},
2: {"items": [{"id": 2}], "has_more": False},
1: {"items": [{"id": 1}], "total": 2, "has_more": True},
2: {"items": [{"id": 2}], "total": 2, "has_more": False},
}
async def work_page(stream, page, limit):
assert stream == "issue"
assert limit == 50
assert limit == 1
return pages[page]
monkeypatch.setattr(gitea_proxy, "work_page", work_page)
assert await gitea_proxy.assigned_issue_snapshot() == {
assert await gitea_proxy.assigned_issue_snapshot(limit=1) == {
"items": [{"id": 1}, {"id": 2}],
"complete": True,
}
@pytest.mark.anyio
async def test_assigned_deadline_snapshot_fetches_remaining_pages_concurrently(monkeypatch):
active = 0
peak = 0
remaining_started = asyncio.Event()
async def work_page(stream, page, limit):
nonlocal active, peak
assert stream == "issue"
assert limit == 1
if page == 1:
return {"items": [{"id": 1}], "total": 5, "has_more": True}
active += 1
peak = max(peak, active)
if active == 2:
remaining_started.set()
await asyncio.wait_for(remaining_started.wait(), timeout=0.2)
await asyncio.sleep(0)
active -= 1
return {"items": [{"id": page}], "total": 5, "has_more": page < 5}
monkeypatch.setattr(gitea_proxy, "work_page", work_page)
assert await gitea_proxy.assigned_issue_snapshot(limit=1, max_concurrency=2) == {
"items": [{"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}, {"id": 5}],
"complete": True,
}
assert peak == 2
@pytest.mark.anyio
@pytest.mark.parametrize(
"remaining",
[
{"items": [{"id": 2}], "total": 3, "has_more": True},
{"items": [{"id": 1}], "total": 2, "has_more": False},
],
)
async def test_assigned_deadline_snapshot_rejects_changed_or_duplicate_pages(
monkeypatch, remaining
):
async def work_page(_stream, page, _limit):
if page == 1:
return {"items": [{"id": 1}], "total": 2, "has_more": True}
return remaining
monkeypatch.setattr(gitea_proxy, "work_page", work_page)
with pytest.raises(ValueError, match="incomplete|changed"):
await gitea_proxy.assigned_issue_snapshot(limit=1)
@pytest.mark.anyio
async def test_assigned_deadline_snapshot_enforces_aggregate_deadline(monkeypatch):
cancelled = asyncio.Event()
async def work_page(_stream, _page, _limit):
try:
await asyncio.sleep(60)
finally:
cancelled.set()
monkeypatch.setattr(gitea_proxy, "work_page", work_page)
with pytest.raises(TimeoutError):
await gitea_proxy.assigned_issue_snapshot(deadline_seconds=0.01)
assert cancelled.is_set()
@pytest.mark.anyio
async def test_competing_workers_send_one_deadline_digest(tmp_path):
path = tmp_path / "push.sqlite3"
+9 -3
View File
@@ -66,10 +66,16 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
security_center = first.feature_bundles["security-center"]
assert b"function attachSecurityCenter" not in first.runtime_bytes
assert b"function attachSecurityCenter" in security_center.runtime_bytes
assert b"function createGlobalSearchController" not in first.runtime_bytes
assert b"function createGlobalSearchController" in first.feature_bundles["today-timer"].runtime_bytes
assert b"function createAssignAndStart" not in first.runtime_bytes
assert b"function createAssignAndStart" in first.feature_bundles["today-timer"].runtime_bytes
assert b"function createQueueToday" not in first.runtime_bytes
assert b"function createQueueToday" in first.feature_bundles["today-timer"].runtime_bytes
assert b"gitea_time_logged" not in first.runtime_bytes
assert b"gitea_time_logged" in security_center.runtime_bytes
# The recap adds only startup wiring; its UI remains in the lazy Today bundle.
assert len(first.runtime_gzip_bytes) <= 96 * 1024
# Core mobile workflows stay below 98 KiB gzip, including transaction-safe Update decisions.
assert len(first.runtime_gzip_bytes) <= 98 * 1024
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
@@ -187,7 +193,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path):
worker = changed_frontend / "service-worker.js"
worker.write_text(
worker.read_text().replace(
"const CACHE = 'stackchain-dashboard-shell-v99';",
"const CACHE = 'stackchain-dashboard-shell-v100';",
"const CACHE = 'stackchain-dashboard-shell-v999';",
)
)
+39 -13
View File
@@ -254,6 +254,8 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
"number": 7, "state": "open", "assignees": [],
"body": "Deploy fails after **three** retries.",
})
if request.url.path.endswith("/issues/7/subscriptions/check"):
return httpx.Response(200, json={"subscribed": True, "ignored": False})
if request.url.path.endswith("/issues/comments/9"):
return httpx.Response(200, json={
"id": 9,
@@ -282,7 +284,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
"http://127.0.0.1:3000/api/v1/notifications/threads/42",
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7",
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/comments/9",
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/comments?limit=20&page=1",
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/subscriptions/check",
]
assert result == {
"id": 42,
@@ -300,18 +302,8 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
},
"issue": {"number": 7, "assignees": [], "claimable": True},
"acknowledge_supported": True,
"conversation": {
"comments": [{
"id": 9,
"author": "alexander",
"body": "Logs point to the worker timeout.",
"created_at": "2026-08-06T12:30:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
}],
"page": 1,
"older_page": None,
"total": 1,
},
"mute_supported": True,
"conversation_available": True,
}
@@ -398,3 +390,37 @@ async def test_reply_to_notification_posts_to_its_issue_conversation(subject_kin
"created_at": "2026-08-07T19:00:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
}
@pytest.mark.anyio
async def test_mute_notification_unsubscribes_current_user_from_trusted_thread_target():
requests = []
def upstream(request):
requests.append((request.method, request.url.path))
if request.url.path.endswith("/notifications/threads/42"):
return httpx.Response(200, json={
"repository": {"full_name": "stackchain/api"},
"subject": {
"type": "Pull",
"url": "http://127.0.0.1:3000/api/v1/repos/stackchain/api/pulls/7",
},
})
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
if request.method == "DELETE":
return httpx.Response(204)
return httpx.Response(404)
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
result = await gitea_proxy.mute_notification(42)
finally:
await gitea_proxy.stop_client()
assert result == {"id": 42, "repository": "stackchain/api", "number": 7, "muted": True}
assert requests == [
("GET", "/api/v1/notifications/threads/42"),
("GET", "/api/v1/user"),
("DELETE", "/api/v1/repos/stackchain/api/issues/7/subscriptions/timmy"),
]
+413 -6
View File
@@ -6,12 +6,146 @@ import pytest
from src import gitea_proxy, main
@pytest.mark.anyio
async def test_global_search_endpoint_scopes_every_page_to_an_exact_repository(monkeypatch):
requested = []
async def search(query, limit, page, kind, state, repository):
requested.append((query, limit, page, kind, state, repository))
return {"items": [], "partial": False, "has_more": False, "next_page": 4}
monkeypatch.setattr(main.gitea_proxy, "global_search", search, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/search?q=mobile&limit=7&page=3&kind=pull&state=open&repository=stackchain%2Fapi"
)
assert response.status_code == 200
assert requested == [("mobile", 7, 3, "pull", "open", "stackchain/api")]
assert response.json()["scope"] == {
"kind": "pull", "state": "open", "repository": "stackchain/api"
}
@pytest.mark.anyio
async def test_global_search_endpoint_forwards_independent_stream_continuation(monkeypatch):
requested = []
async def search(query, limit, page, kind, state, repository, continuation):
requested.append((query, limit, page, kind, state, repository, continuation))
return {
"items": [], "partial": False, "has_more": False, "next_page": 3,
"continuation": {"issues": None, "pulls": None}, "failed_streams": [],
}
monkeypatch.setattr(main.gitea_proxy, "global_search", search, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/search?q=mobile&limit=7&page=2&issues_page=1&pulls_page=3"
)
assert response.status_code == 200
assert requested == [
("mobile", 7, 2, "all", "all", None, {"issues": 1, "pulls": 3})
]
@pytest.mark.anyio
async def test_global_search_rejects_malformed_repository_scope_before_upstream(monkeypatch):
called = False
async def search(*args):
nonlocal called
called = True
monkeypatch.setattr(main.gitea_proxy, "global_search", search, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/search?q=mobile&repository=stackchain%2Fapi%2Fsecrets"
)
assert response.status_code == 422
assert called is False
@pytest.mark.anyio
async def test_global_search_repository_scope_reaches_issue_and_pull_streams():
requests = []
async def handler(request):
requests.append(dict(request.url.params))
return httpx.Response(200, json=[])
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
await gitea_proxy.global_search(
"mobile", limit=6, page=2, repository="stackchain/api"
)
finally:
await gitea_proxy.stop_client()
assert len(requests) == 2
assert all(request["owner"] == "stackchain" and request["repo"] == "api" for request in requests)
assert all(request["page"] == "2" for request in requests)
@pytest.mark.anyio
async def test_global_search_endpoint_forwards_valid_type_and_status_scope(monkeypatch):
requested = []
async def search(query, limit, page, kind, state):
requested.append((query, limit, page, kind, state))
return {"items": [], "partial": False, "has_more": False, "next_page": 2}
monkeypatch.setattr(main.gitea_proxy, "global_search", search, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/search?q=mobile&limit=7&page=1&kind=pull&state=open"
)
assert response.status_code == 200
assert requested == [("mobile", 7, 1, "pull", "open")]
assert response.json()["scope"] == {"kind": "pull", "state": "open"}
@pytest.mark.anyio
async def test_global_search_specific_type_uses_one_full_width_scoped_stream():
requests = []
async def handler(request):
requests.append(dict(request.url.params))
return httpx.Response(200, json=[{
"number": 9,
"title": "Review mobile search",
"state": "open",
"repository": {"full_name": "stackchain/web"},
"html_url": "https://forge.example/stackchain/web/pulls/9",
}])
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.global_search(
"mobile", limit=7, page=2, kind="pull", state="open"
)
finally:
await gitea_proxy.stop_client()
assert requests == [{
"q": "mobile", "type": "pulls", "state": "open", "limit": "7", "page": "2"
}]
assert [item["kind"] for item in result["items"]] == ["pull"]
@pytest.mark.anyio
async def test_global_search_endpoint_returns_bounded_normalized_results(monkeypatch):
requested = []
async def search(query, limit):
requested.append((query, limit))
async def search(query, limit, page, kind, state):
requested.append((query, limit, page, kind, state))
return {
"items": [{
"kind": "issue",
@@ -27,12 +161,12 @@ async def test_global_search_endpoint_returns_bounded_normalized_results(monkeyp
monkeypatch.setattr(main.gitea_proxy, "global_search", search, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/search?q=mobile&limit=7")
response = await client.get("/api/v1/search?q=mobile&limit=7&page=2")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert requested == [("mobile", 7)]
assert response.json() == {"query": "mobile", "items": [{
assert requested == [("mobile", 7, 2, "all", "all")]
assert response.json() == {"query": "mobile", "scope": {"kind": "all", "state": "all"}, "items": [{
"kind": "issue",
"repository": "stackchain/api",
"number": 42,
@@ -76,6 +210,121 @@ async def test_global_search_preview_returns_normalized_action_context(monkeypat
assert response.json()["claimable"] is True
@pytest.mark.anyio
@pytest.mark.parametrize("kind", ["issue", "pull"])
async def test_global_search_preview_conversation_returns_a_bounded_authorized_page(
monkeypatch, kind
):
requested = []
async def conversation(repository, number, page, limit):
requested.append((repository, number, page, limit))
return {
"comments": [{
"id": 7,
"author": "alexander",
"body": "Current **decision**",
"created_at": "2026-08-14T12:00:00Z",
"url": "https://forge.example/stackchain/api/issues/42#issuecomment-7",
}],
"page": 3,
"older_page": 2,
"total": 41,
}
monkeypatch.setattr(
main.gitea_proxy, "issue_conversation_page", conversation, raising=False
)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
f"/api/v1/repos/stackchain/api/issues/42/preview/conversation?kind={kind}&limit=20"
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()["comments"][0]["body"] == "Current **decision**"
assert requested == [("stackchain/api", 42, None, 20)]
@pytest.mark.anyio
async def test_global_search_preview_reply_is_idempotent_for_an_authorized_pull(monkeypatch):
main._idempotency_ledger.clear()
previews = []
comments = []
async def preview(repository, kind, number):
previews.append((repository, kind, number))
return {
"repository": repository,
"kind": "pull",
"number": number,
"title": "Review mobile reply",
"state": "open",
}
async def comment(repository, number, body):
comments.append((repository, number, body))
return {
"id": 91,
"author": "timmy",
"body": body,
"created_at": "2026-08-14T12:30:00Z",
"url": "https://forge.example/stackchain/api/pulls/42#issuecomment-91",
}
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "search-reply-pull-42"}
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
first = await client.post(
"/api/v1/repos/stackchain/api/issues/42/preview/comments?kind=pull",
json={"body": "Ready to merge."},
headers=headers,
)
replay = await client.post(
"/api/v1/repos/stackchain/api/issues/42/preview/comments?kind=pull",
json={"body": "Ready to merge."},
headers=headers,
)
assert first.status_code == replay.status_code == 201
assert replay.json() == first.json()
assert previews == [("stackchain/api", "pull", 42)]
assert comments == [("stackchain/api", 42, "Ready to merge.")]
@pytest.mark.anyio
async def test_global_search_preview_reply_rejects_a_mismatched_target_before_commenting(monkeypatch):
main._idempotency_ledger.clear()
comments = []
async def preview(repository, kind, number):
return {
"repository": repository,
"kind": "issue",
"number": number,
"title": "Different target",
}
async def comment(*args):
comments.append(args)
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/issues/42/preview/comments?kind=pull",
json={"body": "Do not misroute this."},
headers={"Idempotency-Key": "search-reply-mismatch-42"},
)
assert response.status_code == 404
assert comments == []
@pytest.mark.anyio
async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results():
requests = []
@@ -110,7 +359,11 @@ async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results()
await gitea_proxy.stop_client()
assert {request["type"] for request in requests} == {"issues", "pulls"}
assert all(request["q"] == "mobile queue" and request["limit"] == "7" for request in requests)
assert {request["type"]: request["limit"] for request in requests} == {
"issues": "4", "pulls": "3"
}
assert all(request["q"] == "mobile queue" and request["state"] == "all"
for request in requests)
assert results == {
"items": [{
"kind": "issue", "repository": "stackchain/api", "number": 42,
@@ -122,6 +375,10 @@ async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results()
"url": "https://forge.example/stackchain/web/pulls/9",
}],
"partial": False,
"has_more": False,
"next_page": 2,
"continuation": {"issues": None, "pulls": None},
"failed_streams": [],
}
@@ -154,9 +411,93 @@ async def test_global_search_returns_healthy_stream_when_other_stream_fails():
"url": "https://forge.example/stackchain/web/pulls/9",
}],
"partial": True,
"has_more": True,
"next_page": 2,
"continuation": {"issues": 1, "pulls": None},
"failed_streams": ["issue"],
}
@pytest.mark.anyio
async def test_global_search_retries_a_failed_stream_without_advancing_its_page():
requests = []
issue_attempts = 0
async def handler(request):
nonlocal issue_attempts
params = dict(request.url.params)
requests.append((params["type"], params["page"], params["limit"]))
if params["type"] == "issues":
issue_attempts += 1
if issue_attempts == 1:
return httpx.Response(503)
number = int(params["page"])
return httpx.Response(200, json=[{
"number": number,
"title": f'{params["type"]} {number}',
"state": "open",
"repository": {"full_name": "stackchain/web"},
"html_url": f'https://forge.example/stackchain/web/{params["type"]}/{number}',
}])
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
partial = await gitea_proxy.global_search("search", limit=4)
recovered = await gitea_proxy.global_search(
"search", limit=4, continuation=partial["continuation"]
)
finally:
await gitea_proxy.stop_client()
assert partial["partial"] is True
assert partial["has_more"] is True
assert partial["continuation"] == {"issues": 1, "pulls": None}
assert recovered["partial"] is False
assert [item["kind"] for item in recovered["items"]] == ["issue"]
assert requests == [
("issues", "1", "2"), ("pulls", "1", "2"), ("issues", "1", "2")
]
@pytest.mark.anyio
async def test_global_search_odd_limit_keeps_both_streams_contiguous_across_pages():
requests = []
async def handler(request):
params = dict(request.url.params)
stream_limit = int(params["limit"])
page = int(params["page"])
requests.append((params["type"], page, stream_limit))
start = (page - 1) * stream_limit + 1
return httpx.Response(200, json=[{
"number": number,
"title": f'{params["type"]} {number}',
"state": "open",
"repository": {"full_name": "stackchain/web"},
"html_url": f'https://forge.example/stackchain/web/{params["type"]}/{number}',
} for number in range(start, start + stream_limit)])
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
first = await gitea_proxy.global_search("search", limit=7)
second = await gitea_proxy.global_search(
"search", limit=7, continuation=first["continuation"]
)
finally:
await gitea_proxy.stop_client()
pulls = [item["number"] for result in (first, second)
for item in result["items"] if item["kind"] == "pull"]
issues = [item["number"] for result in (first, second)
for item in result["items"] if item["kind"] == "issue"]
assert pulls == [1, 2, 3, 4, 5, 6]
assert issues == [1, 2, 3, 4, 5, 6, 7, 8]
assert requests == [
("issues", 1, 4), ("pulls", 1, 3),
("issues", 2, 4), ("pulls", 2, 3),
]
@pytest.mark.anyio
async def test_global_search_caps_combined_results_to_requested_limit():
async def handler(request):
@@ -180,6 +521,38 @@ async def test_global_search_caps_combined_results_to_requested_limit():
assert result["partial"] is False
@pytest.mark.anyio
async def test_global_search_pages_each_stream_and_balances_results():
requests = []
async def handler(request):
params = dict(request.url.params)
requests.append(params)
kind = params["type"]
page = int(params["page"])
items = [{
"number": page * 10 + number,
"title": f"{kind} page {page} result {number}",
"state": "open",
"repository": {"full_name": "stackchain/web"},
"html_url": f"https://forge.example/stackchain/web/{kind}/{page * 10 + number}",
} for number in (1, 2, 3, 4)]
return httpx.Response(200, json=items)
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.global_search("search", limit=4, page=2)
finally:
await gitea_proxy.stop_client()
assert {(request["type"], request["page"]) for request in requests} == {
("issues", "2"), ("pulls", "2")
}
assert [item["kind"] for item in result["items"]] == ["issue", "pull", "issue", "pull"]
assert result["next_page"] == 3
assert result["has_more"] is True
@pytest.mark.anyio
async def test_global_search_propagates_cancellation_to_stop_obsolete_work():
async def handler(request):
@@ -231,6 +604,8 @@ async def test_work_preview_normalizes_details_and_only_allows_unassigned_open_i
"claimable": True,
"reopenable": False,
"assigned_to_me": False,
"reviewable": False,
"commentable": True,
}
@@ -258,6 +633,38 @@ async def test_work_preview_derives_pull_kind_and_never_offers_issue_claim():
assert preview["claimable"] is False
@pytest.mark.anyio
async def test_work_preview_marks_authoritative_requested_pull_review_actionable():
requested_paths = []
async def handler(request):
requested_paths.append(request.url.path)
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
if request.url.path.endswith("/pulls/9"):
return httpx.Response(200, json={
"state": "open",
"requested_reviewers": [{"login": "timmy"}, None],
})
return httpx.Response(200, json={
"number": 9,
"title": "Improve search",
"state": "open",
"html_url": "https://forge.example/stackchain/web/pulls/9",
"pull_request": {"merged": False},
"assignees": [],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
preview = await gitea_proxy.work_preview("stackchain/web", "issue", 9)
finally:
await gitea_proxy.stop_client()
assert preview["reviewable"] is True
assert "/api/v1/repos/stackchain/web/pulls/9" in requested_paths
@pytest.mark.anyio
async def test_work_preview_offers_reopen_only_for_closed_issues():
async def handler(request):
+1 -1
View File
@@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v99" in source
assert "stackchain-dashboard-shell-v101" in source
assert "BASE + 'static/later-sync.js'" in source
+101
View File
@@ -0,0 +1,101 @@
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).parents[1]
STATUS = ROOT / "frontend" / "live-data-status.js"
HTML = ROOT / "frontend" / "index.html"
CSS = ROOT / "frontend" / "dashboard.css"
DASHBOARD = ROOT / "frontend" / "dashboard.js"
def run_node(script: str) -> dict:
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
return json.loads(result.stdout)
def test_live_data_status_summarizes_each_feed_without_claiming_live():
script = f"""
const status = require({json.dumps(str(STATUS))});
const healthy = {{fresh_for_seconds:8, sections:{{
context:{{age_seconds:1}}, notifications:{{age_seconds:2}}, events:{{age_seconds:3}}
}}}};
const oneDelayed = {{fresh_for_seconds:8, retry_in_seconds:30, sections:{{
context:{{age_seconds:1}}, notifications:{{age_seconds:14, stale:true, retry_in_seconds:30}}, events:{{age_seconds:3}}
}}}};
const twoDelayed = {{fresh_for_seconds:8, sections:{{
context:{{age_seconds:12, degraded:true}}, notifications:{{age_seconds:14, stale:true}}, events:{{age_seconds:3}}
}}}};
process.stdout.write(JSON.stringify({{
healthy:status.describe(healthy),
one:status.describe(oneDelayed),
two:status.describe(twoDelayed),
unavailable:status.describe({{}}),
}}));
"""
result = run_node(script)
assert result["healthy"]["summary"] == "Live"
assert [feed["state"] for feed in result["healthy"]["feeds"]] == ["live"] * 3
assert result["one"]["summary"] == "Updates delayed"
assert result["one"]["nextRetrySeconds"] == 30
assert result["one"]["feeds"][1] == {
"key": "notifications", "label": "Updates", "state": "delayed", "ageSeconds": 14
}
assert result["two"]["summary"] == "2 data feeds delayed"
assert result["unavailable"]["summary"] == "Live data unavailable"
def test_live_data_status_controller_is_single_flight_and_reports_result():
script = f"""
const status = require({json.dumps(str(STATUS))});
let resolveRefresh;
let calls = 0;
const states = [];
const button = {{disabled:false}};
const output = {{textContent:''}};
const controller = status.createRefreshController({{
button, output,
refresh:() => {{ calls += 1; return new Promise(resolve => {{ resolveRefresh = resolve; }}); }},
onState:value => states.push(value),
}});
(async () => {{
const first = controller.run();
const second = controller.run();
const pending = {{calls, disabled:button.disabled, text:output.textContent}};
resolveRefresh({{context:{{}}}});
await Promise.all([first, second]);
process.stdout.write(JSON.stringify({{pending, calls, disabled:button.disabled, text:output.textContent, states}}));
}})();
"""
assert run_node(script) == {
"pending": {"calls": 1, "disabled": True, "text": "Refreshing live data…"},
"calls": 1,
"disabled": False,
"text": "Live data refreshed.",
"states": ["refreshing", "success"],
}
def test_live_data_status_has_accessible_mobile_safe_sheet_contract():
html = HTML.read_text()
css = CSS.read_text()
dashboard = DASHBOARD.read_text()
assert 'id="open-live-data-status"' in html
assert 'aria-controls="live-data-status-sheet"' in html
assert 'id="live-data-status-sheet"' in html
assert 'aria-labelledby="live-data-status-heading"' in html
assert 'id="live-data-status-feeds"' in html
assert 'id="refresh-live-data"' in html
assert '<script src="static/live-data-status.js"></script>' in html
assert ".live-data-status-panel" in css
assert "width:min(560px,100%)" in css
assert "min-height:44px" in css
assert "liveDataStatus.describe" in dashboard
assert "liveDataStatus.createRefreshController" in dashboard
assert "contextPoller.getState()" in dashboard
+198 -1
View File
@@ -169,6 +169,56 @@ const controller = createLoginController({{
}
def test_stalled_token_login_aborts_once_and_restores_both_sign_in_controls():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
const state = {{ requests: 0, timeout: null, aborted: false }};
const status = {{ textContent: '' }};
const button = {{ disabled: false }};
const passkeyButton = {{ disabled: false }};
const controller = createLoginController({{
form: {{ reset: () => {{}} }}, status, button, passkeyButton,
credentials: {{ get: async () => {{ throw new Error('unused'); }} }},
fetchImpl: (_url, options) => {{
state.requests += 1;
options.signal.addEventListener('abort', () => state.aborted = true);
return new Promise((_resolve, reject) => {{
options.signal.addEventListener('abort', () => reject(options.signal.reason));
}});
}},
location: {{ replace: () => {{}} }},
requestTimeoutMs: 25,
setTimeoutImpl: callback => {{ state.timeout = callback; return 7; }},
clearTimeoutImpl: () => {{}},
}});
(async () => {{
const first = controller.submit('operator-token');
const duplicate = controller.submit('operator-token');
state.during = {{ button: button.disabled, passkey: passkeyButton.disabled }};
state.timeout();
await Promise.all([first, duplicate]);
state.after = {{
button: button.disabled, passkey: passkeyButton.disabled,
status: status.textContent,
}};
process.stdout.write(JSON.stringify(state));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
state = json.loads(result.stdout)
assert state["requests"] == 1
assert state["during"] == {"button": True, "passkey": True}
assert state["aborted"] is True
assert state["after"] == {
"button": False,
"passkey": False,
"status": "Sign-in timed out. Check your connection and try again.",
}
def test_successful_login_resumes_valid_shared_capture_continuation():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
@@ -193,6 +243,53 @@ const controller = createLoginController({{
)
def test_successful_login_resumes_only_valid_search_preview_continuation():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
async function destination(continuation) {{
let replaced = null;
const controller = createLoginController({{
form: {{ reset: () => {{}} }}, status: {{ textContent: '' }}, button: {{ disabled: false }},
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
location: {{ replace: value => replaced = value }}, continuation,
}});
await controller.submit('operator-token');
return replaced;
}}
(async () => process.stdout.write(JSON.stringify({{
valid:await destination('./?search=release+blocker&preview=pull%3Astackchain%2Fapi%3A9&search_kind=pull&search_state=open&search_repository=stackchain%2Fapi'),
malformed:await destination('./?search=release&preview=issue%3Astackchain%2Fapi%3A0'),
unknown:await destination('./?search=release&next=https%3A%2F%2Fevil.example'),
}})))().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"valid": "./?search=release+blocker&preview=pull%3Astackchain%2Fapi%3A9&search_kind=pull&search_state=open&search_repository=stackchain%2Fapi",
"malformed": "./",
"unknown": "./",
}
def test_search_preview_login_explains_the_resumable_handoff():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
const status = {{ textContent: '' }};
createLoginController({{
form: {{ reset: () => {{}} }}, status, button: {{ disabled: false }},
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
location: {{ replace: () => {{}} }},
continuation: './?search=release&preview=issue%3Astackchain%2Fapi%3A42',
}});
process.stdout.write(status.textContent);
"""
result = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert result.stdout == "Sign in to open the shared Search result."
def test_passkey_login_uses_web_authentication_without_sending_the_operator_token():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
@@ -254,6 +351,68 @@ const controller = createLoginController({{
assert "access_token" not in json.dumps(output)
def test_stalled_passkey_network_phases_time_out_without_timing_out_biometrics():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
async function scenario(stalledPhase) {{
const state = {{ requests: [], timers: [], credentialCalls: 0 }};
const status = {{textContent:''}};
const button = {{disabled:false}};
const passkeyButton = {{disabled:false}};
const credential = {{
id:'credential-id', type:'public-key', rawId:Uint8Array.from([1]).buffer,
response:{{authenticatorData:Uint8Array.from([2]).buffer,
clientDataJSON:Uint8Array.from([3]).buffer,
signature:Uint8Array.from([4]).buffer,userHandle:null}},
}};
const controller=createLoginController({{
form:{{reset:()=>{{}}}},status,button,passkeyButton,
credentials:{{get:async()=>{{state.credentialCalls+=1;return credential;}}}},
fetchImpl:(url,options)=>{{
state.requests.push(url);
const phase=url.endsWith('/options') ? 'options' : 'verify';
if (phase === stalledPhase) return new Promise((_resolve,reject)=>{{
options.signal.addEventListener('abort',()=>reject(options.signal.reason));
}});
return Promise.resolve(new Response(JSON.stringify({{
challenge:'AQID',allowCredentials:[]
}}),{{status:200,headers:{{'Content-Type':'application/json'}}}}));
}},
location:{{replace:()=>{{}}}},requestTimeoutMs:25,
setTimeoutImpl:callback=>{{state.timers.push(callback);return state.timers.length;}},
clearTimeoutImpl:()=>{{}},
}});
const pending=controller.signInWithPasskey('Phone');
await new Promise(resolve=>setImmediate(resolve));
if (stalledPhase === 'verify') await new Promise(resolve=>setImmediate(resolve));
state.timers.at(-1)();
state.result=await pending;
state.final={{status:status.textContent,button:button.disabled,passkey:passkeyButton.disabled}};
return state;
}}
(async()=>{{
process.stdout.write(JSON.stringify({{
options:await scenario('options'),verify:await scenario('verify'),
}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
state = json.loads(result.stdout)
assert state["options"]["credentialCalls"] == 0
assert state["verify"]["credentialCalls"] == 1
assert state["verify"]["timers"] == [None, None]
for phase in ("options", "verify"):
assert state[phase]["result"] is False
assert state[phase]["final"] == {
"status": "Passkey sign-in timed out. Check your connection and try again.",
"button": False,
"passkey": False,
}
def test_login_sends_a_bounded_device_label_with_the_access_token():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
@@ -343,7 +502,7 @@ const elements = {{
'submit-sign-in': {{ disabled: false }},
}};
const context = {{
URLSearchParams, Response, setInterval, clearInterval,
URLSearchParams, Response, AbortController, setInterval, clearInterval, setTimeout, clearTimeout,
FormData: function () {{ return {{ get: () => 'operator-token' }}; }},
fetch: async () => new Response('{{}}', {{ status: 200 }}),
document: {{ getElementById: id => elements[id] }},
@@ -370,6 +529,44 @@ setTimeout(() => process.stdout.write(JSON.stringify({{
}
def test_login_continues_only_bounded_shared_screenshot_capture():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
async function destination(continuation) {{
let replaced = null;
const controller = createLoginController({{
form: {{ reset: () => {{}} }}, status: {{ textContent: '' }}, button: {{ disabled: false }},
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
location: {{ replace: value => replaced = value }}, continuation,
}});
await controller.submit('operator-token');
return replaced;
}}
(async () => {{
process.stdout.write(JSON.stringify({{
valid: await destination('./?title=Broken&launch=new&shared=image'),
wrongLaunch: await destination('./?launch=agenda&shared=image'),
missingLaunch: await destination('./?shared=image'),
wrongMarker: await destination('./?launch=new&shared=document'),
duplicateMarker: await destination('./?launch=new&shared=image&shared=image'),
launchWithoutMarker: await destination('./?launch=new'),
}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
assert json.loads(result.stdout) == {
"valid": "./?title=Broken&launch=new&shared=image",
"wrongLaunch": "./",
"missingLaunch": "./",
"wrongMarker": "./",
"duplicateMarker": "./",
"launchWithoutMarker": "./?launch=new",
}
@pytest.mark.anyio
async def test_login_page_loads_rate_limit_controller():
html = await login()
+1 -1
View File
@@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v99" in worker
assert "stackchain-dashboard-shell-v101" in worker
+1 -1
View File
@@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v99" in worker
assert "stackchain-dashboard-shell-v101" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
+1 -1
View File
@@ -214,7 +214,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert "promptStorage:localStorage" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker
assert "stackchain-dashboard-shell-v99" in worker
assert "stackchain-dashboard-shell-v101" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css
+32
View File
@@ -8,6 +8,33 @@ from tests.dashboard_bundle import dashboard
MOBILE_LAUNCH = Path(__file__).resolve().parents[1] / "frontend" / "mobile-launch.js"
APP_SHORTCUTS = Path(__file__).resolve().parents[1] / "frontend" / "mobile-app-shortcuts.js"
def test_installed_app_shortcut_accepts_only_bounded_actions_and_runs_once():
script = f"""
const shortcuts = require({json.dumps(str(APP_SHORTCUTS))});
const calls = [];
const controller = shortcuts.createController({{
search:'?launch=continue',
continueWork:() => calls.push('continue'),
newIssue:() => calls.push('new'),
agenda:() => calls.push('agenda'),
}});
Promise.resolve(controller.run()).then(() => controller.run()).then(() => {{
process.stdout.write(JSON.stringify({{
action:controller.action(), calls,
invalid:shortcuts.parse('?launch=delete-account'),
shared:shortcuts.parse('?launch=new&title=Shared'),
}}));
}});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"action": "continue", "calls": ["continue"], "invalid": None, "shared": "new"
}
def test_mobile_launch_selects_priority_queue_without_overriding_saved_choice():
@@ -63,8 +90,13 @@ async def test_mobile_launch_progressively_discloses_secondary_controls():
assert 'id="work-settings-toggle"' in html
assert 'id="active-work-queue"' in html
assert '<script src="static/mobile-launch.js"></script>' in html
assert '<script src="static/mobile-app-shortcuts.js"></script>' in html
service_worker = (Path(__file__).resolve().parents[1] / "frontend" / "service-worker.js").read_text()
assert "BASE + 'static/mobile-launch.js'" in service_worker
assert "BASE + 'static/mobile-app-shortcuts.js'" in service_worker
bootstrap = (Path(__file__).resolve().parents[1] / "frontend" / "dashboard.js").read_text()
assert "mobileAppShortcuts.createController" in bootstrap
assert bootstrap.index("await load();\n await appShortcut.run();") < bootstrap.index("contextPoller.start();")
assert "mobileLaunch.chooseFilter" in html
assert "agenda: counts.agenda" in html
assert "mobileLaunch.createDisclosure" in html
+146 -6
View File
@@ -48,6 +48,43 @@ process.stdout.write(JSON.stringify({{modes, calls}}));
}
def test_mobile_work_entry_preserves_active_today_then_launches_highest_priority_queue():
script = f"""
const createEntry = require({json.dumps(str(ENTRY))});
const calls = [];
const state = {{active:true, today:2, resumable:true, eligible:3, next:{{name:'attention'}}}};
const entry = createEntry({{
isTodayActive: () => state.active,
isTodayResumable: () => state.resumable,
getTodayCount: () => state.today,
getEligibleCount: () => state.eligible,
queueLauncher: {{recommend: () => state.next, open: name => calls.push('queue:' + name)}},
continueToday: () => calls.push('continue'),
resumeToday: () => calls.push('resume'),
startToday: () => calls.push('start'),
planToday: () => calls.push('plan'),
findWork: () => calls.push('find'),
}});
const modes = [entry.open()];
state.active = false; modes.push(entry.open());
state.next = {{name:'today'}}; modes.push(entry.open());
state.today = 0; state.eligible = 0; state.next = {{name:'update'}}; modes.push(entry.open());
state.next = {{name:'agenda'}}; modes.push(entry.open());
state.next = {{name:'later'}}; modes.push(entry.open());
state.next = {{name:'draft'}}; modes.push(entry.open());
state.next = {{name:'find'}}; modes.push(entry.open());
process.stdout.write(JSON.stringify({{modes, calls}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"modes": ["continue", "attention", "resume", "update", "agenda", "later", "draft", "find"],
"calls": ["continue", "queue:attention", "resume", "queue:update", "queue:agenda", "queue:later", "queue:draft", "find"],
}
def test_mobile_task_dock_routes_actions_hides_for_overlays_and_restores_focus():
script = f"""
const createDock = require({json.dumps(str(DOCK))});
@@ -314,14 +351,22 @@ queues.click(); rows.recaps.click();
const closedAfterUtility = !sheet.open;
queues.click(); close.click();
const populated = {{
badge:badge.textContent, badgeHidden:badge.hidden,
deadline:deadline.textContent, deadlineHidden:deadline.hidden,
agendaDue:rows.agenda.attributes['data-deadlines'] || null,
badgeLabel:queues.attributes['aria-label'],
}};
dock.updateQueues({{today:0, agenda:0, attention:0, update:7, later:0, draft:0}});
const updatesOnly = {{
badge:badge.textContent, badgeHidden:badge.hidden,
deadlineHidden:deadline.hidden,
badgeLabel:queues.attributes['aria-label'],
}};
dock.updateQueues({{today:0, agenda:0, attention:0, update:0, later:0, draft:0}});
process.stdout.write(JSON.stringify({{
opened, closedAfterSelect, closedAfterUtility, selected, utilities,
badge:badge.textContent, populated,
badge:badge.textContent, populated, updatesOnly,
clearedBadgeHidden:badge.hidden,
clearedDeadlineHidden:deadline.hidden,
clearedAgendaDue:rows.agenda.attributes['data-deadlines'] || null,
clearedBadgeLabel:queues.attributes['aria-label'],
@@ -340,12 +385,25 @@ process.stdout.write(JSON.stringify({{
"closedAfterUtility": True,
"selected": ["update"],
"utilities": ["recaps:trigger"],
# Updates are a drill-down within Attention and must not inflate the aggregate badge.
"badge": "0",
"populated": {"badgeLabel": "Queues, 10 items, 5 upcoming deadlines", "deadline": "5 due", "deadlineHidden": False, "agendaDue": "true"},
"badge": "0 active",
"populated": {
"badge": "5 active",
"badgeHidden": False,
"badgeLabel": "Queues: Today 2, Agenda 5 due, Attention 1, Updates 5, Later 3, Drafts 4; 5 active queues",
"deadline": "5 due",
"deadlineHidden": False,
"agendaDue": "true",
},
"updatesOnly": {
"badge": "1 active",
"badgeHidden": False,
"deadlineHidden": True,
"badgeLabel": "Queues: Today 0, Agenda 0 due, Attention 0, Updates 7, Later 0, Drafts 0; 1 active queue",
},
"clearedBadgeHidden": True,
"clearedDeadlineHidden": True,
"clearedAgendaDue": None,
"clearedBadgeLabel": "Queues, 0 items",
"clearedBadgeLabel": "Queues: no active queues; no upcoming deadlines",
"counts": {"today": "0", "agenda": "0", "attention": "0", "update": "0", "later": "0", "draft": "0", "recaps": "0"},
"updateLabel": "Updates, 0 unread conversations",
"queueFocuses": 3,
@@ -388,6 +446,7 @@ const calls = [];
let counts = {{attention: 2, today: 1, agenda: 5, later: 4, draft: 3}};
const launcher = createLauncher({{
openToday: () => calls.push('today'),
openAgenda: () => {{ calls.push('agenda'); return 'opened'; }},
selectFilter: name => calls.push('filter:' + name),
firstAction: name => (counts[name] || 0) ? {{click() {{ calls.push('open:' + name); }}}} : null,
announce: message => calls.push('announce:' + message),
@@ -411,7 +470,60 @@ process.stdout.write(JSON.stringify({{first, agenda, opened, fallback, calls}}))
"agenda": {"name": "agenda", "count": 5, "label": "Open Agenda (5)"},
"opened": "opened",
"fallback": {"name": "find", "count": 0, "label": "Find Work"},
"calls": ["filter:agenda", "open:agenda", "find"],
"calls": ["agenda", "find"],
}
def test_mobile_queue_launcher_prioritizes_updates_before_agenda_and_resumes_launcher():
script = f"""
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
const calls = [];
let counts = {{attention:0, today:0, update:6, agenda:2, later:1, draft:1}};
const launcher = createLauncher({{
getCounts: () => counts,
openUpdates: () => {{ calls.push('updates'); return 'resumed'; }},
openAgenda: () => calls.push('agenda'),
selectFilter: name => calls.push('filter:' + name),
firstAction: () => null,
announce: () => {{}},
openFindWork: () => calls.push('find'),
}});
const recommendation = launcher.recommend();
const opened = launcher.continueWork();
process.stdout.write(JSON.stringify({{recommendation, opened, calls}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"recommendation": {"name": "update", "count": 6, "label": "Resume Updates (6)"},
"opened": "resumed",
"calls": ["updates"],
}
def test_mobile_task_dock_labels_each_recommended_work_destination_accessibly():
script = f"""
const createDock = require({json.dumps(str(DOCK))});
const work = {{attributes: {{}}, setAttribute(name, value) {{ this.attributes[name] = value; }}}};
const workLabel = {{textContent:''}};
const dock = createDock({{nav:{{}}, buttons:{{work}}, workLabel}});
const labels = {{}};
for (const mode of ['attention','update','agenda','later','draft']) {{
dock.updateWork(mode);
labels[mode] = [workLabel.textContent, work.attributes['aria-label']];
}}
process.stdout.write(JSON.stringify(labels));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"attention": ["Attention", "Open Attention"],
"update": ["Updates", "Resume Updates"],
"agenda": ["Agenda", "Open Agenda"],
"later": ["Later", "Open Later"],
"draft": ["Drafts", "Open Drafts"],
}
@@ -485,6 +597,17 @@ async def test_dashboard_wires_mobile_work_dock_into_today_session_lifecycle():
assert "runTodayTransition('continue')" in html
@pytest.mark.anyio
async def test_dashboard_wires_work_dock_and_app_shortcut_to_live_queue_recommendation():
html = await dashboard()
assert "queueLauncher: mobileQueueLauncher" in html
assert "work: () => mobileWorkEntry.open()" in html
assert "continueWork: () => mobileWorkEntry.open()" in html
assert "mobileQueueCounts = counts;" in html
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode())" in html
@pytest.mark.anyio
async def test_dashboard_renders_and_wires_phone_safe_task_dock():
html = await dashboard()
@@ -513,6 +636,23 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
assert "mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention)" in html
@pytest.mark.anyio
async def test_mobile_update_reader_keeps_primary_decisions_within_thumb_reach():
html = await dashboard()
assert 'class="update-decision-bar" aria-label="Update decisions"' in html
assert 'id="keep-update-unread" type="button" aria-label="Keep unread and open next update"' in html
assert 'id="mark-update-read-next" type="button" aria-label="Mark read and open next update"' in html
assert 'id="focus-update-reply" type="button">Reply</button>' in html
assert 'class="update-more-actions"' in html
assert "qs('#focus-update-reply').addEventListener('click'" in html
assert "qs('#update-reply').focus({ preventScroll:true })" in html
assert '.update-decision-bar { position:fixed;' in html
assert 'padding-bottom:calc(8px + env(safe-area-inset-bottom))' in html
assert '.update-decision-bar button, .update-decision-bar summary { min-height:44px;' in html
assert 'padding-bottom:calc(82px + env(safe-area-inset-bottom))' in html
@pytest.mark.anyio
async def test_dashboard_renders_phone_safe_today_session_hud_above_task_dock():
html = await dashboard()
+547 -10
View File
@@ -40,6 +40,269 @@ COMMENT_ACTIONS = Path(__file__).parents[1] / "frontend" / "comment-actions.js"
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js"
UPDATE_OWNERSHIP = Path(__file__).parents[1] / "frontend" / "update-ownership.js"
AGENDA_SESSION_LAUNCHER = Path(__file__).parents[1] / "frontend" / "agenda-session-launcher.js"
UPDATE_TRIAGE_LAUNCHER = Path(__file__).parents[1] / "frontend" / "update-triage-launcher.js"
def test_updates_rank_actionable_work_before_newer_routine_activity():
payload = {
"user": {"login": "timmy"},
"issues": [
{"number": 1, "title": "Critical incident", "repository": "stackchain/api",
"labels": ["P0"], "assignees": [], "updated_at": "2026-08-01T10:00:00Z"},
{"number": 2, "title": "Overdue assignment", "repository": "stackchain/api",
"labels": [], "assignees": ["timmy"], "due_date": "2026-08-06T23:59:59Z",
"updated_at": "2026-08-05T10:00:00Z"},
{"number": 3, "title": "Assigned follow-up", "repository": "stackchain/api",
"labels": [], "assignees": ["timmy"], "updated_at": "2026-08-06T10:00:00Z"},
],
"pull_requests": [
{"number": 4, "title": "Requested review", "repository": "stackchain/web",
"labels": [], "assignees": [], "work_reasons": ["review_requested"],
"updated_at": "2026-08-02T10:00:00Z"},
],
"notifications": [
{"id": 11, "number": 1, "repository": "stackchain/api", "unread": True,
"subject_type": "Issue", "url": "https://forge.example/api/issues/1"},
{"id": 12, "number": 2, "repository": "stackchain/api", "unread": True,
"subject_type": "Issue", "url": "https://forge.example/api/issues/2"},
{"id": 13, "number": 3, "repository": "stackchain/api", "unread": True,
"subject_type": "Issue", "url": "https://forge.example/api/issues/3"},
{"id": 14, "number": 4, "repository": "stackchain/web", "unread": True,
"subject_type": "PullRequest", "url": "https://forge.example/web/pulls/4"},
{"id": 15, "number": 5, "repository": "stackchain/web", "unread": True,
"subject_type": "Issue", "title": "Newest routine update",
"updated_at": "2026-08-07T11:00:00Z", "url": "https://forge.example/web/issues/5"},
],
}
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const updates = buildMyWork({json.dumps(payload)}, new Date('2026-08-07T12:00:00Z'))
.filter(item => item.has_update)
.map(item => ({{title:item.title, update_reason:item.update_reason}}));
process.stdout.write(JSON.stringify(updates));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == [
{"title": "Critical incident", "update_reason": "Critical"},
{"title": "Requested review", "update_reason": "Review requested"},
{"title": "Overdue assignment", "update_reason": "Overdue"},
{"title": "Assigned follow-up", "update_reason": "Assigned to you"},
{"title": "Newest routine update", "update_reason": ""},
]
def test_update_triage_launch_waits_for_complete_single_flight_discovery():
script = f"""
const createLauncher = require({json.dumps(str(UPDATE_TRIAGE_LAUNCHER))});
let release;
let discoveries = 0;
let opens = 0;
const messages = [];
const launcher = createLauncher({{
selectUpdates: () => {{}},
discover: () => {{ discoveries += 1; return new Promise(resolve => {{ release = resolve; }}); }},
isUpdatesSelected: () => true,
hasMore: () => false,
hasCheckpoint: () => false,
resume: () => {{ throw new Error('must start'); }},
start: () => {{ opens += 1; return true; }},
announce: message => messages.push(message),
}});
const first = launcher.open();
const second = launcher.open();
Promise.resolve().then(() => {{
const before = {{discoveries, opens, same:first === second}};
release(true);
return Promise.all([first, second]).then(results =>
process.stdout.write(JSON.stringify({{before, discoveries, opens, results, messages}}))
);
}});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"before": {"discoveries": 1, "opens": 0, "same": True},
"discoveries": 1,
"opens": 1,
"results": ["opened", "opened"],
"messages": ["Checking all unread updates…"],
}
def test_update_triage_launch_preserves_checkpoint_when_discovery_fails():
script = f"""
const createLauncher = require({json.dumps(str(UPDATE_TRIAGE_LAUNCHER))});
const messages = [];
let resumed = 0;
const launcher = createLauncher({{
selectUpdates: () => {{}},
discover: async () => false,
isUpdatesSelected: () => true,
hasMore: () => true,
hasCheckpoint: () => true,
resume: () => {{ resumed += 1; return true; }},
start: () => true,
announce: message => messages.push(message),
}});
launcher.open().then(result => process.stdout.write(JSON.stringify({{result, resumed, messages}})));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"result": "incomplete",
"resumed": 0,
"messages": [
"Checking all unread updates…",
"Updates check paused. Retry to check older unread updates.",
],
}
def test_update_triage_launch_applies_atomic_snapshot_before_starting():
script = f"""
const createLauncher = require({json.dumps(str(UPDATE_TRIAGE_LAUNCHER))});
const events = [];
const snapshot = {{items:[{{id:1}},{{id:51}}],total:2,complete:true}};
const launcher = createLauncher({{
selectUpdates: () => events.push('selected'),
discover: async () => snapshot,
applySnapshot: value => events.push(['applied', value.items.map(item => item.id)]),
isUpdatesSelected: () => true,
hasMore: () => false,
hasCheckpoint: () => false,
resume: () => {{ throw new Error('must start'); }},
start: () => {{ events.push('started'); return true; }},
announce: () => {{}},
}});
launcher.open().then(result => process.stdout.write(JSON.stringify({{result, events}})));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"result": "opened",
"events": ["selected", ["applied", [1, 51]], "started"],
}
def test_notification_pager_load_all_retries_from_failed_page_without_duplicates():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
let items = [{{id:1}}];
let fail = true;
const requested = [];
const pager = buildMyWork.createNotificationPager({{
load: async page => {{
requested.push(page);
if (page === 2 && fail) {{ fail = false; throw new Error('offline'); }}
return page === 2 ? {{page:2,total:101,has_more:true,items:[{{id:1}},{{id:51}}]}} :
{{page:3,total:101,has_more:false,items:[{{id:101}}]}};
}},
onNotifications: value => {{ items = value; }},
onPagination: () => {{}},
onStatus: () => {{}},
}});
pager.reset({{page:1,total:101,has_more:true}});
pager.loadAll(() => items).then(firstResult => pager.loadAll(() => items).then(secondResult =>
process.stdout.write(JSON.stringify({{firstResult, secondResult, requested, ids:items.map(item => item.id)}}))
));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"firstResult": False,
"secondResult": True,
"requested": [2, 2, 3],
"ids": [1, 51, 101],
}
def test_agenda_session_launch_waits_for_complete_single_flight_discovery():
script = f"""
const createLauncher = require({json.dumps(str(AGENDA_SESSION_LAUNCHER))});
let finishDiscovery;
let discoveries = 0;
let opens = 0;
let queue = 'agenda';
const launcher = createLauncher({{
selectAgenda: () => {{ queue = 'agenda'; }},
discover: () => {{
discoveries += 1;
return new Promise(resolve => {{ finishDiscovery = resolve; }});
}},
isAgendaSelected: () => queue === 'agenda',
hasMore: () => false,
hasCheckpoint: () => true,
resume: () => {{ opens += 1; return true; }},
start: () => {{ throw new Error('must resume'); }},
announce: () => {{}},
}});
const first = launcher.open();
const second = launcher.open();
Promise.resolve().then(() => {{
const before = {{discoveries, opens, same:first === second}};
finishDiscovery(true);
return Promise.all([first, second]).then(results => {{
process.stdout.write(JSON.stringify({{before, results, discoveries, opens}}));
}});
}});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"before": {"discoveries": 1, "opens": 0, "same": True},
"results": ["opened", "opened"],
"discoveries": 1,
"opens": 1,
}
def test_agenda_session_launch_preserves_progress_when_discovery_fails_or_queue_changes():
script = f"""
const createLauncher = require({json.dumps(str(AGENDA_SESSION_LAUNCHER))});
async function scenario(mode) {{
let queue = 'agenda';
let opens = 0;
const messages = [];
const launcher = createLauncher({{
selectAgenda: () => {{ queue = 'agenda'; }},
discover: async () => {{
if (mode === 'leave') queue = 'today';
if (mode === 'throw') throw new Error('offline');
return mode !== 'incomplete';
}},
isAgendaSelected: () => queue === 'agenda',
hasMore: () => mode === 'incomplete',
hasCheckpoint: () => true,
resume: () => {{ opens += 1; return true; }},
start: () => {{ opens += 1; return true; }},
announce: message => messages.push(message),
}});
return {{result:await launcher.open(), opens, messages}};
}}
Promise.all(['incomplete', 'throw', 'leave'].map(scenario)).then(results =>
process.stdout.write(JSON.stringify(results))
);
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == [
{"result": "incomplete", "opens": 0, "messages": [
"Agenda check paused. Retry to check older assigned deadlines."
]},
{"result": "incomplete", "opens": 0, "messages": [
"Agenda check paused. Retry to check older assigned deadlines."
]},
{"result": "cancelled", "opens": 0, "messages": []},
]
def test_protect_today_route_is_an_explicit_agenda_action():
@@ -195,6 +458,141 @@ process.stdout.write(JSON.stringify(work.agendaMyWork(items, new Date('2026-08-1
assert json.loads(result.stdout) == ["a/r#2", "a/r#10", "z/r#1"]
def test_mobile_agenda_runs_as_an_ordered_non_durable_work_session():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const values = new Map([['stackchain.today-session.v1', 'preserve-today']]);
const checkpoint = {{
read:() => null,
save:() => {{ throw new Error('Agenda must not save Today'); }},
clear:() => {{ throw new Error('Agenda must not clear Today'); }},
}};
const active = [
{{kind:'issue',repository:'o/r',number:3,title:'Tomorrow',is_assigned:true,due_date:'2026-08-14'}},
{{kind:'issue',repository:'o/r',number:1,title:'Overdue',is_assigned:true,due_date:'2026-08-12'}},
{{kind:'issue',repository:'o/r',number:2,title:'Today',is_assigned:true,due_date:'2026-08-13'}},
{{kind:'issue',repository:'o/r',number:4,title:'No deadline',is_assigned:true}},
];
const opened = [];
let finished = 0;
const session = buildMyWork.createWorkSession({{
getItems:() => buildMyWork.agendaMyWork(active, new Date('2026-08-13T12:00:00')),
getFilter:() => 'all', checkpoint, checkpointEnabled:() => false,
onOpen:item => opened.push(item.title), onProgress:() => {{}},
onFinish:() => {{ finished += 1; }},
}});
const started = session.start();
session.next();
session.next();
const completed = session.next();
process.stdout.write(JSON.stringify({{
started, completed, opened, finished, active:session.active(), today:values.get('stackchain.today-session.v1'),
}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"started": True,
"completed": False,
"opened": ["Overdue", "Today", "Tomorrow"],
"finished": 1,
"active": False,
"today": "preserve-today",
}
def test_mobile_agenda_checkpoint_is_account_bound_resumable_and_separate_from_today():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const values = new Map([['stackchain.today-session.v1', 'preserve-today']]);
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key, value) => values.set(key, value),
removeItem:key => values.delete(key),
}};
let login = 'timmy';
const agendaCheckpoint = buildMyWork.createWorkSessionCheckpoint({{
storage, getLogin:() => login, key:'stackchain.agenda-session.v1',
}});
const todayCheckpoint = buildMyWork.createWorkSessionCheckpoint({{
storage, getLogin:() => login, key:'stackchain.today-session.v1',
}});
let mode = 'agenda';
let items = [
{{kind:'issue',repository:'o/r',number:1,title:'Overdue'}},
{{kind:'issue',repository:'o/r',number:2,title:'Today'}},
{{kind:'issue',repository:'o/r',number:3,title:'Tomorrow'}},
];
const opened = [];
const makeSession = () => buildMyWork.createWorkSession({{
getItems:() => items, getFilter:() => 'all',
checkpoint:() => mode === 'agenda' ? agendaCheckpoint : todayCheckpoint,
checkpointEnabled:() => true, checkpointedEnabled:() => mode === 'today',
onOpen:item => opened.push(item.title), onProgress:() => {{}}, onFinish:() => {{}},
}});
const first = makeSession();
first.start();
first.next();
const saved = JSON.parse(values.get('stackchain.agenda-session.v1'));
const todayWhileRunning = first.checkpointed();
login = 'alexander';
const hiddenFromOtherAccount = makeSession().resumable();
login = 'timmy';
items = items.filter(item => item.number !== 2);
const resumedSession = makeSession();
const resumed = resumedSession.resume();
resumedSession.end();
process.stdout.write(JSON.stringify({{
saved, todayWhileRunning, hiddenFromOtherAccount, resumed, opened,
agendaCleared:!values.has('stackchain.agenda-session.v1'),
today:values.get('stackchain.today-session.v1'),
}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
state = json.loads(result.stdout)
assert state["saved"] == {
"version": 1, "login": "timmy", "identity": "issue:o/r:2:", "index": 1,
}
assert state["todayWhileRunning"] is False
assert state["hiddenFromOtherAccount"] is False
assert state["resumed"] is True
assert state["opened"] == ["Overdue", "Today", "Tomorrow"]
assert state["agendaCleared"] is True
assert state["today"] == "preserve-today"
@pytest.mark.anyio
async def test_mobile_agenda_launcher_starts_or_resumes_the_durable_agenda_session():
html = await dashboard()
assert '<script src="static/agenda-session-launcher.js"></script>' in html
assert "openAgenda: openAgendaSession" in html
assert "const agendaSessionLauncher = createAgendaSessionLauncher({" in html
assert "discover: completeAgendaIssues" in html
assert "hasMore: () => Boolean(workPagination.issue?.has_more)" in html
assert "key: 'stackchain.agenda-session.v1'" in html
assert "checkpoint: () => selectedWorkFilter === 'agenda' ? agendaSessionCheckpoint : sessionCheckpoint" in html
assert "checkpointEnabled: () => ['today', 'agenda'].includes(selectedWorkFilter)" in html
assert "checkpointedEnabled: () => selectedWorkFilter === 'today'" in html
assert "agendaSessionCheckpoint.read() ? 'Resume Agenda' : 'Start Agenda'" in html
assert "window.location.hash = '#/my-work/agenda'" in html
@pytest.mark.anyio
async def test_mobile_agenda_launcher_starts_the_rendered_queue_and_finishes_in_agenda():
html = await dashboard()
assert "selectedWorkFilter === 'agenda' ? agendaMyWork(activeMyWork)" in html
assert "selectedWorkFilter === 'agenda' ? 'all'" in html
assert "agendaSessionCheckpoint.read() ? 'Resume Agenda' : 'Start Agenda'" in html
assert "const sessionItems = workSession.items()" in html
assert "qs('#my-work-action-status').textContent = 'Agenda complete.'" in html
assert "checkpointEnabled: () => ['today', 'agenda'].includes(selectedWorkFilter)" in html
@pytest.mark.parametrize("timezone_name", ["Asia/Tokyo", "America/Los_Angeles"])
def test_mobile_agenda_preserves_the_gitea_calendar_day_in_every_timezone(timezone_name):
script = f"""
@@ -1475,7 +1873,7 @@ async def test_mobile_my_work_exposes_touch_safe_milestone_lane_and_issue_editor
assert 'id="save-issue-milestone"' in html
assert 'class="pill milestone-badge"' in html
assert '.work-milestone-filter, .issue-milestone-editor select, .issue-milestone-editor button { min-height:44px;' in html
assert 'getMilestone: () => selectedWorkMilestone' in html
assert "getMilestone: () => selectedWorkFilter === 'agenda' ? 'all' : selectedWorkMilestone" in html
assert 'buildMyWork.replaceIssueMilestone(' in html
@@ -3164,8 +3562,8 @@ async def test_dashboard_offers_account_safe_resume_and_end_today_controls():
assert 'id="end-today-session"' in html
assert 'const sessionCheckpoint = createWorkSessionCheckpoint({' in html
assert 'getLogin: () => confirmedOwnerLogin' in html
assert 'checkpoint: sessionCheckpoint' in html
assert 'checkpointEnabled: () => selectedWorkFilter === \'today\'' in html
assert "checkpoint: () => selectedWorkFilter === 'agenda' ? agendaSessionCheckpoint : sessionCheckpoint" in html
assert "checkpointedEnabled: () => selectedWorkFilter === 'today'" in html
assert "qs('#resume-today-session').addEventListener('click'" in html
assert "qs('#end-today-session').addEventListener('click'" in html
assert "workSession.resume(item)" in html
@@ -4182,10 +4580,12 @@ process.stdout.write(JSON.stringify({{
output = json.loads(result.stdout)
assert len(output["queue"]) == 2
assert [item["key"] for item in output["updates"]] == ["stackchain/web#8", "stackchain/api#7"]
assert output["updates"][0]["kind"] == "update"
assert output["updates"][1]["kind"] == "issue"
assert output["updates"][1]["url"].endswith("#issuecomment-9")
assert [item["key"] for item in output["updates"]] == ["stackchain/api#7", "stackchain/web#8"]
assert output["updates"][0]["kind"] == "issue"
assert output["updates"][0]["update_reason"] == "Assigned to you"
assert output["updates"][0]["url"].endswith("#issuecomment-9")
assert output["updates"][1]["kind"] == "update"
assert output["updates"][1]["update_reason"] == ""
assert output["counts"] == {
"all": 2, "attention": 2, "issue": 1, "pull": 0, "review": 0, "update": 2
}
@@ -4523,6 +4923,76 @@ reader.open(original[0], original).then(() =>
assert output["result"]["next"]["notification_id"] == 43
def test_notification_reader_consumes_one_bounded_prefetch_without_duplicate_load():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const items = [1,2,3].map(notification_id => ({{kind:'update', notification_id, has_update:true}}));
const loaded = [], details = [], statuses = [];
const releases = {{}};
const reader = buildMyWork.createNotificationReader({{
load: id => {{
loaded.push(id);
if (id === 1) return Promise.resolve({{id}});
return new Promise(resolve => {{ releases[id] = resolve; }});
}},
markRead: async () => {{}},
onOpen: () => {{}}, onDetail: detail => details.push(detail.id), onItems: () => {{}},
onStatus: status => statuses.push(status), onClose: () => {{}},
}});
(async () => {{
await reader.open(items[0]);
const first = reader.prefetch(items[1]);
const duplicate = reader.prefetch(items[1]);
const rejected = reader.prefetch(items[2]);
const advancing = reader.open(items[1]);
await Promise.resolve();
releases[2]({{id:2}});
await advancing;
process.stdout.write(JSON.stringify({{
loaded, details, same:first === duplicate, rejected, statuses,
}}));
}})();
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output == {
"loaded": [1, 2],
"details": [1, 2],
"same": True,
"rejected": False,
"statuses": ["Loading update…", "Update ready.", "Update ready."],
}
def test_notification_reader_never_consumes_prefetch_from_another_account_scope():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
let scope = 'timmy';
const loaded = [];
const reader = buildMyWork.createNotificationReader({{
load: async id => {{ loaded.push([scope, id]); return {{owner:scope, id}}; }},
getScope: () => scope,
markRead: async () => {{}}, onOpen: () => {{}}, onItems: () => {{}}, onStatus: () => {{}},
onDetail: detail => {{ if (detail.owner !== scope) throw new Error('cross-account detail'); }},
onClose: () => {{}},
}});
(async () => {{
await reader.open({{notification_id:1}});
await reader.prefetch({{notification_id:2}});
scope = 'alexander';
const opened = await reader.open({{notification_id:2}});
process.stdout.write(JSON.stringify({{loaded, opened}}));
}})();
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output == {"loaded": [["timmy", 1], ["timmy", 2], ["alexander", 2]], "opened": True}
def test_notification_reader_acknowledges_once_and_opens_next_update():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
@@ -4637,6 +5107,21 @@ async def test_mobile_update_sheet_wires_touch_safe_online_acknowledge_and_next(
assert '.update-sheet-actions #acknowledge-update-next { min-height:44px;' in html
@pytest.mark.anyio
async def test_mobile_update_sheet_mutes_future_updates_and_advances_only_on_read_success():
html = await dashboard()
assert 'id="mute-update-next" type="button" hidden' in html
assert ">Mute future updates &amp; next</button>" in html
assert "qs('#mute-update-next').hidden = !detail.mute_supported;" in html
assert "async function muteNotification(notificationId)" in html
assert "'/mute', { method: 'POST'" in html
assert "notificationReader.acceptReadAndNext(lastMyWork, item)" in html
assert "Future updates are muted; current item is still unread" in html
assert "qs('#mute-update-next').disabled = offline;" in html
assert ".update-sheet-actions #mute-update-next" in html
def test_notification_reader_keeps_current_update_retryable_when_detail_load_fails():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
@@ -4736,6 +5221,56 @@ reader.open(firstItem).then(async () => {{
assert output["states"] == [[41], [90]]
def test_notification_reader_renders_core_before_conversation_and_retries_failure_in_place():
script = f"""
const build = require({json.dumps(str(MY_WORK))});
const createPager = require({json.dumps(str(CONVERSATION))});
const events = [];
let attempts = 0;
let release;
const reader = build.createNotificationReader({{
load: async id => ({{id, title:'Core ready', conversation_available:true}}),
createPager,
loadConversation: () => {{
attempts += 1;
if (attempts === 1) return Promise.reject(new Error('comments offline'));
return new Promise(resolve => {{ release = () => resolve({{
comments:[{{id:47, body:'Newest'}}], page:3, older_page:2, total:47,
}}); }});
}},
markRead: async () => {{}}, onOpen: () => events.push('open'),
onDetail: detail => events.push(['detail', detail.title]),
onConversation: state => events.push(['conversation', state.comments.map(item => item.id)]),
onConversationStatus: status => events.push(['conversation-status', status]),
onItems: () => {{}}, onStatus: status => events.push(['status', status]), onClose: () => {{}},
}});
(async () => {{
const opened = await reader.open({{notification_id:42}});
const retrying = reader.retryConversation();
await Promise.resolve();
const beforeRelease = events.slice();
release();
const retried = await retrying;
process.stdout.write(JSON.stringify({{opened, retried, attempts, beforeRelease, events}}));
}})();
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["opened"] is True
assert output["retried"] is True
assert output["attempts"] == 2
assert ["detail", "Core ready"] in output["beforeRelease"]
assert ["status", "Update ready."] in output["beforeRelease"]
assert ["conversation-status", "Conversation temporarily unavailable. Retry."] in output["beforeRelease"]
assert ["conversation", [47]] not in output["beforeRelease"]
assert output["events"][-2:] == [
["conversation", [47]],
["conversation-status", "1 of 47 messages loaded."],
]
def test_notification_reader_hydrates_saved_conversation_without_server_state_actions():
script = f"""
const build = require({json.dumps(str(MY_WORK))});
@@ -5821,7 +6356,7 @@ async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels(
assert '<script src="static/my-work.js"></script>' in html
assert "buildMyWork(data)" in html
assert "markMyWorkStale()" in html
assert "filterMyWork(lastMyWork, selectedWorkFilter)" in html
assert "const sessionItems = workSession.items()" in html
@pytest.mark.anyio
@@ -5856,7 +6391,7 @@ async def test_mobile_update_reader_is_in_app_safe_area_aware_and_actionable():
assert '.update-sheet-actions button, .update-sheet-actions a { min-height:44px;' in html
assert "createNotificationReader" in html
assert "api/v1/notifications/" in html
assert "notificationReader.markReadAndNext(lastMyWork)" in html
assert "reader:notificationReader, items:()=>lastMyWork" in html
def test_notification_replier_preserves_failed_draft_and_clears_only_after_success():
@@ -6693,7 +7228,9 @@ async def test_mobile_update_sheet_renders_and_pages_the_complete_conversation()
assert 'id="load-older-update-comments"' in update_sheet
assert 'id="update-conversation-status"' in update_sheet
assert '.conversation-more' in html and 'min-height:44px' in html
assert "'/conversation?page='" in html
assert "'/conversation?limit=20' + pageQuery" in html
assert 'id="retry-update-conversation"' in update_sheet
assert "notificationReader.retryConversation()" in html
assert "loadConversation: fetchNotificationConversation" in html
assert "onConversation: renderUpdateConversation" in html
assert "notificationReader.loadOlder()" in html
+22 -9
View File
@@ -7,7 +7,7 @@ from src import gitea_proxy, main
@pytest.mark.anyio
async def test_notification_detail_opens_the_newest_conversation_page_in_chronological_order(monkeypatch):
async def test_notification_detail_returns_core_without_loading_conversation(monkeypatch):
requested_pages = []
def comment(comment_id):
@@ -53,14 +53,9 @@ async def test_notification_detail_opens_the_newest_conversation_page_in_chronol
finally:
await gitea_proxy.stop_client()
assert requested_pages == [1, 3]
assert [item["id"] for item in result["conversation"]["comments"]] == list(range(41, 48))
assert result["conversation"] == {
"comments": result["conversation"]["comments"],
"page": 3,
"older_page": 2,
"total": 47,
}
assert requested_pages == []
assert result["conversation_available"] is True
assert "conversation" not in result
assert result["issue"] == {
"number": 7,
"assignees": [],
@@ -130,6 +125,24 @@ async def test_notification_conversation_api_loads_one_bounded_older_page(monkey
assert calls == [(42, 2, 20)]
@pytest.mark.anyio
async def test_notification_conversation_api_opens_newest_page_when_page_is_omitted(monkeypatch):
calls = []
async def conversation(thread_id, page, limit):
calls.append((thread_id, page, limit))
return {"comments": [{"id": 47}], "page": 3, "older_page": 2, "total": 47}
monkeypatch.setattr(main.gitea_proxy, "notification_conversation_page", conversation)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/notifications/42/conversation?limit=20")
assert response.status_code == 200
assert response.json()["page"] == 3
assert calls == [(42, None, 20)]
@pytest.mark.anyio
async def test_notification_detail_timeout_is_sanitized_and_retryable(monkeypatch):
async def detail(_thread_id):
+52
View File
@@ -31,3 +31,55 @@ async def test_notification_page_endpoint_loads_only_requested_page_and_is_not_c
"has_more": True,
}
assert requested == [2]
@pytest.mark.anyio
async def test_notification_snapshot_endpoint_returns_one_complete_atomic_no_store_result(monkeypatch):
requested = []
async def snapshot_loader(*, deadline_seconds):
requested.append(deadline_seconds)
return {
"items": [
{"id": 1, "title": "Newest update"},
{"id": 51, "title": "Older update"},
],
"total": 2,
"complete": True,
}
monkeypatch.setattr(main.gitea_proxy, "unread_notification_snapshot", snapshot_loader)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/notifications/snapshot")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == {
"items": [
{"id": 1, "title": "Newest update"},
{"id": 51, "title": "Older update"},
],
"total": 2,
"complete": True,
}
assert requested == [main.NOTIFICATION_PAGE_TIMEOUT_SECONDS]
@pytest.mark.anyio
@pytest.mark.parametrize("failure", [TimeoutError(), ValueError("pagination changed")])
async def test_notification_snapshot_endpoint_is_bounded_and_retryable(monkeypatch, failure):
async def snapshot_loader(*, deadline_seconds):
raise failure
monkeypatch.setattr(main.gitea_proxy, "unread_notification_snapshot", snapshot_loader)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/notifications/snapshot")
assert response.status_code == 503
assert response.headers["cache-control"] == "no-store"
assert response.headers["retry-after"] == "1"
assert response.json() == {
"error": "Complete unread updates are temporarily unavailable. Please retry."
}
+28
View File
@@ -215,6 +215,34 @@ async def test_mark_notification_unread_api_is_bounded_and_never_cacheable(monke
assert restored == [42]
@pytest.mark.anyio
async def test_mute_notification_then_marks_read_and_reports_partial_success(monkeypatch):
calls = []
async def mute(thread_id):
calls.append(("mute", thread_id))
return {"id": thread_id, "muted": True}
async def fail_read(thread_id):
calls.append(("read", thread_id))
raise httpx.HTTPError("unavailable")
monkeypatch.setattr(main.gitea_proxy, "mute_notification", mute, raising=False)
monkeypatch.setattr(main, "mark_notification_read", fail_read)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post("/api/v1/notifications/42/mute")
assert response.status_code == 409
assert response.json() == {
"id": 42,
"muted": True,
"status": "unread",
"error": "Future updates are muted; current item is still unread. Retry mark read & next.",
}
assert calls == [("mute", 42), ("read", 42)]
@pytest.mark.anyio
async def test_acknowledge_notification_api_confirms_reaction_and_removes_snapshot_item(monkeypatch):
calls = []
+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-v99" in source
assert "stackchain-dashboard-shell-v101" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-readiness.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source
+35 -4
View File
@@ -19,6 +19,7 @@ const deadlineControl = {
addEventListener:(_name, callback) => state.deadlineChange = callback,
};
const deadlineHour = {value:'9', disabled:false, addEventListener:(_name, callback) => state.deadlineHourChange = callback};
const deadlineDays = {value:'2', disabled:false, addEventListener:(_name, callback) => state.deadlineDaysChange = callback};
const deadlineStatus = {set textContent(value) { state.deadlineText = value; }, get textContent() { return state.deadlineText; }};
const status = {set textContent(value) { state.text = value; }, get textContent() { return state.text; }};
const existing = {endpoint:'https://push.example/device', toJSON() { return {endpoint:this.endpoint, keys:{p256dh:'key',auth:'auth'}}; }};
@@ -27,7 +28,7 @@ const registration = {pushManager:{
subscribe: async options => { state.subscriptions.push(options); state.current=existing; return existing; },
}};
const feature = createPushNotifications({
control, status, deadlineControl, deadlineStatus, deadlineHour,
control, status, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
notification: {permission:'default', requestPermission:async () => { state.prompts += 1; return state.permission || 'granted'; }},
serviceWorker: {ready:Promise.resolve(registration)},
fetchJson: async (url, options={}) => { state.requests.push([url,options.method || 'GET',options.body || '']); return state.server || {available:true,subscribed:false,public_key:'AQID'}; },
@@ -80,7 +81,7 @@ process.stdout.write(JSON.stringify(state));
assert body["enabled"] is True
assert body["reminder_hour"] == 9
assert isinstance(body["timezone"], str) and body["timezone"]
assert result["deadlineText"] == "Deadline reminders enabled for 09:00 local time."
assert result["deadlineText"] == "Deadline reminders enabled for 09:00 local time, next 2 days."
def test_deadline_setup_subscribes_once_and_persists_selected_local_hour():
@@ -100,7 +101,35 @@ process.stdout.write(JSON.stringify(state));
body = json.loads(result["requests"][-1][2])
assert body["enabled"] is True
assert body["reminder_hour"] == 8
assert result["deadlineText"] == "Deadline reminders enabled for 08:00 local time."
assert result["deadlineText"] == "Deadline reminders enabled for 08:00 local time, next 2 days."
def test_deadline_setup_persists_and_restores_confirmed_horizon():
saved = run_scenario("""
state.server = {available:true,subscribed:true,deadline_enabled:true,reminder_hour:9,reminder_days:2,public_key:'AQID'};
state.current = existing;
await feature.init();
deadlineDays.value = '7';
await feature.changeDeadline();
process.stdout.write(JSON.stringify(state));
""")
assert json.loads(saved["requests"][-1][2])["reminder_days"] == 7
assert saved["deadlineText"] == "Deadline reminders enabled for 09:00 local time, next 7 days."
restored = run_scenario("""
state.server = {available:true,subscribed:true,deadline_enabled:true,reminder_hour:17,reminder_days:0,public_key:'AQID'};
state.current = existing;
await feature.init();
process.stdout.write(JSON.stringify({days:deadlineDays.value, readiness:feature.deadlineReadiness()}));
""")
assert restored == {
"days": "0",
"readiness": {
"state": "complete",
"detail": "Deadline reminders enabled for 17:00 local time, due today.",
},
}
def test_deadline_setup_restores_confirmed_hour_and_denial_stays_incomplete():
@@ -112,7 +141,7 @@ process.stdout.write(JSON.stringify({hour:deadlineHour.value, checked:deadlineCo
""")
assert restored == {
"hour": "17", "checked": True,
"readiness": {"state": "complete", "detail": "Deadline reminders enabled for 17:00 local time."},
"readiness": {"state": "complete", "detail": "Deadline reminders enabled for 17:00 local time, next 2 days."},
}
denied = run_scenario("""
@@ -142,6 +171,8 @@ def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller():
assert 'id="push-update-status"' in html
assert 'id="push-deadlines"' in html
assert 'id="push-deadline-hour"' in html
assert 'id="push-deadline-days"' in html
assert 'id="device-setup-deadline-days"' in html
assert 'id="push-deadline-status"' in html
assert '<script src="static/push-notifications.js"></script>' in html
assert "createPushNotifications({" in dashboard
+4 -1
View File
@@ -980,6 +980,7 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
"deadline_enabled": False,
"timezone": "UTC",
"reminder_hour": 9,
"reminder_days": 2,
}
assert await main.subscribe_push(payload, request) == {"subscribed": True}
assert (await main.push_status(request))["subscribed"] is True
@@ -1002,15 +1003,17 @@ async def test_authenticated_device_can_enable_deadline_reminders(tmp_path, monk
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
payload = main.DeadlineReminderPayload(
enabled=True, timezone="America/New_York", reminder_hour=9
enabled=True, timezone="America/New_York", reminder_hour=9, reminder_days=7
)
assert await main.update_deadline_reminders(payload, request) == {
"deadline_enabled": True,
"timezone": "America/New_York",
"reminder_hour": 9,
"reminder_days": 7,
}
assert store.deadline_preferences("session-a")["enabled"] is True
assert store.deadline_preferences("session-a")["reminder_days"] == 7
@pytest.mark.anyio
+96
View File
@@ -0,0 +1,96 @@
import json
import subprocess
from pathlib import Path
SEARCH_BATCH_PLAN = Path(__file__).parents[1] / "frontend" / "search-batch-plan.js"
HTML = Path(__file__).parents[1] / "frontend" / "index.html"
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
def run_node(script):
return json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
def test_search_batch_selection_keeps_open_issues_across_pages_and_rejects_ineligible_results():
script = f"""
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
const states=[];
const flow=createSearchBatchPlan({{onChange:state=>states.push(state)}});
const first={{kind:'issue',state:'open',repository:'stackchain/dashboard',number:809,title:'First'}};
const second={{kind:'issue',state:'open',repository:'stackchain/api',number:17,title:'Second'}};
const pull={{kind:'pull',state:'open',repository:'stackchain/dashboard',number:810}};
const closed={{kind:'issue',state:'closed',repository:'stackchain/dashboard',number:808}};
flow.start();
const outcomes=[flow.toggle(first),flow.toggle(pull),flow.toggle(closed),flow.toggle(second)];
process.stdout.write(JSON.stringify({{outcomes,snapshot:flow.snapshot(),states}}));
"""
result = run_node(script)
assert result["outcomes"] == ["selected", "ineligible", "ineligible", "selected"]
assert result["snapshot"] == {
"active": True,
"count": 2,
"items": [
{"kind": "issue", "state": "open", "repository": "stackchain/dashboard", "number": 809, "title": "First"},
{"kind": "issue", "state": "open", "repository": "stackchain/api", "number": 17, "title": "Second"},
],
}
assert result["states"][-1]["count"] == 2
def test_search_batch_prepares_owned_issues_without_reclaiming_and_claims_unassigned_issues():
script = f"""
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
const calls=[];
const details={{
809:{{kind:'issue',state:'open',repository:'stackchain/dashboard',number:809,assigned_to_me:true}},
810:{{kind:'issue',state:'open',repository:'stackchain/dashboard',number:810,claimable:true}},
811:{{kind:'issue',state:'open',repository:'stackchain/dashboard',number:811,claimable:false}},
}};
const flow=createSearchBatchPlan({{
resolve:item=>{{calls.push('resolve:'+item.number);return Promise.resolve(details[item.number]);}},
claim:detail=>{{calls.push('claim:'+detail.number);return Promise.resolve({{...detail,assigned_to_me:true}});}},
}});
Promise.all([
flow.prepare({{number:809}}),
flow.prepare({{number:810}}),
flow.prepare({{number:811}}).catch(error=>error.message),
]).then(results=>process.stdout.write(JSON.stringify({{results,calls}})));
"""
result = run_node(script)
assert result["calls"] == ["resolve:809", "resolve:810", "resolve:811", "claim:810"]
assert result["results"][0]["assigned_to_me"] is True
assert result["results"][1]["assigned_to_me"] is True
assert result["results"][2] == "This issue is no longer available to assign."
def test_mobile_search_exposes_touch_safe_durable_batch_planning_controls():
html = HTML.read_text()
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
worker = (HTML.parent / "service-worker.js").read_text()
controller = SEARCH_BATCH_PLAN.read_text()
assert 'id="select-search-results"' in html
assert 'id="search-batch-actions"' in html
assert 'id="queue-selected-search-results"' in html
assert 'id="resume-search-batch"' in html
assert 'src="static/search-batch-plan.js"' in html
assert "mountSearchBatchPlanning(" in dashboard
assert "journalName:'search-today-batch'" in controller
assert "autoMount:false" in controller
assert "plan.toggle(result)" in controller
assert "processor.run(plan.snapshot().items)" in controller
assert "processor.resume()" in controller
assert 'aria-label="Select ' in controller
assert "result.kind === 'issue' && result.state === 'open'" in controller
assert ".search-batch-actions" in css
assert "position:sticky" in css
assert "env(safe-area-inset-bottom)" in css
assert ".cmd-select-result" in css and "min-height:44px" in css
assert "BASE + 'static/search-batch-plan.js'" in worker
+211
View File
@@ -401,6 +401,217 @@ async def test_successful_protected_actions_record_only_bounded_targets(
assert b"abc123" not in persisted
@pytest.mark.anyio
@pytest.mark.parametrize(
"path",
[
"/api/v1/repos/stackchain/api/issues/7/comments/42",
"/api/v1/repos/stackchain/api/pulls/7/comments/42",
"/api/v1/notifications/91/comments/42",
],
)
async def test_comment_deletion_requires_exact_fresh_authorization_before_gitea(
security_access, monkeypatch, path
):
calls = []
async def assigned(*_args):
return True
async def notification_target(thread_id):
assert thread_id == 91
return "stackchain/api", 7
async def delete(*args):
calls.append(args)
return {"id": 42, "deleted": True}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "notification_conversation_target", notification_target)
monkeypatch.setattr(main.gitea_proxy, "delete_owned_comment", delete)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
)
headers = {
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
}
response = await client.delete(path, headers=headers)
assert response.status_code == 428
assert response.json()["detail"] == {
"detail": "Fresh authorization required",
"code": "step_up_required",
"action": "delete_comment",
"target": "stackchain/api#7:42",
}
assert calls == []
@pytest.mark.anyio
async def test_authorized_comment_deletion_records_only_privacy_safe_target(
security_access, monkeypatch
):
async def assigned(*_args):
return True
async def delete(*_args):
return {"id": 42, "deleted": True}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "delete_owned_comment", delete)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
)
headers = {
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
}
grant = await client.post(
"/api/v1/fresh-authorization",
json={
"access_token": "correct horse battery staple",
"action": "delete_comment",
"target": "stackchain/api#7:42",
},
headers=headers,
)
response = await client.delete(
"/api/v1/repos/stackchain/api/issues/7/comments/42",
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
)
events = (await client.get("/api/v1/security-events")).json()["events"]
assert response.status_code == 200
assert (events[0]["kind"], events[0]["target"], events[0]["status"]) == (
"comment_deleted", "stackchain/api#7:42", "completed"
)
persisted = (security_access / "security.sqlite3").read_bytes()
assert b"private comment body" not in persisted
assert b"correct horse battery staple" not in persisted
@pytest.mark.anyio
async def test_comment_deletion_reservation_failure_prevents_gitea_mutation(
security_access, monkeypatch
):
calls = []
async def assigned(*_args):
return True
async def delete(*args):
calls.append(args)
return {"id": 42, "deleted": True}
class UnavailableJournal:
def reserve(self, *_args, **_kwargs):
raise SecurityEventStoreError("unavailable")
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "delete_owned_comment", delete)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
)
headers = {
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
}
grant = await client.post(
"/api/v1/fresh-authorization",
json={
"access_token": "correct horse battery staple",
"action": "delete_comment",
"target": "stackchain/api#7:42",
},
headers=headers,
)
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
response = await client.delete(
"/api/v1/repos/stackchain/api/issues/7/comments/42",
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
)
assert response.status_code == 503
assert calls == []
@pytest.mark.anyio
@pytest.mark.parametrize("finalize_fails", [False, True])
async def test_comment_deletion_journal_tracks_failed_and_confirmed_outcomes(
security_access, monkeypatch, finalize_fails
):
journal_calls = []
async def assigned(*_args):
return True
async def delete(*_args):
if not finalize_fails:
raise RuntimeError("upstream rejected deletion")
return {"id": 42, "deleted": True}
class Journal:
def reserve(self, kind, *, target):
journal_calls.append(("reserve", kind, target))
return "operation-42"
def discard(self, operation_id):
journal_calls.append(("discard", operation_id))
def finalize(self, operation_id):
journal_calls.append(("finalize", operation_id))
raise SecurityEventStoreError("unavailable")
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "delete_owned_comment", delete)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
)
headers = {
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
}
grant = await client.post(
"/api/v1/fresh-authorization",
json={
"access_token": "correct horse battery staple",
"action": "delete_comment",
"target": "stackchain/api#7:42",
},
headers=headers,
)
monkeypatch.setattr(main, "_security_event_store", lambda: Journal())
response = await client.delete(
"/api/v1/repos/stackchain/api/issues/7/comments/42",
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
)
assert journal_calls[0] == (
"reserve", "comment_deleted", "stackchain/api#7:42"
)
if finalize_fails:
assert response.status_code == 200
assert response.json() == {"id": 42, "deleted": True}
assert journal_calls[1] == ("finalize", "operation-42")
else:
assert response.status_code == 503
assert journal_calls[1] == ("discard", "operation-42")
@pytest.mark.anyio
async def test_approved_pull_review_records_a_privacy_safe_completed_event(
security_access, monkeypatch
+82 -11
View File
@@ -13,7 +13,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: [], 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: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], sharedRecords: {{}}, 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',
@@ -46,6 +46,11 @@ const context = {{
location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }},
__STACKCHAIN_NAVIGATION_TIMEOUT_MS: 15,
__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS: 15,
__STACKCHAIN_SHARED_ATTACHMENT_STORE: {{
put: async (id, value) => {{ state.sharedRecords[id] = value; }},
get: async id => state.sharedRecords[id] || null,
delete: async id => {{ delete state.sharedRecords[id]; }},
}},
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
skipWaiting: async () => {{ state.skipped = true; }},
clients: {{
@@ -145,7 +150,7 @@ async function dispatchPush(payload) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v99" in source
assert "stackchain-dashboard-shell-v101" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -154,14 +159,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v99" in source
assert "stackchain-dashboard-shell-v101" in source
assert "BASE + 'static/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v99" in source
assert "stackchain-dashboard-shell-v101" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -169,7 +174,7 @@ def test_offline_review_next_ships_today_completion_atomically():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v99" in source
assert "stackchain-dashboard-shell-v101" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -177,14 +182,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v99" in source
assert "stackchain-dashboard-shell-v101" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v99" in source
assert "stackchain-dashboard-shell-v101" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -193,24 +198,77 @@ 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-v99" in source
assert "stackchain-dashboard-shell-v101" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v99" in source
assert "stackchain-dashboard-shell-v101" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v99" in source
assert "stackchain-dashboard-shell-v101" in source
assert "BASE + 'static/update-ownership.js'" in source
def test_image_share_target_stages_one_supported_image_and_redirects_to_capture():
result = run_worker_scenario(
"""
const image = new Blob(['pixels'], {type:'image/png'});
Object.defineProperty(image, 'name', {value:'bug.png'});
const form = new FormData();
form.append('title', 'Broken checkout');
form.append('text', 'Steps to reproduce');
form.append('url', 'https://example.test/checkout');
form.append('image', image, 'bug.png');
const request = new Request('https://forge.example/dashboard/', {method:'POST', body:form});
Object.defineProperty(request, 'mode', {value:'navigate'});
const response = await dispatch('fetch', request);
const [id, record] = Object.entries(state.sharedRecords)[0];
process.stdout.write(JSON.stringify({
status:response.status, location:response.headers.get('Location'), id,
filename:record.filename, contentType:record.contentType, size:record.blob.size,
}));
"""
)
assert result == {
"status": 303,
"location": "/dashboard/?launch=new&shared=image&title=Broken+checkout&text=Steps+to+reproduce&url=https%3A%2F%2Fexample.test%2Fcheckout",
"id": "shared-image",
"filename": "bug.png",
"contentType": "image/png",
"size": 6,
}
def test_image_share_target_rejects_multiple_images_without_staging_private_data():
result = run_worker_scenario(
"""
const form = new FormData();
form.append('image', new Blob(['one'], {type:'image/png'}), 'one.png');
form.append('image', new Blob(['two'], {type:'image/jpeg'}), 'two.jpg');
const request = new Request('https://forge.example/dashboard/', {method:'POST', body:form});
Object.defineProperty(request, 'mode', {value:'navigate'});
const response = await dispatch('fetch', request);
process.stdout.write(JSON.stringify({
status:response.status, location:response.headers.get('Location'), records:Object.keys(state.sharedRecords),
}));
"""
)
assert result == {
"status": 303,
"location": "/dashboard/?launch=new&shared=multiple",
"records": [],
}
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
result = run_worker_scenario(
"""
@@ -703,7 +761,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v99" in source
assert "stackchain-dashboard-shell-v101" in source
assert "BASE + 'static/queue-today.js'" in source
@@ -730,9 +788,11 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/markdown.js",
"/dashboard/static/commands.js",
"/dashboard/static/search-preview.js",
"/dashboard/static/search-defer.js",
"/dashboard/static/widgets.js",
"/dashboard/static/drafts.js",
"/dashboard/static/unfiled-captures.js",
"/dashboard/static/shared-image-capture.js",
"/dashboard/static/draft-filing-session.js",
"/dashboard/static/draft-capacity-dialog.js",
"/dashboard/static/outbox-coordinator.js",
@@ -770,6 +830,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/later-picker.js",
"/dashboard/static/pick-work.js",
"/dashboard/static/batch-find-work.js",
"/dashboard/static/search-batch-plan.js",
"/dashboard/static/conversation.js",
"/dashboard/static/comment-actions.js",
"/dashboard/static/issue-attachment.js",
@@ -783,10 +844,20 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/work-route.js",
"/dashboard/static/task-overlay-history.js",
"/dashboard/static/context-poller.js",
"/dashboard/static/live-data-status.js",
"/dashboard/static/mobile-task-dock.js",
"/dashboard/static/mobile-work-entry.js",
"/dashboard/static/mobile-queue-launcher.js",
"/dashboard/static/update-triage-session.js",
"/dashboard/static/update-review-handoff.js",
"/dashboard/static/update-read-position.js",
"/dashboard/static/work-detail-position.js",
"/dashboard/static/update-triage-launcher.js",
"/dashboard/static/update-triage-gesture.js",
"/dashboard/static/update-decision-transaction.js",
"/dashboard/static/agenda-session-launcher.js",
"/dashboard/static/mobile-launch.js",
"/dashboard/static/mobile-app-shortcuts.js",
"/dashboard/static/install-app.js",
"/dashboard/static/mobile-device-setup.js",
"/dashboard/static/mobile-search-viewport.js",
+54
View File
@@ -0,0 +1,54 @@
import json
import subprocess
from pathlib import Path
MODULE = Path(__file__).resolve().parents[1] / "frontend" / "shared-image-capture.js"
def test_shared_image_is_consumed_once_and_restored_into_capture():
script = f"""
const shared = require({json.dumps(str(MODULE))});
const calls=[];
const store={{
get:async id=>({{filename:'bug.png',contentType:'image/png',blob:{{size:6,type:'image/png'}}}}),
delete:async id=>calls.push(['delete',id]),
}};
shared.consume({{
marker:'image', store,
restore:value=>calls.push(['restore',value.filename]),
status:message=>calls.push(['status',message]),
}}).then(first=>shared.consume({{marker:'',store,restore:()=>calls.push(['restore-again'])}}).then(second=>{{
process.stdout.write(JSON.stringify({{first,second,calls}}));
}}));
"""
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
assert json.loads(completed.stdout) == {
"first": True,
"second": False,
"calls": [
["restore", "bug.png"],
["delete", "shared-image"],
["status", "Shared screenshot ready to file with this issue."],
],
}
def test_invalid_shared_image_reports_error_without_touching_open_capture():
script = f"""
const shared = require({json.dumps(str(MODULE))});
const calls=[];
shared.consume({{
marker:'unsupported', store:{{get:async()=>null,delete:async()=>calls.push('delete')}},
restore:()=>calls.push('restore'), status:message=>calls.push(message),
}}).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
"""
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
assert json.loads(completed.stdout) == {
"result": False,
"calls": ["Share one PNG, JPEG, or WebP screenshot."],
}
+130 -1
View File
@@ -207,8 +207,137 @@ def test_dashboard_routes_mobile_task_overlays_through_browser_history():
assert "createTaskOverlayHistory({" in html
assert "taskOverlayHistory.open('new')" in html
assert "taskOverlayHistory.open('find')" in html
assert "taskOverlayHistory.open('search')" in html
assert "taskOverlayHistory.open('search', { scope:currentSearchScope() })" in html
assert "taskOverlayHistory.open('search-preview')" in html
assert "taskOverlayHistory.close()" in html
assert "saveIssueCaptureDraft();" in html
assert "history.replaceState(history.state || {}, '', cleanUrl)" in html
def test_search_history_serializes_bounded_query_and_stable_preview_identity():
script = f"""
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
const pushed = [];
const replaced = [];
const location = {{ pathname:'/dashboard/', search:'?keep=1', hash:'' }};
const history = {{
state: {{ page:'dashboard' }},
pushState(state, title, url) {{ this.state = state; pushed.push([state, url]); }},
replaceState(state, title, url) {{ this.state = state; replaced.push([state, url]); }},
back() {{}},
}};
const controller = createTaskOverlayHistory({{
history, location, eventTarget:{{addEventListener() {{}}}}, onChange() {{}},
}});
controller.open('search', {{ query:' release blocker ' }});
controller.update({{ query:'release candidate' }});
controller.open('search-preview', {{
query:'release candidate', preview:{{kind:'issue', repository:'stackchain/api', number:123,
title:'must not be serialized', body:'secret'}},
}});
process.stdout.write(JSON.stringify({{pushed, replaced, current:controller.currentState()}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
assert len(payload["pushed"]) == 2
assert len(payload["replaced"]) == 1
assert payload["pushed"][0][1] == "/dashboard/?keep=1&search=release+blocker"
assert payload["replaced"][0][1] == "/dashboard/?keep=1&search=release+candidate"
assert payload["pushed"][1][1] == (
"/dashboard/?keep=1&search=release+candidate&preview=issue%3Astackchain%2Fapi%3A123"
)
assert payload["current"] == {
"kind": "search-preview",
"query": "release candidate",
"preview": {"kind": "issue", "repository": "stackchain/api", "number": 123},
}
def test_search_history_restores_direct_url_and_rejects_malformed_or_oversized_state():
script = f"""
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
function restore(search) {{
const changes = [];
const location = {{ pathname:'/dashboard/', search, hash:'' }};
const history = {{ state:null, replaceState(state) {{ this.state = state; }}, back() {{}} }};
const controller = createTaskOverlayHistory({{
history, location, eventTarget:{{addEventListener() {{}}}},
onChange(kind, previous, detail) {{ changes.push([kind, previous, detail]); }},
}});
controller.start();
return {{ state:controller.currentState(), changes }};
}}
process.stdout.write(JSON.stringify({{
valid:restore('?search=release+blocker&preview=pull%3Astackchain%2Fapi%3A9'),
malformed:restore('?search=ok&preview=issue%3Ainvalid%3A0'),
oversized:restore('?search=' + 'x'.repeat(201)),
}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
expected = {
"kind": "search-preview",
"query": "release blocker",
"preview": {"kind": "pull", "repository": "stackchain/api", "number": 9},
}
assert payload["valid"]["state"] == expected
assert payload["valid"]["changes"] == [["search-preview", None, expected]]
assert payload["malformed"]["state"] == {"kind": "search", "query": "ok"}
assert payload["oversized"]["state"] == {"kind": None}
def test_search_history_round_trips_bounded_type_status_and_repository_scope():
script = f"""
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
function restore(search) {{
const location = {{pathname:'/dashboard/', search, hash:''}};
const history = {{state:null, replaceState(state, title, url) {{this.state=state; this.url=url;}}, back() {{}}}};
const controller = createTaskOverlayHistory({{
history, location, eventTarget:{{addEventListener() {{}}}}, onChange() {{}},
}});
return {{state:controller.currentState(), url:history.url}};
}}
const location = {{pathname:'/dashboard/', search:'', hash:''}};
const history = {{state:null, pushState(state,title,url) {{this.state=state; this.url=url;}}, replaceState() {{}}, back() {{}}}};
const controller = createTaskOverlayHistory({{
history, location, eventTarget:{{addEventListener() {{}}}}, onChange() {{}},
}});
controller.open('search', {{query:'mobile', scope:{{kind:'pull',state:'open',repository:'stackchain/api'}}}});
process.stdout.write(JSON.stringify({{
opened:controller.currentState(), url:history.url,
valid:restore('?search=mobile&search_kind=issue&search_state=closed&search_repository=stackchain%2Fapi'),
invalid:restore('?search=mobile&search_kind=script&search_state=secret&search_repository=stackchain%2Fapi%2Fprivate'),
}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
assert payload["opened"] == {
"kind": "search", "query": "mobile", "scope": {
"kind": "pull", "state": "open", "repository": "stackchain/api"
}
}
assert payload["url"] == (
"/dashboard/?search=mobile&search_kind=pull&search_state=open&"
"search_repository=stackchain%2Fapi"
)
assert payload["valid"]["state"]["scope"] == {
"kind": "issue", "state": "closed", "repository": "stackchain/api"
}
assert payload["invalid"]["state"]["scope"] == {
"kind": "all", "state": "all", "repository": ""
}
def test_dashboard_persists_search_query_and_restores_canonical_preview():
html = dashboard_bundle_text()
assert "taskOverlayHistory.update({ query:e.target.value })" in html
assert "scope:currentSearchScope(), preview:item.result" in html
assert "detail?.query" in html
assert "searchPreview.open(detail.preview).catch" in html
+1 -1
View File
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text()
assert "const CACHE = 'stackchain-dashboard-shell-v99';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v101';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
+1 -1
View File
@@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v99" in source
assert "stackchain-dashboard-shell-v101" in source
assert "BASE + 'static/today-sync.js'" in source
+2 -1
View File
@@ -344,7 +344,8 @@ async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow():
assert "const todayWork = createTodayWork({" in html
assert "getTodayCount: () => todayMyWork.length" in html
assert "startToday: startTodaySession" in html
assert "selectedWorkFilter === 'today' ? todayMyWork : activeMyWork" in html
assert "selectedWorkFilter === 'today' ? todayMyWork :" in html
assert "selectedWorkFilter === 'agenda' ? agendaMyWork(activeMyWork) : activeMyWork" in html
assert 'data-today-add' in html
assert 'data-today-remove' in html
assert 'data-today-move="up"' in html
+87
View File
@@ -0,0 +1,87 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
POSITIONER = Path(__file__).resolve().parents[1] / "frontend" / "update-read-position.js"
def run_positioner(script: str) -> dict:
source = f"const createPositioner = require({json.dumps(str(POSITIONER))});\n" + script
result = subprocess.run(["node", "-e", source], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_new_update_scrolls_to_newest_activity_once_without_reusing_previous_offset():
result = run_positioner("""
const listeners = {};
const panel = {scrollTop:480, addEventListener:(name, fn)=>listeners[name]=fn};
const jump = {hidden:true, focus(){}};
const first = {calls:0, scrollIntoView(){this.calls += 1}, classList:{add(){}}};
const second = {calls:0, scrollIntoView(){this.calls += 1}, classList:{add(){}}};
const positioner = createPositioner({panel, jump});
positioner.open('101');
positioner.ready('101', first);
positioner.ready('101', first);
panel.scrollTop = 920;
positioner.open('102');
positioner.ready('102', second);
process.stdout.write(JSON.stringify({first:first.calls, second:second.calls, jumpHidden:jump.hidden}));
""")
assert result == {"first": 1, "second": 1, "jumpHidden": True}
def test_user_scroll_during_loading_preserves_position_until_explicit_jump():
result = run_positioner("""
const listeners = {};
const panel = {scrollTop:120, addEventListener:(name, fn)=>listeners[name]=fn};
const jump = {hidden:true, focus(){}};
const target = {calls:0, scrollIntoView(){this.calls += 1}, classList:{add(){}}};
const positioner = createPositioner({panel, jump});
positioner.open('201');
panel.scrollTop = 260;
listeners.scroll();
positioner.ready('201', target);
const before = {calls:target.calls, hidden:jump.hidden, top:panel.scrollTop};
positioner.jump();
process.stdout.write(JSON.stringify({before, after:{calls:target.calls, hidden:jump.hidden}}));
""")
assert result == {
"before": {"calls": 0, "hidden": False, "top": 260},
"after": {"calls": 1, "hidden": True},
}
def test_stale_or_prefetched_conversation_cannot_move_the_visible_update():
result = run_positioner("""
const panel = {scrollTop:0, addEventListener(){}};
const jump = {hidden:true};
const stale = {calls:0, scrollIntoView(){this.calls += 1}, classList:{add(){}}};
const current = {calls:0, scrollIntoView(){this.calls += 1}, classList:{add(){}}};
const positioner = createPositioner({panel, jump});
positioner.open('302');
positioner.ready('301', stale);
positioner.ready('302', current);
process.stdout.write(JSON.stringify({stale:stale.calls, current:current.calls}));
""")
assert result == {"stale": 0, "current": 1}
@pytest.mark.anyio
async def test_dashboard_mounts_mobile_new_activity_positioning_flow():
html = await dashboard()
assert '<script src="static/update-read-position.js"></script>' in html
assert 'id="jump-update-new-activity"' in html
assert "updateReadPosition.open(String(item.notification_id))" in html
assert "updateReadPosition.ready(String(selectedUpdate?.notification_id || ''), newest)" in html
assert ".update-new-activity" in html
assert "BASE + 'static/update-read-position.js'" in (POSITIONER.parent / "service-worker.js").read_text()
+96
View File
@@ -0,0 +1,96 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
HANDOFF = Path(__file__).resolve().parents[1] / "frontend" / "update-review-handoff.js"
def run_handoff(script):
source = f"const createHandoff = require({json.dumps(str(HANDOFF))});\n" + script
result = subprocess.run(["node", "-e", source], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_review_handoff_only_admits_review_requested_pull_updates():
result = run_handoff("""
const handoff = createHandoff({openReview:()=>{}, admitRead:async()=>{}, advance:()=>{}});
const cases = [
{is_review:true, update_reason:'Review requested', subject_type:'Pull', repository:'acme/app', number:7, notification_id:41},
{is_review:true, update_reason:'Mentioned', subject_type:'Pull', repository:'acme/app', number:7, notification_id:42},
{is_review:true, update_reason:'Review requested', subject_type:'Issue', repository:'acme/app', number:7, notification_id:43},
{is_review:false, update_reason:'Review requested', subject_type:'Pull', repository:'acme/app', number:7, notification_id:44},
];
process.stdout.write(JSON.stringify(cases.map(item => handoff.eligible(item))));
""")
assert result == [True, False, False, False]
def test_review_handoff_preserves_update_on_cancel_and_advances_after_read_admission():
result = run_handoff("""
(async () => {
const events = [];
const update = {is_review:true, update_reason:'Review requested', subject_type:'Pull', repository:'acme/app', number:7, notification_id:41, title:'Ship it'};
const handoff = createHandoff({
openReview:item=>events.push(['open', item.repository, item.number]),
restoreUpdate:item=>events.push(['restore', item.notification_id]),
admitRead:async id=>events.push(['read', id]),
advance:()=>events.push(['advance']),
});
handoff.begin(update);
handoff.cancel();
handoff.begin(update);
const completed = await handoff.complete();
process.stdout.write(JSON.stringify({events, completed, active:handoff.active()}));
})().catch(error => { console.error(error); process.exit(1); });
""")
assert result == {
"events": [
["open", "acme/app", 7],
["restore", 41],
["open", "acme/app", 7],
["read", 41],
["advance"],
],
"completed": True,
"active": False,
}
def test_review_handoff_keeps_current_update_when_read_admission_fails():
result = run_handoff("""
(async () => {
const events = [];
const update = {is_review:true, update_reason:'Review requested', subject_type:'Pull', repository:'acme/app', number:7, notification_id:41};
const handoff = createHandoff({
openReview:()=>events.push('open'),
admitRead:async()=>{ events.push('read'); throw new Error('queue unavailable'); },
advance:()=>events.push('advance'),
});
handoff.begin(update);
let message = '';
try { await handoff.complete(); } catch (error) { message = error.message; }
process.stdout.write(JSON.stringify({events, message, active:handoff.active()}));
})().catch(error => { console.error(error); process.exit(1); });
""")
assert result == {"events": ["open", "read"], "message": "queue unavailable", "active": True}
@pytest.mark.anyio
async def test_dashboard_wires_review_requested_update_into_existing_review_workspace():
html = await dashboard()
assert '<script src="static/update-review-handoff.js"></script>' in html
assert 'id="review-update-now"' in html
assert "updateReviewHandoff.begin(selectedUpdate)" in html
assert "await updateReviewHandoff.complete()" in html
assert "updateReviewHandoff.cancel()" in html
assert "updateReviewHandoff.eligible(detail" in html
+178
View File
@@ -0,0 +1,178 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
GESTURE = Path(__file__).resolve().parents[1] / "frontend" / "update-triage-gesture.js"
TRANSACTION = Path(__file__).resolve().parents[1] / "frontend" / "update-decision-transaction.js"
def run_gesture(script):
source = f"const createGesture = require({json.dumps(str(GESTURE))});\n" + script
result = subprocess.run(["node", "-e", source], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def run_transaction(script):
source = f"const createTransaction = require({json.dumps(str(TRANSACTION))});\n" + script
result = subprocess.run(["node", "-e", source], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_update_decision_transaction_locks_every_action_until_success():
result = run_transaction(r"""
const controls = ['keep', 'read', 'reply', 'more'].map(name => ({name, disabled:false, focus(){}}));
const status = {textContent:'Ready'};
let resolveAction;
let calls = 0;
const transaction = createTransaction({controls, status});
const pending = transaction.run('Marking read…', () => new Promise(resolve => {
calls += 1;
resolveAction = resolve;
}));
const locked = controls.every(control => control.disabled) && transaction.busy();
const rejected = transaction.run('Keeping unread…', () => { calls += 1; });
resolveAction(true);
Promise.all([pending, rejected]).then(([completed, second]) => process.stdout.write(JSON.stringify({
locked, pendingStatus: 'Marking read…', calls, completed, second,
unlocked: controls.every(control => !control.disabled) && !transaction.busy(),
finalStatus: status.textContent,
})));
""")
assert result == {
"locked": True,
"pendingStatus": "Marking read…",
"calls": 1,
"completed": True,
"second": False,
"unlocked": True,
"finalStatus": "Marking read…",
}
def test_update_decision_transaction_restores_focus_and_announces_failure():
result = run_transaction(r"""
let focused = 0;
let focusedWhileEnabled = false;
const origin = {disabled:false, focus(){ focused += 1; focusedWhileEnabled = !this.disabled; }};
const other = {disabled:false, focus(){}};
const status = {textContent:''};
const transaction = createTransaction({controls:[origin, other], status});
transaction.run('Marking read…', async () => false, {
origin,
failure:'Could not mark update read. Retry.',
}).then(completed => process.stdout.write(JSON.stringify({
completed, focused, focusedWhileEnabled, status:status.textContent,
unlocked:!origin.disabled && !other.disabled && !transaction.busy(),
})));
""")
assert result == {
"completed": False,
"focused": 1,
"focusedWhileEnabled": True,
"status": "Could not mark update read. Retry.",
"unlocked": True,
}
def test_committed_horizontal_swipes_decide_once_and_lock_while_pending():
result = run_gesture(r"""
class Surface {
constructor() { this.listeners = {}; this.dataset = {}; }
addEventListener(name, callback) { this.listeners[name] = callback; }
removeEventListener(name) { delete this.listeners[name]; }
setPointerCapture() {}
releasePointerCapture() {}
}
const surface = new Surface();
const calls = [];
let enabled = true;
let resolveRead;
let lockedCalls;
const gesture = createGesture({
surface,
enabled: () => enabled,
keepUnread: () => calls.push('keep'),
markRead: () => new Promise(resolve => { calls.push('read'); resolveRead = resolve; }),
viewportWidth: () => 390,
});
const target = {closest: () => null, parentElement: null, scrollWidth: 100, clientWidth: 100};
const event = (x, y, id=1) => ({pointerId:id, pointerType:'touch', clientX:x, clientY:y, target, preventDefault(){}});
surface.listeners.pointerdown(event(180, 200));
surface.listeners.pointermove(event(250, 205));
surface.listeners.pointerup(event(270, 206));
surface.listeners.pointerdown(event(280, 200));
surface.listeners.pointermove(event(205, 204));
surface.listeners.pointerup(event(190, 205));
surface.listeners.pointerdown(event(280, 200));
surface.listeners.pointermove(event(190, 203));
surface.listeners.pointerup(event(180, 203));
Promise.resolve().then(() => {
lockedCalls = [...calls];
resolveRead();
return Promise.resolve();
}).then(() => process.stdout.write(JSON.stringify({calls, lockedCalls, direction:surface.dataset.triageGesture || null})));
""")
assert result == {
"calls": ["keep", "read"],
"lockedCalls": ["keep", "read"],
"direction": None,
}
def test_scrolls_short_drags_edges_and_interactive_targets_never_decide():
result = run_gesture(r"""
class Surface {
constructor() { this.listeners = {}; this.dataset = {}; }
addEventListener(name, callback) { this.listeners[name] = callback; }
removeEventListener(name) { delete this.listeners[name]; }
setPointerCapture() {}
releasePointerCapture() {}
}
const surface = new Surface();
const calls = [];
createGesture({surface, enabled:()=>true, keepUnread:()=>calls.push('keep'), markRead:()=>calls.push('read'), viewportWidth:()=>390});
const plain = {closest: () => null, parentElement: null, scrollWidth:100, clientWidth:100};
const interactive = {closest: selector => selector.includes('button') ? {} : null, parentElement:null, scrollWidth:100, clientWidth:100};
const scroller = {closest: () => null, parentElement:null, scrollWidth:400, clientWidth:100};
const event = (x,y,target=plain,id=1) => ({pointerId:id,pointerType:'touch',clientX:x,clientY:y,target,preventDefault(){}});
function drag(fromX, fromY, toX, toY, target=plain) {
surface.listeners.pointerdown(event(fromX,fromY,target));
surface.listeners.pointermove(event(toX,toY,target));
surface.listeners.pointerup(event(toX,toY,target));
}
drag(180,100,205,104);
drag(180,100,220,190);
drag(8,100,120,104);
drag(180,100,280,104,interactive);
drag(180,100,280,104,scroller);
surface.listeners.pointerdown(event(180,100));
surface.listeners.pointercancel(event(280,104));
process.stdout.write(JSON.stringify({calls, direction:surface.dataset.triageGesture || null}));
""")
assert result == {"calls": [], "direction": None}
@pytest.mark.anyio
async def test_dashboard_wires_accessible_phone_only_update_swipe_triage():
html = await dashboard()
assert '<script src="static/update-triage-gesture.js"></script>' in html
assert '<script src="static/update-decision-transaction.js"></script>' in html
assert 'id="update-gesture-status"' in html
assert "createUpdateTriageGesture({" in html
assert "createUpdateDecisionActions({" in html
assert "keepUnread: keepUpdateUnread" in html
assert "markRead: markUpdateRead" in html
assert "data-triage-gesture" in html
assert "prefers-reduced-motion: reduce" in html
+191
View File
@@ -0,0 +1,191 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
SESSION = Path(__file__).resolve().parents[1] / "frontend" / "update-triage-session.js"
def run_session(script):
source = f"const createSession = require({json.dumps(str(SESSION))});\n" + script
result = subprocess.run(["node", "-e", source], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_update_triage_progress_exposes_the_current_actionability_reason():
result = run_session("""
const values = new Map();
const items = [
{notification_id:7, update_reason:'Review requested'},
{notification_id:8, update_reason:''},
];
const progress = [];
const session = createSession({
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
getLogin:()=> 'timmy', getItems:()=>items, onOpen:()=>{},
onProgress:value=>progress.push(value), onFinish:()=>{},
});
session.start();
session.completeAndNext();
process.stdout.write(JSON.stringify(progress));
""")
assert result == [
{"index": 1, "total": 2, "reason": "Review requested"},
{"index": 2, "total": 2},
]
def test_update_triage_persists_account_bound_pass_and_resumes_by_identity():
result = run_session("""
const values = new Map();
let login = 'timmy';
let items = [1,2,3].map(id => ({notification_id:id,title:'Update '+id}));
const opened = [], progress = [];
const options = {
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
getLogin:()=>login, getItems:()=>items,
onOpen:item=>opened.push(item.notification_id), onProgress:value=>progress.push(value), onFinish:()=>opened.push('done'),
};
let session = createSession(options);
session.start();
session.keepUnreadAndNext();
const saved = JSON.parse(values.get('stackchain.update-triage.v1'));
session = createSession(options);
const resumable = session.resumable();
session.resume();
login = 'other';
const isolated = session.resumable();
process.stdout.write(JSON.stringify({opened, progress, saved, resumable, isolated}));
""")
assert result["opened"] == [1, 2, 2]
assert result["progress"] == [
{"index": 1, "total": 3},
{"index": 2, "total": 3},
{"index": 2, "total": 3},
]
assert result["saved"] == {
"version": 2,
"login": "timmy",
"identities": ["1", "2", "3"],
"current": "2",
"completed": ["1"],
"kept": ["1"],
}
assert result["resumable"] is True
assert result["isolated"] is False
def test_update_triage_reconciles_removed_items_and_does_not_admit_new_arrivals():
result = run_session("""
const values = new Map();
let items = [1,2,3].map(notification_id => ({notification_id}));
const opened = [], progress = [];
const session = createSession({
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
getLogin:()=> 'timmy', getItems:()=>items,
onOpen:item=>opened.push(item.notification_id), onProgress:value=>progress.push(value), onFinish:()=>opened.push('done'),
});
session.start();
items = [1,3,4].map(notification_id => ({notification_id}));
session.completeAndNext();
session.completeAndNext();
process.stdout.write(JSON.stringify({opened, progress, stored:values.get('stackchain.update-triage.v1') || null}));
""")
assert result == {
"opened": [1, 3, "done"],
"progress": [{"index": 1, "total": 3}, {"index": 3, "total": 3}],
"stored": None,
}
def test_update_triage_exposes_only_the_next_surviving_snapshot_item():
result = run_session("""
const values = new Map();
let items = [1,2,3].map(notification_id => ({notification_id}));
const session = createSession({
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
getLogin:()=> 'timmy', getItems:()=>items,
onOpen:()=>{}, onProgress:()=>{}, onFinish:()=>{},
});
session.start();
const first = session.next()?.notification_id;
items = [1,3,4].map(notification_id => ({notification_id}));
const afterRemoval = session.next()?.notification_id;
session.keepUnreadAndNext();
const afterAdvance = session.next();
process.stdout.write(JSON.stringify({first, afterRemoval, afterAdvance:afterAdvance || null}));
""")
assert result == {"first": 2, "afterRemoval": 3, "afterAdvance": None}
def test_update_triage_reports_truthful_outcomes_and_reopens_only_surviving_kept_items():
result = run_session("""
const values = new Map();
let items = [1,2,3].map(notification_id => ({notification_id}));
const opened = [], outcomes = [];
const session = createSession({
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
getLogin:()=> 'timmy', getItems:()=>items,
onOpen:item=>opened.push(item.notification_id), onProgress:()=>{}, onFinish:value=>outcomes.push(value),
});
session.start();
session.keepUnreadAndNext();
session.completeAndNext();
items = [1,3].map(notification_id => ({notification_id}));
session.keepUnreadAndNext();
const reviewed = session.reviewKept();
process.stdout.write(JSON.stringify({opened, outcomes, reviewed}));
""")
assert result == {
"opened": [1, 2, 3, 1],
"outcomes": [{"reviewed": 3, "resolved": 1, "kept": 2, "keptIdentities": ["1", "3"]}],
"reviewed": True,
}
def test_update_triage_reconciles_kept_outcomes_before_reporting_cleared():
result = run_session("""
const values = new Map();
let items = [1,2].map(notification_id => ({notification_id}));
const outcomes = [];
const session = createSession({
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
getLogin:()=> 'timmy', getItems:()=>items,
onOpen:()=>{}, onProgress:()=>{}, onFinish:value=>outcomes.push(value),
});
session.start();
session.keepUnreadAndNext();
items = [2].map(notification_id => ({notification_id}));
session.completeAndNext();
process.stdout.write(JSON.stringify(outcomes[0]));
""")
assert result == {"reviewed": 2, "resolved": 2, "kept": 0, "keptIdentities": []}
@pytest.mark.anyio
async def test_dashboard_wires_resumable_updates_triage_mobile_flow():
html = await dashboard()
assert '<script src="static/update-triage-session.js"></script>' in html
assert 'id="update-triage-progress"' in html
assert 'id="keep-update-unread" type="button" aria-label="Keep unread and open next update">Keep unread</button>' in html
assert "openUpdates: openUpdateTriage" in html
assert "updateTriage.acceptCompleted()" in html
assert "triage:updateTriage" in html
assert "updateTriage.reconcile()" in html
assert 'id="mobile-update-outcome"' in html
assert 'id="review-kept-updates"' in html
assert "updateTriage.reviewKept(keptUpdateIdentities)" in html
assert "showUpdateTriageOutcome(outcome)" in html
assert ".update-triage-progress" in html
+2
View File
@@ -41,8 +41,10 @@ async def test_global_search_preview_is_a_phone_safe_accessible_dialog():
assert 'id="search-preview" role="dialog" aria-modal="true"' in html
assert 'aria-labelledby="search-preview-title"' in html
assert 'id="claim-search-result"' in html
assert 'id="share-search-result"' in html
assert 'id="open-search-result-gitea"' in html
assert "static/search-preview.js" in FRONTEND_BUILD.page_sources
assert ".search-preview-panel" in css
assert "height:100dvh" in css
assert "env(safe-area-inset-bottom)" in css
assert ".search-preview-actions button, .search-preview-actions a { min-height:44px;" in css
+60
View File
@@ -0,0 +1,60 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
POSITIONER = Path(__file__).resolve().parents[1] / "frontend" / "work-detail-position.js"
def run_positioner(script: str) -> dict:
source = f"const createPositioner = require({json.dumps(str(POSITIONER))});\n" + script
result = subprocess.run(["node", "-e", source], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_different_work_item_resets_to_top_while_same_item_keeps_position():
result = run_positioner("""
const panel = {scrollTop:640};
const positioner = createPositioner({panel});
const first = positioner.open('issue:stackchain/dashboard#41');
panel.scrollTop = 380;
const same = positioner.open('issue:stackchain/dashboard#41');
const changed = positioner.open('pull:stackchain/dashboard#42');
process.stdout.write(JSON.stringify({first, same, changed, top:panel.scrollTop}));
""")
assert result == {"first": True, "same": False, "changed": True, "top": 0}
def test_each_panel_tracks_its_own_visible_identity():
result = run_positioner("""
const issuePanel = {scrollTop:500};
const pullPanel = {scrollTop:700};
const issue = createPositioner({panel:issuePanel});
const pull = createPositioner({panel:pullPanel});
issue.open('issue:stackchain/dashboard#8');
pull.open('pull:stackchain/dashboard#9');
issuePanel.scrollTop = 220;
pullPanel.scrollTop = 330;
issue.open('issue:stackchain/dashboard#8');
pull.open('review:stackchain/dashboard#9');
process.stdout.write(JSON.stringify({issueTop:issuePanel.scrollTop, pullTop:pullPanel.scrollTop}));
""")
assert result == {"issueTop": 220, "pullTop": 0}
@pytest.mark.anyio
async def test_dashboard_positions_issue_pull_and_review_openers():
html = await dashboard()
assert '<script src="static/work-detail-position.js"></script>' in html
assert "issueDetailPosition.open(workDetailIdentity('issue', item))" in html
assert "pullDetailPosition.open(workDetailIdentity('pull', item))" in html
assert "reviewDetailPosition.open(workDetailIdentity('review', item))" in html
assert "BASE + 'static/work-detail-position.js'" in (POSITIONER.parent / "service-worker.js").read_text()
+28 -2
View File
@@ -79,15 +79,41 @@ async def test_pwa_assets_expose_root_scoped_share_target_without_caching_api_da
assert manifest.json()["start_url"] == "./"
assert manifest.json()["scope"] == "./"
assert manifest.json()["share_target"] == {
"action": "./", "method": "GET", "enctype": "application/x-www-form-urlencoded",
"params": {"title": "title", "text": "text", "url": "url"},
"action": "./", "method": "POST", "enctype": "multipart/form-data",
"params": {
"title": "title", "text": "text", "url": "url",
"files": [{"name": "image", "accept": ["image/png", "image/jpeg", "image/webp"]}],
},
}
assert manifest.json()["shortcuts"] == [
{
"name": "Continue work", "short_name": "Continue",
"description": "Resume the highest-priority mobile work flow.",
"url": "./?launch=continue",
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}],
},
{
"name": "New issue", "short_name": "New",
"description": "Capture work now and file it online or offline.",
"url": "./?launch=new",
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}],
},
{
"name": "Open Agenda", "short_name": "Agenda",
"description": "Check every assigned deadline and continue the Agenda session.",
"url": "./?launch=agenda",
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}],
},
]
assert worker.status_code == 200
assert worker.headers["content-type"].startswith("application/javascript")
assert worker.headers["service-worker-allowed"] == "/"
assert "request.url.includes('/api/')" in worker.text
assert "request.method !== 'GET'" in worker.text
assert "new URL('./', self.location.href).pathname" in worker.text
assert "acceptSharedContent(request)" in worker.text
assert "sharedImageCapture.consume" in (Path(__file__).resolve().parents[1] / "frontend" / "dashboard.js").read_text()
assert '<script src="static/shared-image-capture.js"></script>' in (Path(__file__).resolve().parents[1] / "frontend" / "index.html").read_text()
@pytest.mark.anyio