Compare commits

...
Author SHA1 Message Date
timmy 9a24aff537 Merge pull request 'Plan selected mobile Search issues into a release milestone' (#816) from timmy/815-plan-search-release into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m38s
Plan selected mobile Search issues into a release milestone (#816)
2026-08-14 09:34:28 +00:00
timmy 43769460fc feat: plan search batches into releases (Closes #815)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m37s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 09:30:53 +00:00
timmy 2e62eb09f5 Merge pull request 'Batch-schedule selected mobile Search issues into Later' (#814) from timmy/813-batch-defer-search-results 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 09:08:14 +00:00
timmy 1ef3e291c2 feat: batch-defer mobile search results (Closes #813)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m36s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 09:05:30 +00:00
timmy 3694f2501b Merge pull request 'Fit mobile Search batches to Today time capacity' (#812) from timmy/811-search-batch-time-capacity into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m38s
2026-08-14 08:31:24 +00:00
timmy c3d58d7111 feat: fit search batches to Today capacity (Closes #811)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m43s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 08:28:49 +00:00
timmy da45cc36a3 Batch-plan mobile Search results into Today (#810)
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m39s
2026-08-14 08:08:45 +00:00
timmy c04cba29d9 feat: batch-plan mobile search results into Today (Closes #809)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m37s
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 / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m34s
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 / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m33s
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 / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m45s
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 / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m39s
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 / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m42s
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 / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m41s
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 / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m37s
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 / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m45s
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 / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m45s
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 / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m41s
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 / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m31s
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 / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m34s
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 / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m33s
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 / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m28s
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 / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m25s
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 / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m42s
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 / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m37s
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 / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m36s
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 / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m41s
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 / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m32s
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 / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m30s
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 / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m33s
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 / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m38s
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 / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m41s
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 / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m33s
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 / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m39s
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 / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m43s
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 / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m33s
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 / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m37s
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 / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 1m37s
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 / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m34s
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 / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 1m37s
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 / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m33s
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 / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m27s
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 / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m31s
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 / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m26s
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 / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m25s
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 / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m27s
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 / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m30s
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 / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m33s
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
timmy 02a472f841 perf: bulk-authorize push devices (Closes #725)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m35s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 09:29:18 +00:00
timmy c5e6e17fc1 Merge pull request 'Launch Protect Today directly from deadline reminders' (#724) from timmy/723-deadline-protect-today into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m35s
2026-08-13 08:59:34 +00:00
timmy 8a12524595 feat: launch Protect Today from deadline reminders (Closes #723)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m22s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 08:57:32 +00:00
timmy cbe4017bdc Merge pull request 'Protect Today by reconciling urgent Agenda deadlines' (#722) from timmy/721-protect-today into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m30s
2026-08-13 08:30:35 +00:00
timmy fcc9ead33e feat: protect Today from urgent deadlines (Closes #721)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 08:27:01 +00:00
timmy 0d29b29bef Merge pull request 'Preserve deadline calendar days across mobile time zones' (#720) from timmy/719-calendar-day-deadlines into main
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m21s
2026-08-13 07:58:32 +00:00
timmy dce9ae04c9 fix: preserve deadline calendar days (Closes #719)
CI / build-release (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 1m23s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 07:56:26 +00:00
timmy 9ec88179c3 Merge pull request 'Make deadline reminder polling eligibility-aware' (#718) from timmy/717-deadline-eligibility 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 07:29:20 +00:00
timmy 93890e72de test: synchronize deadline fanout assertion
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 07:27:22 +00:00
timmy 8f0df3d393 perf: gate deadline reminder polling (Closes #717)
CI / build-release (pull_request) Has been skipped
CI / lint (pull_request) Failing after 1m27s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 07:23:05 +00:00
timmy fc79e4523d Merge pull request 'Configure deadline reminder time during mobile device setup' (#716) from timmy/configurable-deadline-device-setup 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 06:59:43 +00:00
timmy 741d545199 feat: configure deadline reminder time in device setup (Closes #715)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 06:57:39 +00:00
timmy 9320016b50 Merge pull request 'Run push notification channels on independent fixed cadences' (#714) from timmy/713-independent-push-cadences 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 06:25:01 +00:00
timmy b069379df0 fix: isolate push channel schedules (Closes #713)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m27s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 06:22:58 +00:00
timmy 8387c57ceb Merge pull request 'Isolate and bound deadline reminder delivery' (#712) from timmy/711-isolate-deadline-reminders into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m29s
2026-08-13 05:59:02 +00:00
timmy 72842ecbcd fix: isolate deadline reminder dispatch (Closes #711)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m24s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 05:55:50 +00:00
timmy c503994be1 Merge pull request 'Send opt-in mobile deadline reminders into Agenda' (#710) from timmy/709-mobile-deadline-reminders into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m25s
2026-08-13 05:11:29 +00:00
timmy 5e6328a8d0 feat: send mobile deadline reminders (Closes #709)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 05:00:32 +00:00
timmy 4209834d6d Merge pull request 'Replan overdue Agenda deadlines in one mobile sweep' (#708) from timmy/707-mobile-agenda-replan 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 04:29:06 +00:00
timmy 46888d2876 feat: replan overdue Agenda deadlines (Closes #707)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m27s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 04:26:53 +00:00
timmy 7d7abefb0b Merge pull request 'Surface upcoming deadlines in the mobile task dock' (#706) from timmy/705-mobile-deadline-dock into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m23s
2026-08-13 03:52:58 +00:00
timmy 2796808759 feat: surface mobile deadline urgency (Closes #705)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m24s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 03:50:53 +00:00
timmy a2f83b0955 Merge pull request 'Make mobile Agenda discover every assigned deadline' (#704) from timmy/703-complete-mobile-agenda into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m25s
2026-08-13 03:27:33 +00:00
timmy 6b56188d30 feat: complete mobile Agenda discovery (Closes #703)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m27s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 03:25:25 +00:00
timmy 467c8c665d Merge pull request 'Launch fresh mobile work in the Agenda queue' (#702) from timmy/701-mobile-agenda-launch into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m28s
2026-08-13 02:53:37 +00:00
timmy 1b311c9cd4 feat: launch fresh mobile work in Agenda (Closes #701)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m27s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 02:51:25 +00:00
timmy 71d06347fd Merge pull request 'Keep Today and Later reads available during planning writes' (#700)
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m33s
2026-08-13 02:24:45 +00:00
timmy 1de6d73e75 perf: keep planning reads available during writes (Closes #699)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m28s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 02:22:29 +00:00
timmy 46c75f0b22 Merge pull request 'Continue cleared mobile queues into Agenda deadlines' (#698) from timmy/697-agenda-queue-continuation into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m25s
2026-08-13 01:55:57 +00:00
timmy 5cdb3f7b3e feat: continue cleared queues into Agenda (Closes #697)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m28s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 01:53:36 +00:00
timmy 15d197327a Merge pull request 'Keep cold Find Work startup reliable across workers' (#696) from timmy/695-cold-find-work-workers into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m36s
2026-08-13 01:25:01 +00:00
timmy df8385b325 fix: keep cold Find Work reliable across workers (Closes #695)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 01:22:42 +00:00
timmy 47c6412a12 Merge pull request 'Make the mobile Agenda addressable and Back-safe' (#694) from timmy/693-mobile-agenda-route into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m27s
2026-08-13 00:55:20 +00:00
timmy 6cac778acd feat: make mobile Agenda addressable (Closes #693)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 00:53:09 +00:00
timmy a8a93b2f3b Merge pull request 'Start work from an empty mobile queue' (#692) from timmy/691-empty-mobile-find-start into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m30s
2026-08-13 00:26:37 +00:00
timmy ef37a66fd7 feat: start work from an empty mobile queue (Closes #691)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m33s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 00:24:04 +00:00
timmy 52e2c26304 Merge pull request 'Stabilize recap bounded-concurrency verification' (#690) from timmy/689-stabilize-recap-concurrency-test into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m30s
2026-08-12 23:55:24 +00:00
timmy 3817d2c67b test: stabilize recap concurrency verification (Closes #689)
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 1m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 23:52:56 +00:00
timmy ad6c1edf80 Merge pull request 'Isolate readiness probes from live Gitea traffic' (#688) from timmy/687-isolate-readiness into main
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 1m56s
2026-08-12 23:26:57 +00:00
timmy d2b2c7bada feat: isolate readiness probes from Gitea traffic (Closes #687)
CI / build-release (pull_request) Successful in 9s
CI / lint (pull_request) Successful in 2m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 23:10:29 +00:00
timmy 9ba03613d4 Merge pull request 'Plan upcoming deadlines from a mobile Agenda' (#686) from timmy/685-mobile-agenda into main
CI / build-release (push) Has been skipped
CI / release-candidate (push) Has been skipped
CI / lint (push) Failing after 1m40s
2026-08-12 22:30:56 +00:00
timmy 1132075318 feat: add mobile deadline Agenda (Closes #685)
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 1m39s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 22:28:36 +00:00
timmy 16b1d8670f Merge pull request 'Continue mobile work across queue boundaries' (#684) from timmy/683-mobile-queue-continuation into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m38s
2026-08-12 21:59:42 +00:00
timmy e5c5c8ec31 feat: continue mobile work across queues (Closes #683)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m25s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 21:57:34 +00:00
timmy 87e0d74414 Merge pull request 'Filter Find Work by repository and label' (#682) from timmy/681-find-work-facets into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m25s
2026-08-12 21:35:47 +00:00
timmy f031a37e8d feat: filter Find Work by repository and label (Closes #681)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 21:33:27 +00:00
timmy 057bec7dc2 Merge pull request 'Resume interrupted Find Work batches without duplicate claims' (#680) from timmy/679-resume-find-work-batches into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m28s
2026-08-12 21:04:39 +00:00
timmy 654ae8714e feat: resume interrupted Find Work batches (Closes #679)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m25s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 21:02:25 +00:00
timmy a0dd044995 Merge pull request 'Fill remaining Today slots from Find Work' (#678) from timmy/677-fill-today-from-find-work into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m27s
2026-08-12 20:27:50 +00:00
timmy c93a682dfb feat: fill Today from ranked Find Work results (Closes #677)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m31s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 20:25:37 +00:00
timmy 5675ca5d41 Merge pull request 'Make Find Work batch claiming respect Today time capacity' (#676) from timmy/675-find-work-time-capacity into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m26s
2026-08-12 19:56:09 +00:00
timmy 0767ccaa9d feat: budget Find Work batches by time (Closes #675)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m28s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 19:53:57 +00:00
timmy c6eabb5fc7 Merge pull request 'Search the full Find Work catalog' (#674) from timmy/673-search-find-work into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m25s
2026-08-12 19:27:10 +00:00
timmy 77fab85a66 feat: search the full Find Work catalog (Closes #673)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m30s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 19:24:55 +00:00
timmy dcf26c896c Merge pull request 'Batch-claim available work into Today from Find Work' (#672) from timmy/671-batch-claim-find-work into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m29s
2026-08-12 19:02:54 +00:00
timmy fba1f4238d feat: batch-claim available work into Today (Closes #671)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 18:58:26 +00:00
timmy 9e301924b4 Merge pull request 'Select all matching work for mobile batch planning' (#670) from timmy/669-select-all-matching-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-12 18:25:41 +00:00
timmy 8edd542379 feat: select matching mobile work in one step (Closes #669)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m27s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 18:23:34 +00:00
timmy 0fbe047e03 Merge pull request 'Find work within the active mobile queue' (#668) from timmy/667-find-active-queue into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m27s
2026-08-12 17:58:48 +00:00
timmy 995d8f8527 feat: find work within active queues (Closes #667)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 17:56:42 +00:00
timmy fabd6123f8 Create follow-up and continue the Updates inbox (#666)
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m30s
Closes #665
2026-08-12 17:13:59 +00:00
timmy 2553e7121b feat: create follow-up and continue updates (Closes #665)
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 1m25s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 17:10:16 +00:00
timmy 681e10c7db Merge pull request 'Create follow-up work from unread updates' (#664) from timmy/663-update-follow-up into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m32s
2026-08-12 16:35:34 +00:00
timmy d96a77c5d5 feat: create follow-up work from updates (Closes #663)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m32s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 16:32:50 +00:00
timmy 97eb928187 Merge pull request 'Batch-plan work directly from mobile queues' (#662) from timmy/661-mobile-batch-planning into main
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m37s
2026-08-12 16:05:43 +00:00
timmy 80a4096118 feat: batch-plan mobile work queues (Closes #661)
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 1m42s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 16:03:04 +00:00
timmy f04a547e19 Merge pull request 'Defer selected mobile updates as one unread batch' (#660) from timmy/659-defer-selected-updates into main
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m37s
2026-08-12 15:14:22 +00:00
timmy ea3712ca3e feat: defer selected updates as unread batch (Closes #659)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m36s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 15:11:51 +00:00
timmy 7421a8f62d Merge pull request 'Make every mobile Work queue addressable and Back-safe' (#658) from timmy/657-addressable-mobile-work-queues into main
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 1m56s
2026-08-12 14:31:25 +00:00
timmy 982c2a1fb7 feat: make mobile work queues addressable (Closes #657)
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 1m38s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 14:29:07 +00:00
timmy 3e7d6f7111 Merge pull request 'Isolate unexpected Web Push device failures' (#656) from timmy/655-isolate-push-device-failures into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m22s
2026-08-12 13:55:56 +00:00
timmy 32394fcd8f fix: isolate Web Push device failures (Closes #655)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m27s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 13:53:38 +00:00
timmy ea3194cdca Merge pull request 'Restrict Web Push delivery to public endpoints' (#654) from timmy/653-push-endpoint-egress-policy into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m37s
Merge pull request 'Restrict Web Push delivery to public endpoints' (#654)
2026-08-12 13:32:32 +00:00
timmy 85043b4af6 fix: restrict Web Push endpoint egress (Closes #653)
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 1m46s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 13:29:50 +00:00
timmy dc202bde11 Merge pull request 'Consolidate mobile My Work actions into the thumb dock' (#652) from timmy/651-mobile-command-center into main
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m54s
2026-08-12 13:04:37 +00:00
timmy c4ba78c8cb feat: consolidate mobile work commands (Closes #651)
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 1m51s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 13:01:41 +00:00
timmy e35d7dea35 Merge pull request 'Undo accidental mobile update acknowledgements' (#650) from timmy/649-undo-update-acknowledgement into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m33s
2026-08-12 12:37:12 +00:00
timmy b81d695585 feat: undo accidental update acknowledgements (Closes #649)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m36s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 12:34:55 +00:00
timmy 75da76c6f5 Merge pull request 'Open Web Push digests in the mobile Updates inbox' (#648) from timmy/647-push-updates-inbox into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m43s
2026-08-12 12:04:40 +00:00
timmy f78d69fba5 fix: open push digests in Updates inbox (Closes #647)
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 1m38s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 11:58:11 +00:00
timmy 8ce64d2aa7 Merge pull request 'Launch work directly from mobile queues' (#646) from timmy/645-mobile-queue-launcher into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 1m39s
2026-08-12 11:28:51 +00:00
timmy 48f75cff05 feat: launch work directly from mobile queues (Closes #645)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m36s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 11:26:35 +00:00
timmy 58260d8a26 Merge pull request 'Keep mobile inline review above the keyboard' (#644) from timmy/mobile-review-keyboard-safe into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m30s
2026-08-12 10:56:08 +00:00
timmy 54ff44462e fix: keep mobile inline review above keyboard (Closes #643)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m36s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 10:53:56 +00:00
timmy a87a5a4a90 Merge pull request 'Detect and surface atomic passkey counter anomalies' (#642) from timmy/641-passkey-counter-anomalies into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m32s
2026-08-12 10:29:03 +00:00
timmy c91194c283 fix: detect atomic passkey counter anomalies (Closes #641)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 10:26:51 +00:00
timmy 5806d47b61 Merge pull request 'Expose unread Updates in the mobile queue switcher' (#640) from timmy/639-mobile-updates-queue into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m34s
2026-08-12 09:54:56 +00:00
timmy 95f24a4e90 feat: expose Updates in mobile queues (Closes #639)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 09:52:41 +00:00
timmy c016261b1a Merge pull request 'Bound Build my Today readiness checks' (#638)
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m40s
Closes #637
2026-08-12 09:28:47 +00:00
timmy bbc3e10525 perf: bound Build my Today readiness checks (Closes #637)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 09:26:31 +00:00
timmy 9737300d55 Merge pull request 'Complete Build my Today with inline missing estimates' (#636) from timmy/635-inline-plan-estimates into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m29s
2026-08-12 08:58:11 +00:00
timmy a08a51af02 feat: finish Today plans with inline estimates (Closes #635)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m31s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 08:55:52 +00:00
timmy 528d721cd3 Merge pull request 'Build my Today from ranked ready work' (#634) from timmy/633-build-my-today into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m36s
2026-08-12 08:38:49 +00:00
timmy a91788276c feat: build a ready capacity-fit Today plan (Closes #633)
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 1m30s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 08:28:03 +00:00
timmy 28a2c9faf1 Merge pull request 'Make Draft filing admission atomic and retry-safe' (#632) from timmy/631-atomic-draft-admission into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m29s
2026-08-12 07:57:53 +00:00
timmy b8befc2041 fix: make Draft admission retry-safe (Closes #631)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m29s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 07:54:12 +00:00
timmy d23c0c894b Merge pull request 'File mobile Drafts in one sequential session' (#630) from timmy/629-sequential-draft-filing into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m31s
2026-08-12 07:20:10 +00:00
timmy 7948d2e353 feat: file mobile Drafts sequentially (Closes #629)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m29s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 07:18:02 +00:00
timmy 1079ec6561 Merge pull request 'Unify mobile work queues in a stable dock switcher' (#628) from timmy/627-mobile-queue-switcher into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m41s
2026-08-12 06:29:46 +00:00
timmy 8d142c808b feat: unify mobile work queues (Closes #627)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 06:27:00 +00:00
timmy 776ae70019 Merge pull request 'Preserve mobile Drafts when attachment cleanup fails' (#626) from timmy/625-draft-mutation-rollback into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m22s
2026-08-12 05:56:46 +00:00
timmy 285fdff869 fix: preserve Drafts when cleanup fails (Closes #625)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m32s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 05:54:19 +00:00
timmy 89d153d6a5 Merge pull request 'Prevent silent mobile Draft loss at capacity' (#624) from timmy/623-draft-capacity-guard into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m34s
2026-08-12 05:38:48 +00:00
timmy fcb0575e38 feat: guard mobile Draft capacity (Closes #623)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m31s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 05:36:32 +00:00
timmy 14ba1bcf14 Merge pull request 'Complete the mobile Save to Drafts handoff' (#622) from timmy/621-mobile-draft-handoff into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m28s
2026-08-12 04:57:56 +00:00
timmy a68639668c feat: complete mobile Draft handoff (Closes #621)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m27s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 04:54:45 +00:00
timmy 1f0e9fb59d Merge pull request 'Make PWA updates resilient with progressive feature caching' (#620) from timmy/619-progressive-feature-caching into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m30s
2026-08-12 04:26:38 +00:00
timmy 065275b4ef feat: progressively cache optional PWA features (Closes #619)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m31s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 04:24:18 +00:00
timmy 90f1c48565 Merge pull request 'Purge capture-first screenshot Drafts at every private-data boundary' (#618) from timmy/617-purge-capture-drafts into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m26s
2026-08-12 04:02:55 +00:00
timmy ae07dd185c fix: purge capture Draft screenshots (Closes #617)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m31s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 03:58:10 +00:00
rockachopa f43f776479 Merge pull request 'Preserve screenshots in capture-first mobile Drafts' (#616) from timmy/615-capture-first-screenshots into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m25s
2026-08-12 03:35:30 +00:00
timmy 48e4344f28 feat: preserve capture-first screenshots in Drafts (Closes #615)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m24s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 03:33:19 +00:00
rockachopa 269916486c Merge pull request 'Make mobile New a capture-first flow' (#614) from timmy/613-mobile-capture-first into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m27s
2026-08-12 02:56:56 +00:00
timmy 07235d3870 feat: make mobile New capture-first (Closes #613)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m23s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 02:54:44 +00:00
rockachopa 8c32a9dd81 Merge pull request 'Start an empty Today plan from the mobile Work dock' (#612) from timmy/611-mobile-work-plan-today into main
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m27s
2026-08-12 02:28:30 +00:00
timmy 9edd3f6441 feat: start empty Today planning from mobile dock (Closes #611)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 02:24:10 +00:00
rockachopa 4faf0447be Merge pull request 'Bind operator mode to a canonical public origin' (#610) from timmy/609-canonical-public-origin into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m27s
2026-08-12 01:57:06 +00:00
timmy 8ea46974ff feat: bind operator mode to public origin (Closes #609)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m30s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 01:54:51 +00:00
rockachopa 075a277265 Merge pull request 'Log Today recap time concurrently within the foreground deadline' (#608)
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m32s
2026-08-12 01:24:43 +00:00
timmy a7ec4437d9 feat: bound concurrent recap time logging (Closes #607)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m30s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 01:22:33 +00:00
timmy bf2cf37151 Merge pull request 'Lazy-load active devices and security activity' (#606) from timmy/605-lazy-security-center into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 1m30s
2026-08-12 01:03:13 +00:00
timmy fb58ba30b2 feat: lazy-load security center (Closes #605)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m31s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 01:00:32 +00:00
rockachopa 17823352f2 Merge pull request 'Review unfinished Today work when a new local day begins' (#604) from timmy/603-today-day-rollover into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m26s
2026-08-12 00:40:49 +00:00
timmy 14a3e1ee8d feat: review Today work at day rollover (Closes #603)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m33s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 00:38:13 +00:00
timmy 8dc155e49a Merge pull request 'Complete Today from the mobile session HUD' (#602) from timmy/601-mobile-hud-complete into main
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m31s
2026-08-11 23:58:47 +00:00
timmy 7aa25f1d8c feat: complete Today from mobile HUD (Closes #601)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m27s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 23:56:39 +00:00
timmy f023414929 Merge pull request 'Keep the active Today session visible on mobile' (#600) from timmy/599-mobile-today-session-hud into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m28s
2026-08-11 23:36:22 +00:00
timmy 96e7b35419 feat: keep active Today visible on mobile (Closes #599)
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 1m28s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 23:29:50 +00:00
rockachopa 4c13e13275 Merge pull request 'Replan Today from live budget risk' (#598) from timmy/597-live-today-budget into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m27s
2026-08-11 23:07:01 +00:00
timmy ebe19f2281 feat: replan Today from live budget risk (Closes #597)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m24s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 23:04:49 +00:00
timmy 798790ce1a Merge pull request 'Pause Today while handling Attention' (#596)
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m37s
Closes #595
2026-08-11 22:32:20 +00:00
timmy 9629643a63 feat: pause Today while handling Attention (Closes #595)
CI / build-release (pull_request) Successful in 10s
CI / lint (pull_request) Successful in 1m31s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 22:27:53 +00:00
timmy 9f3855d779 Keep mobile Attention actionable alongside Today
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m29s
Keep mobile Attention actionable alongside Today (#594)
2026-08-11 21:41:26 +00:00
timmy c99dd3e3be test: make async deadline checks contention-safe
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m39s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 21:39:04 +00:00
timmy 7e173640ed refactor: keep mobile work mode update-local
CI / build-release (pull_request) Has been skipped
CI / lint (pull_request) Failing after 1m31s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 21:34:22 +00:00
timmy 87f63370f6 feat: keep mobile Attention actionable alongside Today (Closes #593)
CI / build-release (pull_request) Has been skipped
CI / lint (pull_request) Failing after 1m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 21:25:27 +00:00
rockachopa 614ffae188 Merge pull request 'Reconcile long mobile interruptions before resuming Today time' (#592) from timmy/591-reconcile-today-interruptions into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m29s
Merge pull request 'Reconcile long mobile interruptions before resuming Today time' (#592)
2026-08-11 21:07:41 +00:00
timmy aaa9aef78b feat: reconcile long Today interruptions (Closes #591)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 20:56:40 +00:00
rockachopa 10a8b2738b Merge pull request 'Require fresh authorization and audit recap time logging' (#590) from timmy/589-authorize-audit-recap-time into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m38s
2026-08-11 20:29:36 +00:00
timmy 379f2b8203 feat: authorize and audit recap time logging (Closes #589)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m29s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 20:26:10 +00:00
rockachopa 53bf89bd47 Merge pull request 'Log corrected Today recap time to Gitea' (#588)
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m26s
Closes #587
2026-08-11 19:43:01 +00:00
timmy 697f72da09 feat: log corrected Today recap time (Closes #587)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m25s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 19:40:45 +00:00
timmy c170240057 Merge pull request 'Resume recap-to-plan feedback after interruptions' (#586) from timmy/585-resume-recap-replan into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m32s
2026-08-11 18:59:24 +00:00
timmy 3043ccc846 feat: resume recap feedback after interruptions (Closes #585)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m24s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 18:57:11 +00:00
rockachopa c5ddb1275b Merge pull request 'Turn Today recap feedback into an adjusted plan' (#584) from timmy/583-recap-replan into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m23s
2026-08-11 18:36:20 +00:00
timmy 1fb26e0a86 feat: turn Today recap feedback into a plan
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m24s
CI / release-candidate (pull_request) Has been skipped
Closes #583
2026-08-11 18:33:59 +00:00
rockachopa be7bfcaa5a Merge pull request 'Preserve Today recap drafts across offline reloads' (#582)
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 1m40s
Closes #581
2026-08-11 18:01:33 +00:00
timmy 6a3c6c7a9e feat: preserve offline Today recap drafts (Closes #581)
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 1m52s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 17:58:50 +00:00
rockachopa cab230e3a2 Merge pull request 'Add synced Today recap with estimate-versus-actual history' (#580) from timmy/579-today-recap into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m22s
2026-08-11 17:24:11 +00:00
timmy 80ea81ad73 feat: add synced Today recaps (Closes #579)
CI / build-release (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 1m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 17:21:22 +00:00
timmy 8cd2459468 Merge pull request 'Track interruption-safe actual time through Today' (#578) from timmy/577-today-actual-time into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m28s
2026-08-11 16:17:03 +00:00
timmy 7ee10619a4 feat: track actual time through Today (Closes #577)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m27s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 16:14:17 +00:00
timmy a8da5ce949 Merge pull request 'Edit or delete your own conversation comments' (#576) from timmy/575-owned-comment-actions into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 1m28s
Merge pull request 'Edit or delete your own conversation comments' (#576)
2026-08-11 15:23:16 +00:00
timmy 8e357a097b feat: edit and delete owned comments (Closes #575)
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 1m28s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 15:18:30 +00:00
timmy 43f5833f7f Merge pull request 'Make Web Push pagination-complete and reconcile unread delivery state' (#574) from timmy/573-complete-push-snapshot into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m29s
2026-08-11 14:34:39 +00:00
timmy 27fff15da4 feat: complete unread push snapshots (Closes #573)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m23s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 14:32:19 +00:00
timmy a2b78710f2 Merge pull request 'Re-alert Web Push when unread threads receive new updates' (#572) from timmy/571-revision-aware-web-push into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 1m22s
Merge pull request 'Re-alert Web Push when unread threads receive new updates' (#572)
2026-08-11 13:54:24 +00:00
timmy e9271f5aab feat: re-alert updated Web Push threads (Closes #571)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m22s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 13:51:58 +00:00
timmy 257bc0110e Merge pull request 'Send bounded Web Push digests for update bursts' (#570) from timmy/569-push-burst-digests into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m33s
2026-08-11 12:59:31 +00:00
timmy 5e0683cd4a feat: send bounded push digests for update bursts (Closes #569)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m19s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 12:57:07 +00:00
timmy c94fc5308f Merge pull request 'Hand off assigned pull requests and continue Today' (#568) from timmy/567-pull-ownership into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m21s
feat: hand off assigned pull requests (Closes #567)
2026-08-11 12:40:16 +00:00
timmy 0304f5ab2c feat: hand off assigned pull requests (Closes #567)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m19s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 11:49:15 +00:00
timmy 50d51ed159 Merge pull request 'Make unread Web Push use the production notification contract' (#566) from timmy/565-production-push-contract into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m11s
2026-08-11 10:57:48 +00:00
timmy 8e74f022eb fix: use production IDs for unread Web Push (Closes #565)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m14s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 10:55:38 +00:00
timmy 92b0fd56ca Merge pull request 'Defer unread updates until tomorrow from Web Push' (#564) from timmy/563-web-push-tomorrow into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m13s
Merge pull request #564 from timmy/563-web-push-tomorrow
2026-08-11 10:27:57 +00:00
timmy 7ce352553d feat: defer Web Push updates until tomorrow (Closes #563)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m16s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 10:25:56 +00:00
timmy f4a4ed84c3 Merge pull request 'Mark unread updates read from Web Push' (#562) from timmy/561-web-push-mark-read into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m16s
2026-08-11 09:58:56 +00:00
timmy b21888fd7a feat: mark Web Push updates read (Closes #561)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m12s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 09:57:05 +00:00
timmy 320d6348ca Merge pull request 'Surface mobile device readiness before My Work' (#560) from timmy/559-mobile-readiness-card into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m14s
2026-08-11 09:35:52 +00:00
timmy 7f8ee771dc feat: surface mobile device readiness (Closes #559)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m16s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 09:33:27 +00:00
timmy f446e10670 Merge pull request 'Bound Web Push fan-out latency across devices' (#558) from timmy/557-bounded-push-fanout into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m15s
2026-08-11 08:56:57 +00:00
timmy 79309196b8 perf: bound Web Push device fan-out (Closes #557)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m14s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 08:54:48 +00:00
timmy cdf6ecdcb8 Merge pull request 'Guide mobile operators through device readiness setup' (#556)
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m14s
Closes #555
2026-08-11 08:35:08 +00:00
timmy 797fd0d544 feat: guide mobile device readiness (Closes #555)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m13s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 08:33:01 +00:00
timmy a13e25f6c9 Merge pull request 'Stop Web Push when device sessions expire' (#554)
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m9s
Closes #553
2026-08-11 07:57:06 +00:00
timmy 91e5b1e0c9 fix: stop push for expired device sessions (Closes #553)
CI / build-release (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 1m8s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 07:54:56 +00:00
timmy 1f05521094 Merge pull request 'Make unread Web Push delivery multi-worker safe' (#552) from timmy/551-push-delivery-lease into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m5s
2026-08-11 07:27:55 +00:00
timmy 59c263fc59 feat: make push delivery multi-worker safe (Closes #551)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m7s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 07:25:55 +00:00
timmy 4c64cc4c3e Merge pull request 'Notify mobile operators about new unread updates' (#550) from timmy/549-inbound-web-push into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m10s
2026-08-11 07:10:03 +00:00
timmy 6e90e3fa9e feat: notify mobile operators of unread updates (Closes #549)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m15s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 07:07:52 +00:00
timmy 83702e75b0 Merge pull request 'Reopen and resume closed issues from mobile Search' (#548) from timmy/547-reopen-resume into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m9s
2026-08-11 06:39:23 +00:00
timmy 446281674b feat: reopen and resume closed issues (Closes #547)
CI / build-release (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 1m8s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 06:37:19 +00:00
timmy 9c5b100d6a Merge pull request 'Select updates to mark read from mobile My Work' (#546) from timmy/545-select-updates into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m23s
2026-08-11 06:03:12 +00:00
timmy 163bfad30a feat: select mobile updates to mark read (Closes #545)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m22s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 06:00:48 +00:00
timmy 23c8e57a8e Merge pull request 'Surface failed token sign-in alerts in Security activity' (#544)
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m7s
2026-08-11 05:37:53 +00:00
timmy 350bfc067d feat: surface failed token sign-in alerts (Closes #543)
CI / build-release (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 1m10s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 05:35:35 +00:00
timmy 98aecd8255 Merge pull request 'Lazy-load the complete pull request workspace' (#542) from timmy/541-lazy-pull-workflow into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m7s
2026-08-11 04:55:19 +00:00
timmy 7d4ff1f095 perf: lazy-load pull request workspace (Closes #541)
CI / build-release (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 1m4s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 04:53:18 +00:00
timmy 3605c42651 Merge pull request 'Acknowledge unread updates with a reaction and open the next' (#540) from timmy/539-acknowledge-update-reaction into main
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m5s
2026-08-11 04:12:18 +00:00
timmy d7e2813700 feat: acknowledge unread updates with reactions (Closes #539)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m8s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 04:10:18 +00:00
timmy fb34b5ffda Merge pull request 'Lazy-load the mobile issue capture workflow' (#538) from timmy/537-lazy-issue-capture into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m8s
2026-08-11 03:32:15 +00:00
timmy 820da7130d feat: lazy-load issue capture workflow (Closes #537)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m14s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 03:30:07 +00:00
timmy 3bad8a0198 Merge pull request 'Keep passkey sign-in available during anonymous challenge floods' (#536) from timmy/535-passkey-flood-protection into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m11s
2026-08-11 03:04:42 +00:00
timmy 8bc5f623ac feat: protect passkey sign-in from challenge floods (Closes #535)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m9s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 03:02:42 +00:00
timmy 175817e6d8 Merge pull request 'Manage Today blockers offline and sync on reconnect' (#534) from timmy/533-offline-blocker-sync into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m9s
2026-08-11 02:32:31 +00:00
timmy fff7bccf08 feat: sync Today blockers offline (Closes #533)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m14s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 02:29:56 +00:00
timmy ee36b8824a Merge pull request 'Manage issue blockers without leaving Today' (#532) from timmy/531-manage-issue-blockers into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m15s
2026-08-11 02:04:33 +00:00
timmy 2c8a1225e0 feat: manage issue blockers in app (Closes #531)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m14s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 02:02:23 +00:00
timmy 29ceedfc01 Merge pull request 'Keep dashboard live when IndexedDB stalls' (#530) from timmy/529-indexeddb-startup-deadline into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m13s
2026-08-11 01:31:05 +00:00
timmy 11a56b21a1 fix: bound IndexedDB dashboard startup (Closes #529)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m16s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 01:28:56 +00:00
timmy 3e16dd00d1 Merge pull request 'Attach screenshots to unread update replies' (#528) from timmy/527-update-reply-screenshots into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m11s
2026-08-11 01:10:54 +00:00
timmy 31f6097afc feat: attach screenshots to update replies (Closes #527)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m13s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 01:04:18 +00:00
timmy beaf5e0587 Merge pull request 'Move offline work details from localStorage to IndexedDB' (#526) from timmy/525-indexeddb-offline-work into main
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m3s
2026-08-11 00:36:26 +00:00
timmy 0a96dfb173 feat: move offline work into IndexedDB (Closes #525)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m8s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 00:34:25 +00:00
timmy 7cadb36b41 Merge pull request 'Lock cached dashboard after the confirmed idle deadline' (#524) from timmy/523-confirmed-offline-idle-deadline into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m12s
2026-08-11 00:02:39 +00:00
timmy b1890a29ff feat: enforce confirmed offline idle deadlines (Closes #523)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m15s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 00:00:19 +00:00
timmy 100d38cc5c Merge pull request 'Refresh pull request checks without reloading review data' (#522) from timmy/521-status-only-check-refresh into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m5s
2026-08-10 23:28:26 +00:00
timmy e5a352b418 perf: isolate pull check refreshes (Closes #521)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 23:26:35 +00:00
timmy 8907263760 Merge pull request 'Avoid decoding unchanged live snapshots on every poll' (#520) from timmy/519-metadata-only-live-polls into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m2s
2026-08-10 22:56:35 +00:00
timmy fdcd0d31ab perf: reuse unchanged live snapshot values (Closes #519)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m6s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 22:54:08 +00:00
timmy 5bef6291e8 Merge pull request 'Show actionable CI checks in mobile pull reviews' (#518) from timmy/517-actionable-ci-checks into main
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m4s
2026-08-10 22:33:52 +00:00
timmy 9724358c89 feat: expose actionable pull request checks (Closes #517)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 22:31:50 +00:00
timmy 7555cefccb Merge pull request 'Attach screenshots to assigned pull request comments online and offline' (#516) from timmy/515-pr-comment-screenshots into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m10s
2026-08-10 21:44:58 +00:00
timmy 04621223fd feat: attach screenshots to pull comments (Closes #515)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m4s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 21:39:22 +00:00
timmy e5016d8b86 Merge pull request 'Audit passkey enrollment with durable Security activity' (#514) from timmy/513-audit-passkey-enrollment into main
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 59s
2026-08-10 20:58:07 +00:00
timmy b455dc6286 feat: audit passkey enrollment (Closes #513)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 59s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 20:56:20 +00:00
timmy 338b6ebbd0 Make every PWA shell release content-addressed and atomic
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m5s
Closes #511
2026-08-10 20:27:06 +00:00
timmy f18c59c7b3 feat: content-address complete PWA shell (Closes #511)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m7s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 20:25:05 +00:00
timmy e9cab92aff Merge pull request 'Make consequential pull reviews passkey-native and auditable' (#510) from timmy/509-passkey-review-activity into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m3s
2026-08-10 19:59:06 +00:00
timmy 877dc475dd feat: make consequential reviews passkey-native (Closes #509)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m0s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 19:56:12 +00:00
timmy 28e70f6415 Merge pull request 'Require fresh authorization for consequential pull reviews' (#508) from timmy/507-fresh-review-authorization into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m1s
2026-08-10 19:04:01 +00:00
timmy 2a6f2ab12d feat: authorize consequential pull reviews (Closes #507)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m3s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 19:02:16 +00:00
timmy c8809a0728 Merge pull request 'Take ownership and start work from an unread update' (#506) from timmy/505-take-ownership-start into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m7s
2026-08-10 18:31:05 +00:00
timmy 2717f41957 feat: start work from unread updates (Closes #505)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m9s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 18:29:03 +00:00
timmy b26b57d616 Merge pull request 'Reply, mark read, and continue unread updates' (#504) from timmy/503-reply-read-next into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m8s
2026-08-10 18:04:35 +00:00
timmy 1052ce2c84 feat: reply, mark read, and continue updates (Closes #503)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m8s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 18:02:34 +00:00
timmy 4f0e6544b3 Merge pull request 'Make access revocation atomic across passkeys and sessions' (#502) from timmy/501-atomic-access-revocation into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m4s
2026-08-10 17:27:01 +00:00
timmy 91c59a2953 fix: make access revocation atomic (Closes #501)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 17:24:54 +00:00
timmy 6d1fa3c068 Merge pull request 'Manage and selectively revoke enrolled passkeys' (#500) from timmy/499-manage-enrolled-passkeys into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m3s
2026-08-10 17:08:35 +00:00
timmy 21a386b94f feat: manage enrolled passkeys (Closes #499)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m3s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 17:06:36 +00:00
timmy 7e5b966f77 Merge pull request 'Make destructive actions truthful when security journaling fails' (#498) from timmy/497-truthful-security-journal into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m4s
Merge pull request #498: truthful security-journaled actions
2026-08-10 16:32:39 +00:00
timmy 78d044f1af feat: make security-journaled actions outcome truthful (Closes #497)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m7s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 16:30:31 +00:00
timmy 814c5726ec Merge pull request 'Optimize oversized screenshots before mobile capture' (#496) from timmy/495-optimize-oversized-screenshots into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m2s
2026-08-10 15:58:35 +00:00
timmy 95a14c6de5 feat: optimize oversized mobile screenshots (Closes #495)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 15:56:35 +00:00
timmy 37aa2dd090 Merge pull request 'Add a durable security activity center' (#494) from timmy/493-security-activity-center into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m6s
2026-08-10 15:39:50 +00:00
timmy 6565165295 feat: add durable security activity center (Closes #493)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 15:37:37 +00:00
timmy 4749d15d85 Merge pull request 'Use binary screenshot storage and upload end to end' (#492) from timmy/491-binary-screenshot-transport into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m2s
2026-08-10 15:06:07 +00:00
timmy 16604d697b feat: use binary screenshot transport (Closes #491)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m6s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 15:03:45 +00:00
timmy 59b4d5e6bf Merge pull request 'Keep mobile issue capture visible above the keyboard' (#490) from timmy/489-keyboard-safe-mobile-capture into main
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m3s
2026-08-10 14:28:57 +00:00
timmy 83af187c73 feat: keep mobile issue capture above keyboard (Closes #489)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 14:26:56 +00:00
timmy d4896e10be Merge pull request 'Add an explicit searchable repository picker for mobile capture' (#488) from timmy/487-searchable-repository-picker into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m1s
Merge pull request 'Add an explicit searchable repository picker for mobile capture' (#488)
2026-08-10 14:03:40 +00:00
timmy 81e1af7f16 feat: add explicit searchable repository picker (Closes #487)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m2s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 14:01:49 +00:00
timmy b1b3f758e4 Merge pull request 'Add device passkeys for operator access' (#486)
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 59s
2026-08-10 13:16:21 +00:00
timmy ced644b275 feat: add device passkeys for operator access (Closes #485)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m3s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 13:14:18 +00:00
timmy 131848cc43 Merge pull request 'Require explicit authorization for queued issue closures' (#484) from timmy/483-authorize-queued-closures into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m1s
2026-08-10 12:30:59 +00:00
timmy 62a0841eb5 fix: authorize queued issue closures explicitly (Closes #483)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 12:29:02 +00:00
timmy cc63fc6499 Merge pull request 'Add an actionable offline delivery center' (#482) from timmy/481-offline-delivery-center into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m5s
2026-08-10 11:59:24 +00:00
timmy 9dede4bf2a feat: add offline delivery center (Closes #481)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 54s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 11:57:30 +00:00
timmy b667963968 Merge pull request 'Make background outbox claims lease-safe' (#480) from timmy/479-lease-safe-background-claims into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 55s
Merge pull request #480: Make background outbox claims lease-safe
2026-08-10 11:23:39 +00:00
timmy 31b0e79aa1 fix: fence background delivery claims (Closes #479)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 56s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 11:21:53 +00:00
timmy 8206856d94 Merge pull request 'Queue screenshot comments for offline delivery' (#478) from timmy/477-offline-screenshot-comments into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 58s
2026-08-10 10:57:51 +00:00
timmy 7ec3330175 feat: queue screenshot comments offline (Closes #477)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 58s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 10:56:02 +00:00
timmy ec339d242d Merge pull request 'Preserve screenshots while editing queued captures' (#476) from timmy/475-preserve-queued-screenshots into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 53s
2026-08-10 10:36:15 +00:00
timmy d161cf41c0 fix: preserve screenshots while editing queued captures (Closes #475)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 56s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 10:34:34 +00:00
timmy 535658185a Merge pull request 'Keep offline screenshots outside localStorage quota' (#474) from timmy/473-indexeddb-screenshot-outbox into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 57s
2026-08-10 10:17:54 +00:00
timmy 8b7c56161c fix: move offline screenshots out of localStorage (Closes #473)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 59s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 10:15:58 +00:00
timmy 2b2e2cceb4 Merge pull request 'Make screenshot uploads retry-safe' (#472) from timmy/471-idempotent-attachment-uploads into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 53s
2026-08-10 09:56:37 +00:00
timmy de596f397f fix: make screenshot uploads retry-safe (#471)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 56s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 09:54:56 +00:00
timmy af86fc1e98 Merge pull request 'Attach screenshots while capturing mobile issues' (#470) from timmy/469-capture-issue-screenshots into main
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 53s
2026-08-10 09:36:49 +00:00
timmy 5bf491c725 feat: attach screenshots during issue capture (#469)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 56s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 09:35:09 +00:00
timmy 11341ec5d9 Merge pull request 'Attach screenshots to mobile issue comments' (#468) from timmy/467-mobile-comment-screenshots into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 54s
2026-08-10 08:57:08 +00:00
timmy eefb760169 feat: attach screenshots to issue comments (#467)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 53s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 08:55:13 +00:00
timmy 3f9dd49766 Merge pull request 'Honor server backoff and section freshness in live polling' (#466) from timmy/465-adaptive-live-polling into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 58s
Merge pull request 'Honor server backoff and section freshness in live polling' (#466) from timmy/465-adaptive-live-polling into main
2026-08-10 08:19:37 +00:00
timmy 72453b76db perf: adapt live polling to server freshness (#465)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 55s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 08:17:57 +00:00
timmy 7639a42d7b Merge pull request 'Lock inactive dashboard sessions without losing drafts' (#464) from timmy/463-idle-session-lock into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 54s
2026-08-10 07:47:07 +00:00
timmy 0b992543f1 feat: lock idle dashboard sessions (#463)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 56s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 07:45:00 +00:00
timmy 9904f17e3d Merge pull request 'Drain authored mutations before shutdown' (#462)
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 53s
Closes #461
2026-08-10 07:14:28 +00:00
timmy 0dd3932d14 fix: drain authored actions on shutdown (#461)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 51s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 07:12:51 +00:00
timmy 8e6ee96ede Merge pull request 'Plan Today by available time and work estimates' (#460) from timmy/459-plan-today-capacity into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 52s
2026-08-10 06:55:04 +00:00
timmy 161977cb67 feat: plan Today by available time (#459)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 51s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 06:53:25 +00:00
rockachopa e3c07c2dfb Merge pull request 'Start deferred work directly from Later' (#458) from timmy/457-start-deferred-work into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 51s
2026-08-10 06:03:32 +00:00
timmy a610708e36 feat: start deferred work directly (#457)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 52s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 06:00:35 +00:00
timmy f8c5c2b058 Merge pull request 'Defer active Today work and continue to the next ready item' (#456) from timmy/455-defer-today-next into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 48s
2026-08-10 05:24:08 +00:00
timmy 72c470b652 feat: defer Today work and continue (#455)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 48s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 05:22:01 +00:00
timmy b214a49686 Merge pull request 'Share the Find Work catalog refresh across application workers' (#454) from timmy/453-shared-available-catalog into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 50s
2026-08-10 04:44:57 +00:00
timmy a9508984e2 fix: release cancelled catalog refresh leases (#453)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 50s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 04:43:25 +00:00
timmy 20d5176713 perf: share Find Work catalog across workers (#453)
CI / build-release (pull_request) Has been skipped
CI / lint (pull_request) Failing after 51s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 04:40:15 +00:00
timmy c96ed51b0d Merge pull request 'Reply to updates and continue Today in one action' (#452)
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 52s
2026-08-10 04:17:31 +00:00
timmy cf2e49300a feat: reply to updates and continue Today (#451)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 52s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 04:15:53 +00:00
timmy 5e49f27142 Merge pull request 'Continue Today after ownership exits' (#450) from timmy/449-ownership-exit-next into main
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 48s
2026-08-10 03:43:53 +00:00
timmy 0280077e45 feat: continue Today after ownership exits (#449)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 47s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 03:42:01 +00:00
timmy f781410e80 Merge pull request 'Autocomplete teammate mentions in mobile conversations' (#448) from timmy/447-mobile-mentions into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 48s
2026-08-10 03:04:26 +00:00
timmy 0311f85d96 feat: autocomplete mobile teammate mentions (#447)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 49s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 03:02:35 +00:00
timmy 5a6ef62c4c Merge pull request 'Wrap mobile review diffs without horizontal panning' (#446) from timmy/445-wrap-mobile-review-diffs into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 52s
2026-08-10 02:39:38 +00:00
timmy 461c796227 feat: wrap mobile review diffs (#445)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 51s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 02:37:43 +00:00
timmy 6309ed43cd Merge pull request 'Work Today blockers without leaving Stackchain' (#444) from timmy/443-today-blocker-preview into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 50s
2026-08-10 02:07:06 +00:00
timmy a0aa3c0a22 feat: work Today blockers in-app (#443)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 52s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 02:05:11 +00:00
timmy 60bc7cd686 Merge pull request 'Bound Today readiness latency with demand-driven checks' (#442) from timmy/441-demand-driven-today-readiness into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 49s
2026-08-10 01:35:45 +00:00
timmy 79c8f1626b perf: bound Today readiness latency (#441)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 49s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 01:33:55 +00:00
timmy cd9f40a1c4 Keep notification acknowledgements responsive under snapshot contention (#440)
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 51s
Closes #439
2026-08-10 01:16:02 +00:00
timmy 0a3fb180ed perf: batch notification snapshot maintenance (#439)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 49s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 01:14:11 +00:00
timmy 74d1435ca7 Merge pull request 'Skip blocked items while running the Today queue' (#438) from timmy/437-today-readiness into main
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 47s
2026-08-10 00:40:44 +00:00
timmy 8989e93c2c feat: skip blocked Today work (#437)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 48s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 00:39:13 +00:00
timmy 7b3b7dadb9 Merge pull request 'Surface blockers while planning Today work' (#436) from timmy/435-dependency-aware-today into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 48s
2026-08-10 00:02:47 +00:00
timmy 05f9e06a10 feat: surface Today issue blockers (#435)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 53s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 00:00:51 +00:00
timmy e0de45ea90 Merge pull request 'Coalesce live snapshot refreshes across application workers' (#434) from timmy/433-shared-live-snapshot into main
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 49s
2026-08-09 23:21:58 +00:00
timmy 3224389441 perf: share live refreshes across workers (#433)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 48s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 23:20:07 +00:00
timmy 63723e7fa3 Merge pull request 'Precompress immutable runtime and bypass request-time gzip' (#432) from timmy/431-precompress-runtime into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 46s
Merge pull request 'Precompress immutable runtime and bypass request-time gzip' (#432) from timmy/431-precompress-runtime into main
2026-08-09 22:22:03 +00:00
timmy 740e91a615 perf: precompress immutable dashboard runtime (#431)
CI / build-release (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 45s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 22:12:00 +00:00
timmy 0363f23cef Merge pull request 'Comment and continue to the next Today item' (#430) from timmy/429-comment-next into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 46s
2026-08-09 22:02:02 +00:00
timmy 2cbeba30fd feat: comment and continue Today work (#429)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 50s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 22:00:14 +00:00
timmy 43595de52f Merge pull request 'Queue offline issue closure and continue Today' (#428) from timmy/427-offline-close-next into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 44s
2026-08-09 21:27:59 +00:00
timmy c451cd0bcb feat: queue offline issue closure and continue Today (#427)
CI / build-release (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 43s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 21:26:23 +00:00
timmy ad27db6846 Merge pull request 'Restore background delivery after sign-in following outbox purge' (#426) from timmy/425-resume-outbox-after-purge into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 46s
2026-08-09 20:46:12 +00:00
timmy 791082bae2 fix: resume background delivery after purge (#425)
CI / build-release (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 47s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 20:44:26 +00:00
timmy d3ed0cca0c Merge pull request 'Queue offline Today reviews and continue to the next item' (#424) from timmy/423-queue-offline-review-next into main
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 52s
2026-08-09 20:06:04 +00:00
timmy ccb595a081 feat: queue offline Today reviews and continue (#423)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 49s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 20:04:20 +00:00
timmy 7b1c9a608c Merge pull request 'Finish Today items without changing Gitea' (#422) from timmy/421-done-today-next into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 44s
2026-08-09 19:39:15 +00:00
timmy 854d040e88 feat: finish Today items without changing Gitea (#421)
CI / build-release (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 43s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 19:37:40 +00:00
timmy 95baa20eeb Merge pull request 'Queue completed pull request reviews for reconnect delivery' (#420)
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 48s
Closes #419
2026-08-09 19:06:15 +00:00
timmy 0cf88d7d7a feat: queue offline pull request reviews (#419)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 49s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 19:04:28 +00:00
rockachopa 969d5567d4 Merge pull request 'Compress the mobile dashboard shell over HTTP' (#418) from timmy/417-http-compression into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 46s
2026-08-09 18:35:26 +00:00
191 changed files with 40622 additions and 1332 deletions

No files matched your search

+248 -45
View File
@@ -19,9 +19,35 @@ threads, create and self-assign issues, discover, claim, and release issue assig
list repository labels and open milestones, set or clear due dates on assigned issues, create issue comments, close assigned issues,
inspect/comment on assigned pull
requests, merge assigned pull requests, and submit pull-request reviews.
Assigned-issue and assigned-pull-request comments can include one PNG, JPEG, or WebP screenshot; each mobile composer automatically optimizes oversized screenshots on-device to fit the 2 MB upload boundary while leaving already-valid files unchanged.
For online delivery, the screenshot uploads before the comment is posted—to the exact assigned issue or pull request—and produces one Markdown comment; validation or upload
failures keep both the typed comment and removable preview available for retry. Offline screenshot comments
admit their text and image bytes to IndexedDB before confirmation, keep only bounded metadata in
localStorage, and use checkpointed upload/comment identities so reconnect retries cannot duplicate
either stage. The mobile **New issue** capture-first stage accepts the same image formats before a
repository is chosen. **Save to Drafts** durably writes the optimized Blob to IndexedDB before
confirmation, keeps only account-bound attachment metadata in localStorage, and restores the exact
preview when the operator later chooses a repository. The source Draft remains available until its
screenshot has safely transferred to the issue outbox; discard and bounded pruning remove the Blob.
Repository-aware durable admission likewise stores the screenshot with its account-bound outbox
capture, avoiding base64 quota pressure and synchronous multi-megabyte writes. Online and background
delivery send the original bytes as multipart form data, avoiding the roughly 33% base64 wire
expansion. Existing queued base64 screenshot payloads remain readable and are converted only at
delivery time.
Delivery creates the issue exactly once, then uploads and comments with
the image; after a partial failure, retry resumes with the confirmed issue instead of
creating a duplicate.
Pull-request replies and mobile My Work issue and PR comments use Gitea's
issue-comment API; mobile issue capture requires issue
creation and assignment permission. Once a repository and meaningful title are selected,
issue-comment API. In issue, pull-request, and unread-update conversations, typing
at least two characters after `@` offers repository-scoped teammate suggestions;
touch or keyboard selection inserts the login without leaving the draft. Mention
lookup failure never blocks literal text or comment delivery. Mobile issue capture requires issue
creation and assignment permission. A fresh capture never silently targets the first repository:
the operator must explicitly choose one, either from the paginated browser or through the bounded
authenticated repository search. Search results include only visible repository identities, stale
responses cannot replace a newer query, and search failure leaves the draft and browse fallback
intact. Saved drafts restore their exact repository even when it is outside the first page. Once a
repository and meaningful title are selected,
the New issue sheet checks for similar open issues in that repository. Candidate links keep
the draft intact; the first create attempt pauses until the operator reviews them or explicitly
chooses **Create anyway**. This check is advisory and never blocks offline capture or capture
@@ -36,11 +62,41 @@ 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,
five-item Today plan syncs across the operator's devices. Starting a Today work session also
stores an account-bound checkpoint on the current device. After a reload or installed-app
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,
and an active Today session shows the current estimate plus estimated remaining runway. Adding an issue through **Plan Today**
first previews its Gitea dependencies: unresolved blockers are listed with links and require the
explicit **Add blocked item anyway** override, while an unavailable dependency lookup is reported
as unknown rather than unblocked. Starting a Today work session also
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap.
After a reload or installed-app
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed);
finishing or choosing **End session** clears only the checkpoint and leaves the Today plan
**Comment & next** on that current issue or pull request posts the handoff online or admits it
to durable account-bound delivery, then removes the item only from Today and opens the next
one without closing or merging it. **Reply & next** provides the same one-action continuation
for the current unread-update conversation. It deliberately leaves the notification unread;
an open, unassigned issue update also offers **Take ownership & start**, which checks Today capacity
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. 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,
and reconnecting or returning to the dashboard refreshes server truth after replaying queued
@@ -70,16 +126,30 @@ not changed by this session boundary. Installed-app
navigations are also deadline-bounded: after four seconds without a network response,
Stackchain aborts the request and opens the cached dashboard shell. If the shell has not
been installed yet, it returns explicit HTTP 504 reconnect guidance instead of hanging.
Live dashboard refreshes use the server's per-section freshness windows: healthy sections
refresh at their earliest deadline while a fully degraded snapshot waits for its reported
cooldown. HTTP `Retry-After` delays are honored up to five minutes, and transport failures
back off from eight seconds to a one-minute cap. Reconnect, foreground return, and explicit
refresh still run immediately; a successful response resets transport backoff.
Closed-app
delivery requests are deadline-bounded: a stalled identity, CSRF, or mutation request is
aborted after 15 seconds, its durable claim returns to the queue, and the next sync retries
with the unchanged idempotency key. Device purge cancels an active drain before closing
with the unchanged idempotency key. Each finite, lane-fair drain admits at most 70 records
and runs up to three deliveries concurrently, but claims a record only when a delivery slot
is ready. Claims have unique fencing tokens and are renewed before every network stage;
completion, release, failure, and delivery checkpoints are token-fenced so an expired worker
cannot alter a newer crash-recovery claim. Device purge cancels an active drain before closing
private outbox storage. Results are coordinated through a bounded SQLite ledger. Set `STACKCHAIN_STATE_DIR` to a
persistent, writable service directory (or set `STACKCHAIN_IDEMPOTENCY_DB` to an explicit
SQLite path); the local default is `.stackchain-state/idempotency.sqlite3`. Ledger reads and
writes run outside the request event loop, and lock admission is bounded to 100 ms by
default. Tune it with `STACKCHAIN_IDEMPOTENCY_LOCK_TIMEOUT_SECONDS`; keep the value below
route deadlines. Reservation contention returns retryable HTTP 503 with `Retry-After: 1`.
route deadlines. During a controlled shutdown, the server gives in-flight authored mutations
five seconds to finish and persist their ledger result before it closes the Gitea transport.
Set `STACKCHAIN_AUTHORED_ACTION_SHUTDOWN_GRACE_SECONDS` to match the service manager's
shutdown budget; when that deadline expires, remaining operations are cancelled and their
pending reservations continue to fail closed rather than being retried automatically.
Reservation contention returns retryable HTTP 503 with `Retry-After: 1`.
If contention occurs after the upstream mutation, the dashboard fails closed with
`Retry-After: 5` and asks the caller to verify the result before retrying. Direct API callers
should preserve the `Idempotency-Key` header with the unchanged route and payload until a
@@ -88,7 +158,12 @@ Approve, and Request changes reviews, and assigned-PR merge require repository
write permission. Native Comment, Approve, and Request changes reviews support
head-scoped draft comments anchored to changed lines; the dashboard validates each
comment path and submits the summary, decision, and inline comments in one review
request. The dashboard rechecks the current pull-request head, CI success, draft
request. On phone-width review sheets, changed lines wrap inside a stable old/new
line-number gutter by default so long source lines remain readable and commentable
without horizontal panning. **Lines wrapped** toggles back to whitespace-preserving
horizontal inspection, and an explicit choice persists on the device across files,
review-sheet reopen, and reload. Desktop review diffs remain horizontally scrollable
until wrapping is explicitly enabled. The dashboard rechecks the current pull-request head, CI success, draft
state, and mergeability immediately before every merge.
Serve the dashboard only to trusted users on its own origin; cross-origin API
access is intentionally disabled. Authentication defaults to fail-closed
@@ -105,28 +180,73 @@ export GITEA_TOKEN='<read-notification-and-issue-write-token>'
export STACKCHAIN_DASHBOARD_AUTH_MODE='operator'
export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='<operator-sign-in-secret>'
export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-secret>'
export STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN='https://forge.example.com'
# Optional; defaults to STACKCHAIN_STATE_DIR/sessions.sqlite3.
export STACKCHAIN_SESSION_DB='/var/lib/stackchain-dashboard/sessions.sqlite3'
# Optional; defaults to STACKCHAIN_STATE_DIR/security-events.sqlite3.
export STACKCHAIN_SECURITY_EVENT_DB='/var/lib/stackchain-dashboard/security-events.sqlite3'
# Optional compatibility overrides; defaults derive from the required public origin.
export STACKCHAIN_PASSKEY_RP_ID='forge.example.com'
export STACKCHAIN_PASSKEY_ORIGIN='https://forge.example.com'
# Optional; defaults to eight hours.
export STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS=28800
# Optional; explicit pointer, keyboard, or touch activity renews this idle window.
# Background polling and queued delivery do not. Defaults to 15 minutes.
export STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS=900
# Optional sign-in throttle: five failures per five minutes, up to 10,000 sources.
export STACKCHAIN_LOGIN_MAX_FAILURES=5
export STACKCHAIN_LOGIN_WINDOW_SECONDS=300
export STACKCHAIN_LOGIN_MAX_ENTRIES=10000
# Public passkey ceremonies share the same window and durable source ledger.
export STACKCHAIN_PASSKEY_OPTIONS_MAX_ATTEMPTS=10
# Bound live one-time challenges even if anonymous clients rotate addresses.
export STACKCHAIN_PASSKEY_MAX_CHALLENGES_PER_SOURCE=10
export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000
# Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3.
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
# Trust forwarding headers only from these immediate reverse-proxy networks.
export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
# Optional Web Push. Generate a VAPID key pair outside the repo and inject it.
# The feature stays disabled unless all three values are present. Privacy-safe update
# alerts offer Mark read and Tomorrow; Tomorrow syncs the unread item to Later at
# 09:00 in the device's local timezone without opening the dashboard. Bursts send
# three individual alerts followed by one private digest that opens Updates. A later
# comment on an already-delivered thread triggers a fresh alert when Gitea advances
# that thread's updated_at revision; unchanged and older snapshots remain silent.
# Browser push services must resolve exclusively to public IP addresses. Stackchain
# validates endpoints at enrollment and again before delivery, rejects redirects,
# and removes legacy subscriptions that resolve to private or reserved networks.
export STACKCHAIN_VAPID_PUBLIC_KEY='<url-safe-public-key>'
export STACKCHAIN_VAPID_PRIVATE_KEY='<private-key-from-secret-manager>'
export STACKCHAIN_VAPID_SUBJECT='mailto:ops@example.com'
# Optional; defaults to a 30-second poll, 10-second endpoint deadline,
# 8 concurrently dispatched devices, 60-second renewable cross-worker lease,
# and STACKCHAIN_STATE_DIR/push-subscriptions.sqlite3. Threads stay ordered
# within each device; one slow device does not delay healthy devices behind it.
export STACKCHAIN_PUSH_POLL_SECONDS=30
# Unread updates and deadline reminders run on independent, fixed-cadence
# workers, so a slow channel cannot delay the other or add drift to its ticks.
# On each phone, Device Setup can enable deadline reminders and choose any local
# reminder hour from 00:00 through 23:00; the server-confirmed choice is restored.
export STACKCHAIN_PUSH_SEND_TIMEOUT_SECONDS=10
export STACKCHAIN_PUSH_MAX_CONCURRENCY=8
# Maximum individual alerts per device and poll before one digest covers the rest.
export STACKCHAIN_PUSH_MAX_INDIVIDUAL_NOTIFICATIONS=3
export STACKCHAIN_PUSH_LEASE_SECONDS=60
export STACKCHAIN_PUSH_DB='/var/lib/stackchain-dashboard/push-subscriptions.sqlite3'
uvicorn src.main:app --host 127.0.0.1 --port 8000
```
Sign-in failures are scoped to a hashed canonical client address and persisted across
workers and restarts. Once the budget is exhausted, the server returns `429` with
`Retry-After`; the mobile login form disables retries for that interval. Expired
source records are pruned and the ledger is size-bounded. Keep its SQLite file on
shared writable storage. `X-Forwarded-For` is ignored unless the immediate peer is
inside `STACKCHAIN_TRUSTED_PROXY_CIDRS`; list only networks you operate. Without
that setting, a reverse proxy is safely treated as one shared source.
Token and passkey sign-in failures are scoped to a hashed canonical client address and
persisted across workers and restarts. Public passkey option issuance has a separate
fixed-window admission budget in the same ledger, and live challenges are bounded per
source and globally in the session registry. Once either sign-in budget is exhausted,
the server returns `429` with `Retry-After`; a successful sign-in clears that source's
failure state. Expired source records and challenges are pruned, and both ledgers are
size-bounded. Keep their SQLite files on shared writable storage. `X-Forwarded-For` is
ignored unless the immediate peer is inside `STACKCHAIN_TRUSTED_PROXY_CIDRS`; list only
networks you operate. Without that setting, a reverse proxy is safely treated as one
shared source.
Inbound API mutations are admitted through a body-size boundary before FastAPI
parses JSON: sign-in is capped at 16 KiB and other `POST`, `PUT`, and `PATCH`
@@ -142,18 +262,59 @@ storage shared by all dashboard workers. Operators name a device at sign-in and
open **Active devices** to review creation/expiry times, identify the current device,
and revoke one remote session without interrupting other trusted devices. The API
exposes only independent management IDs and bounded labels—never cookie values,
session hashes, CSRF proofs, or source addresses. Existing two-column registries are
migrated in place and their live sessions remain valid.
session hashes, CSRF proofs, or source addresses. The registry also stores each
session's last explicit activity. Existing two-column registries are migrated in
place, their live sessions remain valid, and their idle clock starts at migration.
High-impact actions—merging a pull request, closing an assigned issue, revoking a
remote device, or signing out every device—require the operator access token again.
The same sheet includes **Security activity**, a reverse-chronological journal of
successful token/passkey sign-ins, passkey enrollments, sign-outs, remote device
revocations, issue closures, and pull-request merges. The separate SQLite journal
retains at most 10,000 events for 90 days and stores only bounded device labels and
action targets. It never stores access tokens, cookies, session/CSRF values,
credential IDs, public keys, challenges, attestation data, raw network addresses,
request bodies, or comment content. Keep its database on the same class of persistent,
writable storage as the session registry. Before passkey credential creation, session
revocation, or issue closure, the journal durably reserves a pending event; if that
reservation fails, the consequential action does not begin. A successful action
remains truthfully reported even if its event cannot immediately be finalized, and
the activity sheet marks that durable record as **Outcome confirmation pending**.
After token bootstrap, **Active devices → Add a passkey for this device** enrolls a
WebAuthn credential with required user verification. That device can then sign in
and authorize high-impact actions with its biometric/PIN gesture. The access token
remains the recovery fallback for browsers without WebAuthn or devices without an
enrolled credential. Registration and authentication challenges are exact-purpose,
single-use, and short-lived. **Enrolled passkeys** lists every durable credential,
including those whose original session has expired or signed out, using only its
bounded label, enrollment time, and active/current status. Removing one requires a
single-use fresh authorization bound to that exact management ID. A remote active
session linked to the credential is revoked atomically; unrelated credentials and
sessions remain valid. Removing the current device's passkey keeps its current
session active, so the sheet warns that the recovery token will be required after
sign-out. Remotely revoking an enrolled device also deletes its passkey; signing out
normally keeps the passkey available for the next sign-in. Passkey assertion counters
advance with an atomic compare-and-swap across workers: stale or equal nonzero counters
fail before a session or authorization grant is issued, while counterless authenticators
remain compatible. The bounded **Passkey counter anomaly** Security activity entry names
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, 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
first use. Expired, replayed, cross-session, and target-substituted grants fail before
Gitea or session state is changed. The browser preserves the pending request and
retries it once after the built-in mobile/keyboard-accessible authorization prompt.
If an active session expires, the first authenticated API rejection replaces the
After 15 minutes without pointer, keyboard, or touch activity (configurable through
`STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS`), the server rejects the session even if
polling or background delivery continued. The dashboard locks and background outbox
delivery pauses, but drafts, Today/Later state, queued mutations, and caches remain on
the device; signing in resumes the existing account-bound work. If an active session
expires, the first authenticated API rejection replaces the
dashboard with sign-in and explains that private drafts remain on the device; signing
in again resumes account-bound queued delivery. Expiry recovery does not clear offline
state. A selectively or globally revoked device instead receives a bounded revocation
@@ -194,12 +355,14 @@ successful response is JSON containing
to search commands plus issues and pull requests across every repository visible to
the configured Gitea token. Remote search starts after two characters, is debounced,
and keeps local commands usable if Gitea search is unavailable. Selecting a remote
result opens a mobile-safe, read-only preview without discarding the search query;
open unassigned issues can be claimed in place and handed into My Work after Gitea
confirms the assignment. Closed work and pull requests remain read-only with a safe
canonical Gitea link. The bounded APIs are available at
`GET /api/v1/search?q=<query>&limit=<1-25>` and
`GET /api/v1/repos/<owner>/<repo>/issues/<number>/preview?kind=issue|pull`. Never commit the token
result opens a mobile-safe preview without discarding the search query. Open
unassigned issues can be claimed in place and handed into My Work after Gitea
confirms the assignment. A closed issue can be reopened, self-assigned, added to
Today, and resumed through the same capacity-guarded flow; pull requests remain
read-only with a safe canonical Gitea link. The bounded APIs are available at
`GET /api/v1/search?q=<query>&limit=<1-25>`,
`GET /api/v1/repos/<owner>/<repo>/issues/<number>/preview?kind=issue|pull`, and
`PATCH /api/v1/repos/<owner>/<repo>/issues/<number>/reopen`. Never commit the token
or place it in a tracked configuration file.
For service monitoring, GET `/healthz` is a liveness check that confirms the
@@ -218,12 +381,34 @@ below the route deadlines. Streaming diff reads share the same read capacity,
while POST, PATCH, PUT, and DELETE requests are never coalesced.
Live snapshots expose independent `context`, `events`, and `notifications`
revision tokens. Each bounded opaque token includes a per-process generation, so a token
from another worker or from before a restart cannot suppress different content. The browser
sends its known tokens on later polls, so `/api/v1/live` can omit unchanged section bodies
while still returning current freshness and retry metadata. The client retains omitted data
and only rebuilds or persists the sections that changed. Malformed or oversized tokens are
rejected before any upstream work.
revision tokens. Snapshot content, freshness/backoff metadata, and revisions are published
atomically through a private SQLite store shared by all application workers. An expiring
refresh lease ensures only one worker loads currently due sections; other workers serve the
same stale snapshot while that refresh runs, and can recover an abandoned lease after expiry.
Set `STACKCHAIN_LIVE_SNAPSHOT_DB` to override the default
`STACKCHAIN_STATE_DIR/live-snapshot.sqlite3`; keep the containing directory on private,
worker-shared writable storage. The database and directory are restricted to the service
account and never contain the Gitea token. Find Work uses the same worker-shared pattern:
`STACKCHAIN_AVAILABLE_ISSUE_SNAPSHOT_DB` overrides
`STACKCHAIN_STATE_DIR/available-issue-snapshot.sqlite3`. One expiring lease bounds each
catalog scan across the deployment, shared retry metadata prevents worker-by-worker retry
bursts, and confirmed claims are removed from every worker's retained catalog. On a cold
catalog, non-owner workers wait within the bounded foreground deadline for the lease owner
to publish; if that owner releases or abandons the lease, a waiter can take over the scan
within its remaining request budget. Releasing
an assignment invalidates the shared catalog so the newly available issue can be discovered
by the next authoritative scan. Each bounded opaque revision token includes the
store generation, so a token from a different deployment or before replacement of the store
cannot suppress different content. The browser sends its known tokens on later polls, so
`/api/v1/live` can omit unchanged section bodies while still returning current freshness and
retry metadata. The client retains omitted data and only rebuilds or persists the sections
that changed. Malformed or oversized tokens are rejected before any upstream work.
Confirmed notification acknowledgements update this worker's live view immediately, then
maintain the shared snapshot outside the async request loop. Bulk acknowledgements remove
all successful notification IDs in one SQLite transaction and advance the notification
revision once. If shared-store lock admission fails after Gitea confirms the mutation, the
API keeps the confirmed result and process-local filtering rather than falsely reporting the
upstream write as failed; a later shared refresh reconciles the cache.
## Offline mobile shell
@@ -233,14 +418,25 @@ 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
shows the device timezone and rejects empty, normalized, invalid, or past values before saving.
Deferred items leave normal and Attention queues without marking notifications read
or changing any Gitea issue or pull request. They automatically return to their
existing priority position at the wake time, and **Bring back now** restores them
early. Later wake times are scoped to the confirmed Gitea login and synchronize
existing priority position at the wake time. **Start now** atomically admits a deferred
item to Today, removes its Later record, and opens that exact item in a resumable Today
session; full or unavailable Today storage leaves the deferral intact for retry. Items
already in Today are opened without duplication. **Bring back now** still restores an
item early without starting it. Later wake times are scoped to the confirmed Gitea login and synchronize
across signed-in tabs and devices. Offline changes apply immediately, survive reload,
and replay after reconnect. Each queued edit carries the account revision it was based
on; if another device has since changed the same item, Stackchain keeps the newer
@@ -264,28 +460,35 @@ newest 20 comments. Requested-review records also retain the head SHA, CI state,
newest 20 prior reviews, and at most 50 sanitized file previews with 400 diff lines
per file. A previously opened unread update is retained by notification
identity with allowlisted subject context and its newest 20 conversation messages.
This account-bound cache is limited to ten records across all detail kinds; credentials,
repository catalogs, events, raw patches, and complete API responses are excluded.
The snapshot and each account-bound detail are committed as separate IndexedDB records,
so large review previews do not consume the small synchronous localStorage quota or require
rewriting the whole cache. Only the opt-in preference remains in localStorage. Existing v1
localStorage payloads migrate after a successful IndexedDB commit. The detail cache remains
limited to ten records across all detail kinds; credentials, repository catalogs, events, raw
patches, and complete API responses are excluded.
A cold offline launch labels the saved time. Cached Today details open in the existing
phone sheet, where comments can enter the account-bound durable outbox. For issue and
non-review pull details, planning, assignment, review, merge, and close controls remain disabled
until reconnection. Cached
requested reviews open in the existing review sheet so file progress, notes, summary,
decision, and inline-comment drafts remain usable under the saved head SHA. Review
submission is never queued: reconnect reloads the current head before enabling submit;
a changed head starts clean SHA-scoped progress while retaining the prior-head draft.
until reconnection. Cached requested reviews open in the existing review sheet so file progress, notes,
summary, decision, and inline-comment drafts remain usable under the saved head SHA.
**Queue review for reconnect** durably stores that complete SHA-bound review with one
idempotency key. Reconnect or Background Sync submits it exactly once; transient failures
retain the queue entry, while stale-head and invalid-inline-comment responses move it to
Drafts as **Needs attention** without deleting feedback. Confirmed delivery removes the
queued operation and its matching SHA-scoped draft and progress.
Cached unread updates use the same phone conversation sheet and replies enter the
account-bound durable outbox, while mark read, ownership, deferral, and older-message loading remain disabled until reconnection.
Cards without a saved detail explain that reconnection is required. **Clear offline
work data** deletes both stores, and opting out or seven-day expiry deletes them
automatically.
work data**, opt-out, sign-out, remote revocation, and absolute session expiry delete the
IndexedDB records; seven-day expiry removes stale records automatically.
API responses and mutations are never cached by the service worker. New issue captures,
issue comments, pull-request comments, and unread-update replies use bounded local
outboxes when connectivity or a retryable server failure prevents delivery. Issue
captures and authored messages are also mirrored into account-bound IndexedDB lanes
and registered with Background Sync, so a supporting installed browser can deliver
new issues, issue comments, pull-request comments, and unread-update replies after
new issues, issue comments, pull-request comments, unread-update replies, and completed
pull-request reviews after
every dashboard client has closed. The worker verifies the current Gitea login, shares
an atomic delivery claim with the foreground path, and preserves the original
idempotency key. Installed browsers can explicitly enable **Notify me when queued
+2 -1
View File
@@ -6,4 +6,5 @@ def explicit_local_dashboard_auth_mode(monkeypatch):
"""Keep tests intentional now that deployed authentication fails closed."""
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "insecure-local")
monkeypatch.delenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", raising=False)
monkeypatch.delenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", raising=False)
monkeypatch.delenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", raising=False)
monkeypatch.setenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "https://test")
+83
View File
@@ -0,0 +1,83 @@
function createAgendaReplan({ now = () => new Date(), update }) {
let items = [];
let index = 0;
let pending = false;
let active = false;
const dayKey = date => [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0'),
].join('-');
const dueDay = value => {
const match = String(value || '').match(/^(\d{4}-\d{2}-\d{2})(?:$|T)/);
return match ? match[1] : '';
};
const current = () => active ? items[index] || null : null;
const snapshot = () => ({
active,
index,
total: items.length,
current: current()?.key || null,
pending,
});
const advance = () => {
index += 1;
if (index >= items.length) active = false;
return { ok:true, done:!active };
};
const change = async dueDate => {
const item = current();
if (!item) return { ok:false, error:'No overdue deadline selected.' };
if (pending) return { ok:false, error:'Deadline update already in progress.' };
pending = true;
try {
await update(item, dueDate);
return advance();
} catch (error) {
return { ok:false, error:error?.message || 'Deadline could not be updated.' };
} finally {
pending = false;
}
};
return {
start(overdue) {
items = (overdue || []).slice().sort((left, right) =>
dueDay(left.due_date).localeCompare(dueDay(right.due_date)) ||
String(left.repository || '').localeCompare(String(right.repository || '')) ||
Number(left.number || 0) - Number(right.number || 0)
);
index = 0;
pending = false;
active = items.length > 0;
return snapshot();
},
snapshot,
current,
keep() {
if (pending) return Promise.resolve({ ok:false, error:'Deadline update already in progress.' });
if (!current()) return Promise.resolve({ ok:false, error:'No overdue deadline selected.' });
return Promise.resolve(advance());
},
tomorrow() {
const date = new Date(now());
date.setDate(date.getDate() + 1);
return change(dayKey(date) + 'T23:59:59Z');
},
choose(value) {
const chosen = String(value || '');
if (!/^\d{4}-\d{2}-\d{2}$/.test(chosen) || chosen <= dayKey(now())) {
return Promise.resolve({ ok:false, error:'Choose a future date.' });
}
return change(chosen + 'T23:59:59Z');
},
cancel() {
active = false;
pending = false;
return snapshot();
},
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createAgendaReplan;
+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};
});
+163 -13
View File
@@ -4,7 +4,26 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
);
const pending = new Map();
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply']);
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker']);
function reviewFingerprint(message) {
return JSON.stringify({
body: String(message.body || ''),
decision: String(message.decision || 'comment'),
expectedHeadSha: String(message.expectedHeadSha || ''),
comments: Array.isArray(message.comments) ? message.comments : [],
});
}
function clearConfirmedReviewState(item) {
if (item.kind !== 'pull-review') return;
try {
if (item.draftKey && item.draftFingerprint &&
storage?.getItem(item.draftKey) === item.draftFingerprint) storage.removeItem(item.draftKey);
if (item.progressKey && item.progressFingerprint &&
storage?.getItem(item.progressKey) === item.progressFingerprint) storage.removeItem(item.progressKey);
} catch (_error) { /* Delivery is confirmed even when local cleanup is unavailable. */ }
}
function read() {
try {
@@ -32,6 +51,16 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
const requestedOperationId = String(message.operationId || '').slice(0, 128);
const existing = requestedOperationId && items.find(item => item.operationId === requestedOperationId);
if (existing) return { ...existing };
if (message.kind === 'pull-review') {
const queuedReview = items.find(item => item.kind === 'pull-review' &&
item.repository === String(message.repository || '') &&
item.number === Number(message.number || 0) &&
item.expectedHeadSha === String(message.expectedHeadSha || ''));
if (queuedReview) {
if (reviewFingerprint(queuedReview) === reviewFingerprint(message)) return { ...queuedReview };
throw new Error('A review for this saved head is already queued. Open Drafts to inspect or discard it first.');
}
}
if (items.length >= maxItems) throw new Error('Message outbox is full. Send or discard a queued message first.');
const id = String(requestedOperationId || makeId()).slice(0, 128);
const item = {
@@ -45,6 +74,28 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
ownerLogin,
status: 'queued',
queuedAt: Number(now()),
...(message.kind === 'update-reply-read' ? { replyConfirmed: message.replyConfirmed === true } : {}),
...(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read'].includes(message.kind) && message.attachment ? {
attachment: {
filename: String(message.attachment.filename || ''),
contentType: String(message.attachment.contentType || ''),
stored: true,
},
} : {}),
...(message.kind === 'pull-review' ? {
decision: String(message.decision || 'comment'),
expectedHeadSha: String(message.expectedHeadSha || ''),
comments: Array.isArray(message.comments) ? message.comments.map(comment => ({ ...comment })) : [],
draftKey: String(message.draftKey || ''),
progressKey: String(message.progressKey || ''),
draftFingerprint: String(message.draftFingerprint || ''),
progressFingerprint: String(message.progressFingerprint || ''),
} : {}),
...(message.kind === 'issue-blocker' ? {
blockerRepository: String(message.blockerRepository || ''),
blockerNumber: Number(message.blockerNumber || 0),
present: message.present === true,
} : {}),
};
items.push(item);
write(items, mirror);
@@ -53,11 +104,30 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
async function enqueueDurably(message) {
const item = enqueue(message, false);
if (message.attachment && ['update-reply', 'update-reply-read'].includes(message.kind) &&
(!backgroundSync?.reconcile || !backgroundSync?.requestSync)) {
write(read().filter(candidate => candidate.id !== item.id), false);
throw new Error('Screenshot delivery needs IndexedDB. Your reply and screenshot are still here; retry.');
}
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
return { item, background: false, durability: 'foreground-only' };
}
const durableItems = read().map(candidate => candidate.id === item.id && message.attachment ? {
...candidate,
attachment: {
filename: String(message.attachment.filename || ''),
contentType: String(message.attachment.contentType || ''),
...(message.attachment.blob ? { blob: message.attachment.blob } :
{ data: String(message.attachment.data || '') }),
},
} : candidate);
try {
await backgroundSync.reconcile(durableItems, 'authored');
} catch (error) {
write(read().filter(candidate => candidate.id !== item.id), false);
throw error;
}
try {
await backgroundSync.reconcile(read(), 'authored');
await backgroundSync.requestSync();
return { item, background: true, durability: 'background' };
} catch (error) {
@@ -95,6 +165,15 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
return 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply';
}
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
if (item.kind === 'issue-close') {
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close';
}
if (item.kind === 'issue-blocker') {
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers';
}
if (item.kind === 'pull-review') {
return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review';
}
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments';
}
@@ -102,6 +181,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
async function sendItem(item, currentLogin) {
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
if (pending.has(item.id)) return pending.get(item.id);
const attemptAt = Number(now());
write(read().map(candidate => candidate.id === item.id ? {
...candidate, status:'sending', lastAttemptAt:attemptAt,
} : candidate), false);
const request = (async () => {
try {
let result;
@@ -114,29 +197,88 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
}
result = delivery.message;
} else {
result = await fetchJson(endpoint(item), {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({ body: item.body }),
});
if (item.kind === 'update-reply-read') {
if (!item.replyConfirmed) {
await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', {
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({ body: item.body }),
});
write(read().map(candidate => candidate.id === item.id ? {
...candidate, replyConfirmed: true, status: 'sending', lastAttemptAt: attemptAt,
} : candidate), false);
}
result = await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read', {
method: 'PATCH', headers: { Accept: 'application/json' },
});
} else if (item.kind === 'issue-close') {
result = await fetchJson(endpoint(item), {
method: 'PATCH',
headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
});
if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.');
} else if (item.kind === 'issue-blocker') {
result = await fetchJson(endpoint(item), {
method: 'PATCH',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({
repository: item.blockerRepository,
number: item.blockerNumber,
present: item.present,
}),
});
const dependencies = Array.isArray(result?.dependencies) ? result.dependencies : [];
const present = dependencies.some(candidate => candidate?.repository === item.blockerRepository &&
Number(candidate?.number) === item.blockerNumber);
if (result?.number !== item.number || result?.dependencies_available !== true || present !== item.present) {
throw new Error('Blocker change was not confirmed.');
}
} else {
const body = item.kind === 'pull-review' ? {
body: item.body,
decision: item.decision,
expected_head_sha: item.expectedHeadSha,
comments: item.comments,
} : { body: item.body };
result = await fetchJson(endpoint(item), {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify(body),
});
}
}
if (!result) return { blocked: true };
clearConfirmedReviewState(item);
discard(item.id);
return { result };
} catch (error) {
const status = Number(error?.status || 0);
const permanent = status >= 400 && status < 500;
const attemptError = String(error.message || 'Delivery failed').slice(0, 240);
if (permanent) {
write(read().map(candidate => candidate.id === item.id ? {
...candidate,
status: 'attention',
error: String(error.message || 'Message needs attention').slice(0, 240),
lastAttemptAt: attemptAt,
lastAttemptError: attemptError,
...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}),
} : candidate));
} else {
write(read().map(candidate => candidate.id === item.id ? {
...candidate, status:'queued', lastAttemptAt:attemptAt, lastAttemptError:attemptError,
} : candidate));
}
return { error, transient: !permanent };
}
@@ -151,7 +293,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
let blocked = 0;
currentLogin = String(currentLogin || '').trim();
for (const item of read()) {
if (item.status === 'attention') continue;
if (item.status === 'attention' || item.kind === 'issue-close') continue;
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
const outcome = await sendItem(item, currentLogin);
if (outcome.result) confirmed.push(outcome.result);
@@ -196,13 +338,21 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
const statuses = new Map((records || []).map(item => [item.id, item]));
const items = read().flatMap(item => {
const background = statuses.get(item.id);
if (background?.status === 'sent') return [];
if (background?.status === 'sent') {
clearConfirmedReviewState(item);
return [];
}
if (background?.status === 'attention') return [{
...item,
status: 'attention',
error: String(background.error || 'Message needs attention').slice(0, 240),
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
}];
if (background?.status === 'authorization') return [{
...item,
status: 'authorization',
error: String(background.error || 'Fresh authorization required').slice(0, 240),
}];
return [item];
});
write(items);
+431 -36
View File
@@ -35,6 +35,7 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error || new Error('Issue outbox transaction aborted'));
Promise.resolve(work({
get: id => requested(objectStore.get(id)),
getAll: () => requested(objectStore.getAll()),
put: value => requested(objectStore.put(value)),
delete: id => requested(objectStore.delete(id)),
@@ -54,7 +55,25 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
return transact;
}
function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000 } = {}) {
function createUnfiledAttachmentStore(indexedDB = globalThis.indexedDB) {
const transact = createIndexedDbTransaction(indexedDB, 'stackchain-unfiled-captures-v1');
return {
put: (id, value) => transact(records => records.put({id, ...value})),
get: id => transact(async records => {
const value = await records.get(id);
if (!value) return null;
const {id: _id, ...attachment} = value;
return attachment;
}),
delete: id => transact(records => records.delete(id)),
};
}
function createIssueSyncStore({
transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000,
createToken = () => globalThis.crypto?.randomUUID?.() ||
(Date.now().toString(36) + '-' + Math.random().toString(36).slice(2)),
} = {}) {
const transact = transaction || createIndexedDbTransaction(indexedDB);
async function reconcile(items, outboxLane = 'issue') {
@@ -65,15 +84,23 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
if (current.recordType === 'receipt-preference') continue;
const currentLane = current.outboxLane || 'issue';
if (currentLane !== outboxLane) continue;
const replacement = incoming.get(current.id);
let replacement = incoming.get(current.id);
if (!replacement) {
if (current.status !== 'sending' || Number(current.claimUntil) <= Number(now())) {
await records.delete(current.id);
}
continue;
}
if (current.operationId === replacement.operationId &&
(current.attachment?.data || current.attachment?.blob) &&
replacement.attachment?.stored &&
!replacement.attachment.data && !replacement.attachment.blob) {
replacement = { ...replacement, attachment: current.attachment };
incoming.set(current.id, replacement);
}
if ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
(current.status === 'attention' && replacement.status === 'attention') ||
(['attention', 'authorization'].includes(current.status) &&
replacement.status === current.status) ||
current.status === 'sent') {
incoming.set(current.id, current);
}
@@ -87,21 +114,25 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
const timestamp = Number(now());
const items = await records.getAll();
const item = items.find(candidate => candidate.ownerLogin === ownerLogin &&
candidate.kind !== 'issue-close' &&
(candidate.status === 'queued' || candidate.status === 'sending') &&
(candidate.status !== 'sending' || Number(candidate.claimUntil) <= timestamp));
if (!item) return null;
const claimed = { ...item, status: 'sending', claimUntil: timestamp + claimMs };
const claimed = {
...item, status: 'sending', claimUntil: timestamp + claimMs, claimToken: createToken(),
};
await records.put(claimed);
return claimed;
});
}
async function claimBatch(ownerLogin, limit = 70) {
async function planBatch(ownerLogin, limit = 70) {
return transact(async records => {
const timestamp = Number(now());
const eligible = (await records.getAll()).filter(candidate =>
candidate.recordType !== 'receipt-preference' &&
candidate.ownerLogin === ownerLogin &&
candidate.kind !== 'issue-close' &&
(candidate.status === 'queued' || candidate.status === 'sending') &&
(candidate.status !== 'sending' || Number(candidate.claimUntil) <= timestamp));
const lanes = {
@@ -111,14 +142,10 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
const selected = [];
const maximum = Math.max(0, Number(limit) || 0);
while (selected.length < maximum && (lanes.issue.length || lanes.authored.length)) {
if (lanes.issue.length) selected.push(lanes.issue.shift());
if (selected.length < maximum && lanes.authored.length) selected.push(lanes.authored.shift());
if (lanes.issue.length) selected.push(lanes.issue.shift().id);
if (selected.length < maximum && lanes.authored.length) selected.push(lanes.authored.shift().id);
}
const claimed = selected.map(item => ({
...item, status: 'sending', claimUntil: timestamp + claimMs,
}));
for (const item of claimed) await records.put(item);
return claimed;
return selected;
});
}
@@ -129,7 +156,9 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
if (!item || item.ownerLogin !== ownerLogin ||
!['queued', 'sending'].includes(item.status) ||
(item.status === 'sending' && Number(item.claimUntil) > timestamp)) return null;
const claimed = { ...item, status: 'sending', claimUntil: timestamp + claimMs };
const claimed = {
...item, status: 'sending', claimUntil: timestamp + claimMs, claimToken: createToken(),
};
await records.put(claimed);
return claimed;
});
@@ -139,10 +168,19 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
return transact(async records => {
const current = (await records.getAll()).find(candidate => candidate.id === item.id);
if (current && ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
(current.status === 'attention' && item.status === 'attention') ||
(['attention', 'authorization'].includes(current.status) &&
item.status === current.status) ||
current.status === 'sent')) return current;
await records.put({ ...item });
return item;
const preservedAttachment = (current?.attachment?.data || current?.attachment?.blob) &&
item?.attachment?.stored && !item.attachment.data && !item.attachment.blob
? { attachment: current.attachment } : {};
const next = current && current.operationId === item.operationId ? {
...item, ...preservedAttachment,
...(current.deliveredIssue ? { deliveredIssue: current.deliveredIssue } : {}),
...(current.attachmentMarkdown ? { attachmentMarkdown: current.attachmentMarkdown } : {}),
} : { ...item };
await records.put(next);
return next;
});
}
@@ -153,6 +191,32 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
});
}
async function updateClaim(id, claimToken, transform) {
return transact(async records => {
const item = records.get ? await records.get(id) :
(await records.getAll()).find(candidate => candidate.id === id);
if (!item || item.status !== 'sending' || !claimToken || item.claimToken !== claimToken) {
return false;
}
await records.put(transform(item));
return true;
});
}
function clearClaim(item, changes) {
const { claimToken: _claimToken, ...unclaimed } = item;
return { ...unclaimed, ...changes, claimUntil: 0 };
}
async function renew(id, claimToken) {
let renewed = null;
await updateClaim(id, claimToken, item => {
renewed = { ...item, claimUntil: Number(now()) + claimMs };
return renewed;
});
return renewed;
}
function preferenceId(ownerLogin) {
return 'receipt-preference:' + String(ownerLogin || '').trim();
}
@@ -175,20 +239,29 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
}
return {
get: id => transact(records => records.get(id)),
reconcile,
upsert,
update,
claim,
claimNext,
claimBatch,
complete: (id, deliveredIssue) => update(id, item => ({
...item, status: 'sent', claimUntil: 0,
planBatch,
supportsClaimTokens: true,
renew,
checkpoint: updateClaim,
complete: (id, claimToken, deliveredIssue) => updateClaim(id, claimToken, item => clearClaim(item, {
status: 'sent',
...(item.completionIntent === 'create-and-start' && deliveredIssue ? { deliveredIssue } : {}),
})),
release: id => update(id, item => ({ ...item, status: 'queued', claimUntil: 0 })),
fail: (id, error, deliveryState) => update(id, item => ({
...item, status: 'attention', claimUntil: 0, error,
release: (id, claimToken) => updateClaim(id, claimToken,
item => clearClaim(item, { status: 'queued' })),
fail: (id, claimToken, error, deliveryState) => updateClaim(id, claimToken, item => clearClaim(item, {
status: 'attention', error,
...(deliveryState ? { deliveryState } : {}),
})),
authorization: (id, claimToken, error) => updateClaim(
id, claimToken, item => clearClaim(item, { status: 'authorization', error })
),
snapshot: () => transact(async records =>
(await records.getAll()).filter(item => item.recordType !== 'receipt-preference')),
countBlocked: ownerLogin => transact(async records =>
@@ -205,10 +278,32 @@ function createBackgroundIssueSync({
batch = work => work(), requestTimeoutMs = 15000,
}) {
let purgeRequested = false;
let activePurge = null;
let activeFlush = null;
const activeRequests = new Set();
const timeoutMs = Math.max(1, Number(requestTimeoutMs) || 15000);
const completeClaim = (item, delivered) => store.supportsClaimTokens
? store.complete(item.id, item.claimToken, delivered) : store.complete(item.id, delivered);
const releaseClaim = item => store.supportsClaimTokens
? store.release(item.id, item.claimToken) : store.release(item.id);
const failClaim = (item, error, deliveryState) => store.supportsClaimTokens
? store.fail(item.id, item.claimToken, error, deliveryState)
: store.fail(item.id, error, deliveryState);
const requireAuthorization = (item, error) => store.supportsClaimTokens
? store.authorization(item.id, item.claimToken, error)
: store.authorization(item.id, error);
const checkpointClaim = (item, transform) => store.supportsClaimTokens
? store.checkpoint(item.id, item.claimToken, transform) : store.update?.(item.id, transform);
async function requestStage(item, url, options) {
if (store.renew) {
const renewed = await store.renew(item.id, item.claimToken);
if (!renewed) throw new Error('Background delivery claim was lost.');
}
return requestJson(url, options);
}
async function requestJson(url, options = {}) {
const controller = new AbortController();
activeRequests.add(controller);
@@ -238,10 +333,22 @@ function createBackgroundIssueSync({
if (item.kind === 'notification-read') {
return { id: item.id, status, kind: 'notification-read', route: '#/my-work/updates' };
}
if (item.kind === 'update-reply') {
if (item.kind === 'update-reply' || item.kind === 'update-reply-read') {
return { id: item.id, status, kind: 'message', route: '#/my-work/update/' + encodeURIComponent(item.notificationId) };
}
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
if (item.kind === 'pull-review') {
return {
id: item.id, status, kind: 'message',
route: '#/my-work/review/' + repository + '/' + encodeURIComponent(item.number),
};
}
if (item.kind === 'issue-close') {
return {
id: item.id, status, kind: 'message',
route: '#/my-work/issue/' + repository + '/' + encodeURIComponent(item.number),
};
}
if (item.kind === 'issue-comment' || item.kind === 'pull-comment') {
const resource = item.kind === 'pull-comment' ? 'pull' : 'issue';
return { id: item.id, status, kind: 'message', route: '#/my-work/' + resource + '/' + repository + '/' + encodeURIComponent(item.number) };
@@ -259,7 +366,59 @@ function createBackgroundIssueSync({
if (item.kind === 'update-reply') {
return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item);
}
if (item.kind === 'update-reply-read') {
return {
url: base + 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read',
options: { method: 'PATCH', headers: { Accept: 'application/json' } },
};
}
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
if (item.kind === 'issue-close') {
return {
url: base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close',
options: {
method: 'PATCH',
headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
},
};
}
if (item.kind === 'issue-blocker') {
return {
url: base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers',
options: {
method: 'PATCH',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({
repository: item.blockerRepository,
number: item.blockerNumber,
present: item.present === true,
}),
},
};
}
if (item.kind === 'pull-review') {
return {
url: base + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review',
options: {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({
body: item.body,
decision: item.decision,
expected_head_sha: item.expectedHeadSha,
comments: item.comments || [],
}),
},
};
}
if (item.kind === 'issue-comment' || item.kind === 'pull-comment') {
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
return authoredRequest(
@@ -302,28 +461,226 @@ function createBackgroundIssueSync({
};
}
function stageOperationId(operationId, stage) {
const suffix = ':' + stage;
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
}
async function deliverReplyRead(item) {
let current = item;
if (!current.replyConfirmed) {
let attachmentMarkdown = current.attachmentMarkdown;
if (current.attachment && !attachmentMarkdown) {
const uploaded = await requestStage(current,
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments', {
method:'POST',
headers:{ Accept:'application/json', 'Idempotency-Key':stageOperationId(current.operationId, 'attachment') },
body:attachmentMultipart(current.attachment),
});
attachmentMarkdown = String(uploaded?.markdown || '');
if (!attachmentMarkdown) {
const error = new Error('The server did not confirm the screenshot upload.');
error.status = 422;
throw error;
}
await checkpointClaim(current, stored => ({ ...stored, attachmentMarkdown }));
current = { ...current, attachmentMarkdown };
}
const text = String(current.body || '').trim();
const replyBody = attachmentMarkdown ?
(text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown) : text;
const options = authoredRequest('', { ...current, body:replyBody }).options;
if (current.attachment) options.headers['Idempotency-Key'] = stageOperationId(current.operationId, 'reply');
await requestStage(
current,
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply',
options,
);
const checkpointed = await checkpointClaim(current, stored => ({ ...stored, replyConfirmed: true }));
if (checkpointed === false) throw new Error('Background delivery claim was lost.');
current = { ...current, replyConfirmed: true };
}
const request = deliveryRequest(current);
return requestStage(current, request.url, request.options);
}
function attachmentMultipart(attachment) {
let blob = attachment?.blob;
if (!blob && attachment?.data) {
const binary = atob(String(attachment.data));
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
blob = new Blob([bytes], { type: String(attachment.contentType || '') });
}
if (!blob) throw new Error('The saved screenshot is unavailable. Retry before sending.');
const form = new FormData();
form.append('file', blob, String(attachment.filename || 'screenshot'));
return form;
}
async function deliverIssueCapture(item) {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
let deliveredIssue = item.deliveredIssue;
if (!deliveredIssue) {
const request = deliveryRequest(item);
deliveredIssue = await requestStage(item, request.url, request.options);
await checkpointClaim(item, current => ({ ...current, deliveredIssue }));
}
let attachmentMarkdown = item.attachmentMarkdown;
if (!attachmentMarkdown) {
const uploaded = await requestStage(
item,
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/attachments',
{
method: 'POST',
headers: {
Accept: 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
},
body: attachmentMultipart(item.attachment),
},
);
attachmentMarkdown = String(uploaded?.markdown || '');
if (!attachmentMarkdown) {
const error = new Error('The server did not confirm the screenshot upload.');
error.status = 422;
throw error;
}
await checkpointClaim(item, current => ({ ...current, deliveredIssue, attachmentMarkdown }));
}
await requestStage(
item,
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/comments',
{
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'attachment-comment'),
},
body: JSON.stringify({ body: attachmentMarkdown }),
},
);
return deliveredIssue;
}
async function deliverScreenshotComment(item) {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
let attachmentMarkdown = item.attachmentMarkdown;
if (!attachmentMarkdown) {
const uploaded = await requestStage(
item,
base + 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/attachments',
{
method: 'POST',
headers: {
Accept: 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
},
body: attachmentMultipart(item.attachment),
},
);
attachmentMarkdown = String(uploaded?.markdown || '');
if (!attachmentMarkdown) {
const error = new Error('The server did not confirm the screenshot upload.');
error.status = 422;
throw error;
}
await checkpointClaim(item, current => ({ ...current, attachmentMarkdown }));
}
const text = String(item.body || '').trim();
return requestStage(
item,
base + 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments',
{
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'comment'),
},
body: JSON.stringify({ body: text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown }),
},
);
}
async function deliverUpdateScreenshotReply(item) {
let current = item;
let attachmentMarkdown = current.attachmentMarkdown;
if (!attachmentMarkdown) {
const uploaded = await requestStage(current,
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments', {
method:'POST',
headers:{ Accept:'application/json', 'Idempotency-Key':stageOperationId(current.operationId, 'attachment') },
body:attachmentMultipart(current.attachment),
});
attachmentMarkdown = String(uploaded?.markdown || '');
if (!attachmentMarkdown) {
const error = new Error('The server did not confirm the screenshot upload.');
error.status = 422;
throw error;
}
await checkpointClaim(current, stored => ({ ...stored, attachmentMarkdown }));
current = { ...current, attachmentMarkdown };
}
const text = String(current.body || '').trim();
return requestStage(current,
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply', {
method:'POST',
headers:{ Accept:'application/json', 'Content-Type':'application/json',
'Idempotency-Key':stageOperationId(current.operationId, 'reply') },
body:JSON.stringify({ body:text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown }),
});
}
async function deliver(item) {
const request = deliveryRequest(item);
try {
const delivered = await requestJson(request.url, request.options);
await store.complete(item.id, delivered);
const delivered = item.kind === 'update-reply-read' ? await deliverReplyRead(item) :
item.kind === 'update-reply' && item.attachment ? await deliverUpdateScreenshotReply(item) :
item.attachment && ['issue-comment', 'pull-comment'].includes(item.kind) ?
await deliverScreenshotComment(item) : item.attachment && !item.kind ?
await deliverIssueCapture(item) : await requestStage(item, request.url, request.options);
if (item.kind === 'issue-close' && delivered?.state !== 'closed') {
const error = new Error('Issue closure was not confirmed.');
error.status = 422;
throw error;
}
if (item.kind === 'issue-blocker') {
const dependencies = Array.isArray(delivered?.dependencies) ? delivered.dependencies : [];
const present = dependencies.some(candidate => candidate?.repository === item.blockerRepository &&
Number(candidate?.number) === item.blockerNumber);
if (delivered?.number !== item.number || delivered?.dependencies_available !== true || present !== item.present) {
const error = new Error('Blocker change was not confirmed.');
error.status = 422;
throw error;
}
}
await completeClaim(item, delivered);
const receipt = receiptFor(item, 'confirmed', delivered);
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
} catch (error) {
const status = Number(error?.status || 0);
if (status === 401) {
await store.release(item.id);
await releaseClaim(item);
throw error;
}
if (status === 428 && item.kind === 'pull-review') {
const message = String(error?.message || 'Fresh authorization required').slice(0, 240);
await requireAuthorization(item, message);
return {
authorization: true,
error,
receipt: receiptFor(item, 'authorization'),
};
}
if (status >= 400 && status < 500) {
await store.fail(
item.id,
await failClaim(
item,
String(error?.message || 'Issue needs attention').slice(0, 240),
error?.code === 'delivery_uncertain' ? 'uncertain' : undefined,
);
return { attention: true, error, receipt: receiptFor(item, 'attention') };
}
await store.release(item.id);
await releaseClaim(item);
throw error;
}
}
@@ -345,14 +702,39 @@ function createBackgroundIssueSync({
const confirmed = [];
const receipts = [];
let attention = 0;
if (!login) return { confirmed, blocked: 0, attention, login, receipts };
let authorization = 0;
if (!login) return { confirmed, blocked: 0, attention, authorization, login, receipts };
const collect = result => {
if (result.issue) confirmed.push(result.issue);
if (result.message) confirmed.push(result.message);
if (result.attention) attention += 1;
if (result.authorization) authorization += 1;
if (result.receipt) receipts.push(result.receipt);
};
if (store.claimBatch) {
if (store.planBatch) {
const planned = await store.planBatch(login, batchSize);
let next = 0;
let authenticationError = null;
let transientError = null;
const worker = async () => {
while (!purgeRequested && !authenticationError && next < planned.length) {
const id = planned[next++];
const item = await store.claim(id, login);
if (!item) continue;
try {
collect(await deliver(item));
} catch (error) {
if (Number(error?.status || 0) === 401) authenticationError = error;
else if (!transientError) transientError = error;
}
}
};
const concurrency = Math.max(1, Math.min(Number(maxConcurrency) || 1, planned.length));
await Promise.all(Array.from({ length: concurrency }, worker));
if (purgeRequested) throw new Error('Background delivery canceled.');
if (authenticationError) throw authenticationError;
if (transientError) throw transientError;
} else if (store.claimBatch) {
const claimed = await store.claimBatch(login, batchSize);
let next = 0;
let authenticationError = null;
@@ -387,7 +769,7 @@ function createBackgroundIssueSync({
}
}
const blocked = store.countBlocked ? await store.countBlocked(login) : 0;
return { confirmed, blocked, attention, login, receipts };
return { confirmed, blocked, attention, authorization, login, receipts };
}
function flush() {
@@ -397,15 +779,27 @@ function createBackgroundIssueSync({
return activeFlush;
}
async function purge() {
function purge() {
if (activePurge) return activePurge;
purgeRequested = true;
activeRequests.forEach(controller => controller.abort());
if (activeFlush) await activeFlush.catch(() => {});
await store.close?.();
activePurge = (async () => {
if (activeFlush) await activeFlush.catch(() => {});
await store.close?.();
})();
return activePurge;
}
async function resume() {
const pendingPurge = activePurge;
if (pendingPurge) await pendingPurge;
if (activePurge === pendingPurge) activePurge = null;
purgeRequested = false;
}
return {
flush, send, purge,
flush, send, purge, resume,
get: id => store.get(id),
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
snapshot: () => store.snapshot(),
setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled),
@@ -418,4 +812,5 @@ if (typeof module !== 'undefined' && module.exports) module.exports = createBack
if (typeof globalThis !== 'undefined') {
globalThis.createBackgroundIssueSync = createBackgroundIssueSync;
globalThis.createIssueSyncStore = createIssueSyncStore;
globalThis.createUnfiledAttachmentStore = createUnfiledAttachmentStore;
}
+162
View File
@@ -0,0 +1,162 @@
function createBatchFindWork({
capacity,
claim,
queue,
timeBudget = () => ({ capacity_minutes: null, planned_minutes: 0 }),
persistEstimate = () => {},
onProgress = () => {},
storage = typeof localStorage === 'undefined' ? null : localStorage,
owner = () => '',
journalName = 'find-work-batch',
queueFailureReason = 'assigned but Today sync is unavailable',
autoMount = true,
}) {
let request = null;
const journalKey = () => 'stackchain.' + journalName + '.v1.' +
encodeURIComponent(String(owner() || ''));
function readJournal() {
if (!storage || !String(owner() || '')) return null;
try {
const value = JSON.parse(storage.getItem(journalKey()) || 'null');
return value?.owner === String(owner()) && Array.isArray(value.items) ? value : null;
} catch (_error) { return null; }
}
function writeJournal(value) {
if (!storage || !String(owner() || '')) return;
storage.setItem(journalKey(), JSON.stringify(value));
}
function clearJournal() {
if (storage && String(owner() || '')) storage.removeItem(journalKey());
}
function result(status, selected, available, queued = [], failed = []) {
return { status, selected, available, queued, failed };
}
function key(item) {
return String(item.repository || '') + '#' + String(item.number || '');
}
async function processJournal(journal) {
const selected = journal.items;
const queued = [];
const failed = [];
for (let index = 0; index < selected.length; index += 1) {
const entry = selected[index];
const item = entry.item;
if (entry.state === 'queued') {
queued.push(key(item));
onProgress({ status: 'running', processed: index + 1, selected: selected.length });
continue;
}
try {
const confirmed = entry.confirmed || await claim(item);
if (!entry.confirmed) {
entry.confirmed = confirmed;
entry.state = 'assigned';
writeJournal(journal);
}
const queueResult = await queue(confirmed, journal.context);
if (queueResult === 'queued' || queueResult === 'exists') {
queued.push(key(item));
entry.state = 'queued';
if (Number.isInteger(entry.estimate) && entry.estimate > 0) {
persistEstimate(confirmed, entry.estimate);
}
writeJournal(journal);
} else {
failed.push({ key: key(item), reason: queueFailureReason, assigned: true });
}
} catch (error) {
failed.push({ key: key(item), reason: error?.message || 'assignment failed' });
}
onProgress({ status: 'running', processed: index + 1, selected: selected.length });
}
const outcome = result('complete', selected.length, journal.available, queued, failed);
if (!failed.length) clearJournal();
else writeJournal(journal);
onProgress({ ...outcome, processed: selected.length });
return outcome;
}
function run(items, estimates = {}, context = null) {
if (request) return request;
const selected = Array.isArray(items) ? items.slice() : [];
const available = Math.max(0, Number(capacity()) || 0);
if (selected.length > available) {
const outcome = result('full', selected.length, available);
onProgress(outcome);
return Promise.resolve(outcome);
}
const budget = timeBudget() || {};
if (Number.isInteger(budget.capacity_minutes) && budget.capacity_minutes > 0) {
const remaining = Math.max(0, budget.capacity_minutes - (Number(budget.planned_minutes) || 0));
const invalid = selected.map(key).filter(id =>
!Number.isInteger(estimates[id]) || estimates[id] <= 0
);
const requested = selected.reduce((sum, item) => sum +
(Number.isInteger(estimates[key(item)]) && estimates[key(item)] > 0 ? estimates[key(item)] : 0), 0);
if (invalid.length) {
const outcome = { ...result('estimates-required', selected.length, available),
remaining_minutes: remaining, requested_minutes: requested, invalid };
onProgress(outcome);
return Promise.resolve(outcome);
}
if (requested > remaining) {
const outcome = { ...result('over-budget', selected.length, available),
remaining_minutes: remaining, requested_minutes: requested, over_minutes: requested - remaining };
onProgress(outcome);
return Promise.resolve(outcome);
}
}
const journal = {
owner: String(owner() || ''), available, context,
items: selected.map(item => ({ item, estimate: estimates[key(item)] || null, state: 'pending' })),
};
writeJournal(journal);
request = processJournal(journal).finally(() => { request = null; });
return request;
}
function resume() {
if (request) return request;
const journal = readJournal();
if (!journal) return Promise.resolve(null);
request = processJournal(journal).finally(() => { request = null; });
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();
button.hidden = !journal;
if (journal) button.textContent = 'Resume ' + journal.items.filter(item => item.state !== 'queued').length + ' interrupted';
};
button.addEventListener('click', async () => {
button.disabled = true;
const outcome = await resume();
button.disabled = false;
show();
if (outcome) {
button.previousElementSibling.textContent = outcome.failed.length ?
outcome.failed.length + ' still need recovery.' : outcome.queued.length + ' queued · batch recovered.';
}
});
opener.addEventListener('click', show);
}
if (autoMount && typeof document !== 'undefined') mountRecovery(
document.getElementById('resume-find-work-batch'), document.getElementById('find-work')
);
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);
},
};
};
+103
View File
@@ -0,0 +1,103 @@
function createCommentActions({ fetchJson, getLogin, confirmDelete = () => false }) {
const encodedRepository = repository => String(repository || '').split('/')
.map(encodeURIComponent).join('/');
function pathFor(context, commentId) {
const item = context?.item || {};
if (context?.kind === 'update') {
return 'api/v1/notifications/' + encodeURIComponent(item.notification_id) +
'/comments/' + encodeURIComponent(commentId);
}
if (!['issue', 'pull'].includes(context?.kind)) throw new Error('Comment conversation is unavailable.');
return 'api/v1/repos/' + encodedRepository(item.repository) + '/' +
(context.kind === 'pull' ? 'pulls/' : 'issues/') + encodeURIComponent(item.number) +
'/comments/' + encodeURIComponent(commentId);
}
const controller = {
isOwned(comment) {
const login = String(getLogin() || '').trim();
return Boolean(login && comment && comment.author === login);
},
async edit(context, pager, commentId, body) {
const draft = String(body || '').trim();
if (!draft) throw new Error('Comment must not be blank.');
const comment = await fetchJson(pathFor(context, commentId), {
method: 'PATCH',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ body: draft }),
});
pager.replace(comment);
return pager.snapshot();
},
async remove(context, pager, commentId) {
if (!confirmDelete('Delete this comment permanently?')) return null;
const result = await fetchJson(pathFor(context, commentId), {
method: 'DELETE', headers: { Accept: 'application/json' },
});
if (!result?.deleted || Number(result.id) !== Number(commentId)) {
throw new Error('Comment deletion was not confirmed.');
}
pager.remove(commentId);
return pager.snapshot();
},
actionHtml(comment) {
return controller.isOwned(comment) ?
'<div class="comment-owned-actions" aria-label="Your comment actions">' +
'<button type="button" data-comment-action="edit">Edit</button>' +
'<button type="button" data-comment-action="delete">Delete</button></div>' : '';
},
wire({ root, getSurface, isOffline, escapeHtml }) {
root.addEventListener('click', async event => {
const button = event.target.closest('[data-comment-action]');
if (!button) return;
const card = button.closest('.issue-comment');
const commentId = Number(card?.dataset.commentId);
const surface = getSurface();
const comment = surface.pager?.snapshot().comments.find(item => item.id === commentId);
if (!comment || !controller.isOwned(comment)) return;
if (isOffline()) {
surface.status.textContent = 'Reconnect to edit or delete this comment.';
return;
}
if (button.dataset.commentAction === 'delete') {
try {
const state = await controller.remove(surface.context, surface.pager, commentId);
if (state) {
surface.render(state);
surface.status.textContent = 'Comment deleted.';
}
} catch (error) {
surface.status.textContent = error.message || 'Comment deletion failed. Retry or open it in Gitea.';
}
return;
}
card.innerHTML = '<div class="small">Editing your comment</div>' +
'<textarea class="comment-edit-textarea" maxlength="10000" aria-label="Edit comment">' +
escapeHtml(comment.body || '') + '</textarea>' +
'<div class="comment-owned-actions"><button type="button" data-comment-edit-save>Save</button>' +
'<button type="button" data-comment-edit-cancel>Cancel</button></div>';
const textarea = card.querySelector('.comment-edit-textarea');
textarea.focus();
card.querySelector('[data-comment-edit-cancel]').addEventListener('click', () => surface.render(surface.pager.snapshot()));
card.querySelector('[data-comment-edit-save]').addEventListener('click', async saveEvent => {
const save = saveEvent.currentTarget;
save.disabled = true;
surface.status.textContent = 'Saving comment…';
try {
const state = await controller.edit(surface.context, surface.pager, commentId, textarea.value);
surface.render(state);
surface.status.textContent = 'Comment updated.';
} catch (error) {
save.disabled = false;
surface.status.textContent = error.message || 'Comment update failed. Your edit is safe; retry or open it in Gitea.';
textarea.focus();
}
});
});
},
};
return controller;
}
if (typeof module !== 'undefined' && module.exports) module.exports = createCommentActions;
+61
View File
@@ -0,0 +1,61 @@
function createCommentNext({ post, queue, queueKind = '', canQueue, accept = () => undefined, complete }) {
let inFlight = null;
async function admitQueued(item, message) {
const admission = await queue(message);
if (!admission || (!admission.item && admission.durable !== true)) {
throw new Error('Comment was not saved for delivery.');
}
accept(item, { delivery: admission.background ? 'queued' : 'saved' });
return {
accepted: true,
delivery: admission.background ? 'queued' : 'saved',
background: Boolean(admission.background),
completed: Boolean(complete(item)),
};
}
function submit(item, body, operationId = '') {
if (inFlight) return inFlight;
inFlight = (async () => {
try {
let comment;
try {
comment = await post(item, body, typeof operationId === 'function' ? operationId() : operationId);
} catch (error) {
if (!canQueue(error)) throw error;
const identity = queueKind === 'update-reply' ? {
kind: 'update-reply', notificationId: item.notification_id,
} : {
kind: item.kind === 'pull' ? 'pull-comment' : 'issue-comment',
repository: item.repository, number: item.number,
};
return admitQueued(item, {
...identity, body,
operationId: typeof operationId === 'function' ? operationId() : operationId,
});
}
accept(item, { delivery: 'posted', comment });
return {
accepted: true,
delivery: 'posted',
comment,
completed: Boolean(complete(item)),
};
} finally {
inFlight = null;
}
})();
return inFlight;
}
function admit(item, message) {
if (inFlight) return inFlight;
inFlight = admitQueued(item, message).finally(() => { inFlight = null; });
return inFlight;
}
return { submit, admit, busy: () => Boolean(inFlight) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createCommentNext;
+60 -4
View File
@@ -10,6 +10,13 @@ function buildLiveRevisionQuery(revisions = {}) {
return params.toString();
}
function retryAfterMs(value) {
if (typeof value !== 'string' || !/^\d+$/.test(value)) return null;
const seconds = Number(value);
if (!Number.isFinite(seconds) || seconds <= 0) return null;
return Math.min(seconds * 1000, 300000);
}
function createContextPoller({
fetchContext,
onSnapshot,
@@ -21,25 +28,55 @@ function createContextPoller({
clearDeadlineTimer = clearTimeout,
intervalMs = 8000,
timeoutMs = 12000,
maxBackoffMs = 60000,
}) {
let activeRequest = null;
let timer = null;
let stopped = false;
let revisions = {};
let retainedSnapshot = null;
let nextDelayMs = intervalMs;
let failureStreak = 0;
let nextRetryAt = null;
let lastSuccessAt = null;
function snapshotDelay(snapshot) {
const freshness = snapshot && snapshot.freshness;
const sections = freshness && freshness.sections;
const sectionValues = sections && Object.values(sections);
if (sectionValues && sectionValues.length && sectionValues.every(section => section.degraded)) {
const retrySeconds = Number(freshness.retry_in_seconds);
if (Number.isFinite(retrySeconds) && retrySeconds > 0) return retrySeconds * 1000;
}
const freshForSeconds = Number(freshness && freshness.fresh_for_seconds);
const healthyDeadlines = (sectionValues || [])
.filter(section => !section.degraded)
.map(section => freshForSeconds - Number(section.age_seconds))
.filter(seconds => Number.isFinite(seconds));
if (Number.isFinite(freshForSeconds) && freshForSeconds > 0 && healthyDeadlines.length) {
const earliestDeadline = Math.min(...healthyDeadlines);
if (earliestDeadline <= 0 && sectionValues.some(section => !section.degraded && section.revalidating)) {
return intervalMs;
}
return Math.max(0, earliestDeadline) * 1000;
}
return intervalMs;
}
function cancelTimer() {
if (timer !== null) clearTimer(timer);
timer = null;
}
function schedule() {
function schedule(delayMs = intervalMs) {
cancelTimer();
if (stopped || isHidden()) return;
nextRetryAt = Date.now() + delayMs;
timer = setTimer(() => {
timer = null;
nextRetryAt = null;
refresh();
}, intervalMs);
}, delayMs);
}
function abortError() {
@@ -91,6 +128,9 @@ function createContextPoller({
requestState.promise = Promise.race([Promise.resolve(request), deadlinePromise])
.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)
);
@@ -100,7 +140,14 @@ function createContextPoller({
return retainedSnapshot;
})
.catch((error) => {
if (activeRequest === requestState && !stopped) onError(error);
if (activeRequest === requestState && !stopped) {
const retryAfterMs = Number(error && error.retryAfterMs);
failureStreak += 1;
nextDelayMs = Number.isFinite(retryAfterMs) && retryAfterMs > 0
? retryAfterMs
: Math.min(intervalMs * (2 ** (failureStreak - 1)), maxBackoffMs);
onError(error);
}
return null;
})
.finally(() => {
@@ -108,7 +155,7 @@ function createContextPoller({
clearDeadlineTimer(requestState.deadline);
if (activeRequest !== requestState) return;
activeRequest = null;
schedule();
schedule(nextDelayMs);
});
return requestState.promise;
}
@@ -123,6 +170,14 @@ function createContextPoller({
start: refresh,
refresh,
setVisible,
getState() {
return {
refreshing: Boolean(activeRequest),
nextRetryAt,
lastSuccessAt,
failureStreak,
};
},
stop() {
stopped = true;
cancelTimer();
@@ -136,6 +191,7 @@ function createContextPoller({
}
createContextPoller.buildRevisionQuery = buildLiveRevisionQuery;
createContextPoller.retryAfterMs = retryAfterMs;
if (typeof module !== 'undefined' && module.exports) {
module.exports = createContextPoller;
+15
View File
@@ -63,6 +63,21 @@ function createConversationPager({ loadPage }) {
}
return snapshot();
},
replace(comment) {
if (!comment || !Number.isInteger(comment.id)) return snapshot();
state = {
...state,
comments: state.comments.map(existing => existing.id === comment.id ? { ...comment } : existing),
};
return snapshot();
},
remove(commentId) {
const comments = state.comments.filter(comment => comment.id !== commentId);
if (comments.length !== state.comments.length) {
state = { ...state, comments, total: Math.max(0, state.total - 1) };
}
return snapshot();
},
};
}
+60 -1
View File
@@ -22,8 +22,10 @@ function normalizeSharedContent(value = {}) {
function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOperationId }) {
const storageKey = 'stackchain.issue-capture.v1';
const sharedStorageKey = 'stackchain.issue-share.v1';
const followUpStorageKey = 'stackchain.issue-follow-up.v1';
let pending = null;
let duplicateRequest = 0;
let repositorySearchRequest = 0;
let duplicateState = {status: 'idle', key: '', candidates: []};
let acknowledgedDuplicateKey = '';
const repositoryPageRequests = new Map();
@@ -125,6 +127,42 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
return {status: 'ready'};
}
function pendingFollowUp() {
try {
const parsed = JSON.parse(storage.getItem(followUpStorageKey) || 'null');
if (!parsed || typeof parsed !== 'object') return null;
const repository = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(String(parsed.repository || '')) ? String(parsed.repository) : '';
const title = String(parsed.title || '').trim().slice(0, 240);
const body = String(parsed.body || '').trim().slice(0, 9500);
return title || body ? {repository, title, body, labelIds: []} : null;
} catch (_error) { return null; }
}
function acceptFollowUp() {
const followUp = pendingFollowUp();
if (!followUp) return loadDraft();
const accepted = saveDraft(followUp);
try { storage.removeItem(followUpStorageKey); }
catch (_error) { /* Accepted content is already persisted as the issue draft. */ }
return accepted;
}
function discardFollowUp() {
try { storage.removeItem(followUpStorageKey); }
catch (_error) { /* The existing capture remains authoritative. */ }
}
function stageFollowUp(value) {
const followUp = {repository: String(value?.repository || ''), title: String(value?.title || ''), body: String(value?.body || '')};
if (!followUp.title && !followUp.body) return {status: 'empty'};
try { storage.setItem(followUpStorageKey, JSON.stringify(followUp)); }
catch (_error) { /* The in-page flow can still continue. */ }
const existing = loadDraft();
if (existing.title || existing.body) return {status: 'conflict'};
acceptFollowUp();
return {status: 'ready'};
}
function loadLabels(repository) {
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
const priorities = new Set(['p0', 'priority-high', 'critical']);
@@ -159,6 +197,25 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
return request;
}
async function searchRepositories(value) {
const query = String(value || '').trim().slice(0, 80);
const request = ++repositorySearchRequest;
if (query.length < 2) return {status: 'idle', items: []};
try {
const payload = await fetchJson(
'api/v1/repositories/search?q=' + encodeURIComponent(query) + '&limit=20'
);
if (request !== repositorySearchRequest) return {status: 'stale', items: []};
return {
status: 'ready',
items: Array.isArray(payload?.items) ? payload.items : [],
};
} catch (error) {
if (request !== repositorySearchRequest) return {status: 'stale', items: []};
return {status: 'failed', items: [], error};
}
}
function duplicateKey(draft) {
const repository = String(draft?.repository || '').trim();
const title = String(draft?.title || '').replace(/\s+/g, ' ').trim();
@@ -230,9 +287,11 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
}
return {
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage, findDuplicates,
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage,
searchRepositories, findDuplicates,
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
stageFollowUp, pendingFollowUp, acceptFollowUp, discardFollowUp,
};
}
+397 -17
View File
@@ -5,23 +5,79 @@ 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; }
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
.notification-undo { position:fixed; z-index:110; left:50%; bottom:calc(88px + env(safe-area-inset-bottom)); transform:translateX(-50%); box-sizing:border-box; width:min(520px,calc(100vw - 24px)); display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 12px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; box-shadow:0 12px 36px rgba(0,0,0,.5); overflow-wrap:anywhere; }
.notification-undo[hidden] { display:none; }
.notification-undo button { min-height:44px; min-width:64px; flex:none; }
.draft-capacity-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); }
.draft-capacity-sheet[hidden] { display:none; }
.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
.draft-capacity-panel h3 { margin:.25rem 0; overflow-wrap:anywhere; }
.draft-capacity-oldest { margin:14px 0; padding:12px; border:1px solid #31577f; border-radius:12px; overflow-wrap:anywhere; }
.draft-capacity-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.draft-capacity-actions button { min-height:44px; width:100%; }
#replace-oldest-draft { grid-column:1 / -1; border-color:#b45309; }
#sign-out-all { min-height:44px; }
.active-devices-sheet { position:fixed; inset:0; z-index:80; display:flex; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.active-devices-sheet[hidden] { display:none; }
.active-devices-panel { box-sizing:border-box; width:min(560px,100%); height:100%; overflow:auto; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
.active-devices-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.active-devices-header h2, .active-devices-header p { margin-top:0; }
.active-devices-header button, .active-device button { min-height:44px; }
.active-devices-header button, .active-device button, .enrolled-passkey button { min-height:44px; }
.active-devices-list { display:grid; gap:10px; margin-top:16px; }
.device-setup-sheet { position:fixed; inset:0; z-index:95; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.device-setup-sheet[hidden] { display:none; }
.device-setup-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
.device-setup-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.device-setup-header h2, .device-setup-header p { margin-top:0; }
.device-setup-header button, .device-setup-action { min-height:44px; }
.device-setup-list { display:grid; gap:10px; margin:14px 0; padding:0; list-style:none; }
.device-setup-step { min-width:0; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:12px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#10233a; overflow-wrap:anywhere; }
.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, #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; }
.device-readiness-summary { color:#bfdbfe; font-weight:700; }
.device-readiness-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.device-readiness-actions button { min-height:44px; min-width:0; }
.active-device { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
.active-device strong, .active-device span { display:block; overflow-wrap:anywhere; }
.active-device-current { color:#55d6be; font-weight:700; }
.enrolled-passkeys { margin-top:24px; padding-top:18px; border-top:1px solid #2a496e; }
.enrolled-passkeys-header h3, .enrolled-passkeys-header p { margin:0 0 6px; }
.enrolled-passkeys-list { display:grid; gap:10px; margin-top:12px; }
.enrolled-passkey { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
.enrolled-passkey strong, .enrolled-passkey span { display:block; overflow-wrap:anywhere; }
.security-activity { margin-top:24px; padding-top:18px; border-top:1px solid #2a496e; }
.security-activity-header h3, .security-activity-header p { margin:0 0 6px; }
.security-activity-list { display:grid; gap:8px; margin:12px 0; }
.security-event { padding:12px; border:1px solid #243d5d; border-radius:12px; background:#0d1c30; }
.authentication-alert { border-color:#d69e2e; background:#241b09; }
.authentication-alert strong { color:#f6c453; }
.security-event strong, .security-event span { display:block; overflow-wrap:anywhere; }
#load-more-security-activity { width:100%; min-height:44px; }
button:hover { filter: brightness(1.15); }
.issue-comment { min-width:0; overflow-wrap:anywhere; }
.comment-owned-actions { display:flex; gap:8px; flex-wrap:wrap; margin:8px 0; }
.comment-owned-actions button { min-height:44px; min-width:72px; }
.comment-edit-textarea { box-sizing:border-box; display:block; width:100%; max-width:100%; min-height:132px; resize:vertical; overflow-wrap:anywhere; }
.panel { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); }
.panel > summary { cursor: pointer; list-style-position: inside; }
.panel > summary h2 { display: inline-block; margin-left: 4px; }
@@ -34,6 +90,14 @@ main { position: relative; z-index: 1; display: grid; grid-template-columns: 300
h2 { font-size: 13px; margin: 8px 0; text-transform: uppercase; letter-spacing: .08em; color: #94a3b8; }
a { color: #60a5fa; text-decoration: none; }
.muted { color: #94a3b8; }
.empty-work-start { display:grid; gap:12px; margin:12px 0; padding:16px; border:1px solid #31577f; border-radius:14px; background:#0b1b2f; }
.empty-work-start[hidden] { display:none; }
.empty-work-start p { margin:6px 0 0; }
.empty-work-start-actions { display:grid; grid-template-columns:minmax(0, 1fr) minmax(0, 1fr); gap:8px; }
.empty-work-start-actions button { min-width:0; min-height:44px; white-space:normal; }
@media (max-width:420px) {
.empty-work-start-actions { grid-template-columns:1fr; }
}
.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 10px; }
.kv .label { color: #94a3b8; }
.kv .value { color: #e5e7eb; }
@@ -50,11 +114,43 @@ 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:repeat(2,minmax(0,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; }
.search-batch-estimate-review { display:grid; gap:12px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; }
.search-batch-estimate-review[hidden] { display:none; }
.search-batch-estimate-review > div:first-child, #search-batch-estimate-list { display:grid; gap:8px; }
.search-batch-estimate-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(92px,auto); gap:10px; align-items:center; overflow-wrap:anywhere; }
.search-batch-estimate-row label { display:flex; align-items:center; gap:6px; }
.search-batch-estimate-review input { min-height:44px; width:76px; }
.search-batch-estimate-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
.search-batch-estimate-actions button { min-height:44px; }
.search-release-review { display:grid; gap:12px; max-height:calc(100dvh - 48px); overflow:auto; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
.search-release-review[hidden] { display:none; }
.search-release-review > div:first-child, #search-release-list, .search-release-row { display:grid; gap:6px; }
.search-release-review label { display:grid; gap:6px; }
.search-release-review select, .search-release-review input { min-height:44px; width:100%; }
.search-release-row { padding:8px 0; border-bottom:1px solid #2a496e; }
.search-release-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
.search-release-actions button { min-height:44px; }
.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; }
@@ -68,6 +164,7 @@ textarea { resize: vertical; min-height: 120px; }
.offline-status[hidden] { display:none; }
.offline-work-controls { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding-top:2px; }
.offline-work-controls label { display:flex; gap:8px; align-items:center; min-height:44px; }
.push-update-control { min-height:44px; }
.offline-work-controls input { width:20px; height:20px; }
.offline-work-controls button { min-height:44px; }
.offline-today-readiness { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
@@ -87,9 +184,24 @@ textarea { resize: vertical; min-height: 120px; }
.plan-today-panel { box-sizing:border-box; width:min(620px,100%); height:100%; overflow:auto; overflow-x:hidden; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
.plan-today-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.plan-today-header h2, .plan-today-header p { margin-top:0; }
.plan-today-header button, .plan-today-list button, .plan-today-candidates button { min-height:44px; }
.plan-today-header button { min-width:44px; min-height:44px; }
.plan-today-list button, .plan-today-candidates button { min-height:44px; }
.plan-today-capacity { position:sticky; top:0; z-index:2; margin:8px 0; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#10233a; font-weight:700; }
.plan-today-available { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:10px 0; font-weight:700; }
.plan-today-available span { display:flex; align-items:center; gap:6px; }
.plan-today-available input, .plan-today-estimate { box-sizing:border-box; min-height:44px; width:108px; }
.plan-today-build { min-height:44px; width:100%; font-weight:700; }
.plan-today-build-status { min-height:1.4em; margin-top:6px; color:#bfdbfe; }
.plan-today-estimates { margin:12px 0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
.plan-today-estimates h3, .plan-today-estimates p { margin-top:0; }
.plan-today-estimates input { min-height:44px; width:108px; box-sizing:border-box; }
.plan-today-estimate-row { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:10px; padding:8px 0; border-top:1px solid #29496d; }
.plan-today-estimate-row label { display:flex; align-items:center; gap:6px; }
.plan-today-skipped { display:grid; gap:6px; margin:8px 0; }
.plan-today-skipped-item { padding:8px 10px; border-left:3px solid #b45309; background:#261a13; overflow-wrap:anywhere; }
.plan-today-estimate-wrap { display:flex; align-items:center; gap:6px; margin-top:8px; }
.plan-today-error { min-height:1.4em; color:#fca5a5; }
.discard-recap-replan { min-height:44px; margin-bottom:8px; }
.plan-today-list, .plan-today-candidates { display:grid; gap:8px; }
.plan-today-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:10px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; }
.plan-today-item-copy { min-width:0; overflow-wrap:anywhere; }
@@ -100,6 +212,46 @@ textarea { resize: vertical; min-height: 120px; }
.plan-preview-actions button { min-height:44px; }
.plan-today-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.plan-today-actions button { min-height:44px; width:100%; }
.today-interruption-sheet { position:fixed; inset:0; z-index:92; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-interruption-sheet[hidden] { display:none; }
.today-interruption-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
.today-interruption-panel h2 { margin:.25rem 0; overflow-wrap:anywhere; }
.today-interruption-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
.today-interruption-actions button { min-height:44px; width:100%; }
.today-recap-sheet { position:fixed; inset:0; z-index:88; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-recap-sheet[hidden] { display:none; }
.today-recap-panel { box-sizing:border-box; width:min(620px,100%); max-height:100%; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
.today-recap-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.today-recap-header h2 { margin:.2rem 0; }
.today-recap-header button { min-height:44px; min-width:44px; }
.today-recap-items, .today-recap-history { display:grid; gap:8px; }
.today-recap-row, .today-recap-history-row { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:8px; padding:10px; border:1px solid #2a496e; border-radius:10px; }
.today-recap-row strong { overflow-wrap:anywhere; }
.today-recap-row input { width:6rem; min-height:44px; }
.today-recap-context { display:block; overflow-wrap:anywhere; }
.today-recap-variance { grid-column:1 / -1; color:#bfdbfe; font-weight:700; }
.today-recap-log { grid-column:1 / -1; display:flex; align-items:center; gap:8px; min-height:44px; }
.today-recap-log input { width:24px; min-height:24px; }
.today-recap-log-success { color:#86efac; font-weight:700; }
.today-recap-log-verify { color:#fde68a; font-weight:700; }
.today-recap-totals { margin:12px 0; padding:12px; border-radius:10px; background:#10233d; font-weight:700; }
.today-recap-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.today-recap-actions button { min-height:44px; width:100%; }
@media (max-width:420px) {
.today-interruption-actions { grid-template-columns:1fr; }
.today-recap-actions { grid-template-columns:1fr; }
.today-recap-row { grid-template-columns:1fr; }
.today-recap-row input { width:min(100%,8rem); }
.today-recap-variance { grid-column:1; }
}
.today-readiness-sheet { position:fixed; inset:0; z-index:90; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-readiness-sheet[hidden] { display:none; }
.today-readiness-panel { box-sizing:border-box; width:min(620px,100%); max-height:100%; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
.today-readiness-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.today-readiness-header h2 { margin:.2rem 0; }
.today-readiness-header button, .today-readiness-actions button { min-height:44px; }
.today-readiness-item { min-width:0; overflow-wrap:anywhere; margin:12px 0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
.today-readiness-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.my-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
.work-settings { width:100%; }
.work-settings > summary { display:none; }
@@ -109,19 +261,59 @@ textarea { resize: vertical; min-height: 120px; }
.work-filter[aria-pressed="true"] { border-color:var(--accent); background:#1d4f7a; }
.milestone-lane { display:flex; align-items:center; gap:8px; min-width:min(100%,260px); }
.work-milestone-filter { min-width:180px; flex:1; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
.queue-finder { display:grid; gap:6px; margin:10px 0; }
.queue-finder > label { font-weight:700; }
.queue-finder-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }
.queue-finder input, .queue-finder button { min-height:44px; box-sizing:border-box; }
.queue-finder input { min-width:0; width:100%; padding:8px 10px; border:1px solid #31577f; border-radius:10px; background:#08111f; color:var(--text); font:inherit; }
.queue-finder button { padding-inline:14px; }
#search-older-work { width:100%; }
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; }
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; scroll-margin-bottom:calc(76px + env(safe-area-inset-bottom)); }
.draft-card:focus-visible { outline:3px solid #60a5fa; outline-offset:3px; border-color:#93c5fd; }
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
.draft-actions button { min-height:44px; width:100%; }
.delivery-center { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; grid-column:1/-1; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
.delivery-center h3, .delivery-center p { margin:0; }
.delivery-center button { min-height:44px; flex:0 0 auto; }
.delivery-attempt { color:#fbbf24; overflow-wrap:anywhere; }
.draft-section { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; grid-column:1/-1; }
.draft-section > h3 { grid-column:1/-1; margin:8px 0 0; }
.create-issue-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); }
.create-issue-actions button { min-height:44px; max-width:100%; width:100%; }
.create-issue-actions #create-issue-status { grid-column:1/-1; }
.draft-filing-session { position:sticky; top:0; z-index:2; display:grid; gap:6px; margin-bottom:12px; padding:10px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:12px; background:rgba(11,21,38,.98); }
.draft-filing-session[hidden] { display:none; }
.draft-filing-session-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.draft-filing-session-actions button { min-height:44px; width:100%; }
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
.agenda-replan { margin:10px 0; padding:12px; border:1px solid #7c4a1d; border-radius:12px; background:#24170d; }
.agenda-replan-launch { display:flex; align-items:center; justify-content:space-between; gap:12px; }
.agenda-replan-launch p { margin:4px 0 0; }
.agenda-replan-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:10px; }
.agenda-replan-actions button { min-height:44px; width:100%; }
.agenda-replan-actions label { grid-column:1/-1; }
.agenda-replan-actions input { box-sizing:border-box; min-height:44px; width:100%; max-width:100%; }
.protect-today { margin:10px 0; padding:12px; border:1px solid #2f6f9f; border-radius:12px; background:#0d2136; display:flex; align-items:center; justify-content:space-between; gap:12px; }
.protect-today p { margin:4px 0; }
.protect-today button { min-height:44px; flex:0 0 auto; }
@media(max-width:360px) { .agenda-replan-launch { align-items:stretch; flex-direction:column; } .agenda-replan-actions { grid-template-columns:1fr; } .agenda-replan-actions label { grid-column:auto; } }
@media(max-width:430px) { .protect-today { align-items:stretch; flex-direction:column; } .protect-today button { width:100%; } }
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
.my-work-card:hover { border-color:var(--accent); }
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
.update-selection-controls { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
.update-selection-controls button { min-height:44px; }
.selection-scope-actions { display:flex; align-items:center; gap:8px; flex-wrap:wrap; max-width:100%; }
.selection-scope-actions button { min-height:44px; max-width:100%; }
.update-selector { min-height:44px; }
.work-selector { min-height:44px; }
.update-selector, .work-selector { display:flex; align-items:center; gap:10px; padding:6px; border:1px solid #31577f; border-radius:8px; cursor:pointer; }
.update-selector input, .work-selector input { width:22px; height:22px; flex:0 0 auto; }
.my-work-card.selection-active { border-color:#31577f; }
.my-work-card.selected { border-color:#60a5fa; box-shadow:inset 4px 0 #60a5fa; }
.later-actions, .today-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
.later-actions button { min-height:44px; width:100%; }
.today-actions button { min-height:44px; width:100%; }
@@ -152,6 +344,9 @@ textarea { resize: vertical; min-height: 120px; }
.retry-work-route[hidden] { display:none; }
.my-work-bulk { position:sticky; bottom:0; z-index:4; margin:10px -4px -12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.my-work-bulk button { min-height:44px; width:100%; }
.my-work-bulk-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.my-work-bulk-actions button { min-height:44px; width:100%; }
.my-work-bulk-actions #bulk-mark-read { grid-column:1 / -1; }
.my-work[data-stale="true"] { border-color:#fcd34d; }
.review-sheet { position:fixed; inset:0; z-index:50; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.review-sheet.open { display:flex; }
@@ -179,9 +374,18 @@ textarea { resize: vertical; min-height: 120px; }
.review-copy-fallback { min-height:140px; }
.review-progress-actions { position:sticky; bottom:0; z-index:2; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:8px -4px 0; padding:10px 4px; background:rgba(11,21,38,.96); border-top:1px solid #2a496e; }
.review-progress-actions button { min-height:44px; max-width:100%; }
.review-display-tools { display:flex; align-items:center; gap:10px; margin-bottom:8px; }
.review-display-tools button { min-height:44px; flex:0 0 auto; }
.review-display-tools button[aria-pressed="true"] { color:#bfdbfe; background:#17365a; outline:1px solid #60a5fa; }
.review-diff { overflow-x:auto; max-width:100%; margin-top:8px; white-space:pre; }
.review-diff-line { display:block; min-width:max-content; }
.review-inline-target { min-height:44px; width:100%; padding:8px; border:0; border-radius:0; text-align:left; font:inherit; white-space:pre; }
.review-diff-line { display:grid; grid-template-columns:max-content minmax(max-content,1fr); align-items:stretch; min-width:max-content; }
.review-line-numbers { position:sticky; left:0; z-index:1; display:grid; grid-template-columns:4ch 4ch; align-self:stretch; background:#0b1526; color:#7694b6; user-select:none; }
.review-line-number { padding:8px 4px; text-align:right; border-right:1px solid #244363; }
.review-line-code { min-width:0; padding:8px; white-space:pre; overflow-wrap:anywhere; }
.review-inline-target { min-height:44px; width:100%; padding:0; border:0; border-radius:0; text-align:left; font:inherit; white-space:normal; }
.wrap-lines .review-diff { overflow-x:hidden; white-space:normal; }
.wrap-lines .review-diff-line { grid-template-columns:max-content minmax(0,1fr); min-width:0; }
.wrap-lines .review-line-code { white-space:pre-wrap; overflow-wrap:anywhere; }
.review-inline-target.has-draft { box-shadow:inset 4px 0 #fbbf24; }
.review-inline-composer { position:sticky; bottom:0; z-index:4; display:grid; gap:8px; padding:10px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); border:1px solid #60a5fa; border-radius:10px; background:#0b1526; }
.review-inline-composer[hidden] { display:none; }
@@ -194,18 +398,50 @@ textarea { resize: vertical; min-height: 120px; }
.review-diff-note, .review-diff-empty { display:block; padding:8px; color:#fcd34d; white-space:normal; }
.review-action { min-height:44px; }
.review-retry { min-height:44px; margin-top:10px; }
.ci-checks { max-width:100%; margin:10px 0; border:1px solid #2a496e; border-radius:10px; overflow:hidden; }
.ci-checks > summary { min-height:44px; display:flex; align-items:center; justify-content:space-between; gap:8px; padding:0 10px; cursor:pointer; }
.ci-checks > summary .small { min-width:0; overflow-wrap:anywhere; text-align:right; }
.ci-check-refresh { width:100%; min-height:44px; margin-bottom:6px; }
.ci-check-list { display:grid; gap:6px; min-width:0; padding:0 8px 8px; }
.ci-check { min-width:0; display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:8px; padding:8px; border:1px solid #203a5c; border-radius:8px; }
.ci-check-failure, .ci-check-error { border-color:#dc2626; background:rgba(127,29,29,.22); }
.ci-check-pending { border-color:#d97706; background:rgba(120,53,15,.2); }
.ci-check-success { border-color:#15803d; }
.ci-check-copy { min-width:0; display:grid; gap:3px; overflow-wrap:anywhere; }
.ci-check-copy strong, .ci-check-copy span { min-width:0; overflow-wrap:anywhere; }
.ci-check-link { min-height:44px; display:flex; align-items:center; justify-content:center; padding:0 10px; border:1px solid #60a5fa; border-radius:8px; white-space:nowrap; }
.update-sheet { position:fixed; inset:0; z-index:55; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.update-sheet.open { display:flex; }
.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%; }
.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.update-reply-actions button { min-height:44px; width:100%; }
.update-reply .issue-attachment-preview { width:100%; min-width:0; }
@media (max-width:390px) {
.update-reply-actions { grid-template-columns:1fr; }
.update-reply .issue-attachment-preview { grid-template-columns:56px minmax(0,1fr); padding:8px; }
.update-reply .issue-attachment-preview img { width:56px; height:56px; }
}
.update-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.update-ownership-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.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; }
@@ -213,6 +449,20 @@ textarea { resize: vertical; min-height: 120px; }
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.issue-sheet-header button { min-height:44px; }
.issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
.issue-blockers { max-width:100%; overflow-x:hidden; margin:16px 0; padding:12px; border:1px solid #b45309; border-radius:12px; background:#291b0c; }
.issue-blockers h2 { margin-top:0; }
.issue-blocker-list { display:grid; gap:8px; }
.issue-blocker { min-width:0; overflow-wrap:anywhere; display:grid; gap:4px; padding:10px; border:1px solid #92400e; border-radius:10px; color:#fef3c7; text-decoration:none; }
.issue-blocker:hover, .issue-blocker:focus-visible { border-color:#f59e0b; }
.issue-blocker-row { min-width:0; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:start; }
.issue-blocker-row .issue-blocker { min-width:0; }
.issue-blocker-remove { min-height:44px; white-space:normal; }
.issue-blocker-manager { display:grid; gap:8px; margin-top:10px; min-width:0; }
.issue-blocker-manager :is(input,button) { min-height:44px; width:100%; max-width:100%; box-sizing:border-box; }
.issue-blocker-manager [role="listbox"] { display:grid; gap:6px; max-height:240px; overflow:auto; }
.issue-blocker-manager [role="option"] { text-align:left; overflow-wrap:anywhere; }
#manage-issue-blockers, #start-unblocked-issue { min-height:44px; width:100%; margin-top:10px; }
.today-readiness-blocker { width:100%; min-height:44px; text-align:left; background:#291b0c; }
.issue-planning { max-width:100%; margin-top:16px; border:1px solid #2a496e; border-radius:12px; padding:0 12px 12px; overflow-x:hidden; }
.issue-planning > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; font-weight:700; }
.issue-planning-retry { min-height:44px; width:100%; }
@@ -226,6 +476,22 @@ textarea { resize: vertical; min-height: 120px; }
.issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; }
.issue-comment-composer { display:grid; gap:8px; margin-top:16px; }
.issue-comment-composer button { min-height:44px; width:100%; }
.visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
.issue-attachment-controls { display:flex; max-width:100%; }
.issue-attachment-trigger { min-height:44px; display:inline-flex; align-items:center; justify-content:center; padding:8px 12px; border:1px solid #60a5fa; border-radius:10px; color:#dbeafe; font-weight:700; cursor:pointer; }
.issue-attachment-preview { display:grid; grid-template-columns:64px minmax(0,1fr); gap:8px 12px; align-items:center; max-width:100%; overflow:hidden; padding:10px; border:1px solid #315781; border-radius:10px; background:#101f34; }
.issue-attachment-preview[hidden] { display:none; }
.issue-attachment-preview img { grid-row:span 2; width:64px; height:64px; object-fit:cover; border-radius:8px; }
.issue-attachment-preview .small { min-width:0; overflow-wrap:anywhere; }
.issue-attachment-preview button { min-height:44px; width:auto; justify-self:start; }
.mention-options { display:grid; max-width:100%; max-height:220px; overflow:auto; border:1px solid #315781; border-radius:10px; background:#101f34; box-shadow:0 10px 28px rgba(0,0,0,.35); }
.mention-options[hidden] { display:none; }
.mention-option { min-height:44px; max-width:100%; overflow:hidden; padding:9px 12px; border:0; border-bottom:1px solid #203a5c; border-radius:0; text-align:left; text-overflow:ellipsis; white-space:nowrap; background:#101f34; color:#dbeafe; }
.mention-option:last-child { border-bottom:0; }
.mention-option[aria-selected="true"], .mention-option:hover { background:#17365a; color:#fff; }
.mention-status:empty { display:none; }
.comment-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.comment-actions button { min-height:44px; width:100%; }
.issue-label-editor { max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
.issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(180px,100%),1fr)); gap:8px; max-width:100%; }
.issue-label-option { min-height:44px; max-width:100%; display:flex; align-items:center; gap:10px; padding:8px; border:1px solid #2a496e; border-radius:10px; overflow-wrap:anywhere; }
@@ -241,27 +507,58 @@ textarea { resize: vertical; min-height: 120px; }
.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.issue-handoff { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
.issue-handoff > div { display:grid; gap:8px; margin-top:10px; }
.issue-handoff select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); }
.issue-handoff, .pull-ownership { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
.issue-handoff > div, .pull-ownership > div { display:grid; gap:8px; margin-top:10px; }
.issue-handoff select, .pull-ownership select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); }
.issue-handoff select, .issue-handoff button { min-height:44px; }
.pull-ownership select, .pull-ownership button { min-height:44px; }
.issue-retry { min-height:44px; width:100%; margin-top:10px; }
.new-issue { min-height:44px; }
.find-work-action { min-height:44px; }
.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; }
.start-work-session { min-height:44px; }
.resume-today-session, .end-today-session { min-height:44px; }
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.work-session-nav[hidden] { display:none; }
.work-session-nav [data-work-session-progress] { grid-column:1 / -1; text-align:center; }
.work-session-nav [data-work-session-timer-toggle] { min-height:44px; }
.work-session-nav button { min-height:44px; width:100%; }
.find-work-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.find-work-sheet.open { display:flex; }
.find-work-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
.find-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.find-work-header-actions { display:flex; gap:8px; }
.find-work-header button, .find-work-card button, .find-work-card a, .find-work-more { min-height:44px; }
.find-work-search { position:sticky; top:0; z-index:2; display:grid; gap:6px; padding:8px 0; background:#0b1526; }
.find-work-search > div { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }
.find-work-search input, .find-work-search button { min-height:44px; }
.find-work-filters { border:1px solid #2a496e; border-radius:10px; padding:10px; }
.find-work-filters summary { min-height:44px; display:flex; align-items:center; justify-content:space-between; cursor:pointer; }
.find-work-filter-panel, .find-work-filter-options { display:grid; gap:8px; }
.find-work-filter-panel fieldset { min-width:0; border:0; padding:6px 0; margin:0; }
.find-work-filter-options { grid-template-columns:repeat(auto-fit,minmax(130px,1fr)); }
.find-work-filter-option { min-height:44px; display:flex; align-items:center; gap:8px; padding:6px; overflow-wrap:anywhere; }
.find-work-filter-option input { width:20px; height:20px; flex:0 0 auto; }
#clear-find-work-filters { min-height:44px; width:100%; }
.find-work-list { display:grid; gap:10px; }
.find-work-card { display:grid; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
.find-work-card.selected { border-color:#60a5fa; box-shadow:0 0 0 2px rgba(96,165,250,.25); }
.find-work-select { display:flex; align-items:center; gap:10px; min-height:44px; }
.find-work-select input { width:22px; height:22px; }
.fill-find-work-today { min-height:44px; width:100%; font-weight:700; }
.find-work-batch-actions { position:sticky; bottom:0; z-index:2; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:12px 0 calc(12px + env(safe-area-inset-bottom)); background:#0b1526; border-top:1px solid #2a496e; }
.find-work-batch-actions[hidden] { display:none; }
.find-work-batch-actions span { grid-column:1 / -1; }
.find-work-batch-actions button { min-height:44px; }
.find-work-batch-recovery { min-height:44px; width:100%; font-weight:700; }
.find-work-estimate-review { display:grid; gap:12px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; }
.find-work-estimate-review[hidden] { display:none; }
.find-work-estimate-review > div:first-child, #find-work-estimate-list { display:grid; gap:8px; }
.find-work-estimate-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(92px,auto); gap:10px; align-items:center; overflow-wrap:anywhere; }
.find-work-estimate-row label { display:flex; align-items:center; gap:6px; }
.find-work-estimate-review input { min-height:44px; width:76px; }
.find-work-estimate-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
.find-work-estimate-actions button { min-height:44px; }
.find-work-card button { width:100%; font-weight:700; }
.find-work-claim-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.find-work-claim-actions [data-claim-start-index] { grid-column:1 / -1; }
@@ -274,8 +571,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; }
@@ -292,11 +604,22 @@ textarea { resize: vertical; min-height: 120px; }
@media(max-width:320px) { .find-work-panel { padding:12px; overflow-x:hidden; } .find-work-card { min-width:0; } .my-work-actions { width:100%; } .my-work-actions button { flex:1 1 100%; } }
.create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.create-issue-sheet.open { display:flex; }
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; overflow-x:hidden; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
.create-issue-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.create-issue-header button, .create-issue-actions button { min-height:44px; }
.create-issue-header button, .create-issue-actions button, .create-issue-capture-actions button { min-height:44px; }
.create-issue-form { display:grid; gap:12px; }
.create-issue-capture-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
.create-issue-capture-actions[hidden] { display:none; }
.create-issue-filing { display:grid; gap:12px; min-width:0; }
.create-issue-filing[hidden] { display:none; }
.create-issue-attachment { display:grid; gap:8px; min-width:0; }
.create-issue-repository-more { min-height:44px; width:100%; }
.create-issue-repository-picker { min-width:0; display:grid; gap:8px; }
.create-issue-repository-picker input { min-width:0; min-height:44px; width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
#create-issue-repository-results { display:grid; max-height:min(36dvh,280px); overflow:auto; border:1px solid #2a496e; border-radius:10px; }
#create-issue-repository-results[hidden] { display:none; }
.create-issue-repository-result { min-height:44px; min-width:0; padding:10px 12px; overflow-wrap:anywhere; text-align:left; border:0; border-bottom:1px solid #1f3a5f; border-radius:0; background:#10213a; color:#e5e7eb; }
.create-issue-repository-result:last-child { border-bottom:0; }
.create-issue-form label { display:grid; gap:6px; }
.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
.create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; }
@@ -342,11 +665,19 @@ textarea { resize: vertical; min-height: 120px; }
.pull-retry { min-height:44px; width:100%; margin-top:10px; }
.mobile-task-dock { display:none; }
.mobile-task-dock[hidden] { display:none; }
.mobile-today-hud { display:none; }
.mobile-today-hud[hidden], .mobile-today-hud[data-overlay-hidden="true"] { display:none; }
.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; }
@media (max-width: 600px) {
body { padding-bottom:calc(66px + env(safe-area-inset-bottom)); }
body.mobile-today-active { padding-bottom:calc(166px + env(safe-area-inset-bottom)); }
header { min-height:56px; max-height:64px; padding:6px 10px; align-items:center; gap:8px; background:rgba(11,21,38,.98); }
.app-brand .muted, #clock { display:none; }
.app-live-status { margin-left:auto; }
@@ -356,14 +687,20 @@ textarea { resize: vertical; min-height: 120px; }
.app-menu:not([open]) > .app-menu-panel { display:none; }
.app-menu-panel { position:absolute; top:calc(100% + 6px); right:0; width:min(280px,calc(100vw - 20px)); display:grid; gap:8px; padding:10px; border:1px solid #2a496e; border-radius:12px; background:#0b1526; box-shadow:0 18px 45px rgba(0,0,0,.5); }
.app-menu-panel button { min-height:44px; width:100%; }
.device-setup-panel { width:100%; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); }
.device-setup-step { grid-template-columns:1fr; }
.device-setup-action { width:100%; }
.device-readiness-card:not([hidden]) { display:grid; gap:12px; margin-left:max(0px,env(safe-area-inset-left)); margin-right:max(0px,env(safe-area-inset-right)); }
.my-work { margin:0; }
.attention-interruption:not([hidden]) { position:sticky; top:64px; z-index:6; margin-inline:max(0px,env(safe-area-inset-left)) max(0px,env(safe-area-inset-right)); }
.my-work-header { align-items:flex-start; }
.my-work-actions { width:100%; flex-wrap:nowrap; }
.my-work-actions button { min-height:44px; flex:1 1 0; padding-inline:6px; }
.my-work-actions { display:none; }
.work-settings > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; padding:0 10px; border:1px solid #2a496e; border-radius:10px; font-weight:700; }
.work-settings:not([open]) > .work-settings-panel { display:none; }
.work-settings-panel { display:grid; gap:10px; margin-top:8px; }
.queue-finder { position:sticky; top:64px; z-index:5; margin-inline:max(0px,env(safe-area-inset-left)) max(0px,env(safe-area-inset-right)); padding:8px; background:rgba(11,21,38,.98); border:1px solid #2a496e; border-radius:10px; }
.my-work-list { grid-template-columns:1fr; }
.my-work-bulk { bottom:calc(56px + env(safe-area-inset-bottom)); }
.my-work-card { min-width:0; overflow-x:hidden; }
.card-planning > summary { min-height:44px; display:flex; align-items:center; justify-content:center; cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; list-style:none; }
.card-planning > summary::-webkit-details-marker { display:none; }
@@ -377,24 +714,67 @@ textarea { resize: vertical; min-height: 120px; }
.work-filters { width:100%; }
.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; }
.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%; }
.search-preview-panel { width:100%; border-left:0; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); overflow-x:hidden; }
#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; }
.create-issue-panel.composer-keyboard-active,
.issue-sheet-panel.composer-keyboard-active,
.pull-sheet-panel.composer-keyboard-active,
.review-sheet-panel.composer-keyboard-active,
.update-sheet-panel.composer-keyboard-active { position:fixed; top:var(--composer-viewport-top,0px); right:0; height:var(--composer-viewport-height,100dvh); overscroll-behavior:contain; padding-bottom:calc(14px + env(safe-area-inset-bottom)); }
.composer-keyboard-active .create-issue-form :is(input,textarea,select,button) { scroll-margin-block:12px; }
.composer-keyboard-active .create-issue-form,
.composer-keyboard-active .issue-comment-composer,
.composer-keyboard-active .pull-comment-composer,
.composer-keyboard-active .review-inline-composer,
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
.mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
.mobile-queue-sheet::backdrop { background:rgba(3,9,18,.7); }
.mobile-queue-panel { padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
.mobile-queue-panel header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
.mobile-queue-panel h2 { margin:0; }
.mobile-queue-list { display:grid; gap:8px; margin-top:12px; }
.mobile-queue-list button { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:56px; width:100%; padding:10px 14px; text-align:left; }
.mobile-queue-list button > span:first-child { display:grid; gap:2px; }
.mobile-queue-list small { color:var(--muted); }
.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; }
.mobile-today-hud [data-mobile-today-complete] { grid-area:complete; }
.mobile-today-hud [data-mobile-today-toggle] { grid-area:toggle; }
.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; }
+3069 -367
View File
File diff suppressed because it is too large. Load diff
+8
View File
@@ -1,6 +1,7 @@
function createDetailDefer({
laterWork,
session,
continueSession = null,
close,
refresh,
focus,
@@ -16,6 +17,13 @@ function createDetailDefer({
return false;
}
const inSession = session.active();
if (continueSession && session.checkpointed?.(item)) {
if (continueSession(item) === true) return true;
laterWork.restore(item);
refresh();
announce('Could not remove this item from Today, so it was restored from Later.');
return false;
}
if (!inSession && closeSheet) close();
refresh();
announce('Deferred until ' + formatTime(until) + '. It stays unread and unchanged in Gitea.');
+65
View File
@@ -0,0 +1,65 @@
function showDraftCapacityDialog(captures) {
const state = captures.capacity();
if (!state.full) return false;
const oldest = state.oldest;
qs('#draft-capacity-oldest').innerHTML = '<strong>' + escapeHtml(oldest.title) + '</strong><div class="small">' +
escapeHtml(oldest.body || 'No note') + (oldest.hasAttachment ? ' · Screenshot attached' : '') + '</div>';
qs('#draft-capacity-sheet').dataset.oldestId = oldest.id;
qs('#draft-capacity-status').textContent = '';
qs('#draft-capacity-sheet').hidden = false;
qs('#keep-editing-draft').focus();
return true;
}
function bindDraftCapacityDialog() {
qs('#keep-editing-draft').addEventListener('click', () => {
qs('#draft-capacity-sheet').hidden = true;
qs('#save-unfiled-issue').focus();
});
qs('#review-full-drafts').addEventListener('click', () => {
const oldestId = qs('#draft-capacity-sheet').dataset.oldestId;
saveIssueCaptureDraft();
qs('#draft-capacity-sheet').hidden = true;
closeCreateIssueSheet(true, false);
qs('[data-work-filter="draft"]').click();
mobileTaskDock.select('queues');
refreshMyWorkView();
requestAnimationFrame(() => {
const card = qs('[data-capture-id="' + CSS.escape(oldestId) + '"]');
card?.scrollIntoView({block:'nearest'});
card?.focus({preventScroll:true});
});
});
qs('#replace-oldest-draft').addEventListener('click', async () => {
const oldest = unfiledCaptures.capacity().oldest;
if (!oldest || oldest.id !== qs('#draft-capacity-sheet').dataset.oldestId) {
qs('#draft-capacity-status').textContent = 'Drafts changed. Review them before replacing anything.';
return;
}
if (!window.confirm('Replace “' + oldest.title + '” and permanently delete its saved contents?')) return;
const button = qs('#replace-oldest-draft');
button.disabled = true;
try {
const draft = {
title:qs('#create-issue-title').value.trim(), body:qs('#create-issue-body').value.trim(),
attachment:await createIssueAttachmentController.serialize(),
};
const saved = await unfiledCaptures.replaceOldest(draft, oldest.id);
qs('#draft-capacity-sheet').hidden = true;
issueCapture.clearDraft();
qs('#create-issue-title').value = '';
qs('#create-issue-body').value = '';
createIssueAttachmentController.clear();
closeCreateIssueSheet(true, false);
qs('[data-work-filter="draft"]').click();
mobileTaskDock.select('queues');
refreshMyWorkView();
requestAnimationFrame(() => qs('[data-capture-id="' + CSS.escape(saved.id) + '"]')?.focus());
qs('#my-work-action-status').textContent = 'Oldest Draft replaced. New work saved to Drafts.';
} catch (error) {
qs('#draft-capacity-status').textContent = error.message + ' Nothing was replaced.';
} finally { button.disabled = false; }
});
}
if (typeof module !== 'undefined' && module.exports) module.exports = {showDraftCapacityDialog, bindDraftCapacityDialog};
+92
View File
@@ -0,0 +1,92 @@
function createDraftFilingSession({list}) {
let orderedIds = [];
let currentId = '';
function visibleIds() {
const available = new Set(list().map(item => item.id));
orderedIds = orderedIds.filter(id => available.has(id));
for (const item of list()) {
if (!orderedIds.includes(item.id)) orderedIds.push(item.id);
}
return orderedIds;
}
function state() {
const ids = visibleIds();
if (!ids.length) {
currentId = '';
return null;
}
if (!ids.includes(currentId)) currentId = ids[0];
const index = ids.indexOf(currentId);
return {id:currentId, position:index + 1, total:ids.length, remaining:ids.length - index - 1};
}
function start(id) {
orderedIds = list().map(item => item.id);
if (!orderedIds.includes(id)) throw new Error('This capture is no longer available.');
currentId = id;
return state();
}
function move(offset) {
const ids = visibleIds();
if (!ids.length) return state();
const index = Math.max(0, ids.indexOf(currentId));
currentId = ids[(index + offset + ids.length) % ids.length];
return state();
}
function removeCurrentAndNext(removedId) {
const previousIds = orderedIds.slice();
const removedIndex = Math.max(0, previousIds.indexOf(removedId));
orderedIds = previousIds.filter(id => id !== removedId);
const ids = visibleIds();
if (!ids.length) return state();
currentId = ids[Math.min(removedIndex, ids.length - 1)];
return state();
}
const session = {start, current:state, next:()=>move(1), previous:()=>move(-1), removeCurrentAndNext};
session.attach = (qs, dependencies) => {
const openCapture = async captureId => {
const resumed = await dependencies.captures.resume(captureId, dependencies.getLogin());
dependencies.issueCapture.saveDraft(resumed);
dependencies.setResumedId(captureId);
await dependencies.openSheet();
dependencies.attachment[resumed.attachment ? 'restore' : 'clear'](resumed.attachment);
dependencies.setFilingMode(true);
session.render();
};
return Object.assign(session, {
nextCapture() { const state = session.current(); return state ? openCapture(state.id) : null; },
render() {
const state = session.current();
qs('#draft-filing-session').hidden = !state;
if (!state) return;
qs('#draft-filing-progress').textContent = `Draft ${state.position} of ${state.total}`;
qs('#draft-filing-remaining').textContent = state.remaining ? `${state.remaining} remaining` : 'Last Draft';
qs('#previous-draft').disabled = qs('#skip-draft').disabled = state.total < 2;
qs('#submit-new-issue').textContent = state.remaining ? 'File & next' : 'File final Draft';
},
bind() {
[['#previous-draft','previous'],['#skip-draft','next']].forEach(([selector, method]) =>
qs(selector).addEventListener('click', async () => {
const state = session[method]();
if (state) await openCapture(state.id);
})
);
},
async advance(completedId) {
const state = session.removeCurrentAndNext(completedId);
if (state) { await openCapture(state.id); return true; }
qs('#draft-filing-session').hidden = true;
qs('#submit-new-issue').textContent = 'Create & assign to me';
return false;
},
});
};
return session;
}
if (typeof module !== 'undefined' && module.exports) module.exports = createDraftFilingSession;
+42 -7
View File
@@ -112,7 +112,8 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
id: 'stackchain.issue-outbox.v1:' + item.id,
outbox_id: item.id,
kind: 'issue-outbox',
status: item.status === 'completion' ? 'completion' : (item.status === 'attention' ? 'attention' : 'queued'),
status: item.status === 'completion' ? 'completion' :
(item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' : 'queued')),
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
(item.status === 'completion' ? 'Created · ready to start' :
(item.status === 'attention' ? 'Needs attention' : 'Queued issue')),
@@ -125,6 +126,8 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
quarantined,
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
last_attempt_at: Number(item.lastAttemptAt || 0),
last_attempt_error: textPreview(item.lastAttemptError),
updated_at: Number(item.queuedAt || 0),
};
});
@@ -138,26 +141,43 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
const currentLogin = String(getCurrentLogin() || '').trim();
return record.items.filter(item => item && typeof item.id === 'string' && typeof item.body === 'string')
.map(item => {
const isUpdate = item.kind === 'update-reply';
const routeKind = item.kind === 'pull-comment' ? 'pull' : 'issue';
const isUpdate = item.kind === 'update-reply' || item.kind === 'update-reply-read';
const isReview = item.kind === 'pull-review';
const isClosure = item.kind === 'issue-close';
const routeKind = isReview ? 'review' : (item.kind === 'pull-comment' ? 'pull' : 'issue');
const target = isUpdate ? 'Update #' + item.notificationId : item.repository + '#' + item.number;
const inlineFeedback = isReview && Array.isArray(item.comments) ? item.comments.map(comment =>
String(comment.path || 'File') + ' · ' +
(comment.new_position ? 'new line ' + comment.new_position : 'old line ' + comment.old_position) +
': ' + String(comment.body || '')
).filter(Boolean) : [];
const copyText = [item.body, inlineFeedback.length ? 'Inline feedback\n' + inlineFeedback.join('\n') : '']
.filter(Boolean).join('\n\n');
const ownerLogin = String(item.ownerLogin || '').trim();
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
return {
id: 'stackchain.authored-outbox.v1:' + item.id,
outbox_id: item.id,
outbox_kind: item.kind,
kind: 'authored-outbox',
status: item.status === 'attention' ? 'attention' : 'queued',
status: item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' :
(item.status === 'authorization' || isClosure ? 'authorization' : 'queued')),
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
(item.status === 'attention' ? 'Needs attention' : 'Queued message'),
(item.status === 'attention' ? (isClosure ? 'Issue closure needs attention' : 'Needs attention') :
(isReview ? (item.status === 'authorization' ? 'Review awaiting authorization' : 'Queued review') :
(isClosure ? 'Awaiting authorization' : 'Queued message'))),
authorization_required: isClosure || (isReview && item.status === 'authorization'),
delivery_state: item.deliveryState,
repository: isUpdate ? '' : item.repository,
title: target,
preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')),
copy_text: item.body,
copy_text: copyText,
...(isReview ? { head_sha: item.expectedHeadSha } : {}),
quarantined,
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
last_attempt_at: Number(item.lastAttemptAt || 0),
last_attempt_error: textPreview(item.lastAttemptError),
updated_at: Number(item.queuedAt || 0),
route: isUpdate ? { kind:'update', notification_id:item.notificationId } :
{ kind:routeKind, repository:item.repository, number:item.number },
@@ -216,7 +236,22 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
} catch (_error) { return false; }
}
return { list, discard };
function partition(items = list()) {
const deliveries = items.filter(item => item.kind === 'issue-outbox' || item.kind === 'authored-outbox');
const drafts = items.filter(item => item.kind !== 'issue-outbox' && item.kind !== 'authored-outbox');
const counts = deliveries.reduce((summary, item) => {
const state = item.status === 'sending' ? 'sending' : (item.status === 'attention' ? 'attention' :
(item.status === 'authorization' ? 'authorization' : 'waiting'));
summary[state] += 1;
return summary;
}, { waiting:0, sending:0, attention:0, authorization:0 });
const retryable = deliveries.filter(item =>
item.status === 'queued' && !item.authorization_required && !item.quarantined && item.delivery_state !== 'uncertain'
);
return { drafts, deliveries, counts, retryable };
}
return { list, discard, partition };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createDraftInbox;
+63
View File
@@ -0,0 +1,63 @@
function createFeatureLoader({ document, urls, timeoutMs = 10000 }) {
const pending = new Map();
const loaded = new Set();
function load(name) {
if (loaded.has(name)) return Promise.resolve();
if (pending.has(name)) return pending.get(name);
const url = urls[name];
if (!url) return Promise.reject(new Error('Unknown feature: ' + name));
const request = new Promise((resolve, reject) => {
const script = document.createElement('script');
let timeout;
const fail = () => {
clearTimeout(timeout);
script.remove();
pending.delete(name);
reject(new Error('Could not load ' + name.replace(/-/g, ' ') + '.'));
};
script.src = url;
script.async = true;
script.onload = () => {
clearTimeout(timeout);
pending.delete(name);
loaded.add(name);
resolve();
};
script.onerror = fail;
timeout = setTimeout(fail, timeoutMs);
document.head.appendChild(script);
});
pending.set(name, request);
return request;
}
async function run(name, elements, callback) {
const trigger = elements?.trigger;
const status = elements?.status;
const retryLabel = elements?.retryLabel || 'Tap New issue to retry.';
if (trigger) trigger.disabled = true;
if (status) status.textContent = 'Loading ' + name.replace(/-/g, ' ') + '…';
try {
await load(name);
callback();
if (status) status.textContent = '';
return true;
} catch (_error) {
if (status) {
const label = name === 'issue-capture' ? 'Issue capture' :
name === 'pull-workflow' ? 'Pull workspace' :
name === 'security-center' ? 'Security center' : name.replace(/-/g, ' ');
status.textContent = label + ' could not load. ' + retryLabel;
}
return false;
} finally {
if (trigger) trigger.disabled = false;
}
}
return { load, run, ready: name => loaded.has(name) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createFeatureLoader;
+590 -27
View File
@@ -12,12 +12,13 @@
<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">
<button id="refresh">Refresh</button>
<button id="open-palette">Command</button>
<button id="open-device-setup" type="button">Device setup</button>
<button id="active-devices" type="button">Active devices</button>
<button id="sign-out" type="button">Sign out &amp; clear this device</button>
<button id="sign-out-all" type="button">Sign out all devices</button>
@@ -25,18 +26,88 @@
</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>
<div id="active-devices-sheet" class="active-devices-sheet" hidden>
<section class="active-devices-panel" role="dialog" aria-modal="true" aria-label="Active devices">
<section class="active-devices-panel" role="dialog" aria-modal="true" aria-label="Active devices and passkeys">
<div class="active-devices-header">
<div><h2>Active devices</h2><p class="small muted">Review signed-in devices and remove access you no longer trust.</p></div>
<button id="close-active-devices" type="button" aria-label="Close active devices">Close</button>
</div>
<div id="active-devices-status" class="small" role="status" aria-live="polite"></div>
<button id="enroll-passkey" type="button">Add a passkey for this device</button>
<div id="active-devices-list" class="active-devices-list"></div>
<section class="enrolled-passkeys" aria-labelledby="enrolled-passkeys-title">
<div class="enrolled-passkeys-header">
<h3 id="enrolled-passkeys-title">Enrolled passkeys</h3>
<p class="small muted">Passkeys can remain enrolled after a device signs out. Remove credentials you no longer trust.</p>
<p class="small muted">A recovery token will be required after sign-out if you remove this device’s passkey.</p>
</div>
<div id="enrolled-passkeys-status" class="small" role="status" aria-live="polite"></div>
<div id="enrolled-passkeys-list" class="enrolled-passkeys-list"></div>
</section>
<section class="security-activity" aria-labelledby="security-activity-title">
<div class="security-activity-header">
<h3 id="security-activity-title">Security activity</h3>
<p class="small muted">Recent sign-ins, device changes, and protected actions.</p>
</div>
<div id="security-activity-status" class="small" role="status" aria-live="polite"></div>
<div id="security-activity-list" class="security-activity-list"></div>
<button id="load-more-security-activity" type="button" hidden>Load older activity</button>
</section>
</section>
</div>
<div id="device-setup-sheet" class="device-setup-sheet" hidden>
<section class="device-setup-panel" role="dialog" aria-modal="true" aria-labelledby="device-setup-heading">
<div class="device-setup-header">
<div>
<h2 id="device-setup-heading">Make this device work-ready</h2>
<p class="small muted">Prepare this phone for fast launch, outages, and new update alerts. Nothing asks for permission until you choose it.</p>
</div>
<button id="close-device-setup" type="button" aria-label="Close device setup">Close</button>
</div>
<ol class="device-setup-list">
<li class="device-setup-step">
<div><strong>Install Stackchain</strong><p class="small" id="device-setup-install-status" role="status"></p></div>
<button class="device-setup-action" id="device-setup-install" type="button">Install</button>
</li>
<li class="device-setup-step">
<div><strong>Keep work available offline</strong><p class="small" id="device-setup-offline-status" role="status"></p></div>
<button class="device-setup-action" id="device-setup-offline" type="button">Enable</button>
</li>
<li class="device-setup-step">
<div><strong>Notify me about new updates</strong><p class="small" id="device-setup-push-status" role="status"></p></div>
<button class="device-setup-action" id="device-setup-push" type="button">Enable</button>
</li>
<li class="device-setup-step">
<div><strong>Remind me about deadlines</strong><p class="small" id="device-setup-deadline-status" role="status"></p></div>
<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>
</ol>
<p id="device-setup-ready-status" class="device-setup-ready" role="status" aria-live="polite"></p>
</section>
</div>
@@ -49,6 +120,7 @@
</div>
<div class="my-work-actions">
<button class="plan-today" id="plan-today" type="button">Plan Today</button>
<button class="today-recap-history-action" id="open-today-recaps" type="button">Recaps</button>
<button class="start-work-session" id="start-work-session" type="button">Start work</button>
<button class="resume-today-session" id="resume-today-session" type="button" hidden>Resume Today</button>
<button class="end-today-session" id="end-today-session" type="button" hidden>End session</button>
@@ -61,6 +133,7 @@
<div class="work-filters" aria-label="Filter My Work">
<button class="work-filter" data-work-filter="all" aria-pressed="true">All <span data-work-count="all">0</span></button>
<button class="work-filter" data-work-filter="today" aria-pressed="false">Today <span data-work-count="today">0</span></button>
<button class="work-filter" data-work-filter="agenda" aria-pressed="false">Agenda <span data-work-count="agenda">0</span></button>
<button class="work-filter" data-work-filter="attention" aria-pressed="false">Attention <span data-work-count="attention">0</span></button>
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
@@ -78,7 +151,14 @@
<div class="offline-work-controls">
<label for="keep-work-offline"><input id="keep-work-offline" type="checkbox" /> Keep My Work available offline</label>
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
<label class="push-update-control" for="push-updates"><input id="push-updates" type="checkbox" /> Notify me about new updates</label>
<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>
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
<span class="offline-today-readiness" id="offline-today-readiness" hidden>
<span class="small" id="offline-today-status" role="status" aria-live="polite"></span>
@@ -96,6 +176,76 @@
</div>
</details>
</div>
<aside class="attention-interruption" id="attention-interruption" role="status" aria-live="polite" hidden>
<span><strong>Today paused</strong> while handling Attention</span>
<button id="return-to-today" type="button">Return to Today</button>
</aside>
<section class="device-readiness-card" id="device-readiness-card" aria-labelledby="device-readiness-heading" hidden>
<div>
<strong id="device-readiness-heading">Make this phone work-ready</strong>
<p class="small muted">Install, save work offline, and enable update alerts.</p>
<p class="small device-readiness-summary" id="device-readiness-summary" role="status" aria-live="polite"></p>
</div>
<div class="device-readiness-actions">
<button id="finish-device-setup" type="button">Finish setup</button>
<button id="dismiss-device-readiness" type="button">Not now</button>
</div>
</section>
<div class="update-selection-controls" id="update-selection-controls" hidden>
<button id="select-work" type="button">Select work</button>
<button id="cancel-work-selection" type="button" hidden>Cancel selection</button>
<button id="select-updates" type="button">Select updates</button>
<button id="cancel-update-selection" type="button" hidden>Cancel selection</button>
<span class="small" id="update-selection-status" role="status" aria-live="polite"></span>
</div>
<div class="selection-scope-actions" id="selection-scope-actions" hidden>
<button id="select-matching-work" type="button">Select all matches</button>
<button id="clear-work-selection" type="button">Clear selection</button>
</div>
<form class="queue-finder" id="queue-finder" role="search">
<label for="queue-find-input">Find in <span id="queue-find-label">All</span></label>
<div class="queue-finder-row">
<input id="queue-find-input" type="search" autocomplete="off" aria-label="Find in active work queue" placeholder="Repository, #number, or title" />
<button id="clear-queue-find" type="button" hidden>Clear</button>
</div>
<div class="small" id="queue-find-status" role="status" aria-live="polite"></div>
<button id="search-older-work" type="button" hidden>Search older work</button>
</form>
<section class="empty-work-start" id="empty-work-start" aria-labelledby="empty-work-start-heading" hidden>
<div>
<strong id="empty-work-start-heading">Ready for something new?</strong>
<p class="small muted">Find an available issue to own, or create the work that needs doing.</p>
</div>
<div class="empty-work-start-actions">
<button id="empty-work-find" type="button">Find and start work</button>
<button id="empty-work-create" type="button">Create an issue</button>
</div>
</section>
<section class="agenda-replan" id="agenda-replan" aria-labelledby="agenda-replan-heading" hidden>
<div class="agenda-replan-launch">
<div><strong id="agenda-replan-heading">Overdue deadlines</strong><p class="small muted">Make the Agenda credible without leaving the queue.</p></div>
<button id="start-agenda-replan" type="button">Replan overdue</button>
</div>
<div id="agenda-replan-controls" hidden>
<div class="small" id="agenda-replan-progress" role="status" aria-live="assertive"></div>
<div class="agenda-replan-actions">
<button id="agenda-replan-keep" type="button">Keep date &amp; next</button>
<button id="agenda-replan-tomorrow" type="button">Tomorrow &amp; next</button>
<label for="agenda-replan-date">Choose a future date</label>
<input id="agenda-replan-date" type="date" />
<button id="agenda-replan-choose" type="button">Choose date &amp; next</button>
<button id="agenda-replan-cancel" type="button">Cancel</button>
</div>
</div>
</section>
<section class="protect-today" id="protect-today-panel" aria-labelledby="protect-today-heading" hidden>
<div>
<strong id="protect-today-heading">Deadlines need a plan</strong>
<p class="small muted">Put overdue and due-today work first, then review what fits.</p>
<div class="small" id="protect-today-status" role="status" aria-live="polite"></div>
</div>
<button id="protect-today" type="button">Protect Today</button>
</section>
<div class="my-work-list" id="my-work-list"></div>
<div class="small" id="work-page-status" aria-live="polite"></div>
<button class="load-more-work" id="load-more-work" type="button" hidden>Load older work</button>
@@ -107,7 +257,14 @@
<button class="retry-work-route" id="retry-work-route" type="button" hidden>Retry shared work item</button>
<div class="small" id="work-route-share-status" aria-live="polite"></div>
<div class="my-work-bulk" id="bulk-mark-read-bar" hidden>
<button id="bulk-mark-read" type="button"></button>
<div class="my-work-bulk-actions">
<button id="batch-add-today" type="button" hidden>Add to Today</button>
<button data-batch-defer="today" type="button" hidden>Later today</button>
<button data-batch-defer="tomorrow" type="button" hidden>Tomorrow</button>
<button id="defer-selected-today" data-defer-selected="today" type="button">Later today</button>
<button id="defer-selected-tomorrow" data-defer-selected="tomorrow" type="button">Tomorrow</button>
<button id="bulk-mark-read" type="button"></button>
</div>
</div>
</section>
<aside class="sidebar">
@@ -180,7 +337,19 @@
<button id="cancel-plan-today" type="button">Cancel</button>
</div>
<div class="plan-today-capacity" id="plan-today-capacity" role="status" aria-live="polite">0 of 5 selected</div>
<label class="plan-today-available" for="plan-today-available">Available today
<span><input id="plan-today-available" type="number" inputmode="numeric" min="15" max="1440" step="15" placeholder="Minutes" /> min</span>
</label>
<button class="plan-today-build" id="build-today-plan" type="button">Build my Today</button>
<div class="small plan-today-build-status" id="plan-today-build-status" role="status" aria-live="polite"></div>
<section class="plan-today-estimates" id="plan-today-estimates" aria-labelledby="plan-today-estimates-heading" hidden>
<h3 id="plan-today-estimates-heading">Estimate to finish your plan</h3>
<p class="small muted">Add minutes for ready work and your plan will re-fit immediately.</p>
<div id="plan-today-estimate-list"></div>
</section>
<div class="plan-today-skipped" id="plan-today-skipped"></div>
<div class="small plan-today-error" id="plan-today-error" role="alert"></div>
<button class="discard-recap-replan" id="discard-recap-replan" type="button" hidden>Discard recap feedback</button>
<section aria-labelledby="today-plan-heading">
<h3 id="today-plan-heading">Today, in order</h3>
<div class="plan-today-list" id="plan-today-list"></div>
@@ -196,6 +365,59 @@
</section>
</div>
<div class="today-interruption-sheet" id="today-interruption-sheet" role="dialog" aria-modal="true" aria-labelledby="today-interruption-title" hidden>
<section class="today-interruption-panel">
<div class="small">Today timer</div>
<h2 id="today-interruption-title">Were you working while away?</h2>
<p id="today-interruption-description" class="small"></p>
<p class="small muted">Choose once before the timer continues. You can include the full interval or remove it from your recap.</p>
<div class="today-interruption-actions">
<button type="button" data-today-interruption="count">Count this time</button>
<button type="button" data-today-interruption="exclude">Exclude this time</button>
</div>
</section>
</div>
<div class="today-recap-sheet" id="today-recap-sheet" role="dialog" aria-modal="true" aria-labelledby="today-recap-title" hidden>
<section class="today-recap-panel">
<div class="today-recap-header">
<div><div class="small">Estimate feedback</div><h2 id="today-recap-title">Today recap</h2></div>
<button id="close-today-recap" type="button">Close</button>
</div>
<p class="small muted">Review actual time before saving. Gitea time logging is explicit and unchecked by default.</p>
<div id="today-recap-status" class="small" role="status" aria-live="polite"></div>
<div id="today-recap-items" class="today-recap-items"></div>
<div id="today-recap-totals" class="today-recap-totals"></div>
<section aria-labelledby="today-recap-history-title">
<h3 id="today-recap-history-title">Recent recaps</h3>
<div id="today-recap-history" class="today-recap-history"></div>
</section>
<div class="today-recap-actions">
<button id="save-today-recap" type="button">Save recap &amp; adjust plan</button>
<button id="save-log-today-recap" type="button">Log selected time to Gitea</button>
<button id="discard-today-recap" type="button">Keep timer &amp; close</button>
</div>
</section>
</div>
<div class="today-readiness-sheet" id="today-readiness-sheet" role="dialog" aria-modal="true" aria-labelledby="today-readiness-title" hidden>
<section class="today-readiness-panel">
<div class="today-readiness-header">
<div><div class="small">Today readiness</div><h2 id="today-readiness-title">This item is blocked</h2></div>
<button id="today-readiness-close" type="button">Close</button>
</div>
<p id="today-readiness-summary" aria-live="polite"></p>
<div class="today-readiness-item" id="today-readiness-item"></div>
<div class="issue-blocker-list" id="today-readiness-blockers"></div>
<div class="today-readiness-actions">
<button id="today-readiness-next" type="button">Start next ready</button>
<button id="today-readiness-anyway" type="button">Work this item anyway</button>
<button id="today-readiness-retry" type="button">Retry status</button>
<button id="today-readiness-plan" type="button">Review Today plan</button>
</div>
</section>
</div>
<div class="plan-preview-actions" id="plan-preview-actions" hidden>
<button id="back-to-plan" type="button">Back to plan</button>
<button id="add-plan-preview" type="button">Add to Today &amp; back</button>
@@ -204,10 +426,65 @@
<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="defer-selected-search-results" type="button" disabled>Defer</button>
<button id="plan-selected-search-results" type="button" disabled>Plan release</button>
<button id="queue-selected-search-results" type="button" disabled>Assign &amp; add to Today</button>
</div>
<section class="search-batch-estimate-review" id="search-batch-estimate-review"
aria-labelledby="search-batch-estimate-heading" hidden>
<div><strong id="search-batch-estimate-heading">Fit this work into Today</strong>
<span class="small" id="search-batch-estimate-summary" aria-live="polite"></span></div>
<div id="search-batch-estimate-list"></div>
<div class="search-batch-estimate-actions">
<button id="cancel-search-batch-estimates" type="button">Back</button>
<button id="confirm-search-batch-estimates" type="button">Confirm &amp; assign</button>
</div>
</section>
<section class="search-release-review" id="search-release-review"
aria-labelledby="search-release-heading" hidden>
<div><strong id="search-release-heading">Plan selected issues into a release</strong>
<span class="small" id="search-release-summary" aria-live="polite"></span></div>
<label>Open milestone<select id="search-release-milestone" required></select></label>
<label>Shared due date <span class="small">(optional)</span>
<input id="search-release-due-date" type="date" /></label>
<div id="search-release-list"></div>
<div class="search-release-actions">
<button id="cancel-search-release" type="button">Back</button>
<button id="confirm-search-release" type="button">Assign &amp; plan</button>
</div>
</section>
</div>
<div class="search-preview" id="search-preview" role="dialog" aria-modal="true" aria-labelledby="search-preview-title">
@@ -222,11 +499,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>
@@ -262,6 +564,21 @@
<button class="issue-retry" id="retry-issue-load" type="button" hidden>Reload latest issue</button>
<div class="row"><span id="issue-labels"></span><span class="small" id="issue-assignees"></span></div>
<div class="issue-sheet-content markdown-content" id="issue-sheet-body"></div>
<section class="issue-blockers" id="issue-blockers" aria-labelledby="issue-blockers-title" hidden>
<h2 id="issue-blockers-title">Blocked by</h2>
<div class="issue-blocker-list" id="issue-blocker-list"></div>
<div id="issue-blocker-status" class="small" aria-live="polite"></div>
<button id="manage-issue-blockers" type="button" hidden>Add blocker</button>
<button id="start-unblocked-issue" type="button" hidden>Start now</button>
<div class="issue-blocker-manager" id="issue-blocker-manager" hidden>
<label for="issue-blocker-search">Search open issues</label>
<input id="issue-blocker-search" type="search" maxlength="100" autocomplete="off"
role="combobox" aria-controls="issue-blocker-results" aria-expanded="false" />
<div id="issue-blocker-results" role="listbox" aria-label="Open issue results"></div>
<div id="issue-blocker-search-status" class="small" aria-live="polite"></div>
<button id="cancel-issue-blocker" type="button">Cancel</button>
</div>
</section>
<h2>Full conversation</h2>
<div id="issue-comments"></div>
<button class="conversation-more" id="load-older-issue-comments" type="button" hidden>Load older messages</button>
@@ -269,7 +586,21 @@
<section class="issue-comment-composer" aria-labelledby="issue-comment-title">
<h2 id="issue-comment-title">Add comment</h2>
<textarea id="issue-comment" maxlength="10000" placeholder="Write a comment"></textarea>
<button id="send-issue-comment" type="button">Post comment</button>
<div class="mention-options" id="issue-comment-mentions" role="listbox" aria-label="Teammates" hidden></div>
<div class="mention-status small" id="issue-comment-mention-status" aria-live="polite"></div>
<div class="issue-attachment-controls">
<input class="visually-hidden" id="issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
<label class="issue-attachment-trigger" for="issue-attachment">Attach screenshot</label>
</div>
<div class="issue-attachment-preview" id="issue-attachment-preview" hidden>
<img id="issue-attachment-image" alt="Selected screenshot preview" />
<span class="small" id="issue-attachment-meta"></span>
<button id="remove-issue-attachment" type="button">Remove screenshot</button>
</div>
<div class="comment-actions">
<button id="send-issue-comment" type="button">Post comment</button>
<button id="send-issue-comment-next" type="button" hidden>Comment &amp; next</button>
</div>
<div id="issue-comment-status" class="small" aria-live="assertive"></div>
</section>
<details class="issue-planning" id="issue-planning">
@@ -333,6 +664,8 @@
<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>
<button type="button" data-work-session-timer-toggle>Pause timer</button>
<button type="button" data-work-session-complete="issue" hidden>Done for Today &amp; next</button>
<button type="button" data-work-session-next>Next work item</button>
</nav>
</section>
@@ -342,19 +675,51 @@
<section class="find-work-panel">
<div class="find-work-header">
<div><div class="small">Open · unassigned</div><h3 id="find-work-heading">Find work</h3></div>
<button id="close-find-work" type="button">Close</button>
<div class="find-work-header-actions"><button id="select-find-work" type="button">Select work</button><button id="close-find-work" type="button">Close</button></div>
</div>
<p class="small">Claim an available issue and continue it in My Work.</p>
<form class="find-work-search" id="find-work-search-form" role="search">
<label for="find-work-search">Search repository, #number, or title</label>
<div><input id="find-work-search" type="search" maxlength="100" autocomplete="off" />
<button id="clear-find-work-search" type="button" hidden>Clear</button></div>
<span id="find-work-match-status" class="small" aria-live="polite"></span>
</form>
<details class="find-work-filters" id="find-work-filters">
<summary>Filter work <span id="find-work-filter-count" class="small"></span></summary>
<div class="find-work-filter-panel" aria-label="Filter available work">
<fieldset><legend>Repositories</legend><div id="find-work-repository-filters" class="find-work-filter-options"></div></fieldset>
<fieldset><legend>Labels</legend><div id="find-work-label-filters" class="find-work-filter-options"></div></fieldset>
<button id="clear-find-work-filters" type="button">Clear filters</button>
</div>
</details>
<button class="fill-find-work-today" id="fill-find-work-today" type="button"
aria-describedby="find-work-selection-status">Fill remaining Today slots</button>
<div id="find-work-status" class="small" aria-live="assertive">Open Find Work to load available issues.</div>
<button class="find-work-batch-recovery" id="resume-find-work-batch" type="button"
aria-describedby="find-work-status" hidden>Resume assigning &amp; queueing</button>
<div class="find-work-list" id="find-work-list"></div>
<button class="find-work-more" id="load-more-available" type="button" hidden>Load more available issues</button>
<section class="find-work-estimate-review" id="find-work-estimate-review" aria-labelledby="find-work-estimate-heading" hidden>
<div><strong id="find-work-estimate-heading">Fit this work into Today</strong>
<span class="small" id="find-work-estimate-summary" aria-live="polite"></span></div>
<div id="find-work-estimate-list"></div>
<div class="find-work-estimate-actions">
<button id="cancel-find-work-estimates" type="button">Back</button>
<button id="confirm-find-work-estimates" type="button">Confirm &amp; assign</button>
</div>
</section>
<div class="find-work-batch-actions" id="batch-find-work-actions" hidden>
<span id="find-work-selection-status" class="small" aria-live="polite">No work selected.</span>
<button id="cancel-find-work-selection" type="button">Cancel</button>
<button id="claim-selected-work" type="button" disabled>Assign &amp; queue Today</button>
</div>
</section>
</div>
<div class="create-issue-sheet" id="create-issue-sheet" role="dialog" aria-modal="true" aria-labelledby="create-issue-heading">
<section class="create-issue-panel">
<div class="create-issue-header">
<h3 id="create-issue-heading">New issue</h3>
<h3 id="create-issue-heading">Capture work</h3>
<button id="cancel-new-issue" type="button">Cancel</button>
</div>
<aside class="shared-content-conflict" id="shared-content-conflict" aria-live="assertive" hidden>
@@ -366,17 +731,49 @@
</div>
</aside>
<form class="create-issue-form" id="create-issue-form">
<label for="create-issue-repository">Repository
<select id="create-issue-repository" required></select>
</label>
<button class="create-issue-repository-more" id="load-more-issue-repositories" type="button" hidden>Load more repositories</button>
<div id="create-issue-repository-status" class="small" aria-live="polite"></div>
<label for="create-issue-title">Title
<input id="create-issue-title" type="text" maxlength="255" required autocomplete="off" />
</label>
<label for="create-issue-body">Description <span class="small">Optional</span>
<label for="create-issue-body">Note <span class="small">Optional</span>
<textarea id="create-issue-body" maxlength="10000"></textarea>
</label>
<div class="create-issue-capture-actions">
<button id="save-unfiled-issue" type="button">Save to Drafts</button>
<button id="file-new-issue" type="button">File now</button>
</div>
<section class="create-issue-attachment" aria-labelledby="create-issue-attachment-label">
<strong id="create-issue-attachment-label">Screenshot <span class="small">Optional · PNG, JPEG, or WebP · 2 MB max</span></strong>
<div class="issue-attachment-controls">
<label class="issue-attachment-trigger" for="create-issue-attachment">Attach screenshot</label>
<input class="visually-hidden" id="create-issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
</div>
<div class="issue-attachment-preview" id="create-issue-attachment-preview" hidden>
<img id="create-issue-attachment-image" alt="Selected screenshot preview" />
<span class="small" id="create-issue-attachment-meta"></span>
<button id="remove-create-issue-attachment" type="button">Remove</button>
</div>
<div class="small" id="create-issue-attachment-status" aria-live="polite"></div>
</section>
<section class="create-issue-filing" id="create-issue-filing" hidden>
<aside class="draft-filing-session" id="draft-filing-session" aria-live="polite" hidden>
<strong id="draft-filing-progress"></strong>
<span class="small" id="draft-filing-remaining"></span>
<div class="draft-filing-session-actions">
<button id="previous-draft" type="button">Previous</button>
<button id="skip-draft" type="button">Skip</button>
</div>
</aside>
<div class="create-issue-repository-picker">
<label for="create-issue-repository-search">Repository search
<input id="create-issue-repository-search" type="search" maxlength="80" placeholder="Search accessible repositories" autocomplete="off" />
</label>
<div id="create-issue-repository-results" role="listbox" aria-label="Repository search results" hidden></div>
<label for="create-issue-repository">Selected repository
<select id="create-issue-repository" required><option value="">Choose repository</option></select>
</label>
</div>
<button class="create-issue-repository-more" id="load-more-issue-repositories" type="button" hidden>Load more repositories</button>
<div id="create-issue-repository-status" class="small" aria-live="polite"></div>
<label for="create-issue-milestone">Milestone <span class="small">Optional</span>
<select id="create-issue-milestone" aria-describedby="create-issue-milestone-status">
<option value="">No milestone</option>
@@ -399,24 +796,41 @@
</section>
<div class="small">The issue will be assigned to you.</div>
<div class="create-issue-actions">
<button id="save-unfiled-issue" type="button">Save for filing</button>
<button id="submit-new-issue" type="submit">Create &amp; assign to me</button>
<button id="create-follow-up-next" type="submit" hidden>Create follow-up &amp; next</button>
<button id="create-and-start-issue" type="submit">Create &amp; start</button>
<div id="create-issue-status" class="small" aria-live="assertive"></div>
</div>
</section>
<div id="create-issue-capture-status" class="small" aria-live="assertive"></div>
</form>
</section>
</div>
<div class="draft-capacity-sheet" id="draft-capacity-sheet" role="dialog" aria-modal="true" aria-labelledby="draft-capacity-heading" hidden>
<section class="draft-capacity-panel">
<h3 id="draft-capacity-heading">Drafts full — nothing was deleted.</h3>
<p class="small">Review saved work, keep editing, or explicitly replace the oldest Draft.</p>
<div class="draft-capacity-oldest" id="draft-capacity-oldest"></div>
<div class="draft-capacity-actions">
<button id="review-full-drafts" type="button">Review Drafts</button>
<button id="keep-editing-draft" type="button">Keep editing</button>
<button id="replace-oldest-draft" type="button">Replace oldest &amp; save</button>
</div>
<div id="draft-capacity-status" class="small" aria-live="assertive"></div>
</section>
</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">
@@ -425,8 +839,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>
@@ -435,24 +851,60 @@
<h2 id="update-reply-title">Reply</h2>
<label for="update-reply" class="small">Continue this issue or pull-request conversation</label>
<textarea id="update-reply" maxlength="10000" placeholder="Write a reply"></textarea>
<button id="send-update-reply" type="button">Send reply</button>
<div class="mention-options" id="update-reply-mentions" role="listbox" aria-label="Teammates" hidden></div>
<div class="mention-status small" id="update-reply-mention-status" aria-live="polite"></div>
<div class="issue-attachment-controls">
<input class="visually-hidden" id="update-reply-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
<label class="issue-attachment-trigger" for="update-reply-attachment">Attach screenshot</label>
</div>
<div class="issue-attachment-preview" id="update-reply-attachment-preview" hidden>
<img id="update-reply-attachment-image" alt="Selected screenshot preview" />
<span class="small" id="update-reply-attachment-meta"></span>
<button id="remove-update-reply-attachment" type="button">Remove screenshot</button>
</div>
<div class="update-reply-actions">
<button id="send-update-reply" type="button">Send reply</button>
<button id="send-update-reply-read-next" type="button">Reply, mark read &amp; next</button>
</div>
<div id="update-reply-status" class="small" aria-live="assertive"></div>
</section>
<div class="update-sheet-actions">
<button id="update-ownership-action" type="button" hidden aria-describedby="update-sheet-status">Take ownership</button>
<button class="share-work-route" type="button">Share</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 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>
</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>
<button type="button" data-work-session-timer-toggle>Pause timer</button>
<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>
<div class="notification-undo" id="notification-undo" role="status" aria-live="assertive" hidden>
<span id="notification-undo-status"></span>
<button id="undo-notification" type="button" aria-label="Undo marking update read">Undo</button>
</div>
<div class="pull-sheet" id="pull-sheet" role="dialog" aria-modal="true" aria-labelledby="pull-sheet-title">
<section class="pull-sheet-panel">
<div class="pull-sheet-header">
@@ -468,7 +920,21 @@
<section class="pull-comment-composer" aria-labelledby="pull-comment-title">
<h2 id="pull-comment-title">Add comment</h2>
<textarea id="pull-comment" maxlength="10000" placeholder="Write a comment"></textarea>
<button id="send-pull-comment" type="button">Post comment</button>
<div class="mention-options" id="pull-comment-mentions" role="listbox" aria-label="Teammates" hidden></div>
<div class="mention-status small" id="pull-comment-mention-status" aria-live="polite"></div>
<div class="issue-attachment-controls">
<input class="visually-hidden" id="pull-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
<label class="issue-attachment-trigger" for="pull-attachment">Attach screenshot</label>
</div>
<div class="issue-attachment-preview" id="pull-attachment-preview" hidden>
<img id="pull-attachment-image" alt="Selected screenshot preview" />
<span class="small" id="pull-attachment-meta"></span>
<button id="remove-pull-attachment" type="button">Remove screenshot</button>
</div>
<div class="comment-actions">
<button id="send-pull-comment" type="button">Post comment</button>
<button id="send-pull-comment-next" type="button" hidden>Comment &amp; next</button>
</div>
<div id="pull-comment-status" class="small" aria-live="assertive"></div>
</section>
<details class="pull-review" id="pull-review">
@@ -476,18 +942,36 @@
<div id="pull-review-status" class="small" aria-live="polite">Expand to load changed files and merge readiness.</div>
<button class="pull-retry" id="pull-review-retry" type="button" hidden>Retry review data</button>
<div class="row"><span class="pill" id="pull-ci-state">CI unknown</span><span class="pill" id="pull-merge-state">Review data not loaded</span></div>
<details class="ci-checks" id="pull-checks">
<summary><span>Checks</span><span class="small" id="pull-checks-summary">Not loaded</span></summary>
<button class="ci-check-refresh" id="refresh-pull-checks" type="button">Refresh checks</button>
<div class="ci-check-list" id="pull-check-list"></div>
</details>
<div class="pull-review-tools"><span id="pull-review-progress" class="small" aria-live="polite">Review progress unavailable.</span><button id="next-unreviewed-pull-file" type="button" disabled>Next unreviewed</button></div>
<div id="pull-files"></div>
<button id="merge-pull" type="button" disabled>Merge</button>
</details>
<details class="pull-ownership" id="pull-ownership">
<summary>Ownership</summary>
<div>
<button id="load-pull-handoff" type="button">Choose teammate</button>
<label for="pull-handoff-recipient" class="small">Eligible repository assignee</label>
<select id="pull-handoff-recipient" disabled><option value="">Select a teammate</option></select>
<button id="confirm-pull-handoff" type="button" disabled>Confirm handoff</button>
<div id="pull-handoff-status" class="small" aria-live="assertive">Load teammates to transfer ownership.</div>
</div>
</details>
<div class="pull-sheet-actions">
<button class="share-work-route" type="button">Share</button>
<button id="release-pull" type="button">Release assignment</button>
<a id="open-pull-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>
<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>
<button type="button" data-work-session-timer-toggle>Pause timer</button>
<button type="button" data-work-session-complete="pull" hidden>Done for Today &amp; next</button>
<button type="button" data-work-session-next>Next work item</button>
</nav>
</section>
@@ -506,7 +990,16 @@
<button class="review-retry" id="retry-review-load" hidden>Retry loading review</button>
<div class="review-sheet-body markdown-content" id="review-sheet-body"></div>
<div class="row"><span class="pill" id="review-ci-state">CI unknown</span><button class="share-work-route" type="button">Share</button><a id="open-review-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a></div>
<details class="ci-checks" id="review-checks">
<summary><span>Checks</span><span class="small" id="review-checks-summary">Not loaded</span></summary>
<button class="ci-check-refresh" id="refresh-review-checks" type="button">Refresh checks</button>
<div class="ci-check-list" id="review-check-list"></div>
</details>
<h2>Changed files</h2>
<div class="review-display-tools">
<button id="review-wrap-lines" type="button" aria-pressed="false" aria-controls="review-files">Wrap lines</button>
<span class="small">Keep long diffs inside the review sheet.</span>
</div>
<div id="review-files" class="muted"></div>
<section class="review-inline-composer" id="review-inline-composer" aria-labelledby="review-inline-title" hidden>
<strong id="review-inline-title">Comment on changed line</strong>
@@ -552,6 +1045,8 @@
<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>
<button type="button" data-work-session-timer-toggle>Pause timer</button>
<button type="button" data-work-session-complete="review" hidden>Done for Today &amp; next</button>
<button type="button" data-work-session-next>Next work item</button>
</nav>
</section>
@@ -569,43 +1064,97 @@
</form>
</dialog>
<section class="mobile-today-hud" data-mobile-today-hud aria-label="Active Today session" hidden>
<button class="mobile-today-summary" data-mobile-today-open type="button" aria-label="Open current Today item"></button>
<button data-mobile-today-complete type="button" hidden>Done &amp; next</button>
<div class="small" data-work-session-progress aria-live="polite"></div>
<button data-mobile-today-toggle data-work-session-timer-toggle type="button">Pause timer</button>
<button data-work-session-adjust-plan type="button" hidden>Adjust remaining plan</button>
</section>
<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>
<button data-mobile-queue="agenda" type="button"><span><strong>Agenda</strong><small>Upcoming deadlines</small></span><span data-mobile-queue-count="agenda">0</span></button>
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
<button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button>
<button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button>
<button data-mobile-queue="draft" type="button"><span><strong>Drafts</strong><small>Unfiled captures</small></span><span data-mobile-queue-count="draft">0</span></button>
<button data-mobile-queue="find" type="button"><span><strong>Find Work</strong><small>Claim something new</small></span></button>
<button data-mobile-queue="recaps" type="button"><span><strong>Recaps</strong><small>History</small></span></button>
</div>
</section>
</dialog>
<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page"><span id="mobile-work-label">Work</span> <span class="mobile-task-count" id="mobile-attention-count" hidden>0</span></button>
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page"><span id="mobile-work-label">Work</span></button>
<button class="mobile-task-action" data-mobile-task="find" type="button">Find</button>
<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="drafts" type="button">Drafts <span class="mobile-task-count" id="mobile-draft-count" aria-label="0 drafts">0</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>
<script src="static/session.js"></script>
<script src="static/feature-loader.js"></script>
<script src="static/security-center.js"></script>
<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>
<script src="static/background-issue-sync.js"></script>
<script src="static/issue-outbox.js"></script>
<script src="static/authored-outbox.js"></script>
<script src="static/offline-issue-close.js"></script>
<script src="static/offline-issue-blocker.js"></script>
<script src="static/notification-read-outbox.js"></script>
<script src="static/offline-work.js"></script>
<script src="static/offline-today.js"></script>
<script src="static/my-work.js"></script>
<script src="static/agenda-replan.js"></script>
<script src="static/protect-today.js"></script>
<script src="static/notification-undo.js"></script>
<script src="static/card-planning.js"></script>
<script src="static/work-selection.js"></script>
<script src="static/today-work.js"></script>
<script src="static/today-timer.js"></script>
<script src="static/today-recap.js"></script>
<script src="static/today-completion.js"></script>
<script src="static/today-readiness.js"></script>
<script src="static/comment-next.js"></script>
<script src="static/update-reply-read-next.js"></script>
<script src="static/plan-today.js"></script>
<script src="static/plan-today-readiness.js"></script>
<script src="static/plan-today-preview.js"></script>
<script src="static/today-sync.js"></script>
<script src="static/today-rollover.js"></script>
<script src="static/update-ownership.js"></script>
<script src="static/update-follow-up.js"></script>
<script src="static/later-work.js"></script>
<script src="static/later-sync.js"></script>
<script src="static/later-and-start.js"></script>
<script src="static/detail-defer.js"></script>
<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>
<script src="static/issue-sheet.js"></script>
<script src="static/create-issue-sheet.js"></script>
<script src="static/create-and-start.js"></script>
@@ -616,12 +1165,26 @@
<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>
<script src="static/mobile-composer-viewport.js"></script>
<script src="static/mention-composer.js"></script>
<script src="static/push-notifications.js"></script>
<script src="static/dashboard.js"></script>
</body>
</html>
+19 -2
View File
@@ -3,7 +3,8 @@
else root.createInstallApp = factory;
})(typeof self !== 'undefined' ? self : this, function createInstallApp(options) {
const dismissedKey = 'stackchain.install.dismissed.v1';
let deferredPrompt = null;
let deferredPrompt = options.initialPrompt || null;
let installed = false;
function hide() {
options.card.hidden = true;
@@ -25,6 +26,7 @@
await prompt.prompt();
const choice = await prompt.userChoice;
if (choice.outcome === 'accepted') {
installed = true;
hide();
options.status.textContent = 'Stackchain was added to your device.';
} else {
@@ -44,6 +46,7 @@
hide();
return;
}
if (deferredPrompt) showNativePrompt(deferredPrompt);
if (options.isIosSafari()) {
options.installButton.hidden = true;
options.guidance.hidden = false;
@@ -51,6 +54,7 @@
}
options.window.addEventListener('beforeinstallprompt', showNativePrompt);
options.window.addEventListener('appinstalled', () => {
installed = true;
deferredPrompt = null;
hide();
});
@@ -58,5 +62,18 @@
options.dismissButton.addEventListener('click', dismiss);
}
return {start};
function state() {
if (installed || options.isStandalone()) {
return {state:'complete', detail:'Stackchain is installed on this device.'};
}
if (options.isIosSafari()) {
return {state:'incomplete', detail:'On Safari, tap Share, then Add to Home Screen.'};
}
if (deferredPrompt) {
return {state:'incomplete', detail:'Stackchain is ready to install.'};
}
return {state:'unavailable', detail:'Installation is available from a supported browser.'};
}
return {start, install, state};
});
+259
View File
@@ -0,0 +1,259 @@
(function(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.issueAttachment = api;
})(typeof self !== 'undefined' ? self : this, function() {
'use strict';
const MAX_BYTES = 2 * 1024 * 1024;
const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
function namedBlob(blob, name) {
if (typeof File === 'function') {
return new File([blob], name, { type: blob.type });
}
Object.defineProperty(blob, 'name', { value: name, configurable: true });
return blob;
}
async function optimizeImage(file, environment = {}) {
if (file.size <= MAX_BYTES) return file;
const decode = environment.createImageBitmap || globalThis.createImageBitmap;
const makeCanvas = environment.createCanvas || (() => document.createElement('canvas'));
if (typeof decode !== 'function') {
throw new Error('This browser cannot optimize the screenshot. Try cropping it and choose it again.');
}
let bitmap;
try {
bitmap = await decode(file);
const canvas = makeCanvas();
const context = canvas && canvas.getContext && canvas.getContext('2d');
if (!context || !bitmap.width || !bitmap.height) throw new Error('decode');
let scale = Math.min(1, Math.sqrt(MAX_BYTES / file.size) * 0.92);
for (let attempt = 0; attempt < 10; attempt += 1) {
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
const blob = await new Promise(resolve => {
canvas.toBlob(resolve, file.type, file.type === 'image/png' ? undefined : 0.86);
});
if (!blob) throw new Error('encode');
if (blob.size > 0 && blob.size <= MAX_BYTES) return namedBlob(blob, file.name);
scale *= 0.8;
}
} catch (_error) {
throw new Error('The screenshot could not be optimized. Try cropping it and choose it again.');
} finally {
if (bitmap && typeof bitmap.close === 'function') bitmap.close();
}
throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.');
}
function multipart(attachment) {
let blob = attachment?.blob;
if (!blob && attachment?.data) {
const binary = atob(String(attachment.data));
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
blob = new Blob([bytes], { type: String(attachment.contentType || '') });
}
if (!blob) throw new Error('The saved screenshot is unavailable. Retry before sending.');
const form = new FormData();
form.append('file', blob, String(attachment.filename || 'screenshot'));
return form;
}
function create(options) {
const upload = options.upload;
const optimizeSelectedImage = options.optimizeImage || optimizeImage;
const createOperationId = options.createOperationId || (() => {
if (typeof globalThis !== 'undefined' && globalThis.crypto?.randomUUID) {
return globalThis.crypto.randomUUID();
}
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
});
let selected = null;
let confirmed = null;
let serialized = null;
let operationId = null;
let selectionGeneration = 0;
function commitSelection(file) {
if (!file || !IMAGE_TYPES.has(file.type) || !Number.isFinite(file.size) ||
file.size <= 0 || file.size > MAX_BYTES) {
throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.');
}
selected = file;
confirmed = null;
serialized = null;
operationId = createOperationId();
return state();
}
function select(file) {
const generation = ++selectionGeneration;
if (!file || !IMAGE_TYPES.has(file.type)) {
throw new Error('Choose a PNG, JPEG, or WebP screenshot.');
}
if (!Number.isFinite(file.size) || file.size <= 0) {
throw new Error('Choose a screenshot that is 2 MB or smaller.');
}
if (file.size > MAX_BYTES) {
return Promise.resolve(optimizeSelectedImage(file)).then(optimized => {
if (generation !== selectionGeneration) return state();
return commitSelection(optimized);
});
}
return commitSelection(file);
}
function clear() {
selectionGeneration += 1;
selected = null;
confirmed = null;
serialized = null;
operationId = null;
}
function restore(value) {
const contentType = String(value?.contentType || '');
const filename = String(value?.filename || '');
const blob = value?.blob;
const data = String(value?.data || '');
if (!blob && !data) {
clear();
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
}
const padding = (data.match(/=*$/) || [''])[0].length;
const size = blob ? Number(blob.size) : Math.max(1, Math.floor(data.length * 3 / 4) - padding);
select({ name: filename, type: contentType, size, ...(blob ? { blob } : {}) });
serialized = blob ? { filename, contentType, blob } : { filename, contentType, data };
return state();
}
function state() {
return selected ? {
name: selected.name,
size: selected.size,
uploaded: Boolean(confirmed),
} : null;
}
async function serialize() {
if (!selected) return null;
if (!serialized) {
serialized = {
filename: selected.name,
contentType: selected.type,
blob: selected.blob || selected,
};
}
return { ...serialized };
}
async function prepareComment(item, body) {
const text = String(body || '').trim();
if (!selected) return text;
if (!confirmed) {
const attachment = await serialize();
confirmed = await upload({
repository: item.repository,
number: item.number,
filename: attachment.filename,
content_type: attachment.contentType,
...(attachment.blob ? { blob: attachment.blob } : { data: attachment.data }),
operation_id: operationId,
});
if (!confirmed || typeof confirmed.markdown !== 'string' || !confirmed.markdown) {
confirmed = null;
throw new Error('The server did not confirm the screenshot upload.');
}
}
return text ? text + '\n\n' + confirmed.markdown : confirmed.markdown;
}
return { select, restore, clear, state, serialize, prepareComment };
}
function mount(options) {
const controller = create(options);
const clearSelection = controller.clear;
let previewUrl = '';
let selectionSequence = 0;
function setBusy(busy) {
options.input.disabled = Boolean(busy);
options.remove.disabled = Boolean(busy);
}
function clearPreview() {
selectionSequence += 1;
if (previewUrl) options.revokeObjectURL(previewUrl);
previewUrl = '';
options.image.src = '';
options.preview.hidden = true;
options.input.value = '';
options.input.disabled = false;
clearSelection();
}
function showPreview(file, optimized) {
if (previewUrl) options.revokeObjectURL(previewUrl);
previewUrl = options.createObjectURL(file);
options.image.src = previewUrl;
options.meta.textContent = file.name + ' · ' + Math.ceil(file.size / 1024) + ' KB';
options.preview.hidden = false;
options.status.textContent = optimized ? 'Screenshot optimized and ready to upload.' :
(options.readyMessage || 'Screenshot ready to upload with this comment.');
}
options.input.addEventListener('change', event => {
const file = event.target.files && event.target.files[0];
const sequence = ++selectionSequence;
let result;
try {
result = controller.select(file);
} catch (error) {
options.status.textContent = error.message;
options.input.value = '';
return;
}
if (!result || typeof result.then !== 'function') {
showPreview(file, false);
return;
}
options.status.textContent = 'Optimizing screenshot…';
options.input.disabled = true;
return result.then(() => controller.serialize()).then(value => {
if (sequence === selectionSequence) showPreview(value.blob, true);
}).catch(error => {
if (sequence === selectionSequence) {
options.status.textContent = error.message;
options.input.value = '';
}
}).finally(() => {
if (sequence === selectionSequence) options.input.disabled = false;
});
});
options.remove.addEventListener('click', () => {
clearPreview();
options.status.textContent = options.removedMessage || 'Screenshot removed. Your comment is unchanged.';
});
function restorePreview(value) {
clearPreview();
const restored = controller.restore(value);
previewUrl = value.blob ? options.createObjectURL(value.blob) :
'data:' + value.contentType + ';base64,' + value.data;
options.image.src = previewUrl;
options.meta.textContent = restored.name + ' · ' + Math.ceil(restored.size / 1024) + ' KB';
options.preview.hidden = false;
options.status.textContent = options.readyMessage || 'Screenshot ready to upload with this comment.';
return restored;
}
return Object.assign(controller, { clear: clearPreview, restore: restorePreview, setBusy });
}
return { create, mount, multipart, optimizeImage, MAX_BYTES };
});
+196 -33
View File
@@ -5,6 +5,18 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
);
const pending = new Map();
function captureAttachment(value) {
const contentType = String(value?.contentType || '');
const filename = String(value?.filename || '').slice(0, 255);
const blob = value?.blob;
const data = String(value?.data || '');
if (!filename || !['image/png', 'image/jpeg', 'image/webp'].includes(contentType)) return undefined;
if (blob) return { filename, contentType, blob };
if (!data && value?.stored === true) return { filename, contentType, stored: true };
if (!data) return undefined;
return { filename, contentType, data };
}
function read() {
try {
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
@@ -23,7 +35,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
}
}
function enqueue(draft, mirror = true) {
function prepareItem(draft) {
const ownerLogin = String(getOwnerLogin() || '').trim();
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing an issue.');
const items = read();
@@ -39,34 +51,90 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
status: 'queued',
queuedAt: Number(now()),
};
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
if (sourceCaptureId) item.sourceCaptureId = sourceCaptureId;
if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start';
const attachment = captureAttachment(draft?.attachment);
if (attachment) item.attachment = attachment;
item.operationId = item.id;
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
item.milestoneId = Number(draft.milestoneId);
}
if (/^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))) item.dueDate = String(draft.dueDate);
return item;
}
function localIndexItem(item) {
if (!item?.attachment?.data && !item?.attachment?.blob) return item;
return {
...item,
attachment: {
filename: item.attachment.filename,
contentType: item.attachment.contentType,
stored: true,
},
};
}
function enqueue(draft, mirror = true) {
const items = read();
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
const existing = sourceCaptureId && items.find(item => item.sourceCaptureId === sourceCaptureId);
if (existing) return { ...existing };
const item = prepareItem(draft);
items.push(item);
write(items, mirror);
return item;
}
async function enqueueDurably(draft) {
const item = enqueue(draft, false);
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
return { item, background: false, durability: 'foreground-only' };
}
try {
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
const existing = sourceCaptureId && read().find(item => item.sourceCaptureId === sourceCaptureId);
if (existing) {
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
return { item: { ...existing }, background: false, durability: 'foreground-only', reused: true };
}
await backgroundSync.reconcile(read());
await backgroundSync.requestSync();
return { item, background: true, durability: 'background' };
return { item: { ...existing }, background: true, durability: 'background', reused: true };
}
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
const item = enqueue(draft, false);
return { item, background: false, durability: 'foreground-only' };
}
const item = prepareItem(draft);
const current = read();
try {
await backgroundSync.reconcile([...current, item]);
const localItem = localIndexItem(item);
write([...current, item].map(localIndexItem), false);
await backgroundSync.requestSync();
return { item: localItem, background: true, durability: 'background' };
} catch (error) {
return { item, background: false, durability: 'foreground-only', error };
const persisted = read().find(candidate => candidate.id === item.id);
if (!persisted) throw error;
return { item: persisted, background: false, durability: 'foreground-only', error };
}
}
function update(id, draft, mirror = true) {
async function hydrateForEdit(id) {
const item = read().find(candidate => candidate.id === id);
if (!item) return null;
if (!item.attachment?.stored || item.attachment.data || item.attachment.blob) return { ...item };
if (!backgroundSync?.get) {
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
}
const durable = await backgroundSync.get(id);
const attachment = captureAttachment(durable?.attachment);
if ((!attachment?.data && !attachment?.blob) || durable?.operationId !== item.operationId) {
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
}
return { ...item, attachment };
}
function prepareUpdate(id, draft) {
let updated = null;
write(read().map(item => {
const items = read().map(item => {
if (item.id !== id) return item;
const nextRepository = String(draft?.repository || '');
const nextTitle = String(draft?.title || '');
@@ -76,14 +144,17 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
? Number(draft.milestoneId) : undefined;
const nextDueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))
? String(draft.dueDate) : undefined;
const nextAttachment = captureAttachment(draft?.attachment);
const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(nextAttachment || null);
const changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate;
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate
|| attachmentChanged;
updated = {
...item,
repository: nextRepository, title: nextTitle,
body: nextBody, labelIds: nextLabelIds,
milestoneId: nextMilestoneId, dueDate: nextDueDate,
milestoneId: nextMilestoneId, dueDate: nextDueDate, attachment: nextAttachment,
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
status: 'queued',
};
@@ -91,24 +162,38 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
else delete updated.completionIntent;
if (nextMilestoneId === undefined) delete updated.milestoneId;
if (nextDueDate === undefined) delete updated.dueDate;
if (nextAttachment === undefined) delete updated.attachment;
if (attachmentChanged) delete updated.attachmentMarkdown;
delete updated.error;
delete updated.deliveryState;
return updated;
}), mirror);
});
return { items, updated };
}
function update(id, draft, mirror = true) {
const { items, updated } = prepareUpdate(id, draft);
write(items, mirror);
return updated;
}
async function updateDurably(id, draft) {
const item = update(id, draft, false);
if (!item || !backgroundSync?.reconcile || !backgroundSync?.requestSync) {
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
const item = update(id, draft, false);
return { item, background: false, durability: 'foreground-only' };
}
const { items, updated: item } = prepareUpdate(id, draft);
if (!item) return { item, background: false, durability: 'foreground-only' };
try {
await backgroundSync.reconcile(read());
await backgroundSync.reconcile(items);
const localItems = items.map(localIndexItem);
write(localItems, false);
await backgroundSync.requestSync();
return { item, background: true, durability: 'background' };
return { item: localIndexItem(item), background: true, durability: 'background' };
} catch (error) {
return { item, background: false, durability: 'foreground-only', error };
const persisted = read().find(candidate => candidate.id === id);
if (!persisted || persisted.operationId !== item.operationId) throw error;
return { item: persisted, background: false, durability: 'foreground-only', error };
}
}
@@ -119,9 +204,88 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
return true;
}
function persistDeliveryStage(id, stage) {
write(read().map(item => item.id === id ? { ...item, ...stage } : item));
}
function stageOperationId(operationId, stage) {
const suffix = ':' + stage;
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
}
function attachmentMultipart(attachment) {
let blob = attachment?.blob;
if (!blob && attachment?.data) {
const binary = atob(String(attachment.data));
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
blob = new Blob([bytes], { type: String(attachment.contentType || '') });
}
if (!blob) throw new Error('The saved screenshot is unavailable. Retry before sending.');
const form = new FormData();
form.append('file', blob, String(attachment.filename || 'screenshot'));
return form;
}
async function sendDirect(item, repository) {
let issue = item.deliveredIssue;
if (!issue) {
issue = await fetchJson('api/v1/repos/' + repository + '/issues', {
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({
title: item.title, body: item.body, label_ids: item.labelIds,
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
}),
});
if (item.attachment) persistDeliveryStage(item.id, { deliveredIssue: issue });
}
if (!item.attachment) return issue;
let markdown = item.attachmentMarkdown;
if (!markdown) {
const uploaded = await fetchJson(
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/attachments',
{
method: 'POST',
headers: {
Accept: 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
},
body: attachmentMultipart(item.attachment),
},
);
markdown = String(uploaded?.markdown || '');
if (!markdown) {
const error = new Error('The server did not confirm the screenshot upload.');
error.status = 422;
throw error;
}
persistDeliveryStage(item.id, { deliveredIssue: issue, attachmentMarkdown: markdown });
}
await fetchJson(
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/comments',
{
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'attachment-comment'),
},
body: JSON.stringify({ body: markdown }),
},
);
return issue;
}
async function sendItem(item, currentLogin) {
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
if (pending.has(item.id)) return pending.get(item.id);
const attemptAt = Number(now());
write(read().map(candidate => candidate.id === item.id ? {
...candidate, status:'sending', lastAttemptAt:attemptAt,
} : candidate), false);
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
const request = (async () => {
try {
@@ -135,29 +299,24 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
}
issue = delivery.issue;
} else {
issue = await fetchJson('api/v1/repos/' + repository + '/issues', {
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({
title: item.title, body: item.body, label_ids: item.labelIds,
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
}),
});
issue = await sendDirect(item, repository);
}
if (!issue) return { blocked: true };
discard(item.id);
return { issue, item };
} catch (error) {
const status = Number(error?.status || 0);
const attemptError = String(error.message || 'Delivery failed').slice(0, 240);
if (status >= 400 && status < 500) {
write(read().map(candidate => candidate.id === item.id ? {
...candidate, status: 'attention', error: String(error.message || 'Issue needs attention').slice(0, 240),
lastAttemptAt: attemptAt, lastAttemptError: attemptError,
...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}),
} : candidate));
} else {
write(read().map(candidate => candidate.id === item.id ? {
...candidate, status:'queued', lastAttemptAt:attemptAt, lastAttemptError:attemptError,
} : candidate));
}
return { error, transient: !(status >= 400 && status < 500) };
}
@@ -237,6 +396,10 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
const statuses = new Map((records || []).map(item => [item.id, item]));
const items = read().flatMap(item => {
const background = statuses.get(item.id);
const deliveryStage = {
...(background?.deliveredIssue ? { deliveredIssue: background.deliveredIssue } : {}),
...(background?.attachmentMarkdown ? { attachmentMarkdown: background.attachmentMarkdown } : {}),
};
if (background?.status === 'sent') {
if (item.completionIntent === 'create-and-start' && background.deliveredIssue) return [{
...item, status: 'completion', deliveredIssue: background.deliveredIssue,
@@ -244,10 +407,10 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
return [];
}
if (background?.status === 'attention') return [{
...item, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240),
...localIndexItem(item), ...deliveryStage, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240),
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
}];
return [item];
return [{ ...localIndexItem(item), ...deliveryStage }];
});
write(items);
return items;
@@ -276,7 +439,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
}
return {
enqueue, enqueueDurably, update, updateDurably, discard, flush, retry, reconcileBackground,
enqueue, enqueueDurably, hydrateForEdit, update, updateDurably, discard, flush, retry, reconcileBackground,
pendingCompletions, completeIntent, list: () => read().map(item => ({ ...item })),
};
}
+19
View File
@@ -27,6 +27,7 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
let editRequest = null;
let dueDateRequest = null;
let milestoneRequest = null;
let blockerRequest = null;
const issuePath = item => 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
'/issues/' + encodeURIComponent(item.number);
const draftKey = item => 'stackchain.issue-comment.v1:' + item.repository + '#' + item.number;
@@ -67,6 +68,24 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
headers: { Accept: 'application/json' },
});
},
updateBlocker(item, blocker, remove = false) {
if (blockerRequest) return blockerRequest;
blockerRequest = fetchJson(issuePath(item) + '/blockers', {
method: remove ? 'DELETE' : 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ repository: blocker.repository, number: blocker.number }),
}).then(result => {
const dependencies = Array.isArray(result?.dependencies) ? result.dependencies : [];
const present = dependencies.some(candidate =>
candidate?.repository === blocker.repository && Number(candidate?.number) === Number(blocker.number)
);
if (result?.number !== item.number || result?.dependencies_available !== true || present === remove) {
throw new Error('Blocker change was not confirmed.');
}
return result;
}).finally(() => { blockerRequest = null; });
return blockerRequest;
},
loadDraft(item) {
try { return storage?.getItem(draftKey(item)) || ''; }
catch (_error) { return ''; }
+51
View File
@@ -0,0 +1,51 @@
function createLaterAndStart({ todayWork, todaySync, laterWork, refresh, warm, start, announce }) {
const pending = new Map();
async function run(item, identity) {
const added = todayWork.add(item);
if (added === 'full') {
announce('Today is limited to 5 items. Remove one, then try Start now again.');
return 'full';
}
if (added !== 'added' && added !== 'exists') {
announce('Could not save Today on this device. The item remains in Later; try again.');
return added;
}
if (added === 'added' && !todaySync.enqueue('add', identity)) {
todayWork.remove(item);
announce('Today sync is unavailable. The item remains in Later; try again.');
return 'sync-unavailable';
}
if (!laterWork.restore(item)) {
if (added === 'added') {
todayWork.remove(item);
todaySync.enqueue('remove', identity);
todaySync.flush();
}
announce('Could not remove this item from Later. Nothing was started; try again.');
return 'later-unavailable';
}
refresh();
if (added === 'added') todaySync.flush();
warm();
const outcome = await start(item);
if (outcome === 'gated') {
announce('Moved to Today. Choose how to handle its blocker before starting.');
return 'gated';
}
announce(added === 'exists' ? 'Opened the existing Today item.' : 'Moved to Today and opened.');
return 'started';
}
function startDeferred(item) {
const identity = todayWork.identity(item);
if (pending.has(identity)) return pending.get(identity);
const operation = run(item, identity).finally(() => pending.delete(identity));
pending.set(identity, operation);
return operation;
}
return { start: startDeferred };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterAndStart;
+2 -1
View File
@@ -90,7 +90,8 @@ function createLaterPicker({
onState({ open:true, message:result.message, value:String(input || '') });
return false;
}
const confirmed = onConfirm(item, result.value, context);
const confirmed = typeof item?.confirm === 'function' ? item.confirm(result.value) :
onConfirm(item, result.value, context);
if (confirmed !== true && typeof confirmed !== 'function') return false;
committed = true;
afterClose = typeof confirmed === 'function' ? confirmed : null;
+17 -1
View File
@@ -52,6 +52,22 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
return 'deferred';
}
function deferMany(items, until) {
const key = storageKey();
const wake = new Date(until);
const entries = (items || []).map(item => [identity(item), item]);
if (!key) return 'unavailable';
if (!entries.length || entries.some(([id]) => !id) || Number.isNaN(wake.getTime()) || wake <= now()) {
return 'invalid';
}
const records = read();
const wakeAt = wake.toISOString();
entries.forEach(([id]) => { records[id] = wakeAt; });
if (!write(records)) return 'unavailable';
entries.forEach(([id]) => onChange('defer', id, wakeAt));
return 'deferred';
}
function presetUntil(preset) {
const current = new Date(now());
if (preset === 'today') return new Date(current.getTime() + 4 * 60 * 60 * 1000);
@@ -127,7 +143,7 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
return { active, later };
}
return { identity, read, adopt, defer, restore, presetUntil, partition };
return { identity, read, adopt, defer, deferMany, restore, presetUntil, partition };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterWork;
+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 };
});
+150 -7
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,27 +15,111 @@
|| 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;
}
const form = options.form;
const status = options.status;
const button = options.button;
const passkeyButton = options.passkeyButton;
const credentials = options.credentials;
const fetchImpl = options.fetchImpl;
const location = options.location;
const clearPrivateDeviceData = options.clearPrivateDeviceData;
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;
if (continuation !== './') status.textContent = 'Sign in to continue your shared capture.';
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('_', '/')
+ '='.repeat((4 - String(value).length % 4) % 4);
return Uint8Array.from(atob(padded), character => character.charCodeAt(0));
}
function encodeBase64Url(value) {
if (value === null || value === undefined) return null;
const bytes = new Uint8Array(value);
let binary = '';
bytes.forEach(byte => { binary += String.fromCharCode(byte); });
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
}
function credentialJSON(credential) {
return {
id: credential.id,
type: credential.type,
rawId: encodeBase64Url(credential.rawId),
response: {
authenticatorData: encodeBase64Url(credential.response.authenticatorData),
clientDataJSON: encodeBase64Url(credential.response.clientDataJSON),
signature: encodeBase64Url(credential.response.signature),
userHandle: encodeBase64Url(credential.response.userHandle),
},
};
}
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') {
status.textContent = 'Your session expired. Private drafts remain on this device. Sign in to continue.';
return;
}
if (reason === 'session-idle') {
status.textContent = 'Stackchain locked after inactivity. Your drafts and queued work are still on this device. Sign in to resume.';
return;
}
if (reason !== 'session-revoked') return;
button.disabled = true;
status.textContent = 'This device was remotely signed out. Clearing Stackchain private data…';
@@ -66,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) {
@@ -91,18 +182,66 @@
status.textContent = 'Sign-in failed. Check the token and try again.';
}
return { submit, showReason };
async function signInWithPasskey(deviceLabel = 'This device') {
if (!credentials?.get) {
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 requestWithDeadline('api/v1/passkeys/authentication/options', {
method: 'POST', headers: { Accept: 'application/json' },
});
if (!optionsResponse.ok) throw new Error('No enrolled passkey');
const publicKey = await optionsResponse.json();
const challenge = publicKey.challenge;
publicKey.challenge = decodeBase64Url(publicKey.challenge);
publicKey.allowCredentials = (publicKey.allowCredentials || []).map(item => ({
...item, id: decodeBase64Url(item.id),
}));
const credential = await credentials.get({ publicKey });
const response = await requestWithDeadline('api/v1/passkeys/authentication/verify', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge,
credential: credentialJSON(credential),
device_label: deviceLabel,
action: 'sign_in',
target: 'dashboard',
}),
});
if (!response.ok) throw new Error('Passkey verification failed');
location.replace(continuation);
return true;
} 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);
}
}
if (passkeyButton) passkeyButton.disabled = !credentials?.get;
return { submit, signInWithPasskey, showReason };
}));
if (typeof document !== 'undefined') {
const form = document.getElementById('sign-in');
const status = document.getElementById('status');
const button = document.getElementById('submit-sign-in');
const passkeyButton = document.getElementById('passkey-sign-in');
const loginParams = new URLSearchParams(window.location.search);
const controller = createLoginController({
form,
status,
button,
passkeyButton,
credentials: window.navigator?.credentials,
fetchImpl: fetch.bind(window),
location: window.location,
continuation: loginParams.get('continue'),
@@ -116,4 +255,8 @@ if (typeof document !== 'undefined') {
const deviceLabel = data.get('device_label');
controller.submit(accessToken, deviceLabel);
});
passkeyButton?.addEventListener('click', () => {
const data = new FormData(form);
controller.signInWithPasskey(data.get('device_label'));
});
}
+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"]}]
}
}
}
+147
View File
@@ -0,0 +1,147 @@
(function(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.createMentionComposer = api.create;
})(typeof globalThis !== 'undefined' ? globalThis : this, function() {
'use strict';
function activeMention(value, caret) {
const before = String(value || '').slice(0, Math.max(0, caret));
const match = before.match(/(^|\s)@([A-Za-z0-9_.-]{2,39})$/);
if (!match) return null;
const start = before.length - match[2].length - 1;
return { start, end:before.length, query:match[2] };
}
function insertMention(value, mention, login) {
const text = String(value || '');
const suffixStart = mention.end < text.length && /\s/.test(text[mention.end]) ? mention.end + 1 : mention.end;
const inserted = '@' + login + ' ';
return {
value:text.slice(0, mention.start) + inserted + text.slice(suffixStart),
caret:mention.start + inserted.length,
};
}
function create(options) {
const textarea = options.textarea;
const listbox = options.listbox;
const status = options.status;
const setTimer = options.setTimer || setTimeout;
const clearTimer = options.clearTimer || clearTimeout;
const createOption = options.createOption || (() => document.createElement('button'));
let timer = null;
let requestId = 0;
let mention = null;
let candidates = [];
let activeIndex = -1;
function dismiss() {
requestId += 1;
candidates = [];
activeIndex = -1;
listbox.replaceChildren();
listbox.hidden = true;
textarea.setAttribute('aria-expanded', 'false');
textarea.removeAttribute('aria-activedescendant');
status.textContent = '';
}
function activate(index) {
if (!candidates.length) return;
activeIndex = (index + candidates.length) % candidates.length;
listbox.children.forEach((option, optionIndex) => {
option.setAttribute('aria-selected', optionIndex === activeIndex ? 'true' : 'false');
});
textarea.setAttribute('aria-activedescendant', listbox.children[activeIndex].id);
}
function select(index) {
if (!mention || !candidates[index]) return;
const result = insertMention(textarea.value, mention, candidates[index].login);
textarea.value = result.value;
textarea.setSelectionRange(result.caret, result.caret);
dismiss();
textarea.focus();
textarea.dispatchEvent?.(new Event('input', { bubbles:true }));
}
function render(items) {
candidates = Array.isArray(items) ? items : [];
activeIndex = -1;
listbox.replaceChildren();
candidates.forEach((candidate, index) => {
const option = createOption();
option.type = 'button';
option.id = listbox.id + '-option-' + index;
option.className = 'mention-option';
option.dataset.login = candidate.login;
option.setAttribute('role', 'option');
option.setAttribute('aria-selected', 'false');
option.textContent = '@' + candidate.login + (candidate.name !== candidate.login ? ' · ' + candidate.name : '');
option.addEventListener('pointerdown', event => {
event.preventDefault();
select(index);
});
listbox.appendChild(option);
});
listbox.hidden = !candidates.length;
textarea.setAttribute('aria-expanded', candidates.length ? 'true' : 'false');
status.textContent = candidates.length ? candidates.length + ' teammates found.' : 'No matching teammates.';
}
function schedule() {
if (timer !== null) clearTimer(timer);
mention = activeMention(textarea.value, textarea.selectionStart);
const repository = options.getRepository();
if (!mention || !repository) {
dismiss();
return;
}
const currentRequest = ++requestId;
timer = setTimer(async () => {
try {
const items = await options.loadCandidates(repository, mention.query);
if (currentRequest === requestId && repository === options.getRepository()) render(items);
} catch (_error) {
if (currentRequest === requestId) {
listbox.hidden = true;
textarea.setAttribute('aria-expanded', 'false');
status.textContent = 'Teammate suggestions unavailable; you can keep typing.';
}
}
}, options.delayMs ?? 180);
}
function keydown(event) {
if (event.key === 'Escape' && !listbox.hidden) {
event.preventDefault();
dismiss();
} else if (event.key === 'ArrowDown' && candidates.length) {
event.preventDefault();
activate(activeIndex + 1);
} else if (event.key === 'ArrowUp' && candidates.length) {
event.preventDefault();
activate(activeIndex < 0 ? candidates.length - 1 : activeIndex - 1);
} else if (event.key === 'Enter' && activeIndex >= 0) {
event.preventDefault();
select(activeIndex);
}
}
return {
start() {
textarea.setAttribute('autocomplete', 'off');
textarea.setAttribute('aria-autocomplete', 'list');
textarea.setAttribute('aria-controls', listbox.id);
textarea.setAttribute('aria-expanded', 'false');
textarea.addEventListener('input', schedule);
textarea.addEventListener('click', schedule);
textarea.addEventListener('keydown', keydown);
},
dismiss,
};
}
return { activeMention, insertMention, create };
});
+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};
});
+7 -3
View File
@@ -14,7 +14,9 @@
function reveal() {
if (!active) return;
active.workspace.scrollIntoView({ block:'end', inline:'nearest' });
const focused = documentRef.activeElement;
const target = active.focusWithin && active.workspace.contains(focused) ? focused : active.workspace;
target.scrollIntoView({ block:active.focusWithin ? 'nearest' : 'end', inline:'nearest' });
}
function applyGeometry() {
@@ -59,12 +61,14 @@
entry.onFocusOut = () => schedule(() => {
if (!entry.workspace.contains(documentRef.activeElement)) deactivate(entry);
});
entry.composer.addEventListener('focus', entry.onFocus);
entry.focusTarget = entry.focusWithin ? entry.workspace : entry.composer;
entry.focusEvent = entry.focusWithin ? 'focusin' : 'focus';
entry.focusTarget.addEventListener(entry.focusEvent, entry.onFocus);
entry.workspace.addEventListener('focusout', entry.onFocusOut);
}
function unbind(entry) {
entry.composer.removeEventListener('focus', entry.onFocus);
entry.focusTarget.removeEventListener(entry.focusEvent, entry.onFocus);
entry.workspace.removeEventListener('focusout', entry.onFocusOut);
}
+131
View File
@@ -0,0 +1,131 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileDeviceSetup = factory;
})(typeof self !== 'undefined' ? self : this, function createMobileDeviceSetup(options) {
const promptDismissKey = 'stackchain.device-setup-prompt-dismissed-until';
const promptDismissMs = 7 * 24 * 60 * 60 * 1000;
const steps = [
['install', options.installButton, options.installStatus, options.install],
['offline', options.offlineButton, options.offlineStatus, options.enableOffline],
['push', options.pushButton, options.pushStatus, options.enablePush],
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline],
];
let trigger = options.launcher;
function readinessCounts(readiness) {
const available = steps.filter(([name]) => readiness[name].state !== 'unavailable').length;
const complete = steps.filter(([name]) => readiness[name].state === 'complete').length;
return {available, complete};
}
function renderPrompt(readiness) {
if (!options.promptCard) return;
const {available, complete} = readinessCounts(readiness);
let dismissed = false;
try {
dismissed = Number(options.promptStorage?.getItem(promptDismissKey) || 0) > (options.now?.() ?? Date.now());
} catch (_error) {}
options.promptSummary.textContent = `${complete} of ${available} steps complete`;
options.promptCard.hidden = dismissed || available === 0 || complete === available;
}
async function render() {
const readiness = await options.getReadiness();
let available = 0;
let complete = 0;
steps.forEach(([name, button, status]) => {
const step = readiness[name];
status.textContent = step.detail;
button.hidden = step.state === 'complete' || step.state === 'unavailable';
button.disabled = step.state === 'pending';
if (step.state !== 'unavailable') available += 1;
if (step.state === 'complete') complete += 1;
});
options.readyStatus.textContent = complete === available
? 'This device is ready.'
: `${complete} of ${available} available steps ready.`;
renderPrompt(readiness);
return readiness;
}
async function open(event) {
trigger = event?.currentTarget || options.launcher;
await render();
options.sheet.hidden = false;
options.closeButton.focus();
}
function close() {
options.sheet.hidden = true;
trigger?.focus?.();
}
async function start() {
options.launcher.addEventListener('click', open);
options.promptLauncher?.addEventListener('click', open);
options.promptDismiss?.addEventListener('click', () => {
try {
options.promptStorage?.setItem(promptDismissKey, String((options.now?.() ?? Date.now()) + promptDismissMs));
} catch (_error) {}
options.promptCard.hidden = true;
});
options.closeButton.addEventListener('click', close);
options.sheet.addEventListener('click', event => {
if (event.target === options.sheet) close();
});
options.escapeTarget.addEventListener('keydown', event => {
if (event.key === 'Escape' && !options.sheet.hidden) close();
});
steps.forEach(([_name, button, _status, action]) => {
button.addEventListener('click', async () => {
button.disabled = true;
try { await action(); }
finally { await render(); }
});
});
renderPrompt(await options.getReadiness());
}
return {start, open, close, render};
});
if (typeof module === 'object' && module.exports) {
module.exports.mount = mountMobileDeviceSetup;
} else {
self.createMobileDeviceSetup.mount = mountMobileDeviceSetup;
}
function mountMobileDeviceSetup(options) {
const qs = selector => options.document.querySelector(selector);
return createMobileDeviceSetup({
launcher:qs('#open-device-setup'), closeButton:qs('#close-device-setup'),
sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'),
offlineButton:qs('#device-setup-offline'), pushButton:qs('#device-setup-push'),
deadlineButton:qs('#device-setup-deadline'),
installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'),
pushStatus:qs('#device-setup-push-status'), deadlineStatus:qs('#device-setup-deadline-status'),
readyStatus:qs('#device-setup-ready-status'),
promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'),
promptLauncher:qs('#finish-device-setup'), promptDismiss:qs('#dismiss-device-readiness'),
promptStorage:options.promptStorage,
escapeTarget:options.document,
getReadiness:() => ({
install:options.installApp.state(),
offline:!options.offlineAvailable()
? {state:'unavailable', detail:'Offline saving is unavailable in this browser.'}
: options.offlineEnabled()
? {state:'complete', detail:qs('#offline-work-status').textContent || 'Offline work is saved.'}
: {state:'incomplete', detail:'Private My Work and Today data are not saved offline.'},
push:qs('#push-updates').disabled
? {state:'unavailable', detail:qs('#push-update-status').textContent || 'Update notifications are unavailable.'}
: qs('#push-updates').checked
? {state:'complete', detail:'New update notifications are enabled.'}
: {state:'incomplete', detail:qs('#push-update-status').textContent || 'New update notifications are off.'},
deadline:options.deadlineReadiness(),
}),
install:() => options.installApp.install(),
enableOffline:options.enableOffline,
enablePush:options.enablePush,
enableDeadline:options.enableDeadline,
});
}
+3 -2
View File
@@ -4,13 +4,14 @@
else root.mobileLaunch = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
const allowedFilters = new Set([
'all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft',
'all', 'today', 'attention', 'agenda', 'issue', 'pull', 'review', 'update', 'later', 'draft',
]);
function chooseFilter({ saved, today = 0, attention = 0 } = {}) {
function chooseFilter({ saved, today = 0, attention = 0, agenda = 0 } = {}) {
if (allowedFilters.has(saved)) return saved;
if (today > 0) return 'today';
if (attention > 0) return 'attention';
if (agenda > 0) return 'agenda';
return 'all';
}
+53
View File
@@ -0,0 +1,53 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileQueueLauncher = factory;
})(typeof self !== 'undefined' ? self : this, function createMobileQueueLauncher(options) {
const emptyMessages = {
attention: 'No items need attention.',
update: 'No unread updates are ready to open.',
later: 'No deferred work is ready to open.',
draft: 'No drafts are ready to open.',
};
const continuation = [
['attention', 'Start Attention'],
['today', 'Continue Today'],
['update', 'Resume Updates'],
['agenda', 'Open Agenda'],
['later', 'Start Later'],
['draft', 'Open Drafts'],
];
function recommend() {
const counts = options.getCounts ? options.getCounts() : {};
const match = continuation.find(([name]) => Number(counts[name]) > 0);
if (!match) return {name: 'find', count: 0, label: 'Find Work'};
const [name, label] = match;
const count = Math.max(0, Number(counts[name]) || 0);
return {name, count, label: label + ' (' + count + ')'};
}
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) {
options.announce(emptyMessages[name] || 'No work is ready to open.');
return 'empty';
}
action.click();
return 'opened';
}
function continueWork() {
const next = recommend();
if (next.name === 'find') {
options.openFindWork();
return 'find';
}
return open(next.name);
}
return { open, recommend, continueWork };
});
+89 -26
View File
@@ -7,60 +7,123 @@
const overlays = options.overlays || [];
let launcher = null;
let wasHidden = false;
let workMode = 'work';
function select(name) {
if (!buttons[name]) return false;
Object.entries(buttons).forEach(([key, button]) => {
if (key === name) button.setAttribute('aria-current', 'page');
else button.removeAttribute('aria-current');
});
return true;
}
function refreshVisibility() {
const hidden = overlays.some(overlay => overlay.classList.contains('open'));
const hidden = overlays.some(overlay => overlay.open || overlay.classList.contains('open'));
nav.hidden = hidden;
if (options.sessionHud) {
if (hidden) options.sessionHud.setAttribute('data-overlay-hidden', 'true');
else options.sessionHud.removeAttribute('data-overlay-hidden');
}
if (wasHidden && !hidden && launcher) launcher.focus();
wasHidden = hidden;
}
function openQueues() {
if (!options.queueSheet || options.queueSheet.open) return;
options.queueSheet.showModal();
}
function closeQueues() {
options.queueSheet?.open && options.queueSheet.close();
}
function start() {
Object.entries(buttons).forEach(([name, button]) => {
button.addEventListener('click', event => {
launcher = event.currentTarget;
select(name);
options.actions[name]();
name === 'queues' && options.queueSheet ? openQueues() : options.actions[name]();
});
});
if (options.queueSheet) {
options.queueClose?.addEventListener('click', closeQueues);
options.queueSheet.addEventListener('cancel', event => {
event.preventDefault();
closeQueues();
});
options.queueSheet.addEventListener('close', () => buttons.queues?.focus());
Object.entries(options.queueRows || {}).forEach(([name, row]) => {
row.addEventListener('click', () => {
closeQueues();
options.onSelectQueue?.(name, row);
});
});
}
options.observe(refreshVisibility, overlays);
refreshVisibility();
}
function updateWork(mode, count) {
const labels = {
continue: ['Continue', 'Continue Today'],
resume: ['Resume', 'Resume Today'],
start: ['Start', 'Start Today'],
work: ['Work', 'Work'],
};
workMode = labels[mode] ? mode : 'work';
const total = Math.max(0, Number(count) || 0);
const badge = options.attentionBadge;
if (options.workLabel) options.workLabel.textContent = labels[workMode][0];
if (badge) {
badge.textContent = String(total);
badge.hidden = total === 0;
}
if (buttons.work) {
buttons.work.setAttribute(
'aria-label',
labels[workMode][1] + (total ? ', ' + total + ' items need attention' : '')
);
}
function updateWork(mode) {
const text = {
continue:'Continue', resume:'Resume', start:'Start', plan:'Plan', find:'Find',
attention:'Attention', update:'Updates', agenda:'Agenda', later:'Later', draft:'Drafts',
}[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) {
updateWork(workMode, count);
const total = Math.max(0, Number(count) || 0);
const visible = total > 0;
if (options.attentionBadge) {
options.attentionBadge.textContent = String(total);
options.attentionBadge.hidden = !visible;
}
if (buttons.attention) {
buttons.attention.hidden = !visible;
buttons.attention.setAttribute('aria-label', 'Attention, ' + total + ' items');
}
if (visible) nav.setAttribute('data-attention', 'true');
else nav.removeAttribute('data-attention');
}
return {start, refreshVisibility, updateAttention, updateWork};
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 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);
});
options.queueRows?.update?.setAttribute(
'aria-label', 'Updates, ' + normalized.update + ' unread conversations'
);
if (options.queueBadge) {
options.queueBadge.textContent = active + ' active';
options.queueBadge.hidden = active === 0;
}
if (options.deadlineBadge) {
options.deadlineBadge.textContent = normalized.agenda + ' due';
options.deadlineBadge.hidden = normalized.agenda === 0;
}
if (options.queueRows?.agenda) {
if (normalized.agenda > 0) options.queueRows.agenda.setAttribute('data-deadlines', 'true');
else options.queueRows.agenda.removeAttribute('data-deadlines');
}
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};
});
+7 -2
View File
@@ -4,9 +4,12 @@
})(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';
return 'work';
if (options.getEligibleCount() > 0) return 'plan';
return 'find';
}
function open() {
@@ -14,7 +17,9 @@
if (current === 'continue') options.continueToday();
else if (current === 'resume') options.resumeToday();
else if (current === 'start') options.startToday();
else options.openFallback();
else if (current === 'plan') options.planToday();
else if (current === 'find') options.findWork();
else options.queueLauncher.open(current);
return current;
}
+323 -44
View File
@@ -1,15 +1,26 @@
function issueDueState(dueDate, now) {
if (!dueDate) return null;
const due = new Date(dueDate);
if (Number.isNaN(due.getTime())) return null;
const day = value => value.getFullYear() + '-' + String(value.getMonth() + 1).padStart(2, '0') + '-' +
function calendarDay(value) {
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})(?:$|T)/);
if (!match) return '';
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
return date.getFullYear() === Number(match[1]) && date.getMonth() === Number(match[2]) - 1 &&
date.getDate() === Number(match[3]) ? match.slice(1, 4).join('-') : '';
}
function localDay(value) {
return value.getFullYear() + '-' + String(value.getMonth() + 1).padStart(2, '0') + '-' +
String(value.getDate()).padStart(2, '0');
const dueDay = day(due);
const today = day(now);
}
function issueDueState(dueDate, now) {
const dueDay = calendarDay(dueDate);
if (!dueDay) return null;
const today = localDay(now);
if (dueDay < today) return { label: 'Overdue', priority: 2 };
if (dueDay === today) return { label: 'Due today', priority: 2.5 };
return {
label: 'Due ' + due.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
label: 'Due ' + new Date(
Number(dueDay.slice(0, 4)), Number(dueDay.slice(5, 7)) - 1, Number(dueDay.slice(8, 10))
).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
priority: 4,
};
}
@@ -33,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' }));
@@ -71,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;
@@ -92,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);
@@ -178,10 +205,67 @@ function createBulkNotificationAcknowledger({ markRead, onItems, onStatus }) {
};
}
function createNotificationSelection({ limit = 50, onChange = () => {} } = {}) {
const maximum = Number.isInteger(limit) && limit > 0 ? limit : 50;
let active = false;
const selected = new Set();
const snapshot = () => ({
active,
ids: Array.from(selected),
count: selected.size,
limit: maximum,
at_limit: selected.size >= maximum,
});
const publish = () => onChange(snapshot());
const select = notificationId => {
if (!active || !Number.isInteger(notificationId)) return 'inactive';
if (selected.has(notificationId)) return 'already-selected';
if (selected.size >= maximum) return 'limit';
selected.add(notificationId);
publish();
return 'selected';
};
return {
start() {
if (active) return snapshot();
active = true;
publish();
return snapshot();
},
select,
toggle(notificationId) {
if (!selected.has(notificationId)) return select(notificationId);
selected.delete(notificationId);
publish();
return 'deselected';
},
retain(notificationIds) {
const allowed = new Set((notificationIds || []).filter(Number.isInteger));
let changed = false;
selected.forEach(id => {
if (!allowed.has(id)) {
selected.delete(id);
changed = true;
}
});
if (changed) publish();
return snapshot();
},
cancel() {
active = false;
selected.clear();
publish();
return snapshot();
},
snapshot,
};
}
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);
@@ -215,18 +299,31 @@ 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 }) {
let pagination = {};
const pending = new Set();
const completing = new Map();
const labels = {
issue: 'issues',
pull: 'pull requests',
review: 'review requests',
};
return {
const pager = {
reset(next) {
Object.entries(next || {}).forEach(([stream, value]) => {
const current = pagination[stream];
@@ -275,31 +372,109 @@ function createWorkPager({ load, onItems, onPagination, onStatus }) {
pending.delete(stream);
}
},
loadAll(stream, getExisting) {
if (completing.has(stream)) return completing.get(stream);
const completion = (async () => {
while (pagination[stream]?.has_more) {
const loaded = await pager.loadMore(stream, getExisting());
if (!loaded) return false;
}
return true;
})().finally(() => completing.delete(stream));
completing.set(stream, completion);
return completion;
},
};
return pager;
}
function createNotificationReader({
load, markRead, onOpen, onDetail, onItems, onStatus, onClose,
acknowledge = null,
queueRead = null,
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);
onItems(updated);
const currentIndex = items.indexOf(current);
const remaining = items.slice(currentIndex + 1).concat(items.slice(0, currentIndex));
const next = remaining.find(item => item && item.has_update &&
Number.isInteger(item.notification_id) && (!queueing || loadSaved(item)));
if (next) await open(next, queueing ? loadSaved(next) : null);
else {
selected = null;
onClose();
onStatus('Inbox cleared.');
}
return { items: updated, item: current, next: next || null };
}
async function open(item, savedDetail = null) {
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) {
@@ -311,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) {
@@ -322,11 +500,24 @@ function createNotificationReader({
return {
open,
prefetch,
retryConversation() {
if (!selected || offlineHydrated) return Promise.resolve(false);
return hydrateConversation(selected, loadVersion);
},
commentPager() {
return conversationPager;
},
appendReply(comment) {
if (!conversationPager) return false;
onConversation(conversationPager.append(comment));
return true;
},
acceptReadAndNext(items, item = selected) {
if (!item || (selected && selected !== item)) return false;
selected = item;
return advanceAfterRead(items, item);
},
async loadOlder() {
if (!conversationPager || !selected || offlineHydrated) return false;
const pager = conversationPager;
@@ -354,19 +545,7 @@ function createNotificationReader({
try {
if (queueing) await queueRead(current.notification_id);
else await markRead(current.notification_id);
const updated = acknowledgeNotification(items, current.notification_id);
onItems(updated);
const currentIndex = items.indexOf(current);
const remaining = items.slice(currentIndex + 1).concat(items.slice(0, currentIndex));
const next = remaining.find(item => item && item.has_update &&
Number.isInteger(item.notification_id) && (!queueing || loadSaved(item)));
if (next) await open(next, queueing ? loadSaved(next) : null);
else {
selected = null;
onClose();
onStatus('Inbox cleared.');
}
return { items: updated, next: next || null };
return await advanceAfterRead(items, current, queueing);
} catch (_error) {
onStatus(queueing ? 'Could not queue update read. Retry.' : 'Could not mark update read. Retry.');
return false;
@@ -374,6 +553,21 @@ function createNotificationReader({
marking = false;
}
},
async acknowledgeAndNext(items) {
if (!selected || marking || offlineHydrated || !acknowledge) return false;
const current = selected;
marking = true;
onStatus('Adding reaction and marking read…');
try {
await acknowledge(current.notification_id);
return await advanceAfterRead(items, current);
} catch (_error) {
onStatus('Could not acknowledge update. Retry.');
return false;
} finally {
marking = false;
}
},
};
}
@@ -394,9 +588,9 @@ 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) {
async submit(item, body, attachment = null) {
if (pending) return false;
pending = true;
this.saveDraft(item, body);
@@ -407,11 +601,28 @@ function createNotificationReplier({
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
onStatus('Sending reply…');
try {
if (attachment) {
onStatus('Saving screenshot for durable delivery…');
const admission = await authoredOutbox.enqueueDurably({
kind:'update-reply', notificationId:item.notification_id, body, operationId, attachment,
});
const delivery = await authoredOutbox.retry(admission.item.id, admission.item.ownerLogin);
if (!delivery.confirmed?.length) {
const remaining = delivery.remaining?.find(candidate => candidate.id === admission.item.id);
if (remaining?.status === 'attention') return false;
onStatus('Queued for sync when the connection returns.');
return { queued:true };
}
try { storage.removeItem(keyFor(item)); storage.removeItem(operationKeyFor(item)); }
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) {
@@ -452,6 +663,29 @@ function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
);
}
function agendaMyWork(items, now = new Date()) {
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const today = localDay(start);
const tomorrowDate = new Date(start); tomorrowDate.setDate(tomorrowDate.getDate() + 1);
const tomorrow = localDay(tomorrowDate);
const afterTomorrowDate = new Date(start); afterTomorrowDate.setDate(afterTomorrowDate.getDate() + 2);
const afterTomorrow = localDay(afterTomorrowDate);
const horizonDate = new Date(start); horizonDate.setDate(horizonDate.getDate() + 7);
const horizon = localDay(horizonDate);
return (items || []).flatMap(item => {
if (item?.kind !== 'issue' || !item.is_assigned || !item.due_date) return [];
const due = calendarDay(item.due_date);
if (!due || due >= horizon) return [];
const group = due < today ? 'Overdue' : due < tomorrow ? 'Today' :
due < afterTomorrow ? 'Tomorrow' : 'Next 7 days';
return [{ ...item, agenda_group: group, _agenda_due: due }];
}).sort((left, right) =>
left._agenda_due.localeCompare(right._agenda_due) ||
String(left.repository || '').localeCompare(String(right.repository || '')) ||
Number(left.number || 0) - Number(right.number || 0)
).map(({ _agenda_due, ...item }) => item);
}
function milestoneLanes(items) {
const lanes = new Map();
(items || []).forEach(item => {
@@ -526,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({
@@ -547,7 +782,7 @@ function createWorkSession({
durable = false;
currentIdentity = '';
currentIndex = -1;
if (clearCheckpoint) checkpoint?.clear();
if (clearCheckpoint) activeCheckpoint()?.clear();
onFinish();
return false;
};
@@ -555,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;
@@ -563,34 +798,38 @@ function createWorkSession({
return {
active: () => running,
checkpointed: () => running && durable,
checkpointed: item => running && durable && checkpointedEnabled() && (!item || workIdentity(item) === currentIdentity),
end: () => finish(),
resumable: () => Boolean(checkpoint?.read()),
reopen() {
resumable: () => Boolean(activeCheckpoint()?.read()),
reopen(requested = null) {
if (!running) return false;
const items = queue();
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
const requestedIdentity = requested ? workIdentity(requested) : currentIdentity;
const index = items.findIndex(item => workIdentity(item) === requestedIdentity);
if (index < 0) return false;
currentIdentity = requestedIdentity;
currentIndex = index;
if (durable && requested) activeCheckpoint()?.save(currentIdentity, currentIndex);
report(items, index);
onOpen(items[index]);
return true;
},
resume() {
const saved = checkpoint?.read();
resume(requested = null) {
const saved = activeCheckpoint()?.read();
if (!saved) return false;
durable = true;
const items = queue();
if (!items.length) return finish();
running = true;
const exact = items.findIndex(item => workIdentity(item) === saved.identity);
const exact = items.findIndex(item => workIdentity(item) ===
(requested ? workIdentity(requested) : saved.identity));
return openAt(items, exact >= 0 ? exact : Math.min(saved.index, items.length - 1));
},
start(item = null) {
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);
},
@@ -609,21 +848,48 @@ function createWorkSession({
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
return index > 0 ? openAt(items, index - 1) : false;
},
next() {
next(requested = null) {
if (!running) return false;
const items = queue();
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
if (requested) {
const requestedIndex = items.findIndex(item => workIdentity(item) === workIdentity(requested));
return requestedIndex >= 0 ? openAt(items, requestedIndex) : false;
}
return index >= 0 && index < items.length - 1 ? openAt(items, index + 1) : finish();
},
complete() {
complete(requested = null) {
if (!running) return false;
const items = queue();
if (requested) {
const requestedIndex = items.findIndex(item => workIdentity(item) === workIdentity(requested));
return requestedIndex >= 0 ? openAt(items, requestedIndex) : false;
}
const stillPresent = items.findIndex(item => workIdentity(item) === currentIdentity);
if (stillPresent >= 0) {
return stillPresent < items.length - 1 ? openAt(items, stillPresent + 1) : finish();
}
return items.length ? openAt(items, Math.min(currentIndex, items.length - 1)) : finish();
},
items: () => queue().slice(),
target(action) {
const items = queue();
if (!items.length) return null;
if (action === 'start') return items[0];
if (action === 'resume') {
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)];
}
const current = items.findIndex(item => workIdentity(item) === currentIdentity);
if (action === 'continue') return current >= 0 ? items[current] : null;
if (action === 'next') return current >= 0 && current < items.length - 1 ? items[current + 1] : null;
if (action === 'complete') {
return items[current >= 0 ? Math.min(current + 1, items.length - 1) : Math.min(currentIndex, items.length - 1)];
}
return null;
},
};
}
@@ -682,6 +948,16 @@ function summarizeMyWork(items) {
return (updates ? updateLabel + ' · ' : '') + reviewLabel + ' · ' + assignedLabel;
}
function findQueueItems(items, query) {
const needle = String(query || '').trim().toLowerCase();
if (!needle) return (items || []).slice();
return (items || []).filter(item => {
const number = Number.isInteger(item?.number) ? '#' + item.number : '';
return [item?.repository, item?.key, number, item?.title]
.some(value => String(value || '').toLowerCase().includes(needle));
});
}
function countMyWork(items) {
return {
all: items.length,
@@ -695,6 +971,7 @@ function countMyWork(items) {
if (typeof module !== 'undefined' && module.exports) {
buildMyWork.filterMyWork = filterMyWork;
buildMyWork.agendaMyWork = agendaMyWork;
buildMyWork.milestoneLanes = milestoneLanes;
buildMyWork.createWorkSession = createWorkSession;
buildMyWork.createWorkSessionCheckpoint = createWorkSessionCheckpoint;
@@ -705,11 +982,13 @@ if (typeof module !== 'undefined' && module.exports) {
buildMyWork.replaceIssueMilestone = replaceIssueMilestone;
buildMyWork.removeIssue = removeIssue;
buildMyWork.summarizeMyWork = summarizeMyWork;
buildMyWork.findQueueItems = findQueueItems;
buildMyWork.countMyWork = countMyWork;
buildMyWork.acknowledgeNotification = acknowledgeNotification;
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
buildMyWork.notificationIds = notificationIds;
buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger;
buildMyWork.createNotificationSelection = createNotificationSelection;
buildMyWork.createNotificationPager = createNotificationPager;
buildMyWork.createWorkPager = createWorkPager;
buildMyWork.createNotificationReader = createNotificationReader;
+85
View File
@@ -0,0 +1,85 @@
function createNotificationUndo({ restore, onItems, onStatus, onOffer = () => {}, onClear = () => {} }) {
let offered = null;
let pending = false;
return {
offer(item, items) {
if (!item || !Number.isInteger(item.notification_id)) return false;
offered = { item, items: Array.isArray(items) ? [...items] : [] };
const label = item.key || 'Update';
onStatus(label + ' marked read. Undo?');
onOffer(item);
return true;
},
async run() {
if (!offered || pending) return false;
const current = offered;
const label = current.item.key || 'Update';
pending = true;
onStatus('Restoring ' + label + '…');
try {
await restore(current.item.notification_id);
if (offered !== current) return false;
const restored = [current.item].concat(current.items.filter(
item => item?.notification_id !== current.item.notification_id
));
offered = null;
onItems(restored, current.item);
onStatus(label + ' is unread again.');
onClear();
return true;
} catch (_error) {
onStatus('Could not restore ' + label + '. Retry Undo.');
return false;
} finally {
pending = false;
}
},
clear() {
offered = null;
onClear();
},
};
}
async function requestNotificationUnread(notificationId) {
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/unread', {
method: 'PATCH', headers: { Accept: 'application/json' },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Restore unread failed.');
return payload;
}
function createDashboardNotificationUndo({ restore, getItems, setItems, getNotifications, setNotifications, refresh, select }) {
let timer = null;
const controller = createNotificationUndo({
restore,
onItems: (items, restored) => {
setItems(items);
const notifications = getNotifications();
if (!notifications.some(item => item.id === restored.notification_id)) {
setNotifications([{ id:restored.notification_id }].concat(notifications));
}
refresh();
},
onStatus: message => {
select('#notification-undo-status').textContent = message;
select('#my-work-action-status').textContent = message;
},
onOffer: item => {
clearTimeout(timer);
select('#notification-undo').hidden = false;
select('#undo-notification').disabled = false;
select('#undo-notification').setAttribute('aria-label', 'Undo marking ' + (item.key || 'update') + ' read');
timer = setTimeout(() => controller.clear(), 10000);
},
onClear: () => { select('#notification-undo').hidden = true; },
});
return controller;
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = createNotificationUndo;
module.exports.createDashboardNotificationUndo = createDashboardNotificationUndo;
}
+21
View File
@@ -0,0 +1,21 @@
function createOfflineIssueBlocker({
enqueueDurably,
onQueued = () => {},
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
}) {
return async function queueOfflineIssueBlocker(item, blocker, present) {
const admission = await enqueueDurably({
kind: 'issue-blocker',
repository: String(item.repository || ''),
number: Number(item.number || 0),
blockerRepository: String(blocker.repository || ''),
blockerNumber: Number(blocker.number || 0),
present: present === true,
operationId: String(createOperationId()).slice(0, 128),
});
onQueued(item, blocker, present === true, admission);
return { admission };
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createOfflineIssueBlocker;
+19
View File
@@ -0,0 +1,19 @@
function createOfflineIssueClose({ enqueueDurably, completeToday, createOperationId = () =>
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2) }) {
return async function closeOfflineIssue(item) {
const admission = await enqueueDurably({
kind: 'issue-close',
repository: String(item.repository || ''),
number: Number(item.number || 0),
body: '',
operationId: String(createOperationId()).slice(0, 128),
});
const advanced = Boolean(completeToday(item, {
successMessage: 'Issue closure queued. Next Today item opened.',
failureMessage: 'Issue closure queued, but Today still needs completion.',
}));
return { admission, advanced };
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createOfflineIssueClose;
+9 -4
View File
@@ -31,14 +31,18 @@
}
const previousFailed = failed;
const readyKeys = new Set(items.filter(item => loadSavedDetail(login, item)).map(itemKey));
const savedDetails = new Map();
await Promise.all(items.map(async item => {
savedDetails.set(itemKey(item), await loadSavedDetail(login, item));
}));
const readyKeys = new Set(items.filter(item => savedDetails.get(itemKey(item))).map(itemKey));
const snapshot = pending => ({
total: items.length, ready: readyKeys.size, failed: failed.size, pending,
});
const candidates = items.filter(item => {
const key = itemKey(item);
if (onlyFailed && !previousFailed.has(key)) return false;
const saved = loadSavedDetail(login, item);
const saved = savedDetails.get(key);
return onlyFailed || !saved || saved.source_updated_at !== item.updated_at;
});
failed = new Set();
@@ -51,8 +55,9 @@
try {
const detail = await loadDetail(item);
if (runGeneration !== generation) return;
const saved = saveDetail(login, item, { ...detail, source_updated_at: item.updated_at });
if (saved !== false) readyKeys.add(itemKey(item));
const saved = await saveDetail(login, item, { ...detail, source_updated_at: item.updated_at });
if (saved === false) throw new Error('Offline detail was not durably admitted.');
readyKeys.add(itemKey(item));
} catch (_error) {
if (runGeneration === generation) failed.add(itemKey(item));
}
+245 -2
View File
@@ -27,6 +27,54 @@
];
const REVIEW_FIELDS = ['id', 'state', 'body', 'submitted_at'];
function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-offline-work-v2') {
if (!indexedDB) return null;
let databasePromise;
function database() {
if (!databasePromise) databasePromise = new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, 1);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains('work')) {
request.result.createObjectStore('work', { keyPath: 'id' });
}
};
request.onsuccess = () => {
const db = request.result;
db.onversionchange = () => { db.close(); databasePromise = undefined; };
resolve(db);
};
request.onerror = () => reject(request.error || new Error('Offline work database failed to open.'));
});
return databasePromise;
}
const requested = request => new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
return async work => {
const db = await database();
return new Promise((resolve, reject) => {
const transaction = db.transaction('work', 'readwrite');
const store = transaction.objectStore('work');
let result;
let workError;
transaction.oncomplete = () => workError ? undefined : resolve(result);
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(workError || transaction.error || new Error('Offline work transaction aborted.'));
Promise.resolve(work({
get: key => requested(store.get(key)),
getAll: () => requested(store.getAll()),
put: value => requested(store.put(value)),
delete: key => requested(store.delete(key)),
clear: () => requested(store.clear()),
})).then(value => { result = value; }).catch(error => {
workError = error;
try { transaction.abort(); } catch (_) { reject(error); }
});
});
};
}
function pick(source, fields) {
const output = {};
fields.forEach(field => {
@@ -36,14 +84,155 @@
}
function createOfflineWorkStore({
storage, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS, maxDetails = 10,
storage, indexedDB, transaction, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS,
maxDetails = 10, initializationTimeoutMs = 1500,
}) {
const transact = transaction || createIndexedDbTransaction(indexedDB);
let migrationPromise;
let readinessPromise;
let readinessGeneration = 0;
let readinessState = transact ? { state:'initializing', reason:'' } : { state:'ready', reason:'' };
let recordCache = null;
let pendingDurableClear = false;
function hydrateLegacyCache() {
const cache = new Map();
try {
const snapshot = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null');
if (snapshot) cache.set('snapshot', { ...snapshot, id:'snapshot' });
const details = JSON.parse(storage.getItem(DETAILS_KEY) || '[]');
if (Array.isArray(details)) details.forEach(record => {
if (!record?.key || !record?.user_login || !record?.data) return;
const id = 'detail:' + record.user_login + ':' + record.key;
cache.set(id, { ...record, id });
});
} catch (_) { cache.clear(); }
recordCache = cache;
}
function migrateLegacy(generation) {
if (!transact) return Promise.resolve(true);
if (!migrationPromise) migrationPromise = (async () => {
let snapshot = null;
let details = [];
try {
snapshot = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null');
const parsedDetails = JSON.parse(storage.getItem(DETAILS_KEY) || '[]');
details = Array.isArray(parsedDetails) ? parsedDetails : [];
} catch (_) {
return false;
}
if (!snapshot && !details.length) return true;
await transact(async records => {
if (snapshot && !(await records.get('snapshot'))) {
await records.put({ ...snapshot, id:'snapshot' });
}
for (const record of details.slice(-Math.max(1, maxDetails))) {
if (!record?.key || !record?.user_login || !record?.data) continue;
const id = 'detail:' + record.user_login + ':' + record.key;
if (!(await records.get(id))) await records.put({ ...record, id });
}
});
if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
storage.removeItem(SNAPSHOT_KEY);
storage.removeItem(DETAILS_KEY);
return true;
})().catch(() => false);
return migrationPromise;
}
async function initialize(generation) {
const migrated = await migrateLegacy(generation);
if (!migrated || generation !== readinessGeneration || readinessState.state === 'degraded') return false;
if (pendingDurableClear) {
await transact(async records => { await records.clear(); });
if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
pendingDurableClear = false;
recordCache = new Map();
return true;
}
return transact(async records => {
const currentTime = now().getTime();
const valid = [];
for (const record of await records.getAll()) {
const savedAt = Date.parse(record?.saved_at || '');
const validShape = record?.id === 'snapshot' ?
record.version === VERSION && record.user_login && record.data :
record?.id?.startsWith('detail:') && record.user_login && record.key && record.data;
if (!validShape || !Number.isFinite(savedAt) || currentTime - savedAt > maxAgeMs) {
if (record?.id) await records.delete(record.id);
} else valid.push(record);
}
if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
recordCache = new Map(valid.map(record => [record.id, record]));
return true;
});
}
function startReady() {
const generation = ++readinessGeneration;
readinessState = { state:'initializing', reason:'' };
const operation = initialize(generation).catch(() => false);
readinessPromise = new Promise(resolve => {
const timer = setTimeout(() => {
if (generation !== readinessGeneration || readinessState.state !== 'initializing') return;
hydrateLegacyCache();
readinessState = { state:'degraded', reason:'deadline' };
resolve(false);
}, Math.max(1, Number(initializationTimeoutMs) || 1500));
operation.then(available => {
if (generation !== readinessGeneration || readinessState.state === 'degraded') return;
clearTimeout(timer);
if (available) readinessState = { state:'ready', reason:'' };
else {
hydrateLegacyCache();
readinessState = { state:'degraded', reason:'unavailable' };
}
resolve(Boolean(available));
});
});
return readinessPromise;
}
function ready() {
if (!transact || readinessState.state === 'ready') return Promise.resolve(true);
if (readinessState.state === 'degraded') return Promise.resolve(false);
return readinessPromise || startReady();
}
function status() {
return { ...readinessState };
}
function retry() {
if (!transact) return Promise.resolve(true);
migrationPromise = undefined;
readinessPromise = undefined;
return startReady();
}
function enabled() {
try { return storage.getItem(ENABLED_KEY) === 'true'; }
catch (_) { return false; }
}
function clear() {
if (transact) {
try {
storage.removeItem(SNAPSHOT_KEY);
storage.removeItem(DETAILS_KEY);
} catch (_) { /* IndexedDB remains the source of truth. */ }
if (readinessState.state === 'degraded') {
pendingDurableClear = true;
recordCache = new Map();
return Promise.resolve(true);
}
migrationPromise = Promise.resolve(true);
return transact(async records => {
await records.clear();
recordCache = new Map();
return true;
}).catch(() => false);
}
try {
storage.removeItem(SNAPSHOT_KEY);
storage.removeItem(DETAILS_KEY);
@@ -104,6 +293,22 @@
saved_at: now().toISOString(),
data,
};
if (transact) {
record.id = 'detail:' + login + ':' + key;
return ready().then(available => available ? transact(async records => {
const existing = (await records.getAll()).filter(candidate =>
candidate?.id?.startsWith('detail:') && candidate.id !== record.id
);
await records.put(record);
const overflow = existing.concat(record).sort((a, b) =>
String(a.saved_at).localeCompare(String(b.saved_at))
).slice(0, -Math.max(1, maxDetails));
for (const candidate of overflow) await records.delete(candidate.id);
overflow.forEach(candidate => recordCache.delete(candidate.id));
recordCache.set(record.id, record);
return true;
}) : false).catch(() => false);
}
const records = readDetails().filter(candidate =>
!(candidate?.key === key && candidate?.user_login === login)
);
@@ -117,6 +322,19 @@
const key = detailKey(item);
login = String(login || '').trim();
if (!login || !key) return null;
if (transact) {
const id = 'detail:' + login + ':' + key;
if (!recordCache) return ready().then(() => loadDetail(login, item));
const record = recordCache.get(id);
if (!record) return null;
const savedAt = Date.parse(record.saved_at || '');
if (!record.data || !Number.isFinite(savedAt) || now().getTime() - savedAt > maxAgeMs) {
recordCache.delete(id);
transact(async records => { await records.delete(id); }).catch(() => {});
return null;
}
return { ...record.data, saved_at: record.saved_at };
}
const records = readDetails();
const currentTime = now().getTime();
const valid = records.filter(record => {
@@ -142,6 +360,7 @@
function save(snapshot) {
if (!enabled() || !snapshot?.user?.login) return false;
const record = {
id: 'snapshot',
version: VERSION,
user_login: String(snapshot.user.login),
saved_at: now().toISOString(),
@@ -154,12 +373,36 @@
notification_pagination: snapshot.notification_pagination || {},
},
};
if (transact) {
return ready().then(available => available ? transact(async records => {
await records.put(record);
recordCache.set(record.id, record);
return true;
}) : false).catch(() => false);
}
try { storage.setItem(SNAPSHOT_KEY, JSON.stringify(record)); }
catch (_) { return false; }
return true;
}
function load(expectedLogin) {
if (transact) {
if (!recordCache) return ready().then(() => load(expectedLogin));
const record = recordCache.get('snapshot');
const savedAt = Date.parse(record?.saved_at || '');
const invalid = record?.version !== VERSION || !record?.user_login || !record?.data ||
!Number.isFinite(savedAt);
const expired = Number.isFinite(savedAt) && now().getTime() - savedAt > maxAgeMs;
if (invalid || expired) {
if (record) {
recordCache.delete('snapshot');
transact(async records => { await records.delete('snapshot'); }).catch(() => {});
}
return null;
}
if (expectedLogin && record.user_login !== expectedLogin) return null;
return { ...record.data, saved_at: record.saved_at };
}
let record;
try { record = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null'); }
catch (_) { clear(); return null; }
@@ -175,7 +418,7 @@
return { ...record.data, saved_at: record.saved_at };
}
return { enabled, setEnabled, save, load, saveDetail, loadDetail, clear };
return { enabled, setEnabled, ready, retry, status, save, load, saveDetail, loadDetail, clear };
}
return createOfflineWorkStore;
+115 -6
View File
@@ -1,14 +1,26 @@
function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelection = () => {}, onFacets = () => {} }) {
let available = [];
let pagination = { page: 1, total: 0, has_more: false };
let loadRequest = null;
let loadGeneration = 0;
let query = '';
let filters = { repositories: [], labels: [] };
let facetView;
let claimRequest = null;
const previewed = new Set();
let selecting = false;
const selected = new Map();
function itemKey(item) {
return String(item?.repository || '') + '#' + String(item?.number || '');
}
function emitSelection() {
const state = { active: selecting, count: selected.size, ids: Array.from(selected.keys()) };
onSelection(state);
return state;
}
function apply(result, append) {
const incoming = Array.isArray(result?.items) ? result.items : [];
if (append) {
@@ -31,13 +43,31 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
};
onItems(available.slice());
onPagination({ ...pagination });
if (result?.facets) onFacets({
repositories: Array.isArray(result.facets.repositories) ? result.facets.repositories.slice() : [],
labels: Array.isArray(result.facets.labels) ? result.facets.labels.slice() : [],
});
if (result?.facets && typeof document !== 'undefined') {
facetView ||= attachFindWorkFacets(document.querySelector('#find-work-filters'), value =>
String(value).replace(/[&<>"']/g, character => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[character])),
next => { filters = next; loadPage(1, false); });
facetView(result.facets);
}
}
function loadPage(page, append) {
if (loadRequest) return loadRequest;
loadRequest = fetchJson('api/v1/available-issues?page=' + page, {
function loadPage(page, append, requestedQuery = query) {
if (loadRequest && requestedQuery === query && append) return loadRequest;
const generation = ++loadGeneration;
const params = new URLSearchParams();
params.set('facets', 'true');
if (requestedQuery) params.set('q', requestedQuery);
filters.repositories.forEach(value => params.append('repository', value));
filters.labels.forEach(value => params.append('label', value));
const queryPart = params.toString() ? '&' + params.toString() : '';
const request = fetchJson('api/v1/available-issues?page=' + page + queryPart, {
headers: { Accept: 'application/json' },
}).then(result => {
if (generation !== loadGeneration) return result;
apply(result, append);
if (result?.refresh_failed === true) {
onStatus('Showing saved available work. Catalog refresh failed; retrying shortly.');
@@ -45,8 +75,9 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
onStatus('Showing saved available work while the catalog refreshes…');
}
return result;
}).finally(() => { loadRequest = null; });
return loadRequest;
}).finally(() => { if (loadRequest === request) loadRequest = null; });
loadRequest = request;
return request;
}
return {
@@ -60,9 +91,66 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
if (!pagination.has_more) return Promise.resolve(false);
return loadPage(pagination.page + 1, true);
},
search(value) {
query = String(value || '').trim();
return loadPage(1, false, query);
},
query() {
return query;
},
setFilters(next) {
filters = {
repositories: Array.from(new Set((next?.repositories || []).map(String))).slice(0, 10),
labels: Array.from(new Set((next?.labels || []).map(String))).slice(0, 10),
};
return loadPage(1, false);
},
filters() {
return { repositories: filters.repositories.slice(), labels: filters.labels.slice() };
},
items() {
return available.slice();
},
startSelection() {
selecting = true;
selected.clear();
return emitSelection();
},
cancelSelection() {
selecting = false;
selected.clear();
return emitSelection();
},
toggleSelection(item) {
if (!selecting || !item) return emitSelection();
const key = itemKey(item);
if (selected.has(key)) selected.delete(key);
else selected.set(key, item);
return emitSelection();
},
fillSelection(limit) {
limit = Math.max(0, Math.floor(Number(limit) || 0));
if (!limit) return { selected: selected.size, added: 0, limit };
selecting = true;
const before = selected.size;
available.some(item => {
if (selected.size >= limit) return true;
selected.set(itemKey(item), item);
return false;
});
emitSelection();
return { selected: selected.size, added: selected.size - before, limit };
},
isSelected(item) {
return selected.has(itemKey(item));
},
selectedItems() {
return Array.from(selected.values());
},
selection() {
return { active: selecting, count: selected.size, ids: Array.from(selected.keys()) };
},
togglePreview(item) {
const key = itemKey(item);
if (previewed.has(key)) previewed.delete(key);
@@ -91,6 +179,8 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
pagination.total = Math.max(available.length, pagination.total - 1);
pagination.has_more = available.length < pagination.total;
previewed.delete(itemKey(item));
selected.delete(itemKey(item));
emitSelection();
onItems(available.slice());
onPagination({ ...pagination });
onStatus('Assigned ' + key + ' to you.');
@@ -101,4 +191,23 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
};
}
function attachFindWorkFacets(root, escapeHtml, onChange) {
const values = name => Array.from(root.querySelectorAll('input[name="' + name + '"]:checked'), input => input.value);
root.onchange = event => event.target.type === 'checkbox' && onChange({ repositories:values('find-work-repository'), labels:values('find-work-label') });
root.querySelector('#clear-find-work-filters').onclick = () => {
root.querySelectorAll('input').forEach(input => { input.checked = false; });
onChange({ repositories:[], labels:[] });
};
return facets => {
const active = { repositories:values('find-work-repository'), labels:values('find-work-label') };
const render = (id, choices, selected, name) => {
root.querySelector(id).innerHTML = choices.map(value => '<label class="find-work-filter-option"><input type="checkbox" name="' + name + '" value="' + escapeHtml(value) + '"' + (selected.includes(value) ? ' checked' : '') + ' />' + escapeHtml(value) + '</label>').join('');
};
render('#find-work-repository-filters', facets.repositories || [], active.repositories, 'find-work-repository');
render('#find-work-label-filters', facets.labels || [], active.labels, 'find-work-label');
const count = active.repositories.length + active.labels.length;
root.querySelector('#find-work-filter-count').textContent = count ? '(' + count + ' active)' : '';
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createFindWork;
+21 -3
View File
@@ -22,8 +22,19 @@
return true;
}
function close({ add = false } = {}) {
function setDependencies({ available, dependencies } = {}) {
if (!current) return false;
current.dependencies_available = available === true;
current.dependencies = Array.isArray(dependencies) ? dependencies.filter(item => item?.state === 'open') : [];
current.requires_override = !current.dependencies_available || current.dependencies.length > 0;
return true;
}
function close({ add = false, override = false } = {}) {
if (!current) return 'closed';
if (add && current.requires_override && !override) {
return current.dependencies_available ? 'blocked' : 'dependencies-unavailable';
}
const { item, trigger, scroll } = current;
let result = 'returned';
if (add) {
@@ -37,9 +48,16 @@
}
function snapshot() {
return current ? { open:true, item:current.item, trigger:current.trigger, scroll:current.scroll } : { open:false };
if (!current) return { open:false };
const state = { open:true, item:current.item, trigger:current.trigger, scroll:current.scroll };
if (Object.prototype.hasOwnProperty.call(current, 'dependencies_available')) {
state.dependencies_available = current.dependencies_available;
state.dependencies = current.dependencies;
state.requires_override = current.requires_override;
}
return state;
}
return { open, close, snapshot };
return { open, close, setDependencies, snapshot };
};
});
+41
View File
@@ -0,0 +1,41 @@
function createPlanTodayReadiness({ identity, inspect, concurrency = 3 }) {
const workerCount = Math.max(1, Math.floor(Number(concurrency) || 1));
let generation = 0;
async function run(items) {
const runGeneration = ++generation;
const candidates = Array.from(items || []);
const results = new Array(candidates.length);
let next = 0;
async function worker() {
while (next < candidates.length) {
const index = next;
next += 1;
const item = candidates[index];
let result;
try {
result = await inspect(item);
} catch (_error) {
result = { status:'unverified' };
}
results[index] = [identity(item), result];
}
}
await Promise.all(Array.from(
{ length:Math.min(workerCount, candidates.length) },
() => worker(),
));
if (runGeneration !== generation) return null;
return Object.fromEntries(results);
}
function cancel() {
generation += 1;
}
return { run, cancel };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createPlanTodayReadiness;
+119 -5
View File
@@ -2,6 +2,13 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
let openState = false;
let draftIds = [];
let itemsById = new Map();
let capacityMinutes = null;
let estimates = {};
let recommendations = {};
let recommendationAware = false;
let capacityAware = false;
let buildState = null;
let buildReadiness = null;
function cleanItems(items) {
const unique = new Map();
@@ -12,13 +19,29 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
return unique;
}
function open(selectedItems, candidates) {
function open(selectedItems, candidates, planning = null, actualMinutes = null) {
itemsById = cleanItems([...(selectedItems || []), ...(candidates || [])]);
draftIds = [];
for (const item of selectedItems || []) {
const id = identity?.(item);
if (id && itemsById.has(id) && !draftIds.includes(id) && draftIds.length < limit) draftIds.push(id);
}
capacityAware = Boolean(planning && ('capacity_minutes' in planning || 'estimates' in planning));
capacityMinutes = Number.isInteger(planning?.capacity_minutes) && planning.capacity_minutes > 0
? planning.capacity_minutes : null;
estimates = {};
for (const [id, minutes] of Object.entries(planning?.estimates || {})) {
if (Number.isInteger(minutes) && minutes > 0) estimates[id] = minutes;
}
recommendations = {};
recommendationAware = actualMinutes !== null;
buildState = null;
buildReadiness = null;
for (const [id, minutes] of Object.entries(actualMinutes || {})) {
if (draftIds.includes(id) && Number.isInteger(minutes) && minutes >= 5 && minutes <= 1440 &&
estimates[id] !== minutes) recommendations[id] = minutes;
}
if (Object.keys(recommendations).length) capacityAware = true;
openState = true;
return snapshot();
}
@@ -50,6 +73,13 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
openState = false;
draftIds = [];
itemsById = new Map();
capacityMinutes = null;
estimates = {};
recommendations = {};
recommendationAware = false;
capacityAware = false;
buildState = null;
buildReadiness = null;
}
function cancel() {
@@ -57,10 +87,77 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
return true;
}
function commit({ start: startAfterSave = false } = {}) {
function setCapacity(minutes) {
capacityAware = true;
capacityMinutes = Number.isInteger(minutes) && minutes > 0 ? minutes : null;
return snapshot();
}
function setEstimate(id, minutes) {
const resolvingBuildEstimate = buildState?.needs_estimate?.includes(id);
if (!openState || (!draftIds.includes(id) && !resolvingBuildEstimate)) return false;
capacityAware = true;
if (!Number.isInteger(minutes) || minutes < 5 || minutes > 1440) return false;
estimates[id] = minutes;
if (resolvingBuildEstimate && buildReadiness) buildRecommendation(buildReadiness);
return true;
}
function applyRecommendation(id) {
const minutes = recommendations[id];
if (!openState || !draftIds.includes(id) || !Number.isInteger(minutes)) return false;
estimates[id] = minutes;
delete recommendations[id];
capacityAware = true;
return true;
}
function buildRecommendation(readiness = {}) {
if (!openState) return null;
buildReadiness = { ...readiness };
const selected = [];
const reasons = {};
const skipped = [];
const needsEstimate = [];
let used = 0;
for (const [id, item] of itemsById) {
if (selected.length >= limit) break;
const check = readiness[id] || { status:'unverified' };
if (check.status !== 'ready') {
skipped.push({
id,
status:check.status === 'blocked' ? 'blocked' : 'unverified',
reason:check.reason || (check.status === 'blocked' ? 'Blocked' : 'Could not verify readiness'),
});
continue;
}
const minutes = estimates[id];
if (!Number.isInteger(minutes)) {
needsEstimate.push(id);
continue;
}
if (capacityMinutes !== null && used + minutes > capacityMinutes) continue;
selected.push(id);
reasons[id] = item.reason || item.attention_reason || 'Ranked My Work';
used += minutes;
}
draftIds = selected;
capacityAware = true;
buildState = { selected:[...selected], reasons, skipped, needs_estimate:needsEstimate };
return buildState;
}
function commit({ start: startAfterSave = false, confirmOverCapacity = false } = {}) {
if (!openState) return 'closed';
const ids = [...draftIds];
if (save?.(ids) === false) return 'unavailable';
const state = snapshot();
if (state.over_capacity && !confirmOverCapacity) return 'confirm-over-capacity';
const payload = capacityAware ? {
ids,
capacity_minutes: capacityMinutes,
estimates: Object.fromEntries(ids.filter(id => estimates[id]).map(id => [id, estimates[id]])),
} : ids;
if (save?.(payload) === false) return 'unavailable';
const first = ids.length ? itemsById.get(ids[0]) : null;
close();
if (startAfterSave && first) start?.(first);
@@ -68,7 +165,24 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
}
function snapshot() {
return { open: openState, ids: [...draftIds], count: draftIds.length, limit };
const basic = { open: openState, ids: [...draftIds], count: draftIds.length, limit };
if (!capacityAware) return basic;
const selectedEstimates = Object.fromEntries(draftIds.filter(id => estimates[id]).map(id => [id, estimates[id]]));
const plannedMinutes = Object.values(selectedEstimates).reduce((total, value) => total + value, 0);
const remainingMinutes = capacityMinutes === null ? null : capacityMinutes - plannedMinutes;
return {
...basic,
capacity_minutes: capacityMinutes,
estimates: selectedEstimates,
...(recommendationAware ? { recommendations:Object.fromEntries(
draftIds.filter(id => recommendations[id]).map(id => [id, recommendations[id]])
) } : {}),
planned_minutes: plannedMinutes,
remaining_minutes: remainingMinutes,
unestimated_count: draftIds.length - Object.keys(selectedEstimates).length,
over_capacity: remainingMinutes !== null && remainingMinutes < 0,
...(buildState ? { build:buildState } : {}),
};
}
function item(id) {
@@ -79,7 +193,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
return [...itemsById.entries()].filter(([id]) => !draftIds.includes(id)).map(([, value]) => value);
}
return { open, toggle, move, cancel, commit, snapshot, item, candidates };
return { open, toggle, move, cancel, setCapacity, setEstimate, applyRecommendation, buildRecommendation, commit, snapshot, item, candidates };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createPlanToday;
+5 -3
View File
@@ -36,11 +36,11 @@
});
}
function deletePrivateOutbox() {
function deletePrivateDatabase(name) {
if (!indexedDB) return Promise.resolve();
return new Promise((resolve, reject) => {
let request;
try { request = indexedDB.deleteDatabase('stackchain-background-outbox-v1'); }
try { request = indexedDB.deleteDatabase(name); }
catch (error) { reject(error); return; }
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
@@ -52,7 +52,9 @@
removeOwnedStorage(localStorage);
if (sessionStorage !== localStorage) removeOwnedStorage(sessionStorage);
await stopWorkerOutbox();
await deletePrivateOutbox();
await deletePrivateDatabase('stackchain-background-outbox-v1');
await deletePrivateDatabase('stackchain-offline-work-v2');
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
const keys = await caches?.keys?.() || [];
await Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
+46
View File
@@ -0,0 +1,46 @@
function createProtectToday() {
function propose({ agenda = [], today = [], identity, limit = 5 }) {
const key = item => String(identity?.(item) || '');
const urgent = [];
const seen = new Set();
for (const item of [...agenda, ...today]) {
const id = key(item);
const eligible = item?.kind === 'issue' && item.is_assigned === true && item.state !== 'closed' &&
['Overdue', 'Today'].includes(item.agenda_group);
if (!eligible || !id || seen.has(id)) continue;
seen.add(id);
urgent.push(item);
}
urgent.sort((left, right) =>
String(left.due_date || '').localeCompare(String(right.due_date || '')) ||
String(left.repository || '').localeCompare(String(right.repository || '')) ||
Number(left.number || 0) - Number(right.number || 0)
);
if (!urgent.length) return {
selected:[], protected:[], displaced:[],
summary:'No overdue or due-today work needs protection.',
};
const existing = [];
for (const item of today) {
const id = key(item);
if (!id || seen.has(id)) continue;
seen.add(id);
existing.push(item);
}
const combined = [...urgent, ...existing];
const selected = combined.slice(0, limit);
const selectedIds = new Set(selected.map(key));
const displaced = today.filter(item => !selectedIds.has(key(item)) && !urgent.some(candidate => key(candidate) === key(item)));
return {
selected,
protected:urgent,
displaced,
summary:urgent.length + ' urgent ' + (urgent.length === 1 ? 'item' : 'items') + ' protected · ' +
displaced.length + (displaced.length ? ' displaced for review' : ' displaced'),
};
}
return { propose };
}
const protectToday = createProtectToday();
if (typeof module !== 'undefined' && module.exports) module.exports = protectToday;
+173 -3
View File
@@ -5,7 +5,11 @@ function mergeEligibility(detail, reviewState) {
if (detail.draft) return { allowed: false, reason: 'Draft pull requests cannot be merged' };
if (!detail.mergeable) return { allowed: false, reason: 'Resolve conflicts before merging' };
if (detail.ci_state !== 'success') {
return { allowed: false, reason: 'CI must succeed before merging' };
const blockers = (detail.checks || []).filter(check =>
['failure', 'error', 'pending'].includes(check.state)
).map(check => check.name).filter(Boolean).slice(0, 3);
return { allowed: false, reason: blockers.length ?
'CI blocked by ' + blockers.join(', ') : 'CI must succeed before merging' };
}
if (!detail.head_sha) return { allowed: false, reason: 'Current head is unavailable' };
if (reviewState && !reviewState.complete) {
@@ -37,9 +41,137 @@ function renderFile(file, index, reviewed, escapeHtml) {
'" aria-pressed="' + String(reviewed) + '">' + (reviewed ? 'Reviewed' : 'Mark reviewed') + '</button></article>';
}
function focusNextUnreviewed(doc, detail, state, controller) {
if (!detail || !state) return;
const filename = controller.nextUnreviewed(detail, state);
const article = Array.from(doc.querySelectorAll('#pull-files .pull-file'))
.find(file => file.dataset.pullFilename === filename);
const toggle = article?.querySelector('.pull-file-toggle');
const panel = toggle && doc.getElementById(toggle.getAttribute('aria-controls'));
if (!toggle || !panel) return;
toggle.setAttribute('aria-expanded', 'true');
panel.hidden = false;
toggle.scrollIntoView({ block: 'center', behavior: 'smooth' });
toggle.focus();
}
function removeFromSnapshot(data, item) {
return { ...data, pulls:(data.pulls || []).filter(candidate =>
candidate.repository !== item.repository || candidate.number !== item.number) };
}
function sameTarget(left, right) {
return Boolean(left && right && left.repository === right.repository && Number(left.number) === Number(right.number));
}
function ownershipSelectors() {
return ['#release-pull', '#load-pull-handoff', '#pull-handoff-recipient', '#confirm-pull-handoff'];
}
function renderHandoffCandidates(select, candidates, doc) {
select.textContent = '';
const placeholder = doc.createElement('option');
placeholder.value = '';
placeholder.textContent = candidates.length ? 'Select a teammate' : 'No eligible teammates';
select.appendChild(placeholder);
candidates.forEach(candidate => {
const option = doc.createElement('option');
option.value = candidate.login;
option.textContent = candidate.name + (candidate.name === candidate.login ? '' : ' (@' + candidate.login + ')');
select.appendChild(option);
});
return candidates.length;
}
function ownershipExitMessage(item, action, transitionResult) {
return item.key + ' ' + action + (transitionResult === 'opened' ?
'. Next work item opened.' : '. Choose the next ready Today item.');
}
function resetOwnershipControls(doc, item, checkpointed) {
const qs = selector => doc.querySelector(selector);
qs('#pull-ownership').open = false;
qs('#pull-handoff-recipient').innerHTML = '<option value="">Select a teammate</option>';
qs('#pull-handoff-recipient').disabled = true;
qs('#confirm-pull-handoff').disabled = true;
qs('#confirm-pull-handoff').textContent = checkpointed(item) ? 'Hand off & next' : 'Confirm handoff';
qs('#load-pull-handoff').disabled = false;
qs('#release-pull').disabled = false;
qs('#release-pull').textContent = checkpointed(item) ? 'Release & next' : 'Release assignment';
qs('#pull-handoff-status').textContent = 'Load teammates to transfer ownership.';
}
function bindOwnershipControls(doc, controller, getSelected, finish) {
const qs = selector => doc.querySelector(selector);
const load = qs('#load-pull-handoff');
if (load.dataset.ownershipBound === 'true') return;
load.dataset.ownershipBound = 'true';
load.addEventListener('click', async () => {
const selected = getSelected();
if (!selected) return;
const select = qs('#pull-handoff-recipient');
load.disabled = true;
qs('#pull-handoff-status').textContent = 'Loading eligible teammates…';
try {
const count = renderHandoffCandidates(select, await controller.loadHandoffCandidates(selected), doc);
select.disabled = !count;
qs('#confirm-pull-handoff').disabled = true;
qs('#pull-handoff-status').textContent = count ?
'Choose who should own this pull request next.' : 'No other eligible assignees were found.';
if (count) select.focus();
} catch (error) {
qs('#pull-handoff-status').textContent = error.message + ' Retry loading teammates.';
load.disabled = false;
load.focus();
}
});
qs('#pull-handoff-recipient').addEventListener('change', event => {
qs('#confirm-pull-handoff').disabled = !event.target.value;
});
qs('#confirm-pull-handoff').addEventListener('click', async () => {
const selected = getSelected();
const recipient = qs('#pull-handoff-recipient').value;
if (!selected || !recipient || !globalThis.confirm('Hand off ' + selected.key + ' to @' + recipient + '?')) return;
const button = qs('#confirm-pull-handoff');
button.disabled = true;
qs('#pull-handoff-status').textContent = 'Confirming handoff…';
try {
await controller.handoff(selected, recipient);
const transition = await finish(selected);
qs('#my-work-action-status').textContent = transition === false ?
selected.key + ' handed off to @' + recipient + '.' :
ownershipExitMessage(selected, 'handed off to @' + recipient, transition);
} catch (error) {
qs('#pull-handoff-status').textContent = error.message + ' The pull request remains in My Work; retry.';
button.disabled = false;
button.focus();
}
});
qs('#release-pull').addEventListener('click', async () => {
const selected = getSelected();
if (!selected || !globalThis.confirm('Release ' + selected.key + ' from your My Work?')) return;
const button = qs('#release-pull');
button.disabled = true;
qs('#pull-sheet-status').textContent = 'Releasing assignment…';
try {
await controller.release(selected);
const transition = await finish(selected);
qs('#my-work-action-status').textContent = transition === false ?
selected.key + ' released.' : ownershipExitMessage(selected, 'released', transition);
} catch (error) {
qs('#pull-sheet-status').textContent = error.message + ' The pull request remains in My Work; retry.';
button.disabled = false;
button.focus();
}
});
}
function createPullSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
let commentRequest = null;
let mergeRequest = null;
let checkRequest = null;
let ownershipRequest = null;
let candidateRequest = null;
const reviewRequests = new Map();
const reviewCache = new Map();
const pathFor = item => 'api/v1/repos/' + String(item.repository || '').split('/')
@@ -64,9 +196,9 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
load(item) {
return fetchJson(pathFor(item) + '/detail', { headers: { Accept: 'application/json' } });
},
loadReview(item, headSha) {
loadReview(item, headSha, { refresh = false } = {}) {
const key = item.repository + '#' + item.number + ':' + headSha;
if (reviewCache.has(key)) return Promise.resolve(reviewCache.get(key));
if (!refresh && reviewCache.has(key)) return Promise.resolve(reviewCache.get(key));
if (reviewRequests.has(key)) return reviewRequests.get(key);
const request = fetchJson(pathFor(item) + '/review-data', {
headers: { Accept: 'application/json' },
@@ -78,6 +210,36 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
reviewRequests.set(key, request);
return request;
},
loadChecks(item) {
if (checkRequest) return checkRequest;
checkRequest = fetchJson(pathFor(item) + '/checks', {
headers: { Accept: 'application/json' },
}).finally(() => { checkRequest = null; });
return checkRequest;
},
loadHandoffCandidates(item) {
if (candidateRequest) return candidateRequest;
candidateRequest = fetchJson(pathFor(item) + '/handoff-candidates', {
headers: { Accept: 'application/json' },
}).finally(() => { candidateRequest = null; });
return candidateRequest;
},
handoff(item, recipient) {
if (ownershipRequest) return ownershipRequest;
ownershipRequest = fetchJson(pathFor(item) + '/handoff', {
method: 'PATCH',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ recipient }),
}).finally(() => { ownershipRequest = null; });
return ownershipRequest;
},
release(item) {
if (ownershipRequest) return ownershipRequest;
ownershipRequest = fetchJson(pathFor(item) + '/release', {
method: 'PATCH', headers: { Accept: 'application/json' },
}).finally(() => { ownershipRequest = null; });
return ownershipRequest;
},
conversation(item, initialPage) {
const pager = createConversationPager({
loadPage: page => fetchJson(pathFor(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20', {
@@ -153,4 +315,12 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
createPullSheet.mergeEligibility = mergeEligibility;
createPullSheet.renderFile = renderFile;
createPullSheet.focusNextUnreviewed = focusNextUnreviewed;
createPullSheet.removeFromSnapshot = removeFromSnapshot;
createPullSheet.sameTarget = sameTarget;
createPullSheet.ownershipSelectors = ownershipSelectors;
createPullSheet.renderHandoffCandidates = renderHandoffCandidates;
createPullSheet.ownershipExitMessage = ownershipExitMessage;
createPullSheet.resetOwnershipControls = resetOwnershipControls;
createPullSheet.bindOwnershipControls = bindOwnershipControls;
if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;
+159
View File
@@ -0,0 +1,159 @@
(function(root, factory) {
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, deadlineDays, notification, serviceWorker, fetchJson,
}) {
let configuration = null;
function formattedHour(value) {
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:enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)}
: {state:'incomplete', detail:deadlineStatus?.textContent || 'Choose when to receive deadline reminders.'};
}
function applicationServerKey(value) {
const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4);
if (typeof atob === 'function') {
return Uint8Array.from(atob(padded), character => character.charCodeAt(0));
}
return Uint8Array.from(Buffer.from(padded, 'base64'));
}
async function disable() {
const registration = await serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
await fetchJson('api/v1/push-subscription', {method:'DELETE'});
await subscription?.unsubscribe?.();
control.checked = false;
if (deadlineControl) deadlineControl.checked = false;
status.textContent = 'New update notifications are off for this device.';
if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.';
}
async function ensureSubscription() {
const permission = await notification.requestPermission();
if (permission !== 'granted') {
control.checked = false;
status.textContent = 'Notifications are blocked. Allow them in your browser settings to enable updates.';
return null;
}
const registration = await serviceWorker.ready;
let subscription = await registration.pushManager.getSubscription();
if (!subscription) {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey(configuration.public_key),
});
}
await fetchJson('api/v1/push-subscription', {
method:'PUT',
headers:{'Content-Type':'application/json'},
body:JSON.stringify(subscription.toJSON()),
});
control.checked = true;
status.textContent = 'New update notifications enabled for this device.';
configuration.subscribed = true;
return subscription;
}
async function enable() {
await ensureSubscription();
}
async function change() {
control.disabled = true;
try {
if (control.checked) await enable();
else await disable();
} catch (error) {
control.checked = !control.checked;
status.textContent = 'Could not change update notifications. Check your connection and try again.';
} finally {
control.disabled = false;
}
}
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();
if (deadlineControl.checked && !subscription) subscription = await ensureSubscription();
if (deadlineControl.checked && !subscription) {
deadlineControl.checked = false;
deadlineStatus.textContent = status.textContent;
return false;
}
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, reminder_days:reminderDays}),
});
configuration.deadline_enabled = deadlineControl.checked;
configuration.reminder_hour = reminderHour;
configuration.reminder_days = reminderDays;
configuration.timezone = timezone;
deadlineStatus.textContent = deadlineControl.checked
? enabledDeadlineText(reminderHour, reminderDays)
: 'Deadline reminders are off for this device.';
return true;
} catch (error) {
deadlineControl.checked = !deadlineControl.checked;
deadlineStatus.textContent = 'Could not change deadline reminders. Check your connection and try again.';
return false;
} finally {
deadlineControl.disabled = false;
if (deadlineHour) deadlineHour.disabled = false;
if (deadlineDays) deadlineDays.disabled = false;
}
}
async function enableDeadline() {
deadlineControl.checked = true;
return changeDeadline();
}
async function init() {
if (!control || !notification || !serviceWorker) return;
control.addEventListener('change', change);
deadlineControl?.addEventListener('change', changeDeadline);
configuration = await fetchJson('api/v1/push-subscription');
if (!configuration.available) {
control.disabled = true;
if (deadlineControl) deadlineControl.disabled = true;
status.textContent = 'New update notifications are not available on this server.';
return;
}
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
? enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)
: 'Deadline reminders are off for this device.';
}
return {init, change, changeDeadline, enableDeadline, deadlineReadiness};
});
+81 -8
View File
@@ -4,6 +4,7 @@ function createReviewController({
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
}) {
let pendingSubmission = null;
let pendingChecks = null;
function endpoint(item) {
const [owner, repo] = String(item.repository || '').split('/');
@@ -18,6 +19,14 @@ function createReviewController({
return fetchJson(endpoint(item), { headers: { Accept: 'application/json' } });
}
function loadChecks(item) {
if (pendingChecks) return pendingChecks;
pendingChecks = fetchJson(endpoint(item) + '/checks', {
headers: { Accept: 'application/json' },
}).finally(() => { pendingChecks = null; });
return pendingChecks;
}
function submit(item, payload) {
if (pendingSubmission) return pendingSubmission;
const operationKey = 'stackchain.review-submit.v1:' + item.repository + '#' + item.number;
@@ -44,7 +53,29 @@ function createReviewController({
return pendingSubmission;
}
return { load, submit };
return { load, loadChecks, submit };
}
function createWrapPreference({ storage, mobile }) {
const key = 'stackchain.review-wrap.v1';
let explicit = false;
let wrapped = Boolean(mobile);
try {
const saved = storage?.getItem(key);
if (saved === 'true' || saved === 'false') {
explicit = true;
wrapped = saved === 'true';
}
} catch (_error) { /* keep the viewport default */ }
function snapshot() { return { wrapped, explicit }; }
function setWrapped(value) {
wrapped = Boolean(value);
explicit = true;
try { storage?.setItem(key, String(wrapped)); } catch (_error) { /* keep in memory */ }
return snapshot();
}
return { snapshot, setWrapped, storageKey: key };
}
function diffLineClass(line) {
@@ -70,16 +101,19 @@ function parseDiffLines(lines) {
return { text, kind: diffLineClass(text), commentable: false };
}
if (text.startsWith('-')) {
const row = { text, kind: 'removed', commentable: true, old_position: oldLine };
const row = { text, kind: 'removed', commentable: true, old_line: oldLine, old_position: oldLine };
oldLine += 1;
return row;
}
if (text.startsWith('+')) {
const row = { text, kind: 'added', commentable: true, new_position: newLine };
const row = { text, kind: 'added', commentable: true, new_line: newLine, new_position: newLine };
newLine += 1;
return row;
}
const row = { text, kind: 'context', commentable: true, new_position: newLine };
const row = {
text, kind: 'context', commentable: true,
old_line: oldLine, new_line: newLine, new_position: newLine,
};
oldLine += 1;
newLine += 1;
return row;
@@ -91,8 +125,14 @@ function renderDiffFile(file, index, escapeHtml) {
let preview;
if (file.diff_available) {
const lines = parseDiffLines(file.diff_lines).map(row => {
const oldNumber = row.old_line || row.old_position || '';
const newNumber = row.new_line || row.new_position || '';
const content = '<span class="review-line-numbers" aria-hidden="true">' +
'<span class="review-line-number old">' + oldNumber + '</span>' +
'<span class="review-line-number new">' + newNumber + '</span></span>' +
'<span class="review-line-code">' + escapeHtml(row.text) + '</span>';
if (!row.commentable) {
return '<span class="review-diff-line ' + row.kind + '">' + escapeHtml(row.text) + '</span>';
return '<span class="review-diff-line ' + row.kind + '">' + content + '</span>';
}
const position = row.old_position
? ' data-old-position="' + row.old_position + '"'
@@ -100,7 +140,7 @@ function renderDiffFile(file, index, escapeHtml) {
return '<button type="button" class="review-diff-line review-inline-target ' + row.kind +
'" data-review-filename="' + escapeHtml(file.filename || '') + '"' + position +
' aria-label="Comment on ' + escapeHtml(file.filename || 'changed file') + ' line ' +
(row.old_position || row.new_position) + '">' + escapeHtml(row.text) + '</button>';
(row.old_position || row.new_position) + '">' + content + '</button>';
}).join('');
preview = '<pre class="review-diff" id="' + panelId + '" hidden>' + lines +
(file.diff_truncated ? '<span class="review-diff-note">Preview truncated · open in Gitea for the full diff.</span>' : '') +
@@ -169,7 +209,7 @@ function createProgress({ storage, repository, number, headSha, files }) {
return snapshot();
}
return { snapshot, markReviewed, clear };
return { snapshot, markReviewed, clear, storageKey: key };
}
function createDraft({ storage, repository, number, headSha, files }) {
@@ -267,7 +307,10 @@ function createDraft({ storage, repository, number, headSha, files }) {
return snapshot();
}
return { snapshot, setNote, setInlineComment, removeInlineComment, setSummary, setDecision, clear };
return {
snapshot, setNote, setInlineComment, removeInlineComment, setSummary, setDecision, clear,
storageKey: key,
};
}
function formatFeedback(draft, files) {
@@ -321,7 +364,36 @@ function prepareMergeContinuation({ storage, item, headSha, reviewed, decision }
return true;
}
function renderChecks(checks, escapeHtml, { offline = false } = {}) {
const rank = { error: 0, failure: 0, pending: 1, warning: 2, unknown: 2, success: 3 };
const items = (Array.isArray(checks) ? checks : []).filter(check =>
check && typeof check.name === 'string' && check.name
).map((check, index) => ({ ...check, index })).sort((left, right) =>
(rank[left.state] ?? 2) - (rank[right.state] ?? 2) || left.index - right.index
);
const failed = items.filter(check => ['failure', 'error'].includes(check.state)).length;
const pending = items.filter(check => check.state === 'pending').length;
const passed = items.filter(check => check.state === 'success').length;
const other = items.length - failed - pending - passed;
const parts = [
failed ? failed + ' failed' : '', pending ? pending + ' pending' : '',
passed ? passed + ' passed' : '', other ? other + ' other' : '',
].filter(Boolean);
const summary = (offline ? 'Last known · ' : '') + (parts.join(' · ') || 'No checks reported');
const html = items.map(check => {
const url = typeof check.url === 'string' ? check.url : '';
const link = url ? '<a class="ci-check-link" href="' + escapeHtml(url) +
'" target="_blank" rel="noopener noreferrer">Open job</a>' : '';
return '<article class="ci-check ci-check-' + escapeHtml(check.state || 'unknown') + '">' +
'<div class="ci-check-copy"><strong>' + escapeHtml(check.name) + '</strong>' +
'<span class="small">' + escapeHtml(check.state || 'unknown') +
(check.description ? ' · ' + escapeHtml(check.description) : '') + '</span></div>' + link + '</article>';
}).join('');
return { summary, html, expanded: failed > 0 };
}
createReviewController.renderDiffFile = renderDiffFile;
createReviewController.createWrapPreference = createWrapPreference;
createReviewController.parseDiffLines = parseDiffLines;
createReviewController.toggleDiff = toggleDiff;
createReviewController.createProgress = createProgress;
@@ -329,6 +401,7 @@ createReviewController.createDraft = createDraft;
createReviewController.formatFeedback = formatFeedback;
createReviewController.copyAndContinue = copyAndContinue;
createReviewController.prepareMergeContinuation = prepareMergeContinuation;
createReviewController.renderChecks = renderChecks;
if (typeof module !== 'undefined' && module.exports) {
module.exports = createReviewController;
+336
View File
@@ -0,0 +1,336 @@
(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.');
}
function releaseRepository() {
const repositories = new Set(Array.from(selected.values()).map(item => item.repository));
return repositories.size === 1 ? repositories.values().next().value : null;
}
return { identity, eligible, start, cancel, toggle, snapshot, prepare, releaseRepository, limit:maximum };
}
createSearchBatchPlan.mount = function mountSearchBatchPlanning(
document, batchFactory, todayWork, getOwner, fetchJson, previewPath, queueToday,
todaySync, acceptIssue, lookup, render, escapeHtml, escapeAttribute,
laterWork = null, laterPicker = null
) {
const get = selector => document.querySelector(selector);
let processor;
let laterProcessor;
let releaseProcessor;
let releaseContext = null;
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('#defer-selected-search-results').disabled = state.count === 0;
get('#plan-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,
timeBudget:() => {
const planning = todayWork.planning();
return {
capacity_minutes:planning.capacity_minutes,
planned_minutes:Object.values(planning.estimates || {}).reduce((sum, minutes) => sum + minutes, 0),
};
},
journalName:'search-today-batch',
autoMount:false,
claim:item => plan.prepare(item),
queue:confirmed => queueToday(acceptIssue(confirmed)),
persistEstimate:(confirmed, minutes) => {
const planning = todayWork.planning();
planning.estimates[todayWork.identity(confirmed)] = minutes;
todayWork.replacePlanning(planning);
todaySync.enqueueConfiguration(planning.capacity_minutes, planning.estimates);
todaySync.flush();
},
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 === 'estimates-required') {
get('#search-batch-estimate-summary').textContent = 'Estimate every selected issue.';
} else if (progress.status === 'over-budget') {
get('#search-batch-estimate-summary').textContent = progress.requested_minutes +
' min selected · ' + progress.remaining_minutes + ' min remain.';
} 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;
closeEstimateReview();
if (!progress.failed.length) plan.cancel();
}
},
});
laterProcessor = batchFactory({
capacity:() => plan.limit,
owner:getOwner,
journalName:'search-later-batch',
queueFailureReason:'assigned but Later could not be saved',
autoMount:false,
claim:item => plan.prepare(item),
queue:(confirmed, context) => {
const outcome = laterWork?.defer(acceptIssue(confirmed), context?.until);
return outcome === 'deferred' ? 'queued' : outcome;
},
onProgress:progress => {
get('#defer-selected-search-results').disabled = progress.status === 'running';
if (progress.status === 'running') {
get('#search-selection-status').textContent = 'Deferring ' + progress.processed + ' of ' + progress.selected + '…';
} else if (progress.status === 'complete') {
get('#cmd-search-action-status').textContent = progress.failed.length ?
progress.queued.length + ' deferred · ' + progress.failed.length + ' need retry.' :
progress.queued.length + ' added to Later.';
restore();
if (!progress.failed.length) plan.cancel();
}
},
});
releaseProcessor = batchFactory({
capacity:() => plan.limit,
owner:getOwner,
journalName:'search-release-batch',
queueFailureReason:'assigned but release planning could not be confirmed',
autoMount:false,
claim:item => plan.prepare(item),
queue:async (confirmed, context) => {
const issuePath = 'api/v1/repos/' + confirmed.repository.split('/').map(encodeURIComponent).join('/') +
'/issues/' + encodeURIComponent(confirmed.number);
const milestone = await fetchJson(issuePath + '/milestone', {
method:'PATCH', headers:{Accept:'application/json','Content-Type':'application/json'},
body:JSON.stringify({milestone_id:context.milestone_id}),
});
if (milestone?.number !== confirmed.number || milestone?.milestone?.id !== context.milestone_id) {
throw new Error('Issue milestone was not confirmed.');
}
if (context.due_date) {
const deadline = await fetchJson(issuePath + '/due-date', {
method:'PATCH', headers:{Accept:'application/json','Content-Type':'application/json'},
body:JSON.stringify({due_date:context.due_date}),
});
if (deadline?.number !== confirmed.number || deadline?.due_date !== context.due_date) {
throw new Error('Issue due date was not confirmed.');
}
}
return 'queued';
},
onProgress:progress => {
get('#confirm-search-release').disabled = progress.status === 'running';
if (progress.status === 'running') {
get('#search-release-summary').textContent = 'Planning ' + progress.processed + ' of ' + progress.selected + '…';
} else if (progress.status === 'complete') {
get('#cmd-search-action-status').textContent = progress.failed.length ?
progress.queued.length + ' planned · ' + progress.failed.length + ' need retry.' :
progress.queued.length + ' planned for ' + (releaseContext?.milestone_title || 'release') + '.';
get('#resume-search-batch').hidden = releaseProcessor.pending() === 0;
closeReleaseReview();
if (!progress.failed.length) plan.cancel();
}
},
});
function estimateValues() {
return Object.fromEntries(Array.from(document.querySelectorAll('[data-search-batch-estimate]')).map(input =>
[input.dataset.searchBatchEstimate, Number(input.value)]
));
}
function closeEstimateReview() {
get('#search-batch-estimate-review').hidden = true;
get('#search-batch-actions').hidden = !plan.snapshot().active;
}
function openEstimateReview(items) {
const planning = todayWork.planning();
const planned = Object.values(planning.estimates || {}).reduce((sum, minutes) => sum + minutes, 0);
const remaining = Math.max(0, planning.capacity_minutes - planned);
get('#search-batch-estimate-summary').textContent = remaining +
' min remain. Estimate before assigning.';
get('#search-batch-estimate-list').innerHTML = items.map(item => {
const id = String(item.repository || '') + '#' + String(item.number || '');
const estimate = planning.estimates?.[id] || '';
return '<div class="search-batch-estimate-row"><div><span class="small">' + escapeHtml(id) +
'</span><strong>' + escapeHtml(item.title || 'Untitled work') + '</strong></div><label class="small">' +
'<input type="number" inputmode="numeric" min="5" max="1440" step="5" value="' +
escapeAttribute(estimate) + '" data-search-batch-estimate="' + escapeAttribute(id) +
'" aria-label="Estimate for ' + escapeAttribute(item.title || id) + ' in minutes" /> min</label></div>';
}).join('');
get('#search-batch-actions').hidden = true;
get('#search-batch-estimate-review').hidden = false;
document.querySelector('[data-search-batch-estimate]')?.focus();
}
function closeReleaseReview() {
get('#search-release-review').hidden = true;
get('#search-batch-actions').hidden = !plan.snapshot().active;
}
async function openReleaseReview() {
const items = plan.snapshot().items;
const repository = plan.releaseRepository();
if (!items.length) return;
if (!repository) {
get('#search-selection-status').textContent = 'Select issues from one repository to plan a release.';
return;
}
get('#plan-selected-search-results').disabled = true;
get('#search-selection-status').textContent = 'Loading release milestones…';
try {
const milestones = await fetchJson(
'api/v1/repos/' + repository.split('/').map(encodeURIComponent).join('/') + '/milestones',
{headers:{Accept:'application/json'}}
);
const open = (Array.isArray(milestones) ? milestones : []).filter(item => item?.state !== 'closed');
get('#search-release-milestone').innerHTML = '<option value="">Choose milestone</option>' + open.map(item =>
'<option value="' + Number(item.id) + '">' + escapeHtml(item.title) + '</option>'
).join('');
get('#search-release-list').innerHTML = items.map(item => '<div class="search-release-row"><span>' +
escapeHtml(item.repository + ' #' + item.number) + '</span><strong>' +
escapeHtml(item.title || 'Untitled work') + '</strong></div>').join('');
get('#search-release-summary').textContent = items.length + ' issues in ' + repository + '. Assignment starts only after confirmation.';
get('#search-batch-actions').hidden = true;
get('#search-release-review').hidden = false;
get('#search-release-milestone').focus();
} catch (error) {
get('#search-selection-status').textContent = error?.message || 'Milestones could not be loaded. Try again.';
} finally {
get('#plan-selected-search-results').disabled = false;
}
}
get('#select-search-results').addEventListener('click', () => plan.start());
get('#cancel-search-selection').addEventListener('click', () => plan.cancel());
get('#queue-selected-search-results').addEventListener('click', () => {
const items = plan.snapshot().items;
const capacity = todayWork.planning().capacity_minutes;
if (Number.isInteger(capacity) && capacity > 0) openEstimateReview(items);
else processor.run(items);
});
get('#defer-selected-search-results').addEventListener('click', event => {
const items = plan.snapshot().items;
if (!items.length) return;
laterPicker?.open({ items, confirm:until => () => laterProcessor.run(
items, {}, { until:new Date(until).toISOString() }
) }, event.currentTarget, 'search-batch');
});
get('#plan-selected-search-results').addEventListener('click', openReleaseReview);
get('#cancel-search-release').addEventListener('click', closeReleaseReview);
get('#confirm-search-release').addEventListener('click', () => {
const milestoneSelect = get('#search-release-milestone');
const milestoneId = Number(milestoneSelect.value);
if (!Number.isInteger(milestoneId) || milestoneId < 1) {
get('#search-release-summary').textContent = 'Choose an open milestone.';
return;
}
const selectedOption = milestoneSelect.options?.[milestoneSelect.selectedIndex];
const milestoneTitle = selectedOption?.textContent || selectedOption?.text || 'release';
const dueDay = get('#search-release-due-date').value;
releaseContext = {
milestone_id:milestoneId, milestone_title:milestoneTitle,
due_date:dueDay ? dueDay + 'T23:59:59Z' : null,
};
releaseProcessor.run(plan.snapshot().items, {}, releaseContext);
});
get('#cancel-search-batch-estimates').addEventListener('click', closeEstimateReview);
get('#confirm-search-batch-estimates').addEventListener('click', () =>
processor.run(plan.snapshot().items, estimateValues())
);
get('#resume-search-batch').addEventListener('click', () => {
if (processor.pending()) return processor.resume();
if (laterProcessor.pending()) return laterProcessor.resume();
return releaseProcessor.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() + laterProcessor.pending() + releaseProcessor.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, laterProcessor, releaseProcessor, 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;
+317 -15
View File
@@ -1,27 +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, claim, 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 claimRequest = null;
let mutationRequest = null;
let shareRequest = null;
let moveRequest = null;
let replyRequest = null;
let conversation = null;
return {
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;
publish({ status: pending, item: current, detail });
mutationRequest = mutate(detail, action).then(result => {
publish({ status: success, item: current, detail, result });
return result;
}).catch(error => {
publish({ status: 'ready', item: current, detail, error });
throw error;
}).finally(() => { mutationRequest = null; });
return mutationRequest;
}
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;
});
@@ -31,18 +229,122 @@
current = null;
onState({ status: 'closed' });
},
claim(detail) {
if (claimRequest) return claimRequest;
onState({ status: 'claiming', item: current, detail });
claimRequest = claim(detail).then(result => {
onState({ status: 'claimed', item: current, detail, result });
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 => {
onState({ status: 'ready', item: current, detail, error });
publish({ status:error?.name === 'AbortError' ? 'share-canceled' : 'share-error', item:current, detail, error });
throw error;
}).finally(() => { claimRequest = null; });
return claimRequest;
}).finally(() => { shareRequest = null; });
return shareRequest;
},
claim(detail) {
return run('claim', 'claiming', 'claimed', detail);
},
reopen(detail) {
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;
};
});
+190
View File
@@ -0,0 +1,190 @@
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
else root.attachSecurityCenter = boundary => factory({ root, boundary });
})(typeof window !== 'undefined' ? window : this, function attachSecurityCenter({ root, boundary }) {
const devicesButton = root.document.getElementById('active-devices');
const devicesSheet = root.document.getElementById('active-devices-sheet');
const devicesList = root.document.getElementById('active-devices-list');
const devicesStatus = root.document.getElementById('active-devices-status');
const passkeysList = root.document.getElementById('enrolled-passkeys-list');
const passkeysStatus = root.document.getElementById('enrolled-passkeys-status');
const enrollPasskey = root.document.getElementById('enroll-passkey');
const activityList = root.document.getElementById('security-activity-list');
const activityStatus = root.document.getElementById('security-activity-status');
const loadMoreActivity = root.document.getElementById('load-more-security-activity');
let activityCursor = null;
const renderDevices = async () => {
devicesStatus.textContent = 'Loading active devices…';
devicesList.replaceChildren();
try {
const devices = await boundary.listActiveDevices();
devices.forEach(device => {
const row = root.document.createElement('article');
row.className = 'active-device';
const details = root.document.createElement('div');
const label = root.document.createElement('strong');
label.textContent = device.device_label;
const timing = root.document.createElement('span');
timing.className = 'small muted';
timing.textContent = `Signed in ${new Date(device.created_at * 1000).toLocaleString()} · expires ${new Date(device.expires_at * 1000).toLocaleString()}`;
details.append(label, timing);
if (device.current) {
const current = root.document.createElement('span');
current.className = 'active-device-current';
current.textContent = 'This device';
details.append(current);
}
const revoke = root.document.createElement('button');
revoke.type = 'button';
revoke.textContent = device.current ? 'Sign out' : 'Revoke';
revoke.addEventListener('click', async () => {
if (device.current) await boundary.signOut();
else if (await boundary.revokeActiveDevice(device)) await renderDevices();
});
row.append(details, revoke);
devicesList.append(row);
});
devicesStatus.textContent = devices.length ? `${devices.length} active device${devices.length === 1 ? '' : 's'}` : 'No active devices.';
} catch (_error) {
devicesStatus.textContent = 'Active devices could not be loaded. Try again.';
}
};
const renderPasskeys = async () => {
if (!passkeysList || !passkeysStatus) return;
passkeysStatus.textContent = 'Loading enrolled passkeys…';
passkeysList.replaceChildren();
try {
const enrolled = await boundary.listPasskeys();
enrolled.forEach(passkey => {
const row = root.document.createElement('article');
row.className = 'enrolled-passkey';
const details = root.document.createElement('div');
const label = root.document.createElement('strong');
label.textContent = passkey.device_label;
const timing = root.document.createElement('span');
timing.className = 'small muted';
const state = passkey.current ? 'This active device' : (passkey.active ? 'Active device' : 'No active session');
timing.textContent = `Enrolled ${new Date(passkey.created_at * 1000).toLocaleString()} · ${state}`;
details.append(label, timing);
const remove = root.document.createElement('button');
remove.type = 'button';
remove.textContent = 'Remove passkey';
remove.addEventListener('click', async () => {
remove.disabled = true;
passkeysStatus.textContent = `Removing passkey for ${passkey.device_label}…`;
try {
const outcome = await boundary.revokePasskey(passkey);
if (outcome) {
passkeysStatus.textContent = outcome.current_session
? 'Passkey removed. This session remains active; keep your recovery token available.'
: 'Passkey removed.';
await renderPasskeys();
if (outcome.session_revoked) await renderDevices();
} else {
remove.disabled = false;
passkeysStatus.textContent = `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`;
}
} catch (_error) {
passkeysStatus.textContent = 'Passkey could not be removed. Refresh to verify before retrying.';
remove.disabled = false;
}
});
row.append(details, remove);
passkeysList.append(row);
});
passkeysStatus.textContent = enrolled.length
? `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`
: 'No passkeys enrolled.';
} catch (_error) {
passkeysStatus.textContent = 'Enrolled passkeys could not be loaded. Try again.';
}
};
const renderSecurityActivity = async (append = false) => {
if (!activityList || !activityStatus || !loadMoreActivity) return;
activityStatus.textContent = append ? 'Loading older activity…' : 'Loading security activity…';
loadMoreActivity.hidden = true;
if (!append) {
activityCursor = null;
activityList.replaceChildren();
}
try {
const page = await boundary.listSecurityEvents(append ? activityCursor : null);
if (!append) {
page.authentication_alerts.forEach(alert => {
const row = root.document.createElement('article');
row.className = 'security-event authentication-alert';
const formatted = boundary.formatAuthenticationAlert(alert);
const title = root.document.createElement('strong');
title.textContent = formatted.title;
const details = root.document.createElement('span');
details.className = 'small muted';
details.textContent = formatted.detail;
row.append(title, details);
activityList.append(row);
});
}
const labels = {
sign_in: 'Signed in', sign_out: 'Signed out', passkey_enrolled: 'Passkey enrolled',
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',
};
page.events.forEach(event => {
const row = root.document.createElement('article');
row.className = 'security-event';
const title = root.document.createElement('strong');
title.textContent = labels[event.kind] || 'Security event';
const details = root.document.createElement('span');
details.className = 'small muted';
const context = [event.device_label, event.method, event.target,
event.kind === 'passkey_counter_anomaly'
? 'Remove and re-enroll this passkey if the alert repeats'
: null,
event.status === 'pending' ? 'Outcome confirmation pending' : null]
.filter(value => typeof value === 'string' && value).join(' · ');
details.textContent = `${new Date(event.created_at * 1000).toLocaleString()}${context ? ' · ' + context : ''}`;
row.append(title, details);
activityList.append(row);
});
activityCursor = page.next_cursor;
activityStatus.textContent = activityList.children.length
? `${activityList.children.length} recent security event${activityList.children.length === 1 ? '' : 's'}`
: 'No security activity yet.';
loadMoreActivity.textContent = 'Load older activity';
loadMoreActivity.hidden = !activityCursor;
} catch (_error) {
activityStatus.textContent = 'Security activity could not be loaded.';
loadMoreActivity.textContent = 'Retry activity';
loadMoreActivity.hidden = false;
}
};
const open = () => {
if (!devicesSheet) return;
devicesSheet.hidden = false;
root.document.getElementById('close-active-devices')?.focus();
return Promise.all([renderDevices(), renderPasskeys(), renderSecurityActivity()]);
};
devicesButton?.addEventListener('click', open);
loadMoreActivity?.addEventListener('click', () => renderSecurityActivity(Boolean(activityCursor)));
enrollPasskey?.addEventListener('click', async () => {
enrollPasskey.disabled = true;
devicesStatus.textContent = 'Waiting for your device passkey…';
try {
await boundary.enrollPasskey();
devicesStatus.textContent = 'Passkey enrolled. You can use it at sign-in and authorization prompts.';
await renderPasskeys();
} catch (_error) {
devicesStatus.textContent = 'Passkey enrollment was not completed. Try again.';
} finally {
enrollPasskey.disabled = false;
}
});
return { open, renderDevices, renderPasskeys, renderSecurityActivity };
});
+306 -27
View File
@@ -1,9 +1,10 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v67';
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;
const PUSH_ACTION_TIMEOUT_MS = self.__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS || 8000;
const SHELL = [
BASE,
BASE + 'manifest.webmanifest',
@@ -12,31 +13,57 @@ const SHELL = [
BASE + 'static/icons/stackchain-192.png',
BASE + 'static/icons/stackchain-512.png',
BASE + 'static/session.js',
BASE + 'static/feature-loader.js',
BASE + 'static/security-center.js',
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',
BASE + 'static/issue-outbox.js',
BASE + 'static/authored-outbox.js',
BASE + 'static/offline-issue-close.js',
BASE + 'static/offline-issue-blocker.js',
BASE + 'static/notification-read-outbox.js',
BASE + 'static/offline-work.js',
BASE + 'static/offline-today.js',
BASE + 'static/my-work.js',
BASE + 'static/agenda-replan.js',
BASE + 'static/protect-today.js',
BASE + 'static/notification-undo.js',
BASE + 'static/card-planning.js',
BASE + 'static/work-selection.js',
BASE + 'static/today-work.js',
BASE + 'static/today-timer.js',
BASE + 'static/today-recap.js',
BASE + 'static/today-completion.js',
BASE + 'static/today-readiness.js',
BASE + 'static/comment-next.js',
BASE + 'static/update-reply-read-next.js',
BASE + 'static/plan-today.js',
BASE + 'static/plan-today-readiness.js',
BASE + 'static/plan-today-preview.js',
BASE + 'static/today-sync.js',
BASE + 'static/today-rollover.js',
BASE + 'static/update-ownership.js',
BASE + 'static/update-follow-up.js',
BASE + 'static/later-work.js',
BASE + 'static/later-sync.js',
BASE + 'static/later-and-start.js',
BASE + 'static/detail-defer.js',
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',
BASE + 'static/issue-sheet.js',
BASE + 'static/create-issue-sheet.js',
BASE + 'static/create-and-start.js',
@@ -47,14 +74,68 @@ 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',
BASE + 'static/mobile-composer-viewport.js',
BASE + 'static/mention-composer.js',
BASE + 'static/push-notifications.js',
BASE + 'static/background-issue-sync.js',
];
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();
@@ -99,8 +180,21 @@ async function withSessionCsrf(work) {
}
}
async function deletePrivateDatabase(name) {
if (!indexedDB) return;
await new Promise((resolve, reject) => {
let request;
try { request = indexedDB.deleteDatabase(name); }
catch (error) { reject(error); return; }
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
request.onblocked = () => reject(new Error('IndexedDB deletion was blocked.'));
});
}
async function purgeRevokedSessionData() {
await issueSync.purge();
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
const keys = await caches.keys();
await Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
@@ -109,24 +203,35 @@ async function purgeRevokedSessionData() {
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-revoked' }));
}
async function storeOfflineLease(expiresAt) {
if (!Number.isInteger(expiresAt) || expiresAt <= 0) return;
async function notifyIdleSession() {
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-idle' }));
}
async function storeOfflineLease(expiresAt, idleExpiresAt) {
if (
!Number.isInteger(expiresAt) || expiresAt <= 0
|| !Number.isInteger(idleExpiresAt) || idleExpiresAt <= 0
) return;
const cache = await caches.open(CACHE);
await cache.put(OFFLINE_LEASE_URL, new Response(
JSON.stringify({ expires_at: expiresAt }),
JSON.stringify({ expires_at: expiresAt, idle_expires_at: idleExpiresAt }),
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } },
));
}
async function validOfflineLease(cache) {
async function offlineLeaseState(cache) {
const response = await cache.match(OFFLINE_LEASE_URL);
const payload = await response?.json?.().catch(() => ({})) || {};
return Number.isInteger(payload.expires_at)
&& payload.expires_at > Math.floor(Date.now() / 1000);
const current = Math.floor(Date.now() / 1000);
if (!Number.isInteger(payload.expires_at) || payload.expires_at <= current) return 'expired';
if (!Number.isInteger(payload.idle_expires_at) || payload.idle_expires_at <= current) return 'idle';
return 'valid';
}
async function expiredOfflineResponse() {
await issueSync.purge();
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
const keys = await caches.keys();
await Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
@@ -139,8 +244,18 @@ async function expiredOfflineResponse() {
);
}
async function idleOfflineResponse() {
await notifyIdleSession();
return new Response(
'Your Stackchain session is locked. Reconnect and sign in.',
{ status: 401, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' } },
);
}
async function cachedShellWithValidLease(cache) {
if (!await validOfflineLease(cache)) return expiredOfflineResponse();
const state = await offlineLeaseState(cache);
if (state === 'expired') return expiredOfflineResponse();
if (state === 'idle') return idleOfflineResponse();
return cache.match(BASE);
}
@@ -171,6 +286,9 @@ async function fetchJson(url, options = {}) {
if (response.status === 401 && code === 'session_revoked') {
await purgeRevokedSessionData();
}
if (response.status === 401 && code === 'session_idle') {
await notifyIdleSession();
}
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Background issue delivery failed.');
error.status = response.status;
error.code = code;
@@ -189,11 +307,13 @@ async function flushAndNotify() {
!await issueSync.getReceiptPreference?.(result.login)) return;
for (const receipt of result.receipts) {
const needsAttention = receipt.status === 'attention';
const title = needsAttention
const needsAuthorization = receipt.status === 'authorization';
const title = needsAuthorization ? 'Queued review needs authorization' : needsAttention
? 'Queued work needs attention'
: receipt.kind === 'issue' ? 'Queued issue created' : 'Queued message sent';
await self.registration.showNotification(title, {
body: needsAttention ? 'Tap to review it in Drafts.' : 'Tap to open it in Stackchain.',
body: needsAuthorization ? 'Tap to authorize it in the Delivery center.' :
needsAttention ? 'Tap to review it in Drafts.' : 'Tap to open it in Stackchain.',
tag: 'stackchain-delivery-' + receipt.id,
data: { route: receipt.route },
});
@@ -205,10 +325,16 @@ self.addEventListener('install', event => {
});
self.addEventListener('activate', event => {
event.waitUntil(caches.keys().then(keys => Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE)
.map(key => caches.delete(key))
)).then(() => self.clients.claim()));
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE)
.map(key => caches.delete(key))
);
const cache = await caches.open(CACHE);
await Promise.allSettled(OPTIONAL_FEATURES.map(asset => cache.add(asset)));
await self.clients.claim();
})());
});
self.addEventListener('sync', event => {
@@ -216,13 +342,17 @@ self.addEventListener('sync', event => {
});
self.addEventListener('message', event => {
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil(flushAndNotify());
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil((async () => {
await issueSync.resume();
await flushAndNotify();
})());
if (event.data?.type === 'stackchain-session-lease') {
event.waitUntil(storeOfflineLease(event.data.expiresAt));
event.waitUntil(storeOfflineLease(event.data.expiresAt, event.data.idleExpiresAt));
}
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
try {
await issueSync.purge();
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
event.ports?.[0]?.postMessage({ ok: true });
} catch (error) {
event.ports?.[0]?.postMessage({ ok: false, error: String(error?.message || 'Outbox purge failed.') });
@@ -230,22 +360,167 @@ self.addEventListener('message', event => {
})());
});
self.addEventListener('notificationclick', event => {
event.notification.close();
const route = String(event.notification.data?.route || '');
if (!route.startsWith('#/my-work/')) return;
self.addEventListener('push', event => {
let payload;
try { payload = event.data?.json?.() || {}; }
catch (_error) { return; }
const route = String(payload.route || '');
const protectRoute = String(payload.protect_route || '');
const tag = String(payload.tag || '');
const notificationId = Number(payload.notification_id);
const updateCount = Number(payload.update_count);
const deadlineCount = Number(payload.deadline_count);
if (
route === '#/my-work/agenda'
&& protectRoute === '#/my-work/agenda/protect-today'
&& /^stackchain-deadline-digest-\d{4}-\d{2}-\d{2}$/.test(tag)
&& Number.isSafeInteger(deadlineCount)
&& deadlineCount > 0
&& deadlineCount <= 50
) {
event.waitUntil(self.registration.showNotification(
deadlineCount + ' deadline' + (deadlineCount === 1 ? '' : 's') + ' need' + (deadlineCount === 1 ? 's' : '') + ' attention',
{
body: 'Open Agenda to review or replan ' + (deadlineCount === 1 ? 'it.' : 'them.'),
tag,
actions: [
{ action: 'protect-today', title: 'Protect Today' },
{ action: 'open-agenda', title: 'Open Agenda' },
],
data: {route, protectRoute},
}
));
return;
}
if (
route === '#/my-work/updates'
&& tag === 'stackchain-update-digest'
&& Number.isSafeInteger(updateCount)
&& updateCount > 0
&& updateCount <= 50
) {
event.waitUntil(self.registration.showNotification(updateCount + ' new work updates', {
body: 'Tap to review them in Stackchain.',
tag,
data: {route},
}));
return;
}
if (!/^#\/my-work\/update\/\d+$/.test(route) || !/^stackchain-update-\d+$/.test(tag)) return;
const options = {
body: 'Tap to review it in Stackchain.',
tag,
data: {route},
};
if (
Number.isSafeInteger(notificationId) && notificationId > 0
&& route === '#/my-work/update/' + notificationId
&& tag === 'stackchain-update-' + notificationId
) {
options.actions = [
{ action: 'mark-read', title: 'Mark read' },
{ action: 'tomorrow', title: 'Tomorrow' },
];
options.data.notificationId = notificationId;
}
event.waitUntil(self.registration.showNotification('New work update', options));
});
async function openWorkRoute(route) {
const target = new URL(BASE + route, self.location.origin).href;
event.waitUntil((async () => {
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE));
if (!client) return self.clients.openWindow(target);
if (client.navigate) await client.navigate(target);
return client.focus();
})());
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE));
if (!client) return self.clients.openWindow(target);
if (client.navigate) await client.navigate(target);
return client.focus();
}
self.addEventListener('notificationclick', event => {
const route = String(event.notification.data?.route || '');
if (event.action === 'protect-today') {
const protectRoute = String(event.notification.data?.protectRoute || route);
if (
protectRoute !== '#/my-work/agenda/protect-today'
|| !['#/my-work/agenda', protectRoute].includes(route)
) return;
event.notification.close();
event.waitUntil(openWorkRoute(protectRoute));
return;
}
if (event.action === 'open-agenda') {
if (route !== '#/my-work/agenda') return;
event.notification.close();
event.waitUntil(openWorkRoute(route));
return;
}
if (event.action === 'tomorrow') {
const notificationId = Number(event.notification.data?.notificationId);
if (
!Number.isSafeInteger(notificationId) || notificationId <= 0
|| route !== '#/my-work/update/' + notificationId
|| event.notification.tag !== 'stackchain-update-' + notificationId
) return;
event.waitUntil((async () => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), PUSH_ACTION_TIMEOUT_MS);
try {
const wake = new Date(self.__STACKCHAIN_NOW?.() || Date.now());
wake.setDate(wake.getDate() + 1);
wake.setHours(9, 0, 0, 0);
await fetchJson(BASE + 'api/v1/notifications/' + notificationId + '/later', {
method: 'PATCH',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ wake_at: wake.toISOString() }),
signal: controller.signal,
});
event.notification.close();
} catch (_error) {
await openWorkRoute(route);
event.notification.close();
} finally {
clearTimeout(timeout);
}
})());
return;
}
if (event.action === 'mark-read') {
const notificationId = Number(event.notification.data?.notificationId);
if (
!Number.isSafeInteger(notificationId) || notificationId <= 0
|| route !== '#/my-work/update/' + notificationId
) return;
event.waitUntil((async () => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), PUSH_ACTION_TIMEOUT_MS);
try {
await fetchJson(BASE + 'api/v1/notifications/' + notificationId + '/read', {
method: 'PATCH', headers: { Accept: 'application/json' }, signal: controller.signal,
});
event.notification.close();
} catch (_error) {
await openWorkRoute(route);
event.notification.close();
} finally {
clearTimeout(timeout);
}
})());
return;
}
event.notification.close();
if (!route.startsWith('#/my-work/')) return;
event.waitUntil(openWorkRoute(route));
});
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;
@@ -275,5 +550,9 @@ self.addEventListener('fetch', event => {
}
if (url.origin === self.location.origin && SHELL.includes(url.pathname)) {
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
return;
}
if (url.origin === self.location.origin && OPTIONAL_FEATURES.includes(url.pathname)) {
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
}
});
+300 -76
View File
@@ -13,8 +13,10 @@
indexedDB: root.indexedDB,
caches: root.caches,
serviceWorker: root.navigator?.serviceWorker,
credentials: root.navigator?.credentials,
MessageChannel: root.MessageChannel,
location: root.location,
addActivityListener: (type, listener, options) => root.addEventListener(type, listener, options),
confirmAction: message => root.confirm(message),
promptAuthorization: details => root.prompt(
`Confirm ${String(details.action || 'this action').replaceAll('_', ' ')} by entering your dashboard access token.`,
@@ -27,56 +29,45 @@
});
root.fetch = boundary.fetch;
const attach = () => {
boundary.startActivityHeartbeat();
const button = root.document.getElementById('sign-out');
if (button) button.addEventListener('click', () => boundary.signOut());
const allDevicesButton = root.document.getElementById('sign-out-all');
if (allDevicesButton) allDevicesButton.addEventListener('click', () => boundary.signOutAllDevices());
const devicesButton = root.document.getElementById('active-devices');
const devicesSheet = root.document.getElementById('active-devices-sheet');
const devicesList = root.document.getElementById('active-devices-list');
const devicesStatus = root.document.getElementById('active-devices-status');
const closeDevices = root.document.getElementById('close-active-devices');
const renderDevices = async () => {
devicesStatus.textContent = 'Loading active devices…';
devicesList.replaceChildren();
try {
const devices = await boundary.listActiveDevices();
devices.forEach(device => {
const row = root.document.createElement('article');
row.className = 'active-device';
const details = root.document.createElement('div');
const label = root.document.createElement('strong');
label.textContent = device.device_label;
const timing = root.document.createElement('span');
timing.className = 'small muted';
timing.textContent = `Signed in ${new Date(device.created_at * 1000).toLocaleString()} · expires ${new Date(device.expires_at * 1000).toLocaleString()}`;
details.append(label, timing);
if (device.current) {
const current = root.document.createElement('span');
current.className = 'active-device-current';
current.textContent = 'This device';
details.append(current);
}
const revoke = root.document.createElement('button');
revoke.type = 'button';
revoke.textContent = device.current ? 'Sign out' : 'Revoke';
revoke.addEventListener('click', async () => {
if (device.current) await boundary.signOut();
else if (await boundary.revokeActiveDevice(device)) await renderDevices();
});
row.append(details, revoke);
devicesList.append(row);
});
devicesStatus.textContent = devices.length ? `${devices.length} active device${devices.length === 1 ? '' : 's'}` : 'No active devices.';
} catch (_error) {
devicesStatus.textContent = 'Active devices could not be loaded. Try again.';
}
};
if (devicesButton && devicesSheet) devicesButton.addEventListener('click', () => {
const securityFeatures = root.createFeatureLoader({
document: root.document,
urls: {
'security-center': root.document.querySelector(
'meta[name="stackchain-feature-security-center"]'
)?.content || '',
},
});
let loadingSecurityCenter = false;
const openSecurityCenter = async () => {
if (loadingSecurityCenter) return;
loadingSecurityCenter = true;
devicesSheet.hidden = false;
closeDevices?.focus();
renderDevices();
});
try {
await securityFeatures.run('security-center', {
trigger: devicesButton,
status: devicesStatus,
retryLabel: 'Tap Active devices to retry.',
}, () => {
devicesButton.removeEventListener('click', openSecurityCenter);
root.attachSecurityCenter(boundary).open();
});
} finally {
loadingSecurityCenter = false;
}
};
if (devicesButton && devicesSheet) {
devicesButton.addEventListener('click', openSecurityCenter);
}
if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => {
devicesSheet.hidden = true;
devicesButton?.focus();
@@ -91,16 +82,20 @@
root.stackchainSession = boundary;
}
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, location, confirmAction,
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, credentials, MessageChannel, location, confirmAction, addActivityListener,
promptAuthorization = () => null,
onExpired = () => {},
onClearError = () => {},
requestTimeoutMs = 15000,
activityHeartbeatIntervalMs = 60000,
now = () => Date.now(),
setTimer = (callback, delay) => setTimeout(callback, delay),
}) {
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
let expirationStarted = false;
let idleLockStarted = false;
let lastActivityHeartbeatAt = Number.NEGATIVE_INFINITY;
let activityHeartbeat = null;
async function handleRemoteRevocation() {
if (expirationStarted) return;
@@ -117,10 +112,19 @@
location.replace(base + 'login?reason=session-expired');
}
function handleSessionIdle() {
if (expirationStarted || idleLockStarted) return;
idleLockStarted = true;
location.replace(base + 'login?reason=session-idle');
}
async function handleServiceWorkerMessage(event) {
if (event?.data?.type === 'stackchain-session-revoked') {
await handleRemoteRevocation();
}
if (event?.data?.type === 'stackchain-session-idle') {
handleSessionIdle();
}
if (event?.data?.type === 'stackchain-session-expired') {
await handleSessionExpiry();
}
@@ -131,16 +135,32 @@
return Number.isInteger(value) && value > 0 ? value : 0;
}
function storedOfflineIdleLease() {
const value = Number(localStorage?.getItem?.('stackchain.session-idle-expires-at'));
return Number.isInteger(value) && value > 0 ? value : 0;
}
function scheduleOfflineExpiry(expiresAt) {
const delay = Math.max(0, expiresAt * 1000 - now());
setTimer(() => handleSessionExpiry(), delay);
}
async function publishOfflineLease(expiresAt) {
function scheduleOfflineIdle(idleExpiresAt) {
const delay = Math.max(0, idleExpiresAt * 1000 - now());
setTimer(() => {
if (storedOfflineIdleLease() * 1000 <= now()) handleSessionIdle();
}, delay);
}
async function publishOfflineLease(expiresAt, idleExpiresAt) {
localStorage?.setItem?.('stackchain.session-expires-at', String(expiresAt));
localStorage?.setItem?.('stackchain.session-idle-expires-at', String(idleExpiresAt));
scheduleOfflineExpiry(expiresAt);
scheduleOfflineIdle(idleExpiresAt);
const registration = await serviceWorker?.ready;
registration?.active?.postMessage({ type: 'stackchain-session-lease', expiresAt });
registration?.active?.postMessage({
type: 'stackchain-session-lease', expiresAt, idleExpiresAt,
});
}
function csrfToken() {
@@ -150,6 +170,33 @@
return entry ? decodeURIComponent(entry.slice('stackchain_csrf='.length)) : '';
}
function decodeBase64Url(value) {
const padded = String(value).replaceAll('-', '+').replaceAll('_', '/')
+ '='.repeat((4 - String(value).length % 4) % 4);
return Uint8Array.from(atob(padded), character => character.charCodeAt(0));
}
function encodeBase64Url(value) {
if (value === null || value === undefined) return null;
let binary = '';
new Uint8Array(value).forEach(byte => { binary += String.fromCharCode(byte); });
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
}
function authenticationCredentialJSON(credential) {
return {
id: credential.id,
type: credential.type,
rawId: encodeBase64Url(credential.rawId),
response: {
authenticatorData: encodeBase64Url(credential.response.authenticatorData),
clientDataJSON: encodeBase64Url(credential.response.clientDataJSON),
signature: encodeBase64Url(credential.response.signature),
userHandle: encodeBase64Url(credential.response.userHandle),
},
};
}
function isSameOrigin(input) {
try { return new URL(String(input?.url || input), origin).origin === origin; }
catch (_error) { return false; }
@@ -205,6 +252,37 @@
}
}
async function authorizeWithPasskey(details) {
if (!credentials?.get) return null;
const headers = new Headers({ Accept: 'application/json', 'Content-Type': 'application/json' });
const csrf = csrfToken();
if (csrf) headers.set('X-CSRF-Token', csrf);
const optionsResponse = await fetchWithDeadline(base + 'api/v1/passkeys/authorization/options', {
method: 'POST', headers,
body: JSON.stringify({ action: details.action, target: details.target }),
}, 'POST', 'passkey-authorization');
if (!optionsResponse.ok) return null;
const publicKey = await optionsResponse.json();
const challenge = publicKey.challenge;
publicKey.challenge = decodeBase64Url(publicKey.challenge);
publicKey.allowCredentials = (publicKey.allowCredentials || []).map(item => ({
...item, id: decodeBase64Url(item.id),
}));
const credential = await credentials.get({ publicKey });
const verified = await fetchWithDeadline(base + 'api/v1/passkeys/authorization/verify', {
method: 'POST', headers,
body: JSON.stringify({
challenge,
credential: authenticationCredentialJSON(credential),
action: details.action,
target: details.target,
}),
}, 'POST', 'passkey-authorization');
if (!verified.ok) return null;
const payload = await verified.json().catch(() => ({}));
return payload.grant || null;
}
async function sessionFetch(input, options = {}, allowStepUp = true) {
const method = String(options.method || input?.method || 'GET').toUpperCase();
const requestOptions = { ...options };
@@ -219,40 +297,52 @@
const payload = await response.clone().json().catch(() => ({}));
const detail = payload?.detail || {};
if (detail.code === 'step_up_required' && detail.action && detail.target) {
const accessToken = await promptAuthorization({
action: detail.action,
target: detail.target,
});
if (!accessToken) return response;
const authorizationHeaders = new Headers({
Accept: 'application/json',
'Content-Type': 'application/json',
});
const csrf = csrfToken();
if (csrf) authorizationHeaders.set('X-CSRF-Token', csrf);
const authorization = await fetchWithDeadline(base + 'api/v1/fresh-authorization', {
method: 'POST',
headers: authorizationHeaders,
body: JSON.stringify({
access_token: accessToken,
let grant = null;
try { grant = await authorizeWithPasskey(detail); }
catch (_error) { /* Cancellation and unavailable passkeys fall back to recovery. */ }
if (!grant) {
const accessToken = await promptAuthorization({
action: detail.action,
target: detail.target,
}),
}, 'POST', 'fresh-authorization');
if (!authorization.ok) return authorization;
const grant = await authorization.json().catch(() => ({}));
if (!grant.grant) return response;
});
if (!accessToken) return response;
const authorizationHeaders = new Headers({
Accept: 'application/json',
'Content-Type': 'application/json',
});
const csrf = csrfToken();
if (csrf) authorizationHeaders.set('X-CSRF-Token', csrf);
const authorization = await fetchWithDeadline(base + 'api/v1/fresh-authorization', {
method: 'POST',
headers: authorizationHeaders,
body: JSON.stringify({
access_token: accessToken,
action: detail.action,
target: detail.target,
}),
}, 'POST', 'fresh-authorization');
if (!authorization.ok) return authorization;
const authorizationPayload = await authorization.json().catch(() => ({}));
grant = authorizationPayload.grant;
}
if (!grant) return response;
const retryHeaders = new Headers(requestOptions.headers || input?.headers || {});
retryHeaders.set('X-Step-Up-Grant', grant.grant);
retryHeaders.set('X-Step-Up-Grant', grant);
return sessionFetch(input, { ...requestOptions, headers: retryHeaders }, false);
}
}
if (response.status === 401 && isSameOrigin(input) && !expirationStarted) {
if (
response.status === 401 && isSameOrigin(input)
&& !expirationStarted && !idleLockStarted
) {
expirationStarted = true;
const payload = await response.clone().json().catch(() => ({}));
if (payload.code === 'session_revoked') {
expirationStarted = false;
await handleRemoteRevocation();
} else if (payload.code === 'session_idle') {
expirationStarted = false;
handleSessionIdle();
} else {
expirationStarted = false;
await handleSessionExpiry();
@@ -261,22 +351,62 @@
return response;
}
function recordActivity() {
const current = now();
if (
expirationStarted
|| idleLockStarted
|| activityHeartbeat
|| current - lastActivityHeartbeatAt < activityHeartbeatIntervalMs
) return Promise.resolve(false);
lastActivityHeartbeatAt = current;
activityHeartbeat = sessionFetch(base + 'api/v1/session/activity', { method: 'POST' })
.then(async response => {
if (!response.ok) return false;
const payload = await response.json().catch(() => ({}));
const expiresAt = storedOfflineLease();
if (expiresAt && Number.isInteger(payload.idle_expires_at)) {
await publishOfflineLease(expiresAt, payload.idle_expires_at);
}
return true;
})
.catch(() => false)
.finally(() => { activityHeartbeat = null; });
return activityHeartbeat;
}
function startActivityHeartbeat() {
['pointerdown', 'keydown', 'touchstart'].forEach(type => {
addActivityListener?.(type, recordActivity, { passive: true });
});
}
async function refreshOfflineLease() {
try {
const response = await sessionFetch(base + 'api/v1/session');
if (!response.ok) return false;
const payload = await response.json().catch(() => ({}));
if (!payload.authenticated || !Number.isInteger(payload.expires_at)) return false;
await publishOfflineLease(payload.expires_at);
if (
!payload.authenticated
|| !Number.isInteger(payload.expires_at)
|| !Number.isInteger(payload.idle_expires_at)
) return false;
await publishOfflineLease(payload.expires_at, payload.idle_expires_at);
return true;
} catch (_error) {
const expiresAt = storedOfflineLease();
if (expiresAt * 1000 > now()) {
scheduleOfflineExpiry(expiresAt);
return true;
if (expiresAt * 1000 <= now()) {
if (expiresAt) await handleSessionExpiry();
return false;
}
if (expiresAt) await handleSessionExpiry();
return false;
const idleExpiresAt = storedOfflineIdleLease();
if (!idleExpiresAt || idleExpiresAt * 1000 <= now()) {
handleSessionIdle();
return false;
}
scheduleOfflineExpiry(expiresAt);
scheduleOfflineIdle(idleExpiresAt);
return true;
}
}
@@ -307,11 +437,11 @@
});
}
function deletePrivateOutbox() {
function deletePrivateDatabase(name) {
if (!indexedDB) return Promise.resolve();
return new Promise((resolve, reject) => {
let request;
try { request = indexedDB.deleteDatabase('stackchain-background-outbox-v1'); }
try { request = indexedDB.deleteDatabase(name); }
catch (error) { reject(error); return; }
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
@@ -324,7 +454,9 @@
if (sessionStorage !== localStorage) removeDashboardStorage(sessionStorage);
try {
await stopWorkerOutbox();
await deletePrivateOutbox();
await deletePrivateDatabase('stackchain-background-outbox-v1');
await deletePrivateDatabase('stackchain-offline-work-v2');
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
} catch (_error) {
const error = new Error('Could not clear private queued work from this device.');
onClearError(error);
@@ -336,6 +468,41 @@
} catch (_error) { /* A later service-worker activation can clear stale caches. */ }
}
async function enrollPasskey() {
if (!credentials?.create) throw new Error('Passkeys are not supported');
const optionsResponse = await sessionFetch(
base + 'api/v1/passkeys/registration/options', { method: 'POST' }
);
if (!optionsResponse.ok) throw new Error('Could not start passkey enrollment');
const publicKey = await optionsResponse.json();
const challenge = publicKey.challenge;
publicKey.challenge = decodeBase64Url(publicKey.challenge);
publicKey.user.id = decodeBase64Url(publicKey.user.id);
publicKey.excludeCredentials = (publicKey.excludeCredentials || []).map(item => ({
...item, id: decodeBase64Url(item.id),
}));
const credential = await credentials.create({ publicKey });
const response = await sessionFetch(base + 'api/v1/passkeys/registration/verify', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge,
credential: {
id: credential.id,
type: credential.type,
rawId: encodeBase64Url(credential.rawId),
response: {
attestationObject: encodeBase64Url(credential.response.attestationObject),
clientDataJSON: encodeBase64Url(credential.response.clientDataJSON),
transports: credential.response.getTransports?.() || [],
},
},
}),
});
if (!response.ok) throw new Error('Could not verify passkey enrollment');
return true;
}
async function listActiveDevices() {
const response = await sessionFetch(base + 'api/v1/sessions');
if (!response.ok) throw new Error('Could not load active devices');
@@ -343,6 +510,56 @@
return Array.isArray(payload.devices) ? payload.devices : [];
}
async function listPasskeys() {
const response = await sessionFetch(base + 'api/v1/passkeys');
if (!response.ok) throw new Error('Could not load enrolled passkeys');
const payload = await response.json();
return Array.isArray(payload.passkeys) ? payload.passkeys : [];
}
async function revokePasskey(passkey) {
if (!passkey?.management_id) return false;
const confirmed = confirmAction?.(
`Remove the passkey for ${passkey.device_label}? This cannot be undone.`
);
if (!confirmed) return false;
const response = await sessionFetch(
base + 'api/v1/passkeys/' + encodeURIComponent(passkey.management_id),
{ method: 'DELETE' },
);
if (!response.ok) throw new Error('Could not remove enrolled passkey');
return response.json();
}
function formatAuthenticationAlert(alert) {
const failed = Math.max(0, Number(alert?.failed_count) || 0);
const blocked = Math.max(0, Number(alert?.blocked_count) || 0);
const method = alert?.method === 'passkey' ? 'passkey' : 'token';
const title = `${failed} failed ${method} sign-in${failed === 1 ? '' : 's'}`
+ (blocked ? ` · ${blocked} blocked` : '');
const formatTime = value => new Date(Number(value) * 1000).toLocaleString(
'en-US', { timeZone: 'UTC' },
);
return {
title,
detail: `${formatTime(alert?.first_at)} – ${formatTime(alert?.last_at)}`,
};
}
async function listSecurityEvents(cursor = null) {
const query = new URLSearchParams({ limit: '25' });
if (Number.isInteger(cursor) && cursor > 0) query.set('cursor', String(cursor));
const response = await sessionFetch(base + 'api/v1/security-events?' + query);
if (!response.ok) throw new Error('Could not load security activity');
const payload = await response.json();
return {
events: Array.isArray(payload.events) ? payload.events : [],
authentication_alerts: Array.isArray(payload.authentication_alerts)
? payload.authentication_alerts : [],
next_cursor: Number.isInteger(payload.next_cursor) ? payload.next_cursor : null,
};
}
async function revokeActiveDevice(device) {
if (!device?.management_id || device.current) return false;
const confirmed = confirmAction?.(`Sign out ${device.device_label}?`);
@@ -386,10 +603,17 @@
signOut,
signOutAllDevices,
listActiveDevices,
listPasskeys,
listSecurityEvents,
formatAuthenticationAlert,
enrollPasskey,
revokeActiveDevice,
revokePasskey,
clearPrivateDeviceData,
handleServiceWorkerMessage,
refreshOfflineLease,
resumeQueuedWork,
recordActivity,
startActivityHeartbeat,
};
});
+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};
});
+155 -23
View File
@@ -5,22 +5,133 @@
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
const allowed = new Set(['new', 'find', 'search', 'search-preview', 'plan-today', 'plan-today-preview']);
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 } } : {}) }; },
};
};
});
+17
View File
@@ -0,0 +1,17 @@
function createTodayCompletion({ todayWork, todaySync, workSession, refresh, warm, announce, advance = null }) {
return function completeTodayItem(item, options = {}) {
if (!item || !todayWork.remove(item)) {
announce(options.failureMessage || 'Could not update Today on this device. Try again.');
return false;
}
todaySync.enqueue('remove', todayWork.identity(item));
todaySync.flush();
refresh();
warm();
(advance || (() => workSession.complete()))();
announce(options.successMessage || 'Done for Today. The Gitea item is unchanged.');
return true;
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayCompletion;
+93
View File
@@ -0,0 +1,93 @@
(function (root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.createTodayReadiness = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
return function createTodayReadiness({ inspect, onOpen = () => {}, onGate = () => {}, onPreview = () => {} }) {
let pending = null;
let generation = 0;
async function classify(item) {
if (item?.kind !== 'issue') return { status:'ready', dependencies:[] };
try {
const detail = await inspect(item);
if (detail?.available !== true) return { status:'unknown', dependencies:[] };
const dependencies = Array.isArray(detail.dependencies)
? detail.dependencies.filter(dependency => dependency?.state === 'open')
: [];
return { status:dependencies.length ? 'blocked' : 'ready', dependencies };
} catch (_error) {
return { status:'unknown', dependencies:[] };
}
}
async function run(action, items, target = null) {
const request = ++generation;
const ordered = Array.isArray(items) ? items.slice() : [];
const preferred = target || ordered[0] || null;
if (!preferred) return 'empty';
const preferredIndex = ordered.indexOf(preferred);
const candidates = preferredIndex >= 0
? ordered.slice(preferredIndex).concat(ordered.slice(0, preferredIndex))
: [preferred].concat(ordered);
const targetState = { item:preferred, ...(await classify(preferred)) };
if (request !== generation) return 'superseded';
if (targetState.status === 'ready') {
pending = null;
onOpen(action, preferred);
return 'opened';
}
const fallbackStates = await Promise.all(candidates.slice(1).map(async item => ({
item,
...(await classify(item)),
})));
const states = [targetState, ...fallbackStates];
if (request !== generation) return 'superseded';
const nextReadyState = states.slice(1).find(state => state.status === 'ready');
pending = {
action,
items:ordered,
target:preferred,
status:targetState.status,
dependencies:targetState.dependencies,
nextReady:nextReadyState?.item || null,
};
onGate({ ...pending, items:pending.items.slice() });
return 'gated';
}
function open(item) {
if (!pending || !item) return false;
const action = pending.action;
pending = null;
generation += 1;
onOpen(action, item);
return true;
}
function previewBlocker(blocker) {
if (!pending || !blocker) return false;
const current = pending.dependencies.find(dependency =>
dependency?.repository === blocker.repository && Number(dependency?.number) === Number(blocker.number)
);
if (!current) return false;
onPreview(current);
return true;
}
return {
run,
retry: () => pending ? run(pending.action, pending.items, pending.target) : Promise.resolve('closed'),
previewBlocker,
startNextReady: () => open(pending?.nextReady),
workAnyway: () => open(pending?.target),
cancel() {
generation += 1;
pending = null;
},
snapshot: () => pending ? { ...pending, items:pending.items.slice() } : { open:false },
};
};
});
+369
View File
@@ -0,0 +1,369 @@
function todayRecapFeedbackRows(draft, describe = () => null) {
return (draft?.items || []).map(item => {
const work = describe(item.identity) || {};
const estimate = Number.isInteger(item.estimate_minutes) ? item.estimate_minutes : null;
return {
identity:item.identity,
label:String(work.title || item.identity).slice(0, 180),
context:String(work.key || work.repository || 'Work details unavailable').slice(0, 180),
estimate_minutes:estimate,
actual_minutes:item.actual_minutes,
variance_minutes:estimate === null ? null : item.actual_minutes - estimate,
};
});
}
function createTodayRecap({ save, saveAndLog = null, clear, makeId = () => crypto.randomUUID(), storage = null, getLogin = () => '' }) {
const totals = items => {
const estimated = items.reduce((sum, item) => sum + (item.estimate_minutes ?? 0), 0);
const actual = items.reduce((sum, item) => sum + item.actual_minutes, 0);
return { estimated_minutes:estimated, actual_minutes:actual, variance_minutes:actual - estimated };
};
const storageKey = () => {
const login = String(getLogin() || '').trim().toLowerCase();
return login ? 'stackchain.today-recap-draft.v1.' + login : '';
};
const handoffKey = () => {
const login = String(getLogin() || '').trim().toLowerCase();
return login ? 'stackchain.today-recap-handoff.v1.' + login : '';
};
const validStoredDraft = saved => {
if (!saved || typeof saved.session_id !== 'string' || !saved.session_id.length || saved.session_id.length > 100 ||
!Array.isArray(saved.items) || !saved.items.length || saved.items.length > 20) return false;
const itemsValid = saved.items.every(item => item && typeof item.identity === 'string' && item.identity.length > 0 &&
item.identity.length <= 500 && (item.estimate_minutes === null ||
(Number.isInteger(item.estimate_minutes) && item.estimate_minutes >= 5 && item.estimate_minutes <= 1440)) &&
Number.isInteger(item.actual_minutes) && item.actual_minutes >= 0 && item.actual_minutes <= 1440);
if (!itemsValid) return false;
const identities = new Set(saved.items.map(item => item.identity));
if (saved.log_identities !== undefined && (!Array.isArray(saved.log_identities) ||
saved.log_identities.length > 20 || saved.log_identities.some(identity => !identities.has(identity)))) return false;
if (saved.time_logs !== undefined && (!saved.time_logs || typeof saved.time_logs !== 'object' ||
Array.isArray(saved.time_logs) || Object.entries(saved.time_logs).some(([identity, status]) =>
!identities.has(identity) || !['logged', 'retry', 'verify'].includes(status)))) return false;
return true;
};
const load = () => {
const key = storageKey();
if (!storage || !key) return null;
try {
const saved = JSON.parse(storage.getItem(key) || 'null');
if (!validStoredDraft(saved)) {
if (saved !== null) storage.removeItem(key);
return null;
}
return { ...saved, ...totals(saved.items) };
}
catch (_error) {
try { storage.removeItem(key); } catch (_ignored) {}
return null;
}
};
const validHandoff = saved => {
if (!saved || typeof saved.session_id !== 'string' || !saved.session_id.length || saved.session_id.length > 100 ||
!saved.actual_minutes || typeof saved.actual_minutes !== 'object' || Array.isArray(saved.actual_minutes)) return false;
const entries = Object.entries(saved.actual_minutes);
return entries.length > 0 && entries.length <= 20 && entries.every(([identity, minutes]) =>
identity.length > 0 && identity.length <= 500 && Number.isInteger(minutes) && minutes >= 0 && minutes <= 1440
);
};
const pendingReplan = () => {
const key = handoffKey();
if (!storage || !key) return null;
try {
const saved = JSON.parse(storage.getItem(key) || 'null');
if (!validHandoff(saved)) {
if (saved !== null) storage.removeItem(key);
return null;
}
return { session_id:saved.session_id, actual_minutes:{...saved.actual_minutes} };
} catch (_error) {
try { storage.removeItem(key); } catch (_ignored) {}
return null;
}
};
const completeReplan = () => {
const key = handoffKey();
if (storage && key) storage.removeItem(key);
};
let draftKey = storageKey();
let draft = load();
const persist = () => {
const key = storageKey();
if (!storage || !key || !draft) return;
const saved = { session_id:draft.session_id, items:draft.items };
if (draft.log_identities) saved.log_identities = draft.log_identities;
if (draft.time_logs) saved.time_logs = draft.time_logs;
storage.setItem(key, JSON.stringify(saved));
};
const restore = () => {
const key = storageKey();
if (key !== draftKey) {
draft = null;
draftKey = key;
}
if (!draft) draft = load();
return Boolean(draft);
};
const snapshot = () => {
restore();
return draft ? {
...draft, items:draft.items.map(item => ({...item})),
...(draft.log_identities ? {log_identities:[...draft.log_identities]} : {}),
...(draft.time_logs ? {time_logs:{...draft.time_logs}} : {}),
} : null;
};
const saveConfirmed = async includeActuals => {
restore();
if (!draft?.items.length) throw new Error('No timed work to save.');
const payload = { session_id:draft.session_id, items:draft.items.map(item => ({...item})) };
const actualMinutes = includeActuals ? Object.fromEntries(
payload.items.map(item => [item.identity, item.actual_minutes])
) : null;
const result = await save(payload);
if (includeActuals && storage && handoffKey()) storage.setItem(handoffKey(), JSON.stringify({
session_id:payload.session_id, actual_minutes:actualMinutes,
}));
clear();
if (storage && draftKey) storage.removeItem(draftKey);
draft = null;
return includeActuals ? { result, actual_minutes:actualMinutes } : result;
};
const saveWithTime = async identities => {
restore();
if (!draft?.items.length) throw new Error('No timed work to save.');
if (typeof saveAndLog !== 'function') throw new Error('Gitea time logging is unavailable.');
const requested = new Set(Array.isArray(identities) ? identities : []);
const eligible = new Set(draft.items.filter(item => item.actual_minutes > 0).map(item => item.identity));
const intended = [...new Set([...(draft.log_identities || []), ...requested])]
.filter(identity => eligible.has(identity));
if (!intended.length) throw new Error('Select at least one non-zero time entry.');
draft.log_identities = intended;
draft.time_logs = draft.time_logs || {};
persist();
const logIdentities = intended.filter(identity => !['logged', 'verify'].includes(draft.time_logs[identity]));
const payload = {
session_id:draft.session_id,
items:draft.items.map(item => ({...item})),
log_identities:logIdentities,
};
const result = await saveAndLog(payload);
Object.assign(draft.time_logs, Object.fromEntries((result.time_logs || []).map(item => [item.identity, item.status])));
persist();
const logged = intended.filter(identity => draft.time_logs[identity] === 'logged').length;
const verify = intended.filter(identity => draft.time_logs[identity] === 'verify').length;
const retry = intended.length - logged - verify;
if (retry || verify) {
if (verify) throw new Error(verify + ' time ' + (verify === 1 ? 'entry needs' : 'entries need') +
' verification in Gitea; it will not be posted again automatically.');
throw new Error(logged + ' time ' + (logged === 1 ? 'entry' : 'entries') + ' logged; ' + retry + ' needs retry.');
}
clear();
if (storage && draftKey) storage.removeItem(draftKey);
draft = null;
return result;
};
return {
restore,
begin(entries, estimates = {}) {
const items = (entries || []).filter(entry => entry?.identity).map(entry => ({
identity:String(entry.identity),
estimate_minutes:Number.isInteger(estimates[entry.identity]) ? estimates[entry.identity] : null,
actual_minutes:Math.min(1440, Math.max(0, Math.round(Number(entry.elapsed_ms || 0) / 60000))),
}));
draftKey = storageKey();
draft = { session_id:makeId(), items, ...totals(items) };
persist();
return snapshot();
},
correct(identity, minutes) {
restore();
if (!draft || !Number.isInteger(minutes) || minutes < 0 || minutes > 1440) return false;
const item = draft.items.find(candidate => candidate.identity === identity);
if (!item) return false;
item.actual_minutes = minutes;
Object.assign(draft, totals(draft.items));
persist();
return true;
},
snapshot,
save:() => saveConfirmed(false),
saveForReplan:() => saveConfirmed(true),
saveAndLog:saveWithTime,
pendingReplan,
completeReplan,
discardReplan:completeReplan,
};
}
async function saveTodayRecap(payload) {
const response = await fetch('api/v1/today/recaps', {
method:'POST', headers:{ Accept:'application/json', 'Content-Type':'application/json' },
body:JSON.stringify(payload),
});
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result.detail || 'Recap could not be saved.');
return result;
}
async function saveTodayRecapTime(payload) {
const response = await fetch('api/v1/today/recaps/log-time', {
method:'POST', headers:{ Accept:'application/json', 'Content-Type':'application/json' },
body:JSON.stringify(payload),
});
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result.detail || 'Recap time could not be logged.');
return result;
}
function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, escapeHtml,
describeWork = () => null, adjustPlan = () => {} }) {
const minutes = value => String(Math.max(0, Number(value) || 0)) + 'm';
const render = () => {
const draft = recap.snapshot();
const container = qs('#today-recap-items');
const rows = todayRecapFeedbackRows(draft, describeWork);
container.innerHTML = rows.map(item => {
const variance = item.variance_minutes === null ? 'No estimate comparison' :
(item.variance_minutes === 0 ? 'On estimate' : minutes(Math.abs(item.variance_minutes)) +
(item.variance_minutes > 0 ? ' over estimate' : ' under estimate'));
const canonical = /^(issue|pull):[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+:[1-9][0-9]*:$/.test(item.identity);
const logStatus = draft?.time_logs?.[item.identity];
const logControl = logStatus === 'logged' ?
'<span class="today-recap-log today-recap-log-success">Logged to Gitea</span>' :
logStatus === 'verify' ? '<span class="today-recap-log today-recap-log-verify">Verify this entry in Gitea</span>' :
item.actual_minutes > 0 && canonical ?
'<label class="today-recap-log"><input type="checkbox" data-recap-log-identity="' +
escapeHtml(item.identity) + '"> ' + (logStatus === 'retry' ? 'Retry ' : 'Log ') +
minutes(item.actual_minutes) + ' to Gitea</label>' :
'<span class="small muted today-recap-log">No non-zero Gitea time to log</span>';
return '<div class="today-recap-row"><span><strong>' + escapeHtml(item.label) + '</strong><span class="small today-recap-context">' +
escapeHtml(item.context) + ' · ' + (item.estimate_minutes === null ? 'Not estimated' : minutes(item.estimate_minutes) + ' estimated') +
'</span></span><span><input type="number" inputmode="numeric" min="0" max="1440" step="1" value="' +
item.actual_minutes + '" data-recap-identity="' + escapeHtml(item.identity) + '" aria-label="Actual minutes for ' +
escapeHtml(item.label) + '"> min</span><span class="today-recap-variance">' + variance + '</span>' + logControl + '</div>';
}).join('') || '';
qs('#today-recap-totals').textContent = draft ? minutes(draft.estimated_minutes) + ' estimated · ' +
minutes(draft.actual_minutes) + ' actual · ' + minutes(Math.abs(draft.variance_minutes)) +
(draft.variance_minutes >= 0 ? ' over' : ' under') : '';
container.querySelectorAll('[data-recap-identity]').forEach(input => input.addEventListener('change', () => {
if (!recap.correct(input.dataset.recapIdentity, Number(input.value))) {
qs('#today-recap-status').textContent = 'Actual time must be a whole number from 0 to 1,440 minutes.';
}
render();
}));
};
const loadHistory = async () => {
qs('#today-recap-history').innerHTML = '<div class="small">Loading recent recaps…</div>';
try {
const result = await api('api/v1/today/recaps');
qs('#today-recap-history').innerHTML = (result.recaps || []).map(saved =>
'<div class="today-recap-history-row"><span>' + new Date(saved.created_at * 1000).toLocaleString() +
'</span><strong>' + minutes(saved.actual_minutes) + ' actual · ' + minutes(Math.abs(saved.variance_minutes)) +
(saved.variance_minutes >= 0 ? ' over' : ' under') + '</strong></div>'
).join('') || '<div class="small">No saved recaps yet.</div>';
} catch (_error) {
qs('#today-recap-history').innerHTML = '<div class="small">Recent recaps are unavailable. Try again.</div>';
}
};
const open = ({ begin = false } = {}) => {
if (begin) recap.begin(timer.recapEntries(), todayWork.planning().estimates);
else recap.restore();
render();
qs('#save-today-recap').hidden = !recap.snapshot()?.items.length;
qs('#save-log-today-recap').hidden = !recap.snapshot()?.items.length;
qs('#today-recap-sheet').hidden = false;
document.body.classList.add('task-overlay-open');
loadHistory();
requestAnimationFrame(() => (qs('#today-recap-items input') || qs('#close-today-recap')).focus());
};
const close = () => {
qs('#today-recap-sheet').hidden = true;
document.body.classList.remove('task-overlay-open');
qs('#open-today-recaps').focus();
};
const saveDraft = async button => {
button.disabled = true;
qs('#today-recap-status').textContent = 'Saving recap…';
try {
const handoff = await recap.saveForReplan();
qs('#today-recap-status').textContent = 'Recap saved to your account.';
await loadHistory(); render(); button.hidden = true;
close();
adjustPlan(handoff.actual_minutes);
} catch (error) {
qs('#today-recap-status').textContent = error.message || 'Recap could not be saved. Your timer is unchanged.';
} finally { button.disabled = false; }
};
const saveTimeDraft = async button => {
const selected = Array.from(qs('#today-recap-items').querySelectorAll('[data-recap-log-identity]:checked'))
.map(input => input.dataset.recapLogIdentity);
if (!selected.length) {
qs('#today-recap-status').textContent = 'Select at least one non-zero time entry.';
return;
}
const actualMinutes = Object.fromEntries(recap.snapshot().items.map(item => [item.identity, item.actual_minutes]));
button.disabled = true;
qs('#today-recap-status').textContent = 'Logging selected time…';
try {
await recap.saveAndLog(selected);
qs('#today-recap-status').textContent = 'Selected time logged to Gitea.';
await loadHistory(); render(); button.hidden = true;
close();
adjustPlan(actualMinutes);
} catch (error) {
qs('#today-recap-status').textContent = error.message || 'Time could not be logged. Your recap is ready to retry.';
render();
} finally { button.disabled = false; }
};
const bind = () => {
qs('#open-today-recaps').addEventListener('click', () => open());
qs('#close-today-recap').addEventListener('click', close);
qs('#discard-today-recap').addEventListener('click', close);
qs('#save-today-recap').addEventListener('click', event => saveDraft(event.currentTarget));
qs('#save-log-today-recap').addEventListener('click', event => saveTimeDraft(event.currentTarget));
};
return {
open, close, saveDraft, saveTimeDraft, loadHistory, render, bind,
pendingReplan:recap.pendingReplan,
completeReplan:recap.completeReplan,
discardReplan:recap.discardReplan,
};
}
function endTodaySession(workSession, qs) {
workSession.end();
qs('#my-work-action-status').textContent = 'Today session ended. Your plan is unchanged.';
}
function openTodayRecapAfterSession(view, timer, timerView, workFilter, qs) {
timerView.finish();
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = true; });
qs('#my-work-action-status').textContent = 'Work session complete.';
if (workFilter === 'today' && timer.recapEntries().length) view.open({ begin:true });
else qs('#start-work-session').focus();
}
function setupTodayRecap(timer, timerView, todayWork, api, qs, escapeHtml, closeSheets, updateActions, getLogin,
describeWork, adjustPlan) {
const options = { timer, timerView, todayWork, api, qs, escapeHtml, describeWork, adjustPlan };
const recap = createTodayRecap({
save:saveTodayRecap, saveAndLog:saveTodayRecapTime,
clear:() => timer.clearRecap(), storage:localStorage, getLogin,
});
const view = createTodayRecapView({ ...options, recap });
view.bind();
setInterval(timerView.render, 1000);
view.finish = workFilter => {
closeSheets();
updateActions();
openTodayRecapAfterSession(view, timer, timerView, workFilter, qs);
};
return view;
}
if (typeof module !== 'undefined' && module.exports) {
createTodayRecap.createView = createTodayRecapView;
createTodayRecap.feedbackRows = todayRecapFeedbackRows;
module.exports = createTodayRecap;
}
+38
View File
@@ -0,0 +1,38 @@
function createTodayRollover(options = {}) {
const now = options.now || (() => new Date());
const resolvedTimeZone = options.resolvedTimeZone || (() =>
Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC');
function timeZone() {
return options.timeZone?.() || resolvedTimeZone();
}
function localDate() {
if (options.localDate) return options.localDate();
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: timeZone(), year: 'numeric', month: '2-digit', day: '2-digit',
}).formatToParts(now());
const value = Object.fromEntries(parts.map(part => [part.type, part.value]));
return `${value.year}-${value.month}-${value.day}`;
}
function reviewState(plan) {
if (!Array.isArray(plan?.ids) || !plan.ids.length) return 'empty';
if (!plan.plan_date) return 'legacy';
return plan.plan_date === localDate() ? 'current' : 'stale';
}
function operation({ operation_id, base_revision, selected_ids, capacity_minutes, estimates = {} }) {
const ids = [...new Set((selected_ids || []).filter(id => typeof id === 'string' && id))];
const selected = new Set(ids);
return {
operation_id, action: 'rollover', item_id: 'plan', base_revision,
plan_date: localDate(), timezone: timeZone(), ids, capacity_minutes,
estimates: Object.fromEntries(Object.entries(estimates).filter(([id]) => selected.has(id))),
};
}
return { localDate, timeZone, reviewState, operation };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayRollover;
+55 -5
View File
@@ -1,4 +1,4 @@
function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, createOperationId, createChannel, coordinator,
function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePlan, onStatus, createOperationId, createChannel, coordinator,
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000,
now = Date.now, maxOfflineMs = 30 * 24 * 60 * 60 * 1000 }) {
const prefix = 'stackchain.today-sync.v1.';
@@ -55,13 +55,22 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
function adopt(plan, broadcast = true) {
if (!Number.isInteger(plan?.revision) || !Array.isArray(plan?.ids)) return false;
if (plan.revision < savedRevision()) return false;
const snapshot = {
revision: plan.revision, ids: plan.ids,
capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {},
};
if (plan.plan_date) {
snapshot.plan_date = plan.plan_date;
snapshot.timezone = plan.timezone || null;
}
try {
storage?.setItem(snapshotKey(), JSON.stringify({ revision: plan.revision, ids: plan.ids }));
storage?.setItem(snapshotKey(), JSON.stringify(snapshot));
} catch (_error) {
// A storage quota failure must not prevent the current tab from using server truth.
}
onRemoteIds?.(plan.ids);
if (broadcast) channel?.postMessage({ revision: plan.revision, ids: plan.ids });
onRemotePlan?.(plan);
if (broadcast) channel?.postMessage(snapshot);
return true;
}
@@ -122,7 +131,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
? record.operation.base_revision : Math.max(0, savedRevision()),
}))
.filter(operation => operation && typeof operation.operation_id === 'string' &&
['add', 'remove', 'move'].includes(operation.action) && typeof operation.item_id === 'string');
['add', 'remove', 'move', 'configure', 'rollover'].includes(operation.action) && typeof operation.item_id === 'string');
} catch (_error) {
return [];
}
@@ -173,6 +182,47 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
return saved;
}
function enqueueConfiguration(capacityMinutes, estimates) {
const operation = {
operation_id: operationId(), action: 'configure', item_id: 'plan', direction: null,
capacity_minutes: capacityMinutes, estimates: estimates || {},
base_revision: Math.max(0, savedRevision()),
};
const storageKey = key();
if (!storageKey || !storage) return false;
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
try {
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
knownOperationKeys.add(recordKey);
coordinator?.notify('today');
onStatus?.('pending');
return true;
} catch (_error) {
onStatus?.('error');
return false;
}
}
function enqueueRollover(proposed) {
if (!proposed || proposed.action !== 'rollover') return false;
const operation = {
...proposed, operation_id: operationId(), base_revision: Math.max(0, savedRevision()),
};
const storageKey = key();
if (!storageKey || !storage) return false;
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
try {
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
knownOperationKeys.add(recordKey);
coordinator?.notify('today');
onStatus?.('pending');
return true;
} catch (_error) {
onStatus?.('error');
return false;
}
}
function migrate(ids) {
const storageKey = key();
if (!storageKey || !storage) return false;
@@ -253,7 +303,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
if (change.queue === 'today' && pending().length) flush();
});
return { enqueue, migrate, flush, pending, startLifecycle };
return { enqueue, enqueueConfiguration, enqueueRollover, migrate, flush, pending, startLifecycle };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
+374
View File
@@ -0,0 +1,374 @@
function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
const key = () => {
const login = String(getLogin?.() || '').trim().toLowerCase();
return login ? 'stackchain.today-timer.v1.' + encodeURIComponent(login) : '';
};
const empty = () => ({
version:1, active_identity:'', entries:{}, away_at:null,
pending_interruption:null, attention_interruption:null,
});
const read = () => {
const ownerKey = key();
if (!ownerKey || !storage) return empty();
try {
const value = JSON.parse(storage.getItem(ownerKey) || 'null');
if (value?.version !== 1 || typeof value.active_identity !== 'string' ||
!value.entries || typeof value.entries !== 'object') return empty();
return value;
} catch (_error) {
return empty();
}
};
const write = state => {
const ownerKey = key();
if (!ownerKey || !storage) return false;
try {
storage.setItem(ownerKey, JSON.stringify(state));
return true;
} catch (_error) {
return false;
}
};
const validPending = state => {
const pending = state.pending_interruption;
return pending && typeof pending.identity === 'string' && pending.identity &&
Number.isFinite(pending.away_ms) && pending.away_ms >= 0 ?
{ identity:pending.identity, away_ms:pending.away_ms } : null;
};
const validAttention = state => {
const pending = state.attention_interruption;
return pending && typeof pending.identity === 'string' && pending.identity &&
typeof pending.resume === 'boolean' ?
{ identity:pending.identity, resume:pending.resume } : null;
};
const settle = (state, at = now()) => {
const entry = state.entries[state.active_identity];
if (!entry?.running) return state;
entry.elapsed_ms = Math.max(0, Number(entry.elapsed_ms) || 0) +
Math.max(0, at - Number(entry.started_at ?? at));
entry.started_at = null;
entry.running = false;
return state;
};
const snapshot = (identity = '') => {
const state = read();
const selected = identity || state.active_identity;
const entry = state.entries[selected];
if (!selected || !entry) return { identity:selected, elapsed_ms:0, running:false };
const elapsed = Math.max(0, Number(entry.elapsed_ms) || 0) + (entry.running ?
Math.max(0, now() - Number(entry.started_at ?? now())) : 0);
return { identity:selected, elapsed_ms:elapsed, running:Boolean(entry.running) };
};
return {
activate(identity) {
if (!key() || typeof identity !== 'string' || !identity) return false;
const state = read();
if (state.active_identity === identity && state.entries[identity]?.running) return true;
settle(state);
state.active_identity = identity;
const entry = state.entries[identity] || { elapsed_ms:0, started_at:null, running:false };
entry.started_at = now();
entry.running = true;
state.entries[identity] = entry;
state.away_at = null;
state.pending_interruption = null;
state.attention_interruption = null;
return write(state);
},
pause() {
const state = read();
settle(state);
state.away_at = null;
return write(state);
},
resume() {
const state = read();
const entry = state.entries[state.active_identity];
if (!entry) return false;
if (!entry.running) {
entry.started_at = now();
entry.running = true;
}
return write(state);
},
stop() {
const state = read();
settle(state);
state.away_at = null;
state.pending_interruption = null;
state.attention_interruption = null;
return write(state);
},
beginAttention() {
const state = read();
const existing = validAttention(state);
if (existing) return existing;
const identity = state.active_identity;
const entry = state.entries[identity];
if (!identity || !entry) return null;
const resume = Boolean(entry.running);
if (resume) settle(state);
state.away_at = null;
state.attention_interruption = { identity, resume };
return write(state) ? { ...state.attention_interruption } : null;
},
attentionInterruption() {
return validAttention(read());
},
returnFromAttention() {
const state = read();
const pending = validAttention(state);
const entry = pending && state.entries[pending.identity];
if (!pending || !entry) return null;
state.active_identity = pending.identity;
if (pending.resume && !entry.running) {
entry.started_at = now();
entry.running = true;
}
state.attention_interruption = null;
state.away_at = null;
return write(state) ? { identity:pending.identity, resumed:pending.resume } : null;
},
markAway() {
const state = read();
const entry = state.entries[state.active_identity];
if (!entry?.running || validPending(state)) return false;
if (Number.isFinite(state.away_at) && state.away_at >= 0) return true;
state.away_at = now();
return write(state);
},
reconcileInterruption(thresholdMs = 5 * 60 * 1000) {
const state = read();
const existing = validPending(state);
if (existing) return existing;
state.pending_interruption = null;
const entry = state.entries[state.active_identity];
const awayAt = Number(state.away_at);
const detectedAt = now();
state.away_at = null;
if (!entry?.running || !Number.isFinite(awayAt) || awayAt < 0 || detectedAt < awayAt ||
detectedAt - awayAt < thresholdMs) {
write(state);
return null;
}
const awayMs = detectedAt - awayAt;
settle(state, detectedAt);
state.pending_interruption = { identity:state.active_identity, away_ms:awayMs };
write(state);
return { ...state.pending_interruption };
},
pendingInterruption() {
return validPending(read());
},
resolveInterruption(decision) {
if (decision !== 'count' && decision !== 'exclude') return false;
const state = read();
const pending = state.pending_interruption;
const entry = pending && state.entries[pending.identity];
if (!entry || !Number.isFinite(pending.away_ms) || pending.away_ms < 0) return false;
if (decision === 'exclude') {
entry.elapsed_ms = Math.max(0, Number(entry.elapsed_ms) - pending.away_ms);
}
state.active_identity = pending.identity;
entry.started_at = now();
entry.running = true;
state.pending_interruption = null;
state.away_at = null;
return write(state);
},
recapEntries() {
const state = read();
settle(state);
write(state);
return Object.entries(state.entries).map(([identity, entry]) => ({
identity,
elapsed_ms:Math.max(0, Number(entry.elapsed_ms) || 0),
})).filter(entry => entry.elapsed_ms > 0);
},
clearRecap() {
return write(empty());
},
totalElapsed() {
const state = read();
return Object.entries(state.entries).reduce((total, [identity, entry]) => {
const live = identity === state.active_identity && entry.running ?
Math.max(0, now() - Number(entry.started_at ?? now())) : 0;
return total + Math.max(0, Number(entry.elapsed_ms) || 0) + live;
}, 0);
},
snapshot,
};
}
function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRunway, getItem, onReopen, onComplete }) {
let progress = null;
let runway = null;
queryAll('[data-mobile-today-open]').forEach(button =>
button.addEventListener('click', () => {
const identity = timer.snapshot().identity;
if (identity) onReopen?.(identity);
})
);
queryAll('[data-mobile-today-toggle]').forEach(button =>
button.addEventListener('click', () => view.toggle())
);
queryAll('[data-mobile-today-complete]').forEach(button =>
button.addEventListener('click', () => {
const identity = timer.snapshot().identity;
if (identity) onComplete?.(identity);
})
);
if (typeof document !== 'undefined') queryAll('.work-session-nav').forEach(nav => {
const button = document.createElement('button');
button.type = 'button';
button.hidden = true;
button.dataset.workSessionAdjustPlan = '';
button.textContent = 'Adjust remaining plan';
nav.insertBefore(button, nav.querySelector('[data-work-session-complete]'));
});
if (typeof MutationObserver !== 'undefined') {
const plan = queryAll('#plan-today')[0];
const sheet = queryAll('#plan-today-sheet')[0];
if (plan && sheet) {
const replan = createTodayBudgetReplan({ timer, openPlan:() => plan.click() });
queryAll('[data-work-session-adjust-plan]').forEach(button =>
button.addEventListener('click', () => replan.open())
);
new MutationObserver(() => {
if (sheet.hidden && replan.restore()) render();
}).observe(sheet, { attributes:true, attributeFilter:['hidden'] });
}
}
const elapsed = milliseconds => {
const seconds = Math.max(0, Math.floor(Number(milliseconds || 0) / 1000));
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainder = String(seconds % 60).padStart(2, '0');
return hours ? hours + ':' + String(minutes).padStart(2, '0') + ':' + remainder : minutes + ':' + remainder;
};
const render = () => {
const snapshot = timer.snapshot();
const active = Boolean(progress && isActive() && snapshot.identity);
queryAll('[data-mobile-today-hud]').forEach(element => { element.hidden = !active; });
queryAll('[data-mobile-today-open]').forEach(element => {
element.textContent = active ? String(getItem?.(snapshot.identity)?.title || 'Current Today item') : '';
});
queryAll('[data-mobile-today-complete]').forEach(button => {
const itemTitle = String(getItem?.(snapshot.identity)?.title || 'current Today item');
const finalItem = Boolean(progress && progress.index >= progress.total);
button.hidden = !active;
button.textContent = finalItem ? 'Done & recap' : 'Done & next';
button.setAttribute('aria-label', finalItem ?
'Complete ' + itemTitle + ' and open Today recap' :
'Complete ' + itemTitle + ' and open next Today item');
});
if (typeof document !== 'undefined') document.body.classList.toggle('mobile-today-active', active);
if (!progress) return;
const sourceRunway = getRunway?.(snapshot) || runway;
const liveRunway = sourceRunway?.future_minutes !== undefined ? (() => {
const currentElapsed = Math.max(0, Math.ceil(snapshot.elapsed_ms / 60000));
const currentRemaining = sourceRunway.current_minutes === null ? null :
Math.max(0, sourceRunway.current_minutes - currentElapsed);
const remaining = currentRemaining === null || sourceRunway.future_minutes === null ? null :
currentRemaining + sourceRunway.future_minutes;
const projected = remaining === null ? null : Math.ceil(timer.totalElapsed() / 60000) + remaining;
const capacityRemaining = sourceRunway.capacity_minutes === null || projected === null ? null :
sourceRunway.capacity_minutes - projected;
return {
...sourceRunway,
remaining_minutes:remaining,
over_estimate_minutes:sourceRunway.current_minutes === null ? 0 :
Math.max(0, currentElapsed - sourceRunway.current_minutes),
over_capacity_minutes:capacityRemaining === null ? 0 : Math.max(0, -capacityRemaining),
};
})() : sourceRunway;
const timing = isActive() && snapshot.identity ? ' · ' + elapsed(snapshot.elapsed_ms) +
(liveRunway?.current_minutes ? ' / ' + formatEstimate(liveRunway.current_minutes) : '') : '';
const estimateRisk = liveRunway?.over_estimate_minutes ?
' · ' + formatEstimate(liveRunway.over_estimate_minutes) + ' over estimate' : '';
const capacityRisk = liveRunway?.over_capacity_minutes ?
' · Today projected ' + formatEstimate(liveRunway.over_capacity_minutes) + ' over capacity' : '';
queryAll('[data-work-session-progress]').forEach(element => {
element.textContent = 'Item ' + progress.index + ' of ' + progress.total + timing + estimateRisk + capacityRisk +
(!estimateRisk && !capacityRisk && liveRunway?.remaining_minutes ?
' · ' + formatEstimate(liveRunway.remaining_minutes) + ' remaining' : '');
});
queryAll('[data-work-session-adjust-plan]').forEach(button => {
button.hidden = !(liveRunway?.over_estimate_minutes || liveRunway?.over_capacity_minutes);
});
queryAll('[data-work-session-timer-toggle]').forEach(button => {
button.hidden = !isActive();
button.textContent = snapshot.running ? 'Pause timer' : 'Resume timer';
button.setAttribute('aria-pressed', String(!snapshot.running));
});
};
return {
open(identity, active) {
const current = timer.snapshot();
if (active && current.identity !== identity) timer.activate(identity);
else if (!active) timer.stop();
render();
},
finish() { timer.stop(); progress = null; runway = null; render(); },
update(nextProgress, nextRunway) { progress = nextProgress; runway = nextRunway; render(); },
reset() { progress = null; runway = null; render(); },
toggle() { const state = timer.snapshot(); state.running ? timer.pause() : timer.resume(); render(); },
render,
};
}
function createTodayBudgetReplan({ timer, openPlan }) {
let active = false;
let resume = false;
return {
open() {
if (active) return false;
const state = timer.snapshot();
if (!state.identity) return false;
resume = Boolean(state.running);
if (resume && timer.pause() === false) return false;
active = true;
openPlan?.(state);
return true;
},
restore() {
if (!active) return false;
active = false;
const shouldResume = resume;
resume = false;
return !shouldResume || timer.resume() !== false;
},
};
}
function createTodayInterruptionPrompt({ timer, sheet, description, getItemLabel, onResolved }) {
const render = pending => {
if (!pending) {
sheet.hidden = true;
return false;
}
const label = String(getItemLabel?.(pending.identity) || 'Current Today item');
const minutes = Math.max(1, Math.round(pending.away_ms / 60000));
description.textContent = label + ' · away for ' + minutes + ' minute' + (minutes === 1 ? '' : 's');
sheet.hidden = false;
return true;
};
return {
background() { return timer.markAway(); },
foreground() { return render(timer.reconcileInterruption()); },
restore() { return render(timer.pendingInterruption()); },
resolve(decision) {
if (!timer.resolveInterruption(decision)) return false;
sheet.hidden = true;
onResolved?.();
return true;
},
};
}
if (typeof module !== 'undefined' && module.exports) {
createTodayTimer.createView = createTodayTimerView;
createTodayTimer.createInterruptionPrompt = createTodayInterruptionPrompt;
createTodayTimer.createBudgetReplan = createTodayBudgetReplan;
module.exports = createTodayTimer;
}
+84 -6
View File
@@ -1,5 +1,6 @@
function createTodayWork({ storage, getLogin, limit = 5 }) {
const prefix = 'stackchain.today-work.v1.';
const planningPrefix = 'stackchain.today-planning.v1.';
function identity(item) {
if (!item) return '';
@@ -9,9 +10,17 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
return [kind, item.repository || '', number, notification].join(':');
}
function storageKey() {
function ownerKey(keyPrefix) {
const login = String(getLogin?.() || '').trim().toLowerCase();
return login ? prefix + encodeURIComponent(login) : '';
return login ? keyPrefix + encodeURIComponent(login) : '';
}
function storageKey() {
return ownerKey(prefix);
}
function planningKey() {
return ownerKey(planningPrefix);
}
function read() {
@@ -37,6 +46,40 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
}
}
function planning() {
const key = planningKey();
if (!key || !storage) return { capacity_minutes: null, estimates: {} };
try {
const value = JSON.parse(storage.getItem(key) || 'null');
const capacity = Number.isInteger(value?.capacity_minutes) && value.capacity_minutes > 0
? value.capacity_minutes : null;
const ids = new Set(read());
const estimates = Object.fromEntries(Object.entries(value?.estimates || {}).filter(([id, minutes]) =>
ids.has(id) && Number.isInteger(minutes) && minutes > 0
));
return { capacity_minutes: capacity, estimates };
} catch (_error) {
return { capacity_minutes: null, estimates: {} };
}
}
function replacePlanning(value) {
const key = planningKey();
if (!key || !storage) return false;
const ids = new Set(read());
const capacity = Number.isInteger(value?.capacity_minutes) && value.capacity_minutes > 0
? value.capacity_minutes : null;
const estimates = Object.fromEntries(Object.entries(value?.estimates || {}).filter(([id, minutes]) =>
ids.has(id) && Number.isInteger(minutes) && minutes > 0
));
try {
storage.setItem(key, JSON.stringify({ capacity_minutes: capacity, estimates }));
return true;
} catch (_error) {
return false;
}
}
function add(item) {
if (!storageKey()) return 'unavailable';
const id = identity(item);
@@ -48,6 +91,21 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
return write(ids) ? 'added' : 'unavailable';
}
function addMany(items) {
if (!storageKey()) return { status: 'unavailable', ids: [] };
const current = read();
const additions = [];
for (const item of items || []) {
const id = identity(item);
if (!id) return { status: 'unavailable', ids: [] };
if (!current.includes(id) && !additions.includes(id)) additions.push(id);
}
if (!additions.length) return { status: 'exists', ids: [] };
if (current.length + additions.length > limit) return { status: 'full', ids: [] };
return write(current.concat(additions)) ?
{ status: 'added', ids: additions } : { status: 'unavailable', ids: [] };
}
function replace(ids) {
const unique = [];
for (const id of ids || []) {
@@ -55,14 +113,18 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
unique.push(id);
}
}
return write(unique);
const saved = write(unique);
if (saved && planningKey()) replacePlanning(planning());
return saved;
}
function remove(item) {
const id = identity(item);
const ids = read();
const next = ids.filter(candidate => candidate !== id);
return next.length !== ids.length && write(next);
const saved = next.length !== ids.length && write(next);
if (saved) replacePlanning(planning());
return saved;
}
function move(item, direction) {
@@ -81,7 +143,10 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
const retired = ids.filter(id => !retained.includes(id));
if (retired.length) {
const reported = onPrune?.(retired);
if (reported !== false) write(retained);
if (reported !== false) {
write(retained);
replacePlanning(planning());
}
}
return retained.flatMap(id => available.has(id) ? [available.get(id)] : []);
}
@@ -99,7 +164,20 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
};
}
return { identity, read, replace, add, remove, move, reconcile, contains, position, limit };
function runway(items, currentIndex = 0) {
const plan = planning();
const minutes = (items || []).slice(Math.max(0, currentIndex)).map(item => plan.estimates[identity(item)] || null);
const total = values => values.some(value => value === null) ? null :
values.reduce((sum, value) => sum + value, 0);
return {
current_minutes: minutes[0] || null,
remaining_minutes: total(minutes),
future_minutes: total(minutes.slice(1)),
capacity_minutes: plan.capacity_minutes,
};
}
return { identity, read, replace, planning, replacePlanning, runway, add, addMany, remove, move, reconcile, contains, position, limit };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;
+97 -10
View File
@@ -1,5 +1,6 @@
function createUnfiledCaptures({
storage,
attachmentStore = null,
getCaptureLogin = () => '',
getCurrentLogin = () => '',
createId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
@@ -29,24 +30,101 @@ function createUnfiledCaptures({
.map(item => ({...item, quarantined: !currentLogin || currentLogin !== item.ownerLogin}));
}
function save(note) {
function capacity() {
const items = read().slice().sort((left, right) => Number(right.savedAt) - Number(left.savedAt));
return {full:items.length >= maxItems, count:items.length, maxItems, oldest:items.at(-1) || null};
}
function prepare(note) {
const title = String(note?.title || '').trim().slice(0, 255);
const body = String(note?.body || '').trim().slice(0, 10000);
if (!title) throw new Error('Add a title before saving.');
const ownerLogin = String(getCaptureLogin() || '').trim();
if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.');
const item = {id:String(createId()), ownerLogin, title, body, savedAt:Number(now())};
const items = [item, ...read().filter(existing => existing.id !== item.id)].slice(0, maxItems);
write(items);
return item;
const attachment = note?.attachment;
const hasAttachment = Boolean(attachment?.blob && attachment?.filename &&
['image/png', 'image/jpeg', 'image/webp'].includes(String(attachment?.contentType || '')));
if (attachment && !hasAttachment) throw new Error('The screenshot is unavailable. Choose it again before saving.');
if (hasAttachment && !attachmentStore) {
throw new Error('Screenshot storage is unavailable. Your capture is still open; retry after reloading.');
}
return {title, body, ownerLogin, attachment, hasAttachment};
}
function persist(prepared, existing, removed = null) {
const item = {
id:String(createId()), ownerLogin:prepared.ownerLogin, title:prepared.title, body:prepared.body,
savedAt:Number(now()), ...(prepared.hasAttachment ? {hasAttachment:true} : {}),
};
const items = [item, ...existing.filter(candidate => candidate.id !== item.id)];
const writeItems = () => {
try { write(items); }
catch (error) {
if (prepared.hasAttachment) Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
throw error;
}
return item;
};
const stage = prepared.hasAttachment
? Promise.resolve(attachmentStore.put(item.id, {
filename:String(prepared.attachment.filename).slice(0, 255),
contentType:String(prepared.attachment.contentType), blob:prepared.attachment.blob,
}))
: null;
if (!stage && !removed?.hasAttachment) return writeItems();
return Promise.resolve(stage).then(async () => {
if (!removed?.hasAttachment) return writeItems();
const removedAttachment = await attachmentStore.get(removed.id);
try {
await attachmentStore.delete(removed.id);
} catch (error) {
if (prepared.hasAttachment) await Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
throw error;
}
try { return writeItems(); }
catch (error) {
if (removedAttachment) await Promise.resolve(attachmentStore.put(removed.id, removedAttachment)).catch(() => {});
throw error;
}
});
}
function save(note) {
const prepared = prepare(note);
const existing = read();
if (existing.length >= maxItems) throw new Error('Drafts full — nothing was deleted.');
return persist(prepared, existing);
}
function replaceOldest(note, expectedOldestId) {
const prepared = prepare(note);
const existing = read().slice().sort((left, right) => Number(right.savedAt) - Number(left.savedAt));
const oldest = existing.at(-1);
if (existing.length < maxItems || !oldest || oldest.id !== expectedOldestId) {
throw new Error('Drafts changed. Review them before replacing anything.');
}
return persist(prepared, existing.filter(item => item.id !== oldest.id), oldest);
}
function discard(id) {
const items = read();
const remaining = items.filter(item => item.id !== id);
if (remaining.length === items.length) return false;
write(remaining);
return true;
const removed = items.find(item => item.id === id);
if (!removed?.hasAttachment) {
write(remaining);
return true;
}
return Promise.resolve(attachmentStore?.get(id)).then(removedAttachment =>
Promise.resolve(attachmentStore?.delete(id)).then(async () => {
try { write(remaining); }
catch (error) {
if (removedAttachment) await Promise.resolve(attachmentStore?.put(id, removedAttachment)).catch(() => {});
throw error;
}
return true;
})
);
}
function resume(id, confirmedLogin) {
@@ -55,11 +133,20 @@ function createUnfiledCaptures({
if (!confirmedLogin || String(confirmedLogin).trim() !== item.ownerLogin) {
throw new Error('Reconnect with the account that saved this capture.');
}
discard(id);
return {repository:'', title:item.title, body:item.body, labelIds:[]};
const draft = {repository:'', title:item.title, body:item.body, labelIds:[]};
if (!item.hasAttachment) return draft;
if (!attachmentStore) throw new Error('The saved screenshot is unavailable. Retry after reloading.');
return Promise.resolve(attachmentStore.get(id)).then(attachment => {
if (!attachment?.blob || !attachment?.filename || !attachment?.contentType) {
throw new Error('The saved screenshot is unavailable. Keep this Draft and retry.');
}
return {...draft, attachment};
});
}
return {list, save, discard, resume};
function completeResume(id) { return discard(id); }
return {list, capacity, save, replaceOldest, discard, resume, completeResume};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures;
+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);
+87
View File
@@ -0,0 +1,87 @@
function createUpdateFollowUp({ storage = null, getLogin = () => '' } = {}) {
const sourceKey = 'stackchain.update-follow-up-source.v1';
const clean = (value, limit) => String(value || '').replace(/\s+/g, ' ').trim().slice(0, limit);
const safeRepository = value => {
const candidate = String(value || '');
return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(candidate) &&
candidate.split('/').every(part => part !== '.' && part !== '..') ? candidate : '';
};
const safeUrl = value => {
try {
const parsed = new URL(String(value || ''));
return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? parsed.href : '';
} catch (_error) { return ''; }
};
function draft(detail = {}) {
const source = safeUrl(detail.url || detail.latest_comment?.url);
const context = clean(detail.latest_comment?.body || detail.subject_body, 9000);
const title = clean(detail.title, 228);
const sections = [];
if (source) sections.push('Source: ' + source);
if (context) sections.push('Latest context:\n> ' + context.replace(/\n/g, '\n> '));
return {
repository: safeRepository(detail.repository),
title: ('Follow up: ' + (title || 'Unread update')).slice(0, 240),
body: sections.join('\n\n').slice(0, 9500),
};
}
function readSource() {
const login = String(getLogin() || '').trim();
if (!login) return null;
try {
const record = JSON.parse(storage?.getItem(sourceKey) || 'null');
return record?.version === 1 && record.ownerLogin === login &&
Number.isInteger(record.source?.notification_id) && record.source.notification_id > 0 ? record : null;
} catch (_error) { return null; }
}
function writeSource(record) {
storage?.setItem(sourceKey, JSON.stringify(record));
}
function stageSource(item = {}) {
const ownerLogin = String(getLogin() || '').trim();
const notificationId = Number(item.notification_id);
if (!ownerLogin) throw new Error('Confirm your Gitea account before continuing this follow-up.');
if (!Number.isInteger(notificationId) || notificationId <= 0) throw new Error('Choose a valid source update.');
const source = {
notification_id: notificationId,
repository: safeRepository(item.repository),
number: Number.isInteger(item.number) && item.number > 0 ? item.number : null,
title: clean(item.title, 240), kind: 'update', has_update: true,
};
writeSource({ version:1, ownerLogin, source, admission:null });
return { ...source };
}
function source() {
const record = readSource();
return record ? { ...record.source } : null;
}
function discardSource() {
if (!readSource()) return false;
storage?.removeItem(sourceKey);
return true;
}
async function complete({ admit, queueRead, advance }) {
const record = readSource();
if (!record) throw new Error('Reopen the source update before continuing.');
let admission = record.admission;
if (!admission) {
admission = await admit();
writeSource({ ...record, admission });
}
await queueRead(record.source.notification_id);
const advanced = await advance({ ...record.source });
storage?.removeItem(sourceKey);
return { admission, advanced:Boolean(advanced) };
}
return { draft, stageSource, source, discardSource, complete };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateFollowUp;
+92 -12
View File
@@ -1,4 +1,6 @@
function createUpdateOwnership({ claim, addToday, onClaimed, onState }) {
function createUpdateOwnership({
claim, addToday, available, start, recover, onClaimed, onState, onStartState,
}) {
let detail = null;
let update = null;
let claimedItem = null;
@@ -8,6 +10,36 @@ function createUpdateOwnership({ claim, addToday, onClaimed, onState }) {
onState?.(value);
}
function startState(value) {
onStartState?.(value);
}
function claimed(result) {
claimedItem = {
...result,
repository: detail.repository,
kind: 'issue',
notification_id: update?.notification_id,
updated_at: update?.updated_at || result.updated_at,
has_update: true,
};
detail.issue.claimable = false;
onClaimed?.(claimedItem);
startState({ action: 'hidden', busy: false, message: '' });
return claimedItem;
}
function recoverStartedItem(item) {
recover?.(item);
startState({
action: 'hidden',
busy: false,
message: 'Assigned to you, but Today could not start. The update is still unread; the owned issue is open so you can recover.',
});
state({ action: 'today', busy: false, message: '' });
return 'recovery';
}
function open(nextDetail, nextUpdate) {
detail = nextDetail;
update = nextUpdate;
@@ -18,6 +50,11 @@ function createUpdateOwnership({ claim, addToday, onClaimed, onState }) {
busy: false,
message: '',
});
startState({
action: nextDetail?.issue?.claimable && available && start ? 'start' : 'hidden',
busy: false,
message: '',
});
}
function act() {
@@ -40,21 +77,13 @@ function createUpdateOwnership({ claim, addToday, onClaimed, onState }) {
repository: detail.repository,
number: detail.issue.number,
})).then(result => {
claimedItem = {
...result,
repository: detail.repository,
kind: 'issue',
notification_id: update?.notification_id,
updated_at: update?.updated_at || result.updated_at,
has_update: true,
};
detail.issue.claimable = false;
onClaimed?.(claimedItem);
claimed(result);
state({ action: 'today', busy: false, message: 'Assigned to you. The update is still unread.' });
return claimedItem;
}).catch(error => {
if (error?.status === 409) {
detail.issue.claimable = false;
startState({ action: 'hidden', busy: false, message: '' });
state({
action: 'hidden',
busy: false,
@@ -72,7 +101,58 @@ function createUpdateOwnership({ claim, addToday, onClaimed, onState }) {
return request;
}
return { open, act };
function startNow() {
if (request) return request;
if (!detail?.issue?.claimable || !available || !start) return Promise.resolve('hidden');
if (!available()) {
const message = 'Today is limited to 5 items. Remove one before taking ownership.';
startState({ action: 'start', busy: false, message });
return Promise.resolve('full');
}
startState({ action: 'start', busy: true, message: 'Assigning and starting…' });
state({ action: 'claim', busy: true, message: '' });
request = Promise.resolve(claim({
repository: detail.repository,
number: detail.issue.number,
})).then(result => {
const item = claimed(result);
const outcome = start(item);
if (outcome === 'started') {
startState({
action: 'hidden',
busy: false,
message: 'Assigned, added to Today, and ready to work. The update is still unread.',
});
state({ action: 'today', busy: false, message: '' });
return outcome;
}
return recoverStartedItem(item);
}).catch(error => {
if (claimedItem) {
return recoverStartedItem(claimedItem);
}
if (error?.status === 409) {
detail.issue.claimable = false;
state({ action: 'hidden', busy: false, message: '' });
startState({
action: 'hidden',
busy: false,
message: 'Someone else claimed or closed this issue. The update is still unread.',
});
return 'conflict';
}
state({ action: 'claim', busy: false, message: '' });
startState({
action: 'start',
busy: false,
message: (error?.message || 'The issue could not be assigned.') + ' The update and your reply draft are safe; retry.',
});
return 'retry';
}).finally(() => { request = null; });
return request;
}
return { open, act, start:startNow };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateOwnership;
+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();
},
};
});
+64
View File
@@ -0,0 +1,64 @@
function createUpdateReplyReadNext({
post, markRead, queue, canQueue, deliver, accept = () => undefined, next = () => undefined,
}) {
let inFlight = null;
async function admit(item, body, operationId, replyConfirmed) {
const admission = await queue({
kind: 'update-reply-read',
notificationId: item.notification_id,
body,
operationId,
replyConfirmed,
});
if (!admission || (!admission.item && admission.durable !== true)) {
throw new Error('Reply and acknowledgement were not saved for delivery.');
}
accept(item);
return {
accepted: true,
delivery: admission.background ? 'queued' : 'saved',
next: await next(item),
};
}
function submit(item, body, operationId, attachment = null) {
if (inFlight) return inFlight;
inFlight = (async () => {
let replyConfirmed = false;
try {
if (attachment) {
const admission = await queue({
kind:'update-reply-read', notificationId:item.notification_id, body,
operationId, replyConfirmed:false, attachment,
});
if (!admission?.item) throw new Error('Reply and screenshot were not saved for delivery.');
const outcome = deliver ? await deliver(admission.item) : null;
const remaining = outcome?.remaining?.find(candidate => candidate.id === admission.item.id);
if (remaining?.status === 'attention') {
throw new Error(remaining.error || 'Reply and screenshot need attention.');
}
accept(item);
return { accepted:true, delivery:outcome?.confirmed?.length ? 'posted' : 'queued', next:await next(item) };
}
try {
await post(item, body, operationId);
replyConfirmed = true;
await markRead(item.notification_id);
} catch (error) {
if (!canQueue(error)) throw error;
return await admit(item, body, operationId, replyConfirmed);
}
accept(item);
return { accepted: true, delivery: 'posted', next: await next(item) };
} finally {
inFlight = null;
}
})();
return inFlight;
}
return { submit, busy: () => Boolean(inFlight) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateReplyReadNext;
+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 -14
View File
@@ -5,19 +5,6 @@ function renderRepoMix(element, data) {
element.innerHTML = '<div class="kv"><div class="label">Repos</div><div class="value">' + repos.length + '</div><div class="label">Open issues</div><div class="value">' + issues.length + '</div><div class="label">Open PRs</div><div class="value">' + pullRequests.length + '</div></div>';
}
async function updateRepoMix(element, fetchContext = fetch) {
try {
const response = await fetchContext('api/v1/context', {
headers: { Accept: 'application/json' },
});
if (!response.ok) throw new Error('HTTP ' + response.status);
renderRepoMix(element, await response.json());
} catch (error) {
element.innerHTML = '<div class="muted">Widget unavailable.</div>';
}
}
if (typeof module !== 'undefined' && module.exports) {
updateRepoMix.renderRepoMix = renderRepoMix;
module.exports = updateRepoMix;
module.exports = renderRepoMix;
}
+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}}});
+29
View File
@@ -6,6 +6,7 @@
'use strict';
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
const queueFilters = ['today', 'agenda', 'attention', 'update', 'later', 'draft'];
function positiveInteger(value) {
const number = Number(value);
@@ -15,6 +16,13 @@
function parse(fragment) {
const parts = String(fragment || '').split('/');
if (parts[0] !== '#' || parts[1] !== 'my-work') return null;
const queue = parts[2]?.replace(/s$/, '');
if (queueFilters.includes(queue) && parts.length === 3) {
return { kind: 'queue', filter: queue };
}
if (queue === 'agenda' && parts[3] === 'protect-today' && parts.length === 4) {
return { kind: 'queue', filter: 'agenda', action: 'protect-today' };
}
if (parts[2] === 'update' && parts.length === 4) {
const notificationId = positiveInteger(parts[3]);
return notificationId ? { kind: 'update', notification_id: notificationId } : null;
@@ -38,6 +46,7 @@
}
function sameRoute(item, route) {
if (route.kind === 'queue') return false;
if (route.kind === 'update') {
return Number(item.notification_id) === route.notification_id;
}
@@ -48,6 +57,7 @@
function createController({
location, history, eventTarget, onOpen, onClose, onInvalid,
onQueue = function () {},
resolve, onResolving = function () {}, onError = function () {},
}) {
let items = [];
@@ -100,6 +110,16 @@
active = '';
return;
}
if (route.kind === 'queue') {
if (!ready) return;
resolution += 1;
resolving = '';
if (active && active !== fragment) onClose();
if (active === fragment) return;
active = fragment;
onQueue(route.filter, route.action || null);
return;
}
const item = items.find(candidate => sameRoute(candidate, route));
if (!item) {
if (ready) resolveMissing(fragment, route);
@@ -134,6 +154,15 @@
onOpen(item);
return true;
},
queue(filter) {
if (!queueFilters.includes(filter)) return false;
const name = filter + (['update', 'draft'].includes(filter) ? 's' : '');
const fragment = '#/my-work/' + name;
if (location.hash !== fragment) history.pushState(null, '', fragment);
active = fragment;
onQueue(filter);
return true;
},
close() {
if (parse(location.hash)) history.back();
else {
+92
View File
@@ -0,0 +1,92 @@
function createWorkSelection({ limit = 50, onChange = () => {} } = {}) {
const maximum = Number.isInteger(limit) && limit > 0 ? limit : 50;
let active = false;
const selected = new Set();
function identity(item) {
if (!item) return '';
const kind = item.is_review ? 'review' : (item.kind || 'work');
const number = Number.isInteger(item.number) ? item.number : '';
const notification = Number.isInteger(item.notification_id) ? item.notification_id : '';
return [kind, item.repository || '', number, notification].join(':');
}
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 select(item) {
if (!active) return 'inactive';
const id = identity(item);
if (!id) return 'invalid';
if (selected.has(id)) return 'selected';
if (selected.size >= maximum) return 'limit';
selected.add(id);
changed();
return 'selected';
}
function toggle(item) {
if (!active) return 'inactive';
const id = identity(item);
if (!id) return 'invalid';
if (selected.has(id)) {
selected.delete(id);
changed();
return 'removed';
}
return select(item);
}
function selectMany(items) {
if (!active) return { status: 'inactive', added: 0, count: selected.size, limit: maximum };
let added = 0;
let capped = false;
for (const item of items || []) {
const id = identity(item);
if (!id || selected.has(id)) continue;
if (selected.size >= maximum) {
capped = true;
break;
}
selected.add(id);
added += 1;
}
changed();
return { status: capped ? 'limit' : 'selected', added, count: selected.size, limit: maximum };
}
function clear() {
if (!active) return snapshot();
selected.clear();
return changed();
}
function retain(items) {
const retained = new Set((items || []).map(identity).filter(Boolean));
Array.from(selected).forEach(id => { if (!retained.has(id)) selected.delete(id); });
return changed();
}
function snapshot() {
return { active, count: selected.size, ids: Array.from(selected) };
}
return { identity, start, cancel, select, selectMany, clear, toggle, retain, snapshot, limit: maximum };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createWorkSelection;
+4
View File
@@ -1,5 +1,9 @@
fastapi==0.133.1
httpx==0.28.1
pydantic==2.13.4
python-multipart==0.0.22
pytest==9.1.1
pywebpush==2.1.2
rjsmin==1.2.5
uvicorn==0.41.0
webauthn==3.0.0
+208
View File
@@ -0,0 +1,208 @@
"""Worker-shared Find Work catalog state."""
from __future__ import annotations
import json
import os
import secrets
import stat
import sqlite3
import time
from dataclasses import dataclass
from pathlib import Path
class RefreshLeaseLost(RuntimeError):
pass
@dataclass(frozen=True)
class AvailableIssueSnapshotState:
items: list[dict] | None
created_at: float | None
retry_at: float | None
refreshing: bool
lease_expires_at: float | None
class AvailableIssueSnapshotStore:
def __init__(self, path, *, clock=None):
self.path = Path(path)
self.clock = clock or time.time
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(self.path.parent, stat.S_IRWXU)
old_umask = os.umask(0o077)
try:
with self._connect() as connection:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS available_issue_snapshot (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
items_json TEXT,
created_at REAL,
retry_at REAL
);
CREATE TABLE IF NOT EXISTS available_issue_refresh_lease (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
owner TEXT NOT NULL,
expires_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS available_issue_claim (
repository TEXT NOT NULL,
number INTEGER NOT NULL,
PRIMARY KEY (repository, number)
);
"""
)
connection.execute(
"INSERT OR IGNORE INTO available_issue_snapshot VALUES (1, NULL, NULL, NULL)"
)
finally:
os.umask(old_umask)
os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
def _connect(self):
connection = sqlite3.connect(self.path, timeout=1.0, isolation_level=None)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA busy_timeout = 1000")
return connection
def try_acquire_refresh(self, *, lease_seconds: float) -> str | None:
if lease_seconds <= 0:
raise ValueError("refresh lease duration must be positive")
now = self.clock()
owner = secrets.token_hex(16)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
active = connection.execute(
"SELECT expires_at FROM available_issue_refresh_lease WHERE singleton = 1"
).fetchone()
if active is not None and active["expires_at"] > now:
connection.rollback()
return None
connection.execute("DELETE FROM available_issue_refresh_lease WHERE singleton = 1")
connection.execute(
"INSERT INTO available_issue_refresh_lease VALUES (1, ?, ?)",
(owner, now + lease_seconds),
)
connection.commit()
return owner
def load(self) -> AvailableIssueSnapshotState:
now = self.clock()
with self._connect() as connection:
row = connection.execute(
"SELECT * FROM available_issue_snapshot WHERE singleton = 1"
).fetchone()
lease = connection.execute(
"SELECT expires_at FROM available_issue_refresh_lease "
"WHERE singleton = 1 AND expires_at > ?", (now,)
).fetchone()
return AvailableIssueSnapshotState(
items=json.loads(row["items_json"]) if row["items_json"] else None,
created_at=row["created_at"],
retry_at=row["retry_at"],
refreshing=lease is not None,
lease_expires_at=lease["expires_at"] if lease else None,
)
def publish(self, owner: str | None, *, items: list[dict]) -> AvailableIssueSnapshotState:
now = self.clock()
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
lease = connection.execute(
"SELECT owner, expires_at FROM available_issue_refresh_lease WHERE singleton = 1"
).fetchone()
if (
owner is None
or lease is None
or lease["owner"] != owner
or lease["expires_at"] <= now
):
connection.rollback()
raise RefreshLeaseLost("available issue refresh lease expired or changed owner")
claimed = {
(row["repository"], row["number"])
for row in connection.execute(
"SELECT repository, number FROM available_issue_claim"
)
}
items = [
item for item in items
if (item.get("repository"), item.get("number")) not in claimed
]
connection.execute(
"UPDATE available_issue_snapshot SET items_json = ?, created_at = ?, "
"retry_at = NULL WHERE singleton = 1",
(json.dumps(items, separators=(",", ":")), now),
)
connection.execute("DELETE FROM available_issue_refresh_lease WHERE singleton = 1")
connection.commit()
return self.load()
def remove_claimed(self, repository: str, number: int) -> AvailableIssueSnapshotState:
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
connection.execute(
"INSERT OR IGNORE INTO available_issue_claim VALUES (?, ?)",
(repository, number),
)
row = connection.execute(
"SELECT items_json FROM available_issue_snapshot WHERE singleton = 1"
).fetchone()
items = json.loads(row["items_json"]) if row["items_json"] else None
if items is not None:
items = [
item for item in items
if item.get("repository") != repository or item.get("number") != number
]
connection.execute(
"UPDATE available_issue_snapshot SET items_json = ? WHERE singleton = 1",
(json.dumps(items, separators=(",", ":")),),
)
connection.commit()
return self.load()
def invalidate(self) -> AvailableIssueSnapshotState:
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
connection.execute(
"UPDATE available_issue_snapshot SET items_json = NULL, "
"created_at = NULL, retry_at = NULL WHERE singleton = 1"
)
connection.execute("DELETE FROM available_issue_claim")
connection.commit()
return self.load()
def release_refresh(self, owner: str | None) -> None:
if owner is None:
return
with self._connect() as connection:
connection.execute(
"DELETE FROM available_issue_refresh_lease "
"WHERE singleton = 1 AND owner = ?",
(owner,),
)
def fail_refresh(self, owner: str | None, *, retry_at: float) -> AvailableIssueSnapshotState:
now = self.clock()
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
lease = connection.execute(
"SELECT owner, expires_at FROM available_issue_refresh_lease WHERE singleton = 1"
).fetchone()
if (
owner is None
or lease is None
or lease["owner"] != owner
or lease["expires_at"] <= now
):
connection.rollback()
raise RefreshLeaseLost("available issue refresh lease expired or changed owner")
connection.execute(
"UPDATE available_issue_snapshot SET retry_at = ? WHERE singleton = 1",
(retry_at,),
)
connection.execute("DELETE FROM available_issue_refresh_lease WHERE singleton = 1")
connection.commit()
return self.load()
+4 -2
View File
@@ -25,7 +25,7 @@ def _quality(accept_encoding: str, coding: str) -> float:
class NegotiatedGZipMiddleware:
"""Compress substantial responses only when the client permits gzip."""
def __init__(self, app: ASGIApp, minimum_size: int = 500, compresslevel: int = 9):
def __init__(self, app: ASGIApp, minimum_size: int = 500, compresslevel: int = 6):
self.app = app
self.minimum_size = minimum_size
self.compresslevel = compresslevel
@@ -36,7 +36,9 @@ class NegotiatedGZipMiddleware:
return
accepted = Headers(scope=scope).get("Accept-Encoding", "")
if _quality(accepted, "gzip") > 0:
path = scope.get("path", "").rsplit("/", 1)[-1]
immutable_runtime = path.startswith("runtime-") and path.endswith(".js")
if _quality(accepted, "gzip") > 0 and not immutable_runtime:
responder: ASGIApp = GZipResponder(
self.app, self.minimum_size, compresslevel=self.compresslevel
)
+100 -6
View File
@@ -10,6 +10,7 @@ import os
import secrets
import time
from dataclasses import dataclass
from urllib.parse import urlsplit
from fastapi import Request
@@ -18,6 +19,7 @@ from src.session_store import SessionStore, SessionStoreError
SESSION_COOKIE = "stackchain_session"
CSRF_COOKIE = "stackchain_csrf"
DEFAULT_TTL_SECONDS = 8 * 60 * 60
DEFAULT_IDLE_TIMEOUT_SECONDS = 15 * 60
STEP_UP_TTL_SECONDS = 90
MIN_SECRET_LENGTH = 24
OPERATOR_MODE = "operator"
@@ -29,6 +31,7 @@ class Session:
session_id: str
csrf: str
expires_at: int
idle_expires_at: int | None = None
@dataclass(frozen=True)
@@ -45,6 +48,33 @@ def mode() -> str:
return os.getenv("STACKCHAIN_DASHBOARD_AUTH_MODE", OPERATOR_MODE).strip().lower()
def public_origin() -> str | None:
"""Return the canonical HTTPS browser origin, or None when it is invalid."""
configured = os.getenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "").strip()
if not configured:
return None
try:
parsed = urlsplit(configured)
port = parsed.port
except ValueError:
return None
if (
parsed.scheme != "https"
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.path not in {"", "/"}
or parsed.query
or parsed.fragment
):
return None
hostname = parsed.hostname.lower()
authority = f"[{hostname}]" if ":" in hostname else hostname
if port is not None and port != 443:
authority += f":{port}"
return f"https://{authority}"
def configuration_error() -> str | None:
configured_mode = mode()
if configured_mode == INSECURE_LOCAL_MODE:
@@ -57,6 +87,8 @@ def configuration_error() -> str | None:
return "operator mode requires two sufficiently long secrets"
if hmac.compare_digest(operator_secret, signing_secret):
return "operator and session secrets must be independent"
if public_origin() is None:
return "operator mode requires a canonical HTTPS public origin"
return None
@@ -96,8 +128,23 @@ def _session_store(now: int | None = None) -> SessionStore:
return SessionStore(database, clock=current)
def idle_timeout_seconds() -> int:
return max(
1,
int(
os.getenv(
"STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS",
str(DEFAULT_IDLE_TIMEOUT_SECONDS),
)
),
)
def issue_session(
now: int | None = None, *, device_label: str = "This device"
now: int | None = None,
*,
device_label: str = "This device",
management_id: str | None = None,
) -> tuple[str, Session]:
issued_at = int(time.time() if now is None else now)
ttl = int(os.getenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", str(DEFAULT_TTL_SECONDS)))
@@ -114,7 +161,10 @@ def issue_session(
encoded = _encode(payload)
signature = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest())
_session_store(now).activate(
session.session_id, session.expires_at, device_label=device_label
session.session_id,
session.expires_at,
device_label=device_label,
management_id=management_id,
)
return f"{encoded}.{signature}", session
@@ -146,9 +196,23 @@ def verify_session_with_reason(
or not session.csrf
):
return SessionVerification(None)
if not _session_store(now).is_active(session.session_id, session.expires_at):
status = _session_store(now).status(
session.session_id,
session.expires_at,
idle_timeout_seconds=idle_timeout_seconds(),
)
if status == "idle":
return SessionVerification(None, "session_idle")
if status != "active":
return SessionVerification(None, "session_revoked")
return SessionVerification(session)
return SessionVerification(
Session(
session_id=session.session_id,
csrf=session.csrf,
expires_at=session.expires_at,
idle_expires_at=getattr(status, "idle_expires_at", None),
)
)
def verify_session(value: str | None, now: int | None = None) -> Session | None:
@@ -167,6 +231,36 @@ async def active_devices(session: Session):
return await asyncio.to_thread(_session_store().list_active, session.session_id)
async def session_management_id(session: Session) -> str:
return await asyncio.to_thread(_session_store().management_id, session.session_id)
async def managed_session_active(management_id: str) -> bool:
status = await asyncio.to_thread(
_session_store().managed_status,
management_id,
idle_timeout_seconds=idle_timeout_seconds(),
)
return status == "active"
async def managed_session_statuses(management_ids) -> dict[str, str]:
return await asyncio.to_thread(
_session_store().managed_statuses,
management_ids,
idle_timeout_seconds=idle_timeout_seconds(),
)
async def touch_session(session: Session) -> bool:
return await asyncio.to_thread(
_session_store().touch,
session.session_id,
session.expires_at,
idle_timeout_seconds=idle_timeout_seconds(),
)
async def revoke_managed_session(management_id: str) -> bool:
return await asyncio.to_thread(_session_store().revoke_managed, management_id)
@@ -218,5 +312,5 @@ def application_path(request: Request) -> str:
def same_origin(request: Request) -> bool:
origin = request.headers.get("origin", "")
expected = f"{request.url.scheme}://{request.url.netloc}"
return bool(origin) and hmac.compare_digest(origin.rstrip("/"), expected)
expected = public_origin()
return bool(origin and expected) and hmac.compare_digest(origin.rstrip("/"), expected)
+130 -15
View File
@@ -2,23 +2,63 @@
from __future__ import annotations
import gzip
import hashlib
import re
from dataclasses import dataclass
from pathlib import Path
import rjsmin
SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', re.MULTILINE)
COMMONJS_EXPORT_LINE = re.compile(
rb"^\s*if \(typeof module[^\n]+module\.exports[^\n]+;\s*$(?!\n\s*else)", re.MULTILINE
)
COMMONJS_BROWSER_BRANCH = re.compile(
rb"^\s*if \(typeof module[^\n]+module\.exports[^\n]+;\s*\n\s*else\s*\{",
re.MULTILINE,
)
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
FEATURE_SOURCES = {
"comment-actions": ("static/conversation.js", "static/comment-actions.js"),
"issue-capture": ("static/create-issue-sheet.js", "static/update-follow-up.js"),
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
"push-notifications": ("static/push-notifications.js",),
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
"security-center": ("static/security-center.js",),
"today-timer": (
"static/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(
r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';"
)
@dataclass(frozen=True)
class RuntimeBundle:
runtime_bytes: bytes
runtime_gzip_bytes: bytes
runtime_digest: str
runtime_name: str
@dataclass(frozen=True)
class FrontendBuild:
dashboard_html: str
runtime_bytes: bytes
runtime_gzip_bytes: bytes
runtime_digest: str
runtime_name: str
shell_digest: str
page_sources: tuple[str, ...]
feature_bundles: dict[str, RuntimeBundle]
service_worker_source: str
@@ -27,7 +67,25 @@ def _bundle(frontend_dir: Path, sources: tuple[str, ...]) -> bytes:
for source in sources:
path = frontend_dir / source.removeprefix("static/")
chunks.append(f"/* {source} */\n".encode() + path.read_bytes() + b"\n;\n")
return b"".join(chunks)
source = b"".join(chunks)
# Node-only export shims support source-level unit tests but are unreachable
# in the browser. Strip the simple one-line form from shipped bundles.
source = COMMONJS_BROWSER_BRANCH.sub(b"{", source)
source = COMMONJS_EXPORT_LINE.sub(b"", source)
revision = hashlib.sha256(source).hexdigest()
minified = rjsmin.jsmin(source.decode()).encode()
return minified + f';"source-sha256:{revision}";'.encode()
def _runtime(frontend_dir: Path, sources: tuple[str, ...], prefix: str) -> RuntimeBundle:
content = _bundle(frontend_dir, sources)
digest = hashlib.sha256(content).hexdigest()[:16]
return RuntimeBundle(
runtime_bytes=content,
runtime_gzip_bytes=gzip.compress(content, compresslevel=6, mtime=0),
runtime_digest=digest,
runtime_name=f"{prefix}-{digest}.js",
)
def build_frontend(frontend_dir: Path) -> FrontendBuild:
@@ -37,34 +95,91 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
if not sources:
raise ValueError("dashboard entry document has no page scripts")
runtime = _bundle(frontend_dir, sources)
digest = hashlib.sha256(runtime).hexdigest()[:16]
runtime_name = f"runtime-{digest}.js"
feature_source_names = {source for group in FEATURE_SOURCES.values() for source in group}
core_sources = tuple(source for source in sources if source not in feature_source_names)
core = _runtime(frontend_dir, core_sources, "runtime")
feature_bundles = {
name: _runtime(frontend_dir, feature_sources, f"feature-{name}")
for name, feature_sources in FEATURE_SOURCES.items()
}
dashboard_html = SCRIPT_TAG.sub("", source_html)
feature_metadata = "\n".join(
f'<meta name="stackchain-feature-{name}" content="{bundle.runtime_name}">'
for name, bundle in feature_bundles.items()
)
dashboard_html = dashboard_html.replace("</head>", feature_metadata + "\n</head>")
dashboard_html = dashboard_html.replace(
"</body>", f'<script src="{runtime_name}"></script>\n</body>'
"</body>",
f'<script src="{feature_bundles["today-timer"].runtime_name}"></script>\n'
f'<script src="{core.runtime_name}"></script>\n</body>',
)
worker = (frontend_dir / "service-worker.js").read_text()
for source in sources:
if source != WORKER_RUNTIME_SOURCE:
worker = worker.replace(f" BASE + '{source}',\n", "")
eager_feature = feature_bundles["today-timer"]
optional_features = {
name: bundle for name, bundle in feature_bundles.items() if name != "today-timer"
}
worker = worker.replace(
" BASE + 'static/dashboard.css',\n",
f" BASE + 'static/dashboard.css',\n BASE + '{runtime_name}',\n",
" BASE + 'static/dashboard.css',\n"
+ f" BASE + '{core.runtime_name}',\n"
+ f" BASE + '{eager_feature.runtime_name}',\n",
)
worker = re.sub(
r"const CACHE = 'stackchain-dashboard-shell-v\d+';",
f"const CACHE = 'stackchain-dashboard-shell-{digest}';",
worker,
count=1,
worker = worker.replace(
"const OPTIONAL_FEATURES = [\n",
"const OPTIONAL_FEATURES = [\n"
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in optional_features.values()),
)
worker = CACHE_DECLARATION.sub(
"const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1
)
shell_hasher = hashlib.sha256()
shell_inputs = (
("dashboard.html", dashboard_html.encode()),
(core.runtime_name, core.runtime_bytes),
*((bundle.runtime_name, bundle.runtime_bytes) for bundle in feature_bundles.values()),
("static/dashboard.css", (frontend_dir / "dashboard.css").read_bytes()),
("manifest.webmanifest", (frontend_dir / "manifest.webmanifest").read_bytes()),
(
"static/icons/stackchain-192.png",
(frontend_dir / "icons/stackchain-192.png").read_bytes(),
),
(
"static/icons/stackchain-512.png",
(frontend_dir / "icons/stackchain-512.png").read_bytes(),
),
(
WORKER_RUNTIME_SOURCE,
(
frontend_dir / WORKER_RUNTIME_SOURCE.removeprefix("static/")
).read_bytes(),
),
("service-worker.js", worker.encode()),
)
for name, content in shell_inputs:
shell_hasher.update(len(name).to_bytes(4, "big"))
shell_hasher.update(name.encode())
shell_hasher.update(len(content).to_bytes(8, "big"))
shell_hasher.update(content)
shell_digest = shell_hasher.hexdigest()[:16]
worker = worker.replace(
"const CACHE = 'stackchain-dashboard-shell-BUILD';",
f"const CACHE = 'stackchain-dashboard-shell-{shell_digest}';",
1,
)
return FrontendBuild(
dashboard_html=dashboard_html,
runtime_bytes=runtime,
runtime_digest=digest,
runtime_name=runtime_name,
runtime_bytes=core.runtime_bytes,
runtime_gzip_bytes=core.runtime_gzip_bytes,
runtime_digest=core.runtime_digest,
runtime_name=core.runtime_name,
shell_digest=shell_digest,
page_sources=sources,
feature_bundles=feature_bundles,
service_worker_source=worker,
)
)
+833 -22
View File
File diff suppressed because it is too large. Load diff
+10 -2
View File
@@ -22,10 +22,14 @@ class LaterStore:
self.operation_limit = operation_limit
self.operation_retention_seconds = operation_retention_seconds
self.clock = clock
self._initialize()
def _connect(self) -> sqlite3.Connection:
def _initialize(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
connection = sqlite3.connect(self.path, timeout=self.timeout)
if connection.execute("PRAGMA user_version").fetchone()[0] >= 1:
connection.close()
return
connection.execute("PRAGMA journal_mode=WAL")
connection.execute(
"""
@@ -63,8 +67,12 @@ class LaterStore:
"UPDATE later_operations SET created_at = ? WHERE created_at IS NULL",
(self.clock(),),
)
connection.execute("PRAGMA user_version = 1")
connection.commit()
return connection
connection.close()
def _connect(self) -> sqlite3.Connection:
return sqlite3.connect(self.path, timeout=self.timeout)
def _record_operation(self, connection: sqlite3.Connection, login: str, operation_id: str) -> None:
now = self.clock()
+332
View File
@@ -0,0 +1,332 @@
"""Process-shared live snapshot state and refresh coordination."""
from __future__ import annotations
import json
import os
import secrets
import sqlite3
import stat
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Iterable
SECTIONS = ("context", "events", "notifications")
class RefreshLeaseLost(RuntimeError):
"""The refresh is no longer authorized to publish shared state."""
@dataclass(frozen=True)
class LiveSnapshotState:
value: dict | None
created_at: dict[str, float | None]
failure_count: dict[str, int]
retry_at: dict[str, float | None]
revisions: dict[str, int]
generation: str
refreshing_sections: set[str]
lease_expires_at: float | None
@dataclass(frozen=True)
class LiveSnapshotMetadata:
"""Shared snapshot coordination fields without the potentially large value."""
created_at: dict[str, float | None]
failure_count: dict[str, int]
retry_at: dict[str, float | None]
revisions: dict[str, int]
generation: str
refreshing_sections: set[str]
lease_expires_at: float | None
class LiveSnapshotStore:
"""A private SQLite snapshot with an expiring, cross-process refresh lease."""
def __init__(
self,
path: str | os.PathLike[str],
*,
clock: Callable[[], float] | None = None,
):
self.path = Path(path)
self.clock = clock or time.time
self._initialize()
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.path, timeout=1.0, isolation_level=None)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA busy_timeout = 1000")
return connection
def _initialize(self) -> None:
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
try:
os.chmod(self.path.parent, stat.S_IRWXU)
except OSError:
pass
old_umask = os.umask(0o077)
try:
with self._connect() as connection:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS live_snapshot (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
value_json TEXT,
created_at_json TEXT NOT NULL,
failure_count_json TEXT NOT NULL,
retry_at_json TEXT NOT NULL,
revisions_json TEXT NOT NULL,
generation TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS live_refresh_lease (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
owner TEXT NOT NULL,
sections_json TEXT NOT NULL,
expires_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS live_read_notification (
notification_id INTEGER PRIMARY KEY
);
"""
)
connection.execute(
"""INSERT OR IGNORE INTO live_snapshot VALUES
(1, NULL, ?, ?, ?, ?, ?)""",
(
json.dumps({section: None for section in SECTIONS}),
json.dumps({section: 0 for section in SECTIONS}),
json.dumps({section: None for section in SECTIONS}),
json.dumps({section: 0 for section in SECTIONS}),
secrets.token_hex(8),
),
)
finally:
os.umask(old_umask)
os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
def try_acquire_refresh(
self, sections: Iterable[str], *, lease_seconds: float
) -> str | None:
requested = sorted(set(sections))
if not requested or any(section not in SECTIONS for section in requested):
raise ValueError("refresh lease requires known sections")
if lease_seconds <= 0:
raise ValueError("refresh lease duration must be positive")
now = self.clock()
owner = secrets.token_hex(16)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
active = connection.execute(
"SELECT expires_at FROM live_refresh_lease WHERE singleton = 1"
).fetchone()
if active is not None and active["expires_at"] > now:
connection.rollback()
return None
connection.execute("DELETE FROM live_refresh_lease WHERE singleton = 1")
connection.execute(
"INSERT INTO live_refresh_lease VALUES (1, ?, ?, ?)",
(owner, json.dumps(requested, separators=(",", ":")), now + lease_seconds),
)
connection.commit()
return owner
def load(self) -> LiveSnapshotState:
now = self.clock()
with self._connect() as connection:
row = connection.execute(
"SELECT * FROM live_snapshot WHERE singleton = 1"
).fetchone()
lease = connection.execute(
"SELECT sections_json, expires_at FROM live_refresh_lease "
"WHERE singleton = 1 AND expires_at > ?",
(now,),
).fetchone()
assert row is not None
return LiveSnapshotState(
value=json.loads(row["value_json"]) if row["value_json"] is not None else None,
created_at=json.loads(row["created_at_json"]),
failure_count=json.loads(row["failure_count_json"]),
retry_at=json.loads(row["retry_at_json"]),
revisions=json.loads(row["revisions_json"]),
generation=row["generation"],
refreshing_sections=set(json.loads(lease["sections_json"])) if lease else set(),
lease_expires_at=lease["expires_at"] if lease else None,
)
def load_metadata(self) -> LiveSnapshotMetadata:
"""Load freshness and coordination state without reading the snapshot value."""
now = self.clock()
with self._connect() as connection:
row = connection.execute(
"""SELECT created_at_json, failure_count_json, retry_at_json,
revisions_json, generation FROM live_snapshot WHERE singleton = 1"""
).fetchone()
lease = connection.execute(
"SELECT sections_json, expires_at FROM live_refresh_lease "
"WHERE singleton = 1 AND expires_at > ?",
(now,),
).fetchone()
assert row is not None
return LiveSnapshotMetadata(
created_at=json.loads(row["created_at_json"]),
failure_count=json.loads(row["failure_count_json"]),
retry_at=json.loads(row["retry_at_json"]),
revisions=json.loads(row["revisions_json"]),
generation=row["generation"],
refreshing_sections=set(json.loads(lease["sections_json"])) if lease else set(),
lease_expires_at=lease["expires_at"] if lease else None,
)
def publish_refresh(
self,
owner: str,
*,
value: dict,
created_at: dict[str, float | None],
failure_count: dict[str, int],
retry_at: dict[str, float | None],
changed_sections: Iterable[str],
) -> LiveSnapshotState:
changed = set(changed_sections)
if changed - set(SECTIONS):
raise ValueError("publication contains unknown sections")
if any(set(mapping) != set(SECTIONS) for mapping in (created_at, failure_count, retry_at)):
raise ValueError("publication metadata must contain every section")
now = self.clock()
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
lease = connection.execute(
"SELECT owner, expires_at FROM live_refresh_lease WHERE singleton = 1"
).fetchone()
if lease is None or lease["owner"] != owner or lease["expires_at"] <= now:
connection.rollback()
raise RefreshLeaseLost("live refresh lease expired or changed owner")
row = connection.execute(
"SELECT revisions_json FROM live_snapshot WHERE singleton = 1"
).fetchone()
revisions = json.loads(row["revisions_json"])
notifications = value.get("notifications")
if isinstance(notifications, list):
returned_ids = {
item.get("id") for item in notifications if isinstance(item, dict)
}
read_ids = {
item["notification_id"]
for item in connection.execute(
"SELECT notification_id FROM live_read_notification"
)
}
value = dict(value)
value["notifications"] = [
item for item in notifications
if not isinstance(item, dict) or item.get("id") not in read_ids
]
if returned_ids:
placeholders = ",".join("?" for _ in returned_ids)
connection.execute(
f"DELETE FROM live_read_notification WHERE notification_id NOT IN ({placeholders})",
tuple(returned_ids),
)
else:
connection.execute("DELETE FROM live_read_notification")
for section in changed:
revisions[section] += 1
connection.execute(
"""UPDATE live_snapshot SET value_json = ?, created_at_json = ?,
failure_count_json = ?, retry_at_json = ?, revisions_json = ?
WHERE singleton = 1""",
(
json.dumps(value, separators=(",", ":")),
json.dumps(created_at, separators=(",", ":")),
json.dumps(failure_count, separators=(",", ":")),
json.dumps(retry_at, separators=(",", ":")),
json.dumps(revisions, separators=(",", ":")),
),
)
connection.execute(
"DELETE FROM live_refresh_lease WHERE singleton = 1 AND owner = ?", (owner,)
)
connection.commit()
return self.load()
def remove_notifications(self, notification_ids: Iterable[int]) -> LiveSnapshotState:
"""Filter confirmed reads from every worker and subsequent stale refreshes."""
read_ids = set(notification_ids)
if not read_ids:
return self.load()
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
connection.executemany(
"INSERT OR IGNORE INTO live_read_notification VALUES (?)",
((notification_id,) for notification_id in read_ids),
)
row = connection.execute(
"SELECT value_json, revisions_json FROM live_snapshot WHERE singleton = 1"
).fetchone()
value = json.loads(row["value_json"]) if row["value_json"] else None
revisions = json.loads(row["revisions_json"])
if value is not None and isinstance(value.get("notifications"), list):
previous = value["notifications"]
retained = [
item
for item in previous
if not isinstance(item, dict) or item.get("id") not in read_ids
]
if retained != previous:
value["notifications"] = retained
revisions["notifications"] += 1
connection.execute(
"UPDATE live_snapshot SET value_json = ?, revisions_json = ? WHERE singleton = 1",
(
json.dumps(value, separators=(",", ":")),
json.dumps(revisions, separators=(",", ":")),
),
)
connection.commit()
return self.load()
def remove_notification(self, notification_id: int) -> LiveSnapshotState:
"""Filter one confirmed read from every worker and subsequent stale refresh."""
return self.remove_notifications([notification_id])
def release_refresh(self, owner: str) -> None:
"""Relinquish a lease that became unnecessary after a coherent recheck."""
with self._connect() as connection:
connection.execute(
"DELETE FROM live_refresh_lease WHERE singleton = 1 AND owner = ?",
(owner,),
)
def fail_refresh(
self,
owner: str,
*,
failure_count: dict[str, int],
retry_at: dict[str, float | None],
) -> LiveSnapshotState:
"""Publish backoff metadata and relinquish a failed refresh atomically."""
now = self.clock()
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
lease = connection.execute(
"SELECT owner, expires_at FROM live_refresh_lease WHERE singleton = 1"
).fetchone()
if lease is None or lease["owner"] != owner or lease["expires_at"] <= now:
connection.rollback()
raise RefreshLeaseLost("live refresh lease expired or changed owner")
connection.execute(
"""UPDATE live_snapshot SET failure_count_json = ?, retry_at_json = ?
WHERE singleton = 1""",
(
json.dumps(failure_count, separators=(",", ":")),
json.dumps(retry_at, separators=(",", ":")),
),
)
connection.execute("DELETE FROM live_refresh_lease WHERE singleton = 1")
connection.commit()
return self.load()
+146 -1
View File
@@ -49,6 +49,9 @@ class LoginAttemptStore:
window_seconds: int,
max_entries: int = 10_000,
lock_timeout_seconds: float = 0.1,
alert_bucket_seconds: int = 60 * 60,
alert_retention_seconds: int = 30 * 24 * 60 * 60,
max_alert_buckets: int = 720,
) -> None:
self.path = Path(path)
self.clock = clock
@@ -56,6 +59,9 @@ class LoginAttemptStore:
self.window_seconds = max(1, window_seconds)
self.max_entries = max(1, max_entries)
self.lock_timeout_seconds = lock_timeout_seconds
self.alert_bucket_seconds = max(1, alert_bucket_seconds)
self.alert_retention_seconds = max(1, alert_retention_seconds)
self.max_alert_buckets = max(1, max_alert_buckets)
@staticmethod
def _digest(source: str) -> str:
@@ -74,6 +80,30 @@ class LoginAttemptStore:
)
"""
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS login_admissions (
bucket TEXT NOT NULL,
source_hash TEXT NOT NULL,
attempts INTEGER NOT NULL,
window_started_at REAL NOT NULL,
PRIMARY KEY (bucket, source_hash)
)
"""
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS login_alerts (
method TEXT NOT NULL,
bucket_started_at INTEGER NOT NULL,
failed_count INTEGER NOT NULL DEFAULT 0,
blocked_count INTEGER NOT NULL DEFAULT 0,
first_at INTEGER NOT NULL,
last_at INTEGER NOT NULL,
PRIMARY KEY (method, bucket_started_at)
)
"""
)
return connection
except (OSError, sqlite3.Error) as exc:
raise LoginAttemptStoreError(
@@ -97,7 +127,81 @@ class LoginAttemptStore:
remaining = row[1] + self.window_seconds - now
return max(0, math.ceil(remaining))
def record_failure(self, source: str) -> None:
def admit(self, bucket: str, source: str, *, limit: int) -> int:
"""Consume one fixed-window admission or return seconds until retry."""
now = self.clock()
source_hash = self._digest(source)
limit = max(1, limit)
try:
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
connection.execute(
"DELETE FROM login_admissions WHERE window_started_at + ? <= ?",
(self.window_seconds, now),
)
row = connection.execute(
"SELECT attempts, window_started_at FROM login_admissions "
"WHERE bucket = ? AND source_hash = ?",
(bucket, source_hash),
).fetchone()
if row is not None and row[0] >= limit:
return max(1, math.ceil(row[1] + self.window_seconds - now))
if row is None:
connection.execute(
"INSERT INTO login_admissions VALUES (?, ?, 1, ?)",
(bucket, source_hash, now),
)
else:
connection.execute(
"UPDATE login_admissions SET attempts = attempts + 1 "
"WHERE bucket = ? AND source_hash = ?",
(bucket, source_hash),
)
connection.execute(
"""
DELETE FROM login_admissions
WHERE (bucket, source_hash) NOT IN (
SELECT bucket, source_hash FROM login_admissions
ORDER BY window_started_at DESC, rowid DESC LIMIT ?
)
""",
(self.max_entries,),
)
return 0
except (OSError, sqlite3.Error) as exc:
raise LoginAttemptStoreError(
"Sign-in throttling is temporarily unavailable"
) from exc
def _record_alert(
self, connection: sqlite3.Connection, method: str, *, blocked: bool, now: float
) -> None:
safe_method = method if method in {"token", "passkey"} else "unknown"
occurred_at = int(now)
bucket = occurred_at - (occurred_at % self.alert_bucket_seconds)
failed = 0 if blocked else 1
blocked_count = 1 if blocked else 0
connection.execute(
"""
INSERT INTO login_alerts(
method, bucket_started_at, failed_count, blocked_count, first_at, last_at
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(method, bucket_started_at) DO UPDATE SET
failed_count = failed_count + excluded.failed_count,
blocked_count = blocked_count + excluded.blocked_count,
first_at = MIN(first_at, excluded.first_at),
last_at = MAX(last_at, excluded.last_at)
""",
(safe_method, bucket, failed, blocked_count, occurred_at, occurred_at),
)
connection.execute(
"DELETE FROM login_alerts WHERE (method, bucket_started_at) NOT IN "
"(SELECT method, bucket_started_at FROM login_alerts "
"ORDER BY bucket_started_at DESC LIMIT ?)",
(self.max_alert_buckets,),
)
def record_failure(self, source: str, *, method: str = "token") -> None:
now = self.clock()
source_hash = self._digest(source)
try:
@@ -131,11 +235,52 @@ class LoginAttemptStore:
""",
(self.max_entries,),
)
self._record_alert(connection, method, blocked=False, now=now)
except (OSError, sqlite3.Error) as exc:
raise LoginAttemptStoreError(
"Sign-in throttling is temporarily unavailable"
) from exc
def record_blocked(self, *, method: str = "token") -> None:
now = self.clock()
try:
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
self._record_alert(connection, method, blocked=True, now=now)
except (OSError, sqlite3.Error) as exc:
raise LoginAttemptStoreError(
"Sign-in throttling is temporarily unavailable"
) from exc
def list_alerts(self, *, limit: int = 24) -> list[dict[str, int | str]]:
bounded_limit = min(100, max(1, limit))
now = int(self.clock())
try:
with self._connect() as connection:
connection.execute(
"DELETE FROM login_alerts WHERE last_at < ?",
(now - self.alert_retention_seconds,),
)
rows = connection.execute(
"SELECT method, failed_count, blocked_count, first_at, last_at "
"FROM login_alerts ORDER BY bucket_started_at DESC LIMIT ?",
(bounded_limit,),
).fetchall()
except (OSError, sqlite3.Error) as exc:
raise LoginAttemptStoreError(
"Sign-in throttling is temporarily unavailable"
) from exc
return [
{
"method": row[0],
"failed_count": row[1],
"blocked_count": row[2],
"first_at": row[3],
"last_at": row[4],
}
for row in rows
]
def clear(self, source: str) -> None:
try:
with self._connect() as connection:
+2804 -116
View File
File diff suppressed because it is too large. Load diff
+338
View File
@@ -0,0 +1,338 @@
"""Durable, one-time WebAuthn challenges and device-bound passkey credentials."""
import hashlib
import sqlite3
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
from src.session_store import SessionStoreError
@dataclass(frozen=True)
class StoredPasskey:
credential_id: bytes
public_key: bytes
sign_count: int
device_label: str
management_id: str
created_at: int
class PasskeyStore:
def __init__(
self,
path: str | Path,
*,
clock: Callable[[], float],
max_challenges: int = 10_000,
max_challenges_per_source: int = 10,
) -> None:
self.path = Path(path)
self.clock = clock
self.max_challenges = max(1, max_challenges)
self.max_challenges_per_source = max(1, max_challenges_per_source)
@staticmethod
def _digest(value: bytes | str) -> str:
raw = value if isinstance(value, bytes) else value.encode()
return hashlib.sha256(raw).hexdigest()
def _connect(self) -> sqlite3.Connection:
try:
self.path.parent.mkdir(parents=True, exist_ok=True)
connection = sqlite3.connect(self.path, timeout=0.1)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS passkey_credentials (
credential_id BLOB PRIMARY KEY,
public_key BLOB NOT NULL,
sign_count INTEGER NOT NULL,
device_label TEXT NOT NULL,
management_id TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL
)
"""
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS passkey_challenges (
challenge_hash TEXT PRIMARY KEY,
session_hash TEXT,
source_hash TEXT,
purpose TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT NOT NULL,
expires_at INTEGER NOT NULL
)
"""
)
columns = {
row[1]
for row in connection.execute("PRAGMA table_info(passkey_challenges)")
}
if "source_hash" not in columns:
connection.execute(
"ALTER TABLE passkey_challenges ADD COLUMN source_hash TEXT"
)
return connection
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def issue_challenge(
self,
challenge: bytes,
*,
session_id: str | None,
purpose: str,
action: str,
target: str,
source: str | None = None,
ttl_seconds: int = 120,
) -> None:
now = int(self.clock())
source_hash = self._digest(source or session_id or f"{purpose}:{action}:{target}")
try:
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
connection.execute("DELETE FROM passkey_challenges WHERE expires_at <= ?", (now,))
connection.execute(
"INSERT INTO passkey_challenges("
"challenge_hash, session_hash, source_hash, purpose, action, target, expires_at"
") VALUES (?, ?, ?, ?, ?, ?, ?)",
(
self._digest(challenge),
self._digest(session_id) if session_id else None,
source_hash,
purpose,
action,
target,
now + max(1, ttl_seconds),
),
)
connection.execute(
"""
DELETE FROM passkey_challenges
WHERE source_hash = ? AND challenge_hash NOT IN (
SELECT challenge_hash FROM passkey_challenges
WHERE source_hash = ?
ORDER BY expires_at DESC, rowid DESC LIMIT ?
)
""",
(source_hash, source_hash, self.max_challenges_per_source),
)
connection.execute(
"""
DELETE FROM passkey_challenges
WHERE challenge_hash NOT IN (
SELECT challenge_hash FROM passkey_challenges
ORDER BY expires_at DESC, rowid DESC LIMIT ?
)
""",
(self.max_challenges,),
)
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def consume_challenge(
self,
challenge: bytes,
*,
session_id: str | None,
purpose: str,
action: str,
target: str,
) -> bool:
now = int(self.clock())
try:
with self._connect() as connection:
connection.execute("DELETE FROM passkey_challenges WHERE expires_at <= ?", (now,))
cursor = connection.execute(
"DELETE FROM passkey_challenges WHERE challenge_hash = ? "
"AND session_hash IS ? AND purpose = ? AND action = ? AND target = ? "
"AND expires_at > ?",
(
self._digest(challenge),
self._digest(session_id) if session_id else None,
purpose,
action,
target,
now,
),
)
return cursor.rowcount == 1
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def register(
self,
*,
credential_id: bytes,
public_key: bytes,
sign_count: int,
device_label: str,
management_id: str,
) -> None:
try:
with self._connect() as connection:
connection.execute(
"INSERT INTO passkey_credentials(credential_id, public_key, sign_count, "
"device_label, management_id, created_at) VALUES (?, ?, ?, ?, ?, ?)",
(
credential_id,
public_key,
sign_count,
device_label,
management_id,
int(self.clock()),
),
)
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def all(self) -> list[StoredPasskey]:
try:
with self._connect() as connection:
rows = connection.execute(
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
"FROM passkey_credentials ORDER BY created_at DESC"
).fetchall()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
return [StoredPasskey(*row) for row in rows]
def get(self, credential_id: bytes) -> StoredPasskey | None:
try:
with self._connect() as connection:
row = connection.execute(
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
"FROM passkey_credentials WHERE credential_id = ?",
(credential_id,),
).fetchone()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
return StoredPasskey(*row) if row else None
def get_management_id(self, management_id: str) -> StoredPasskey | None:
try:
with self._connect() as connection:
row = connection.execute(
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
"FROM passkey_credentials WHERE management_id = ?",
(management_id,),
).fetchone()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
return StoredPasskey(*row) if row else None
def advance_counter(
self, credential_id: bytes, *, expected: int, new: int
) -> bool:
if expected == 0 and new == 0:
comparison = "sign_count = 0"
elif new > expected:
comparison = "sign_count = ?"
else:
return False
try:
with self._connect() as connection:
parameters = (
(new, credential_id)
if expected == 0 and new == 0
else (new, credential_id, expected)
)
cursor = connection.execute(
f"UPDATE passkey_credentials SET sign_count = ? "
f"WHERE credential_id = ? AND {comparison}",
parameters,
)
return cursor.rowcount == 1
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def revoke_management_id(self, management_id: str) -> bool:
try:
with self._connect() as connection:
cursor = connection.execute(
"DELETE FROM passkey_credentials WHERE management_id = ?", (management_id,)
)
return cursor.rowcount == 1
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def revoke_access(
self, management_id: str, *, preserve_session: bool
) -> tuple[bool, bool]:
"""Atomically remove one passkey and, when requested, its active session."""
try:
with self._connect() as connection:
credential = connection.execute(
"SELECT 1 FROM passkey_credentials WHERE management_id = ?",
(management_id,),
).fetchone()
if credential is None:
return False, False
session_revoked = False
if not preserve_session:
session = connection.execute(
"SELECT session_hash FROM active_sessions WHERE management_id = ?",
(management_id,),
).fetchone()
if session is not None:
connection.execute(
"DELETE FROM step_up_grants WHERE session_hash = ?", (session[0],)
)
cursor = connection.execute(
"DELETE FROM active_sessions WHERE management_id = ?",
(management_id,),
)
session_revoked = cursor.rowcount == 1
cursor = connection.execute(
"DELETE FROM passkey_credentials WHERE management_id = ?",
(management_id,),
)
return cursor.rowcount == 1, session_revoked
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def revoke_all(self) -> None:
try:
with self._connect() as connection:
connection.execute("DELETE FROM passkey_credentials")
connection.execute("DELETE FROM passkey_challenges")
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def revoke_device_access(self, management_id: str) -> bool:
"""Atomically remove one active session, its grants, and its linked passkey."""
try:
with self._connect() as connection:
session = connection.execute(
"SELECT session_hash FROM active_sessions WHERE management_id = ?",
(management_id,),
).fetchone()
if session is None:
return False
connection.execute(
"DELETE FROM step_up_grants WHERE session_hash = ?", (session[0],)
)
connection.execute(
"DELETE FROM passkey_credentials WHERE management_id = ?", (management_id,)
)
cursor = connection.execute(
"DELETE FROM active_sessions WHERE management_id = ?", (management_id,)
)
return cursor.rowcount == 1
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
def revoke_all_access(self) -> None:
"""Atomically remove every passkey, challenge, grant, and active session."""
try:
with self._connect() as connection:
connection.execute("DELETE FROM passkey_credentials")
connection.execute("DELETE FROM passkey_challenges")
connection.execute("DELETE FROM step_up_grants")
connection.execute("DELETE FROM active_sessions")
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
+87
View File
@@ -0,0 +1,87 @@
"""WebAuthn passkey ceremony helpers for the single dashboard operator."""
import base64
import json
import secrets
from webauthn import (
generate_authentication_options,
generate_registration_options,
options_to_json,
verify_authentication_response,
verify_registration_response,
)
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
PublicKeyCredentialDescriptor,
ResidentKeyRequirement,
UserVerificationRequirement,
)
def encode(value: bytes) -> str:
return base64.urlsafe_b64encode(value).rstrip(b"=").decode()
def decode(value: str) -> bytes:
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
def registration_options(*, rp_id: str, excluded: list[bytes] | None = None) -> tuple[dict, bytes]:
challenge = secrets.token_bytes(32)
options = generate_registration_options(
rp_id=rp_id,
rp_name="Stackchain Dashboard",
user_name="stackchain-operator",
user_display_name="Stackchain operator",
challenge=challenge,
exclude_credentials=[
PublicKeyCredentialDescriptor(id=credential_id)
for credential_id in (excluded or [])
],
authenticator_selection=AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.PREFERRED,
user_verification=UserVerificationRequirement.REQUIRED,
),
)
return json.loads(options_to_json(options)), challenge
def authentication_options(*, rp_id: str, credentials: list[bytes]) -> tuple[dict, bytes]:
challenge = secrets.token_bytes(32)
options = generate_authentication_options(
rp_id=rp_id,
challenge=challenge,
allow_credentials=[
PublicKeyCredentialDescriptor(id=credential_id)
for credential_id in credentials
],
user_verification=UserVerificationRequirement.REQUIRED,
)
return json.loads(options_to_json(options)), challenge
def verify_registration(
*, credential: dict, challenge: bytes, rp_id: str, origin: str
):
return verify_registration_response(
credential=credential,
expected_challenge=challenge,
expected_rp_id=rp_id,
expected_origin=origin,
require_user_verification=True,
)
def verify_authentication(
*, credential: dict, challenge: bytes, rp_id: str, origin: str, stored
):
return verify_authentication_response(
credential=credential,
expected_challenge=challenge,
expected_rp_id=rp_id,
expected_origin=origin,
credential_public_key=stored.public_key,
credential_current_sign_count=stored.sign_count,
require_user_verification=True,
)
+62
View File
@@ -0,0 +1,62 @@
import asyncio
import ipaddress
import socket
from collections.abc import Awaitable, Callable
from urllib.parse import urlsplit
class UnsafePushEndpoint(ValueError):
"""Raised when a Web Push endpoint could reach a non-public service."""
Resolver = Callable[[str, int], Awaitable[list[str]]]
async def _resolve(host: str, port: int) -> list[str]:
loop = asyncio.get_running_loop()
records = await loop.getaddrinfo(
host,
port,
family=socket.AF_UNSPEC,
type=socket.SOCK_STREAM,
)
return sorted({record[4][0] for record in records})
async def validate_public_push_endpoint(
endpoint: str,
*,
resolver: Resolver = _resolve,
timeout_seconds: float = 2.0,
) -> str:
"""Return a canonical public HTTPS push endpoint or fail closed."""
parsed = urlsplit(endpoint)
try:
port = parsed.port
except ValueError as error:
raise UnsafePushEndpoint(
"Endpoint must be a canonical public Web Push service URL"
) from error
if (
parsed.scheme != "https"
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.fragment
or port not in {None, 443}
):
raise UnsafePushEndpoint("Endpoint must be a canonical public Web Push service URL")
try:
addresses = await asyncio.wait_for(
resolver(parsed.hostname, port or 443),
timeout=max(0.01, timeout_seconds),
)
except (OSError, asyncio.TimeoutError) as error:
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service") from error
try:
public = addresses and all(ipaddress.ip_address(address).is_global for address in addresses)
except ValueError as error:
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service") from error
if not public:
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service")
return endpoint
+401
View File
@@ -0,0 +1,401 @@
import asyncio
import json
import secrets
import time
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Awaitable, Callable
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from src.push_subscription_store import PushSubscriptionStore
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
@dataclass(frozen=True)
class PushConfiguration:
public_key: str
private_key: str
subject: str
@property
def enabled(self) -> bool:
return bool(self.public_key and self.private_key and self.subject)
async def send_web_push(
subscription: dict, payload: str, configuration: PushConfiguration
) -> None:
import requests
from pywebpush import webpush
session = requests.Session()
session.max_redirects = 0
await asyncio.to_thread(
webpush,
subscription_info=subscription,
data=payload,
vapid_private_key=configuration.private_key,
vapid_claims={"sub": configuration.subject},
ttl=300,
timeout=10,
requests_session=session,
)
async def dispatch_unread_updates(
store: PushSubscriptionStore,
configuration: PushConfiguration,
unread: Callable[[], Awaitable[dict]],
send: Callable[[dict, str], Awaitable[None]] | None = None,
*,
session_statuses: Callable[[list[str]], Awaitable[dict[str, str]]] | None = None,
lease_seconds: float = 60.0,
send_timeout_seconds: float = 10.0,
max_concurrency: int = 8,
max_individual_notifications: int = 3,
endpoint_validator: Callable[[str], Awaitable[str]] | None = None,
) -> int:
if not configuration.enabled:
return 0
owner = secrets.token_urlsafe(18)
acquired = await asyncio.to_thread(
store.acquire_dispatch_lease,
owner,
channel="unread",
now=time.time(),
lease_seconds=lease_seconds,
)
if not acquired:
return 0
try:
page = await unread()
if page.get("complete") is False:
return 0
thread_revisions = {
int(item["id"]): str(item.get("updated_at") or "")
for item in page.get("items", [])
if isinstance(item, dict) and str(item.get("id", "")).isdigit()
}
await asyncio.to_thread(store.reconcile_unread, thread_revisions)
deliveries = await asyncio.to_thread(store.claim_unseen, thread_revisions)
if session_statuses is not None:
try:
statuses = await session_statuses(
[item.session_id for item in deliveries]
)
except Exception:
# Authorization state is mandatory for delivery. Preserve subscriptions
# so a temporary registry failure can be retried safely.
return 0
for delivery in deliveries:
if statuses.get(delivery.session_id) != "active":
await asyncio.to_thread(store.delete_session, delivery.session_id)
deliveries = [
delivery
for delivery in deliveries
if statuses.get(delivery.session_id) == "active"
]
semaphore = asyncio.Semaphore(max(1, max_concurrency))
ownership_lost = asyncio.Event()
async def dispatch_device(delivery) -> int:
async with semaphore:
if ownership_lost.is_set():
return 0
validate_endpoint = endpoint_validator
if validate_endpoint is None and send is None:
validate_endpoint = validate_public_push_endpoint
try:
if validate_endpoint is not None:
await validate_endpoint(delivery.subscription["endpoint"])
except UnsafePushEndpoint:
await asyncio.to_thread(store.delete_session, delivery.session_id)
return 0
count = 0
digest_pending = set(delivery.digest_revisions)
new_revisions = tuple(
thread_revision
for thread_revision in delivery.thread_revisions
if thread_revision not in digest_pending
)
individual_revisions = new_revisions[
:max(0, max_individual_notifications)
]
overflow_revisions = (
delivery.digest_revisions
+ new_revisions[len(individual_revisions):]
)
can_send_digest = True
for thread_id, revision in individual_revisions:
still_owner = await asyncio.to_thread(
store.acquire_dispatch_lease,
owner,
channel="unread",
now=time.time(),
lease_seconds=lease_seconds,
)
if not still_owner:
ownership_lost.set()
return count
payload = json.dumps(
{
"title": "New work update",
"body": "Tap to review it in Stackchain.",
"route": f"#/my-work/update/{thread_id}",
"tag": f"stackchain-update-{thread_id}",
"notification_id": thread_id,
},
separators=(",", ":"),
)
try:
if send is None:
operation = send_web_push(
delivery.subscription, payload, configuration
)
else:
operation = send(delivery.subscription, payload)
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
except Exception as error:
status = getattr(
getattr(error, "response", None), "status_code", None
)
if status in {404, 410}:
await asyncio.to_thread(
store.delete_session, delivery.session_id
)
can_send_digest = False
# Leave this device's transient failures unseen for a later
# poll instead of paying the endpoint deadline repeatedly.
break
await asyncio.to_thread(
store.mark_delivered,
delivery.session_id,
((thread_id, revision),),
)
count += 1
if overflow_revisions and can_send_digest:
still_owner = await asyncio.to_thread(
store.acquire_dispatch_lease,
owner,
channel="unread",
now=time.time(),
lease_seconds=lease_seconds,
)
if not still_owner:
ownership_lost.set()
return count
payload = json.dumps(
{
"title": f"{len(overflow_revisions)} new work updates",
"body": "Tap to review them in Stackchain.",
"route": "#/my-work/updates",
"tag": "stackchain-update-digest",
"update_count": len(overflow_revisions),
},
separators=(",", ":"),
)
try:
if send is None:
operation = send_web_push(
delivery.subscription, payload, configuration
)
else:
operation = send(delivery.subscription, payload)
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
except Exception as error:
status = getattr(
getattr(error, "response", None), "status_code", None
)
if status in {404, 410}:
await asyncio.to_thread(
store.delete_session, delivery.session_id
)
else:
await asyncio.to_thread(
store.mark_digest_pending,
delivery.session_id,
overflow_revisions,
)
return count
await asyncio.to_thread(
store.mark_delivered,
delivery.session_id,
overflow_revisions,
)
count += 1
return count
async def dispatch_device_safely(delivery) -> int:
try:
return await dispatch_device(delivery)
except Exception:
# Device-specific validation, persistence, or provider failures
# must not cancel healthy siblings. Leave any uncheckpointed
# revisions unseen so a later poll can retry them.
return 0
counts = await asyncio.gather(
*(dispatch_device_safely(delivery) for delivery in deliveries)
)
return sum(counts)
finally:
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="unread")
async def dispatch_deadline_reminders(
store: PushSubscriptionStore,
configuration: PushConfiguration,
assigned: Callable[[], Awaitable[dict]],
send: Callable[[dict, str], Awaitable[None]] | None = None,
**kwargs,
) -> int:
owner = secrets.token_urlsafe(18)
acquired = await asyncio.to_thread(
store.acquire_dispatch_lease,
owner,
channel="deadline",
now=time.time(),
lease_seconds=max(
15.0,
float(kwargs.get("lease_seconds", 60.0)),
float(kwargs.get("send_timeout_seconds", 10.0)) + 5.0,
),
)
if not acquired:
return 0
try:
return await _dispatch_deadline_reminders_unlocked(
store, configuration, assigned, send, owner=owner, **kwargs
)
finally:
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="deadline")
async def _dispatch_deadline_reminders_unlocked(
store: PushSubscriptionStore,
configuration: PushConfiguration,
assigned: Callable[[], Awaitable[dict]],
send: Callable[[dict, str], Awaitable[None]] | None = None,
*,
now: datetime | None = None,
session_statuses: Callable[[list[str]], Awaitable[dict[str, str]]] | None = None,
send_timeout_seconds: float = 10.0,
lease_seconds: float = 60.0,
max_concurrency: int = 8,
owner: str,
) -> int:
"""Send one privacy-safe Agenda digest per eligible device and local day."""
if not configuration.enabled:
return 0
devices = await asyncio.to_thread(store.deadline_reminder_devices)
if not devices:
return 0
current = now or datetime.now(timezone.utc)
eligible_devices = []
for device in devices:
try:
local_now = current.astimezone(ZoneInfo(device.timezone))
except ZoneInfoNotFoundError:
continue
if (
local_now.hour >= device.reminder_hour
and device.delivered_local_day != local_now.date().isoformat()
):
eligible_devices.append(device)
if not eligible_devices:
return 0
snapshot = await assigned()
if snapshot.get("complete") is False:
return 0
due_days = []
for item in snapshot.get("items", []):
if not isinstance(item, dict) or not item.get("due_date"):
continue
raw_due = str(item["due_date"])
try:
due_day = datetime.strptime(raw_due[:10], "%Y-%m-%d").date()
except ValueError:
continue
due_days.append(due_day)
if not due_days:
return 0
due_counts = {}
for device in eligible_devices:
local_now = current.astimezone(ZoneInfo(device.timezone))
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
eligible_devices = [
device for device in eligible_devices if device.session_id in due_counts
]
if not eligible_devices:
return 0
if session_statuses is not None:
try:
statuses = await session_statuses(
[device.session_id for device in eligible_devices]
)
except Exception:
return 0
for device in eligible_devices:
if statuses.get(device.session_id) != "active":
await asyncio.to_thread(store.delete_session, device.session_id)
eligible_devices = [
device
for device in eligible_devices
if statuses.get(device.session_id) == "active"
]
if not eligible_devices:
return 0
semaphore = asyncio.Semaphore(max(1, max_concurrency))
async def dispatch_device(device) -> int:
async with semaphore:
try:
local_now = current.astimezone(ZoneInfo(device.timezone))
except ZoneInfoNotFoundError:
return 0
local_day = local_now.date().isoformat()
if (
local_now.hour < device.reminder_hour
or device.delivered_local_day == local_day
):
return 0
due_count = due_counts[device.session_id]
still_owner = await asyncio.to_thread(
store.acquire_dispatch_lease,
owner,
channel="deadline",
now=time.time(),
lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
)
if not still_owner:
return 0
payload = json.dumps({
"title": f"{due_count} deadline{'s' if due_count != 1 else ''} need{'s' if due_count == 1 else ''} attention",
"body": f"Open Agenda to review or replan {'it' if due_count == 1 else 'them'}.",
"route": "#/my-work/agenda",
"protect_route": "#/my-work/agenda/protect-today",
"tag": f"stackchain-deadline-digest-{local_day}",
"deadline_count": due_count,
}, separators=(",", ":"))
try:
operation = (
send(device.subscription, payload)
if send is not None
else send_web_push(device.subscription, payload, configuration)
)
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
except Exception:
return 0
await asyncio.to_thread(
store.mark_deadline_reminder_delivered, device.session_id, local_day
)
return 1
results = await asyncio.gather(
*(dispatch_device(device) for device in eligible_devices), return_exceptions=True
)
return sum(result for result in results if isinstance(result, int))
+385
View File
@@ -0,0 +1,385 @@
import json
import os
import sqlite3
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class PushDelivery:
session_id: str
subscription: dict
thread_revisions: tuple[tuple[int, str], ...]
digest_revisions: tuple[tuple[int, str], ...] = ()
@property
def thread_ids(self) -> tuple[int, ...]:
return tuple(thread_id for thread_id, _revision in self.thread_revisions)
@property
def digest_ids(self) -> tuple[int, ...]:
return tuple(thread_id for thread_id, _revision in self.digest_revisions)
@dataclass(frozen=True)
class DeadlineReminderDevice:
session_id: str
subscription: dict
timezone: str
reminder_hour: int
reminder_days: int
delivered_local_day: str | None
def _revisions(
values: Mapping[int, str] | Iterable[int | tuple[int, str]],
) -> tuple[tuple[int, str], ...]:
if isinstance(values, Mapping):
items = list(values.items())
else:
items = []
for value in values:
if isinstance(value, tuple):
items.append(value)
else:
items.append((value, ""))
normalized = {}
for thread_id, revision in items:
thread_id = int(thread_id)
if thread_id > 0:
normalized[thread_id] = str(revision or "")
return tuple(sorted(normalized.items()))
class PushSubscriptionStore:
"""Durable, device-bound Web Push subscriptions and delivery deduplication."""
def __init__(self, path: str | Path):
self.path = Path(path)
self.path.parent.mkdir(parents=True, exist_ok=True)
os.chmod(self.path.parent, 0o700)
with self._connect() as connection:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS push_subscriptions (
session_id TEXT PRIMARY KEY,
endpoint TEXT NOT NULL UNIQUE,
subscription_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS push_deliveries (
session_id TEXT NOT NULL,
thread_id INTEGER NOT NULL,
revision TEXT NOT NULL DEFAULT '',
PRIMARY KEY (session_id, thread_id),
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS push_dispatch_lease (
channel TEXT PRIMARY KEY,
owner TEXT NOT NULL,
expires_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS push_digest_pending (
session_id TEXT NOT NULL,
thread_id INTEGER NOT NULL,
revision TEXT NOT NULL DEFAULT '',
PRIMARY KEY (session_id, thread_id),
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS 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,
reminder_days INTEGER NOT NULL DEFAULT 2,
delivered_local_day TEXT,
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
ON DELETE CASCADE
);
"""
)
delivery_columns = {
row[1] for row in connection.execute("PRAGMA table_info(push_deliveries)")
}
if "revision" not in delivery_columns:
connection.execute(
"ALTER TABLE push_deliveries ADD COLUMN revision TEXT NOT NULL DEFAULT '*'"
)
digest_columns = {
row[1]
for row in connection.execute("PRAGMA table_info(push_digest_pending)")
}
if "revision" not in digest_columns:
connection.execute(
"ALTER TABLE push_digest_pending ADD COLUMN revision TEXT NOT NULL DEFAULT '*'"
)
lease_columns = {
row[1] for row in connection.execute("PRAGMA table_info(push_dispatch_lease)")
}
if "singleton" in lease_columns:
connection.executescript(
"""
ALTER TABLE push_dispatch_lease RENAME TO push_dispatch_lease_legacy;
CREATE TABLE push_dispatch_lease (
channel TEXT PRIMARY KEY,
owner TEXT NOT NULL,
expires_at REAL NOT NULL
);
INSERT INTO push_dispatch_lease(channel, owner, expires_at)
SELECT 'unread', owner, expires_at
FROM push_dispatch_lease_legacy WHERE singleton = 1;
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):
connection = sqlite3.connect(self.path, timeout=2)
connection.execute("PRAGMA foreign_keys = ON")
return connection
def acquire_dispatch_lease(
self, owner: str, *, channel: str = "unread", now: float, lease_seconds: float
) -> bool:
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
current = connection.execute(
"SELECT owner, expires_at FROM push_dispatch_lease WHERE channel = ?",
(channel,),
).fetchone()
if current is not None and current[0] != owner and current[1] > now:
return False
connection.execute(
"""INSERT INTO push_dispatch_lease(channel, owner, expires_at)
VALUES (?, ?, ?)
ON CONFLICT(channel) DO UPDATE SET
owner = excluded.owner, expires_at = excluded.expires_at""",
(channel, owner, now + lease_seconds),
)
return True
def release_dispatch_lease(self, owner: str, *, channel: str = "unread") -> bool:
with self._connect() as connection:
result = connection.execute(
"DELETE FROM push_dispatch_lease WHERE channel = ? AND owner = ?",
(channel, owner),
)
return result.rowcount == 1
def upsert(self, session_id: str, subscription: dict) -> None:
endpoint = subscription["endpoint"]
encoded = json.dumps(subscription, separators=(",", ":"), sort_keys=True)
with self._connect() as connection:
connection.execute("DELETE FROM push_subscriptions WHERE endpoint = ?", (endpoint,))
connection.execute("DELETE FROM push_subscriptions WHERE session_id = ?", (session_id,))
connection.execute(
"INSERT INTO push_subscriptions(session_id, endpoint, subscription_json) VALUES (?, ?, ?)",
(session_id, endpoint, encoded),
)
def delete_session(self, session_id: str) -> None:
with self._connect() as connection:
connection.execute("DELETE FROM push_subscriptions WHERE session_id = ?", (session_id,))
def delete_all(self) -> None:
with self._connect() as connection:
connection.execute("DELETE FROM push_subscriptions")
def is_subscribed(self, session_id: str) -> bool:
with self._connect() as connection:
return connection.execute(
"SELECT 1 FROM push_subscriptions WHERE session_id = ?", (session_id,)
).fetchone() is not None
def set_deadline_preferences(
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, reminder_days
) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(session_id) DO UPDATE SET
enabled = excluded.enabled,
timezone = excluded.timezone,
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, reminder_days
FROM push_deadline_preferences WHERE session_id = ?""",
(session_id,),
).fetchone()
return {
"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.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], row[5])
for row in rows
]
def mark_deadline_reminder_delivered(self, session_id: str, local_day: str) -> None:
with self._connect() as connection:
connection.execute(
"""UPDATE push_deadline_preferences SET delivered_local_day = ?
WHERE session_id = ? AND enabled = 1""",
(local_day, session_id),
)
def claim_unseen(
self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]]
) -> list[PushDelivery]:
candidates = _revisions(thread_revisions)
if not candidates:
return []
with self._connect() as connection:
rows = connection.execute(
"SELECT session_id, subscription_json FROM push_subscriptions ORDER BY session_id"
).fetchall()
deliveries = []
for session_id, encoded in rows:
delivered = {
row[0]: row[1]
for row in connection.execute(
"SELECT thread_id, revision FROM push_deliveries WHERE session_id = ?",
(session_id,),
)
}
for thread_id, revision in candidates:
if delivered.get(thread_id) == "*":
connection.execute(
"""UPDATE push_deliveries SET revision = ?
WHERE session_id = ? AND thread_id = ?""",
(revision, session_id, thread_id),
)
delivered[thread_id] = revision
unseen = tuple(
candidate
for candidate in candidates
if (
candidate[0] not in delivered
or (
bool(candidate[1])
and (
not delivered[candidate[0]]
or candidate[1] > delivered[candidate[0]]
)
)
)
)
if unseen:
pending = {
row[0]: row[1]
for row in connection.execute(
"SELECT thread_id, revision FROM push_digest_pending WHERE session_id = ?",
(session_id,),
)
}
digest_revisions = []
for thread_id, revision in unseen:
if pending.get(thread_id) in {revision, "*"}:
digest_revisions.append((thread_id, revision))
if pending[thread_id] == "*":
connection.execute(
"""UPDATE push_digest_pending SET revision = ?
WHERE session_id = ? AND thread_id = ?""",
(revision, session_id, thread_id),
)
deliveries.append(
PushDelivery(
session_id,
json.loads(encoded),
unseen,
tuple(digest_revisions),
)
)
return deliveries
def reconcile_unread(self, thread_ids: Iterable[int]) -> None:
"""Prune per-device checkpoints that are absent from a complete snapshot."""
unread_ids = tuple(sorted({int(value) for value in thread_ids if int(value) > 0}))
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
connection.execute(
"CREATE TEMP TABLE current_unread(thread_id INTEGER PRIMARY KEY)"
)
connection.executemany(
"INSERT INTO current_unread(thread_id) VALUES (?)",
((thread_id,) for thread_id in unread_ids),
)
connection.execute(
"""DELETE FROM push_deliveries
WHERE NOT EXISTS (
SELECT 1 FROM current_unread
WHERE current_unread.thread_id = push_deliveries.thread_id
)"""
)
connection.execute(
"""DELETE FROM push_digest_pending
WHERE NOT EXISTS (
SELECT 1 FROM current_unread
WHERE current_unread.thread_id = push_digest_pending.thread_id
)"""
)
def mark_digest_pending(
self,
session_id: str,
thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]],
) -> None:
values = _revisions(thread_revisions)
with self._connect() as connection:
connection.executemany(
"""INSERT INTO push_digest_pending(session_id, thread_id, revision)
VALUES (?, ?, ?)
ON CONFLICT(session_id, thread_id) DO UPDATE SET
revision = excluded.revision""",
((session_id, thread_id, revision) for thread_id, revision in values),
)
def mark_delivered(
self,
session_id: str,
thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]],
) -> None:
values = _revisions(thread_revisions)
with self._connect() as connection:
connection.executemany(
"""INSERT INTO push_deliveries(session_id, thread_id, revision)
VALUES (?, ?, ?)
ON CONFLICT(session_id, thread_id) DO UPDATE SET
revision = excluded.revision""",
((session_id, thread_id, revision) for thread_id, revision in values),
)
connection.executemany(
"DELETE FROM push_digest_pending WHERE session_id = ? AND thread_id = ?",
((session_id, thread_id) for thread_id, _revision in values),
)
+23
View File
@@ -5,6 +5,8 @@ from starlette.types import ASGIApp, Message, Receive, Scope, Send
SESSION_BODY_LIMIT = 16 * 1024
API_MUTATION_BODY_LIMIT = 64 * 1024
ISSUE_ATTACHMENT_BODY_LIMIT = 2 * 1024 * 1024 + 64 * 1024
LEGACY_JSON_ATTACHMENT_BODY_LIMIT = 3 * 1024 * 1024
MUTATION_METHODS = frozenset({"POST", "PUT", "PATCH"})
@@ -13,6 +15,15 @@ def request_body_limit(method: str, path: str) -> int | None:
normalized_method = method.upper()
if normalized_method == "POST" and path == "/api/v1/session":
return SESSION_BODY_LIMIT
if (
normalized_method == "POST"
and (
(path.startswith("/api/v1/repos/") and ("/issues/" in path or "/pulls/" in path))
or path.startswith("/api/v1/notifications/")
)
and path.endswith("/attachments")
):
return ISSUE_ATTACHMENT_BODY_LIMIT
if normalized_method in MUTATION_METHODS and path.startswith("/api/v1/"):
return API_MUTATION_BODY_LIMIT
return None
@@ -42,6 +53,11 @@ class RequestBodyLimitMiddleware:
if limit is None:
await self.app(scope, receive, send)
return
if (
limit == ISSUE_ATTACHMENT_BODY_LIMIT
and self._header(scope, b"content-type").startswith(b"application/json")
):
limit = LEGACY_JSON_ATTACHMENT_BODY_LIMIT
declared_length = self._content_length(scope)
if declared_length is not None and declared_length > limit:
@@ -69,6 +85,13 @@ class RequestBodyLimitMiddleware:
await self.app(scope, replay, send)
@staticmethod
def _header(scope: Scope, wanted: bytes) -> bytes:
for name, value in scope.get("headers", []):
if name.lower() == wanted:
return value.lower()
return b""
@staticmethod
def _content_length(scope: Scope) -> int | None:
for name, value in scope.get("headers", []):
+219
View File
@@ -0,0 +1,219 @@
"""Bounded, privacy-preserving journal of operator security activity."""
import secrets
import sqlite3
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
class SecurityEventStoreError(RuntimeError):
"""Raised when security activity cannot be persisted or read safely."""
@dataclass(frozen=True)
class SecurityEvent:
id: int
kind: str
method: str | None
device_label: str | None
target: str | None
created_at: int
status: str
@dataclass(frozen=True)
class SecurityEventPage:
events: list[SecurityEvent]
next_cursor: int | None
class SecurityEventStore:
def __init__(
self,
path: str | Path,
*,
clock: Callable[[], float],
max_events: int = 10_000,
retention_seconds: int = 90 * 24 * 60 * 60,
lock_timeout_seconds: float = 0.1,
) -> None:
self.path = Path(path)
self.clock = clock
self.max_events = max(1, max_events)
self.retention_seconds = max(1, retention_seconds)
self.lock_timeout_seconds = lock_timeout_seconds
@staticmethod
def _bounded(value: str | None, limit: int) -> str | None:
if value is None:
return None
normalized = " ".join(str(value).split())[:limit]
return normalized or None
def _connect(self) -> sqlite3.Connection:
try:
self.path.parent.mkdir(parents=True, exist_ok=True)
connection = sqlite3.connect(self.path, timeout=self.lock_timeout_seconds)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS security_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
method TEXT,
device_label TEXT,
target TEXT,
created_at INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'completed',
operation_id TEXT
)
"""
)
columns = {
row[1] for row in connection.execute("PRAGMA table_info(security_events)")
}
if "status" not in columns:
connection.execute(
"ALTER TABLE security_events ADD COLUMN status TEXT NOT NULL DEFAULT 'completed'"
)
if "operation_id" not in columns:
connection.execute(
"ALTER TABLE security_events ADD COLUMN operation_id TEXT"
)
connection.execute(
"CREATE INDEX IF NOT EXISTS security_events_created "
"ON security_events(created_at DESC, id DESC)"
)
connection.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS security_events_operation "
"ON security_events(operation_id) WHERE operation_id IS NOT NULL"
)
return connection
except (OSError, sqlite3.Error) as exc:
raise SecurityEventStoreError(
"Security activity is temporarily unavailable"
) from exc
def _prune(self, connection: sqlite3.Connection, now: int) -> None:
connection.execute(
"DELETE FROM security_events WHERE created_at < ?",
(now - self.retention_seconds,),
)
connection.execute(
"DELETE FROM security_events WHERE id NOT IN "
"(SELECT id FROM security_events ORDER BY id DESC LIMIT ?)",
(self.max_events,),
)
def record(
self,
kind: str,
*,
method: str | None = None,
device_label: str | None = None,
target: str | None = None,
) -> None:
now = int(self.clock())
try:
with self._connect() as connection:
connection.execute(
"INSERT INTO security_events(kind, method, device_label, target, created_at, status) "
"VALUES (?, ?, ?, ?, ?, 'completed')",
(
self._bounded(kind, 48) or "security_event",
self._bounded(method, 32),
self._bounded(device_label, 64),
self._bounded(target, 255),
now,
),
)
self._prune(connection, now)
except (OSError, sqlite3.Error) as exc:
raise SecurityEventStoreError(
"Security activity is temporarily unavailable"
) from exc
def reserve(
self,
kind: str,
*,
method: str | None = None,
device_label: str | None = None,
target: str | None = None,
) -> str:
now = int(self.clock())
operation_id = secrets.token_urlsafe(24)
try:
with self._connect() as connection:
connection.execute(
"INSERT INTO security_events(kind, method, device_label, target, created_at, status, operation_id) "
"VALUES (?, ?, ?, ?, ?, 'pending', ?)",
(
self._bounded(kind, 48) or "security_event",
self._bounded(method, 32),
self._bounded(device_label, 64),
self._bounded(target, 255),
now,
operation_id,
),
)
self._prune(connection, now)
except (OSError, sqlite3.Error) as exc:
raise SecurityEventStoreError(
"Security activity is temporarily unavailable"
) from exc
return operation_id
def finalize(self, operation_id: str) -> None:
try:
with self._connect() as connection:
cursor = connection.execute(
"UPDATE security_events SET status = 'completed' WHERE operation_id = ?",
(operation_id,),
)
if cursor.rowcount != 1:
raise SecurityEventStoreError(
"Security activity reservation was not found"
)
except (OSError, sqlite3.Error) as exc:
raise SecurityEventStoreError(
"Security activity is temporarily unavailable"
) from exc
def discard(self, operation_id: str) -> None:
try:
with self._connect() as connection:
connection.execute(
"DELETE FROM security_events WHERE operation_id = ? AND status = 'pending'",
(operation_id,),
)
except (OSError, sqlite3.Error) as exc:
raise SecurityEventStoreError(
"Security activity is temporarily unavailable"
) from exc
def list(self, *, limit: int = 50, cursor: int | None = None) -> SecurityEventPage:
bounded_limit = min(100, max(1, limit))
parameters: list[int] = []
where = ""
if cursor is not None:
where = "WHERE id < ?"
parameters.append(cursor)
parameters.append(bounded_limit + 1)
try:
with self._connect() as connection:
rows = connection.execute(
"SELECT id, kind, method, device_label, target, created_at, status "
f"FROM security_events {where} ORDER BY id DESC LIMIT ?",
parameters,
).fetchall()
except (OSError, sqlite3.Error) as exc:
raise SecurityEventStoreError(
"Security activity is temporarily unavailable"
) from exc
has_more = len(rows) > bounded_limit
visible = rows[:bounded_limit]
return SecurityEventPage(
events=[SecurityEvent(*row) for row in visible],
next_cursor=visible[-1][0] if has_more else None,
)
Loaded 100 of 191 files, more files were not shown because too many files have changed in this diff. Show more