Compare commits

..
Author SHA1 Message Date
rockachopa 51f4223f4b Merge pull request 'Make Security Center a Back-safe mobile investigation flow' (#1021) from timmy/1020-mobile-security-center-flow into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m34s
CI / lint (push) Successful in 3m11s
CI / release-candidate (push) Successful in 8s
2026-08-17 12:04:30 +00:00
timmy 241f0f3a2a feat: make Security Center Back-safe on mobile (Closes #1020)
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 2m35s
CI / lint (pull_request) Successful in 3m17s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 11:57:27 +00:00
rockachopa 34d5f86d53 Merge pull request 'Foreground mobile delivery attention handoffs' (#1019) from timmy/1018-mobile-delivery-attention-handoff into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m21s
CI / lint (push) Successful in 3m10s
CI / release-candidate (push) Successful in 7s
2026-08-17 10:48:48 +00:00
timmy 3fde7cf3ad fix: foreground mobile delivery attention handoffs (Closes #1018)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m22s
CI / lint (pull_request) Successful in 3m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 10:42:19 +00:00
rockachopa 6913fc8257 Merge pull request 'Keep mobile delivery recovery single-flight until retry settles' (#1017) from timmy/1016-single-flight-delivery-recovery into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m13s
CI / lint (push) Successful in 3m7s
CI / release-candidate (push) Successful in 7s
2026-08-17 10:08:11 +00:00
timmy 437da72f3d fix: keep delivery recovery single-flight (Closes #1016)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m20s
CI / lint (pull_request) Successful in 3m2s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 10:02:00 +00:00
timmy 9a2b8d97bd Merge pull request 'Guide mobile delivery recovery one item at a time' (#1015) from timmy/1014-guided-delivery-recovery into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m13s
CI / lint (push) Successful in 2m49s
CI / release-candidate (push) Successful in 7s
2026-08-17 09:12:25 +00:00
timmy 1e4f101219 feat: guide mobile delivery recovery (Closes #1014)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m4s
CI / lint (pull_request) Successful in 2m43s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 09:04:41 +00:00
timmy c0a02a05d8 Merge pull request 'Make actionable delivery recovery a first-class mobile queue' (#1013) from timmy/1012-mobile-delivery-recovery-queue into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m9s
CI / lint (push) Successful in 2m55s
CI / release-candidate (push) Successful in 7s
2026-08-17 08:36:15 +00:00
timmy 246f995b56 feat: prioritize mobile delivery recovery (Closes #1012)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m5s
CI / lint (pull_request) Successful in 2m58s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 08:30:09 +00:00
timmy db28cc5690 Merge pull request 'Guide blocked deliveries through Prepare Today' (#1011) from timmy/1010-prepare-today-delivery-recovery into main
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 1m51s
CI / lint (push) Successful in 2m40s
CI / release-candidate (push) Successful in 6s
2026-08-17 08:07:58 +00:00
timmy 26aa12019e feat: guide blocked deliveries through Prepare Today (Closes #1010)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m53s
CI / lint (pull_request) Successful in 2m50s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 08:01:48 +00:00
timmy 1baa9c1f91 Merge pull request 'Continue mobile Work into Filed follow-up' (#1009) from timmy/1008-mobile-work-filed-follow-up into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m48s
2026-08-17 07:39:03 +00:00
timmy d64c646b68 feat: continue mobile Work into Filed follow-up (Closes #1008)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m56s
CI / lint (pull_request) Successful in 2m43s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 07:28:00 +00:00
rockachopa 021af60cf3 Merge pull request 'Preserve mobile My Work section history across Back and reload' (#1007) from timmy/1006-mobile-work-section-history into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m53s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m40s
2026-08-17 07:07:39 +00:00
timmy 09dbdb9961 feat: preserve mobile work section history (Closes #1006)
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 2m1s
CI / lint (pull_request) Successful in 2m58s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 06:59:20 +00:00
rockachopa 484dcc6b9c Merge pull request 'Resume private mobile share bundles after sign-in' (#1005)
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m2s
CI / lint (push) Successful in 2m49s
CI / release-candidate (push) Successful in 7s
Closes #1004
2026-08-17 06:32:24 +00:00
timmy 7571a4aa2d fix: resume private share bundles after sign-in (Closes #1004)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m30s
CI / lint (pull_request) Successful in 2m53s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 06:25:58 +00:00
rockachopa dc68305dcb Merge pull request 'Add mobile pull-to-refresh for My Work' (#1003) from timmy/1002-mobile-pull-to-refresh into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m55s
CI / lint (push) Successful in 2m54s
CI / release-candidate (push) Successful in 6s
2026-08-17 05:49:33 +00:00
timmy 386acda1dc feat: add mobile pull-to-refresh for My Work (Closes #1002)
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 2m1s
CI / lint (pull_request) Successful in 2m57s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 05:43:39 +00:00
timmy a2606f1dc2 Merge pull request 'Guard IndexedDB private work cleanup' (#1001)
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m9s
CI / lint (push) Successful in 2m58s
CI / release-candidate (push) Successful in 7s
Closes #1000
2026-08-17 04:45:00 +00:00
timmy a2c91e0296 fix: guard IndexedDB private work cleanup (Closes #1000)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m9s
CI / lint (pull_request) Successful in 3m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 04:39:04 +00:00
rockachopa 8148545295 Merge pull request 'Show and safely clear private device storage' (#999) from timmy/998-private-device-storage into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m12s
CI / lint (push) Successful in 3m1s
CI / release-candidate (push) Successful in 7s
2026-08-17 03:43:21 +00:00
timmy 70c8ea6584 feat: manage private device storage (Closes #998)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m5s
CI / lint (pull_request) Successful in 2m51s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 03:37:21 +00:00
timmy f30143b5ea Merge pull request 'Defer the mobile workspace bundle behind an offline-safe bootstrap' (#997) from timmy/996-defer-mobile-workspace-bootstrap into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m59s
CI / lint (push) Successful in 2m44s
CI / release-candidate (push) Successful in 6s
2026-08-17 02:52:39 +00:00
timmy cf76bc3636 test: model worker transport outage deterministically
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m3s
CI / lint (pull_request) Successful in 2m40s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 02:46:42 +00:00
timmy 0fc152f623 fix: recover deferred workspace reconnects
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 2m45s
CI / lint (pull_request) Successful in 2m53s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 02:16:48 +00:00
timmy 377fd889c5 fix: preserve reconnect across deferred workspace startup
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 2m53s
CI / lint (pull_request) Successful in 2m49s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 01:41:22 +00:00
timmy 0a4c9a0ae8 fix: replay reconnect after workspace bootstrap
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 2m44s
CI / lint (pull_request) Successful in 2m49s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 01:23:44 +00:00
timmy 1a4f930b50 test: allow workspace activation before offline delivery
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 3m4s
CI / lint (pull_request) Successful in 2m54s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 01:13:15 +00:00
timmy 49bbeadc48 perf: defer mobile workspace bootstrap (Closes #996)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 2m43s
CI / lint (pull_request) Successful in 2m49s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 01:04:20 +00:00
rockachopa 5470e48e26 Make mobile Home task-first with a Back-safe Insights view
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m53s
CI / lint (push) Successful in 3m2s
CI / release-candidate (push) Successful in 7s
Closes #994
2026-08-17 00:14:58 +00:00
timmy 0fcb7f5152 feat: make mobile Home task-first with Insights (Closes #994)
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 2m8s
CI / lint (pull_request) Successful in 3m2s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 00:08:48 +00:00
rockachopa 0f30ca94ab Merge pull request 'Review wrap-up commitments before rebuilding Today' (#993) from timmy/992-review-wrap-up-commitments into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m47s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m45s
2026-08-16 23:20:55 +00:00
timmy 468676d2f4 feat: review wrap-up commitments in Today (Closes #992)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m50s
CI / lint (pull_request) Successful in 2m56s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 23:15:00 +00:00
rockachopa 40623f939c Merge pull request 'Schedule unfinished Today work during session wrap-up' (#991) from timmy/990-schedule-unfinished-today-work into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m39s
CI / lint (push) Successful in 2m53s
CI / release-candidate (push) Successful in 7s
2026-08-16 22:12:24 +00:00
timmy 110fd860b3 feat: schedule unfinished work during Today wrap-up (Closes #990)
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 1m36s
CI / lint (pull_request) Successful in 2m44s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 22:07:04 +00:00
rockachopa edf5d3fda4 Merge pull request 'Block releases with vulnerable runtime dependencies' (#989) from timmy/988-block-vulnerable-runtime-dependencies into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m33s
CI / lint (push) Successful in 2m51s
CI / release-candidate (push) Successful in 7s
2026-08-16 21:09:47 +00:00
timmy e395878f92 security: block vulnerable runtime dependencies (Closes #988)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m20s
CI / lint (pull_request) Successful in 2m48s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 21:04:44 +00:00
rockachopa f48b9b5a6c Merge pull request 'feat: Gate releases on the packaged mobile Plan Today handoff journey' (#987) from timmy/985-gate-releases-on-the-packaged-mobile-plan-today- into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 1m34s
CI / lint (push) Successful in 2m23s
CI / release-candidate (push) Successful in 6s
Merge pull request 'Gate releases on the packaged mobile Plan Today handoff journey' (#987)
2026-08-16 20:47:59 +00:00
timmy 971656b76c test: gate packaged mobile Plan Today handoff (Closes #985)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m34s
CI / lint (pull_request) Successful in 2m17s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 20:27:25 +00:00
timmy 48ec66cc30 Merge pull request 'Sanitize image evidence at the server upload boundary' (#984) from timmy/983-server-image-sanitization into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m16s
CI / lint (push) Successful in 2m11s
CI / release-candidate (push) Successful in 6s
2026-08-16 19:38:34 +00:00
timmy 966028e0a6 security: sanitize image evidence server-side (Closes #983)
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 1m11s
CI / lint (pull_request) Successful in 2m11s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 19:33:46 +00:00
rockachopa 21eb223882 Merge pull request 'Gate releases on the packaged mobile Find Work journey' (#982)
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 1m12s
CI / lint (push) Successful in 2m10s
CI / release-candidate (push) Successful in 6s
Closes #981
2026-08-16 19:02:46 +00:00
timmy e10bd2d261 test: gate packaged mobile Find Work journey (Closes #981)
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 1m20s
CI / lint (pull_request) Successful in 2m21s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 18:58:13 +00:00
timmy 0728bbdd6a Merge pull request 'Guide mobile Find Work from discovery through assignment' (#980) from timmy/979-mobile-find-work-navigation into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m11s
2026-08-16 18:11:41 +00:00
timmy 53e556ddab feat: guide mobile Find Work workflow (Closes #979)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m18s
CI / lint (pull_request) Successful in 2m20s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 18:04:55 +00:00
timmy 611b10b477 Merge pull request 'Preserve offline workflows across PWA activation' (#978) from timmy/977-preserve-offline-feature-cache-activation into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m6s
CI / lint (push) Successful in 2m6s
CI / release-candidate (push) Successful in 6s
2026-08-16 17:39:09 +00:00
timmy 85a000422d test: exercise migrated workflow offline
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 1m1s
CI / lint (pull_request) Successful in 2m9s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 17:35:24 +00:00
timmy d80091d244 fix: preserve offline workflows across activation (Closes #977)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 1m43s
CI / lint (pull_request) Successful in 2m3s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 17:25:48 +00:00
rockachopa 5217f6a86f Merge pull request 'Add persistent mobile Plan Today workflow navigation' (#976) from timmy/975-mobile-plan-today-navigation into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m43s
CI / lint (push) Successful in 1m58s
CI / release-candidate (push) Successful in 6s
2026-08-16 16:40:44 +00:00
timmy fc33746d4c feat: add mobile Plan Today navigation (Closes #975)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m2s
CI / lint (pull_request) Successful in 2m0s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 16:36:49 +00:00
rockachopa cc449b3d2f Merge pull request 'Add persistent mobile Search Preview workflow navigation' (#974) from timmy/973-mobile-search-preview-navigation into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m2s
CI / lint (push) Successful in 2m4s
CI / release-candidate (push) Successful in 6s
2026-08-16 16:08:44 +00:00
timmy 8de2318f43 test: isolate Search Preview browser checks
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 1m8s
CI / lint (pull_request) Successful in 1m58s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 16:05:06 +00:00
timmy 24f781d398 feat: add mobile Search Preview navigation (Closes #973)
CI / build-release (pull_request) Has been skipped
CI / browser-journey (pull_request) Has been skipped
CI / lint (pull_request) Failing after 2m7s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 15:58:41 +00:00
timmy 25f752339f Merge pull request 'Add mobile navigation to the New issue workflow' (#972) from timmy/971-mobile-new-issue-navigation into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 59s
CI / lint (push) Successful in 2m1s
CI / release-candidate (push) Successful in 6s
2026-08-16 15:16:04 +00:00
timmy fe95b4e32a fix: keep mobile filing navigation selected
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m1s
CI / lint (pull_request) Successful in 2m12s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 15:12:02 +00:00
timmy 2b671d91fd feat: add mobile new issue navigation (Closes #971)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 1m0s
CI / lint (pull_request) Successful in 1m50s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 15:02:13 +00:00
timmy dcc3348c12 Merge pull request 'Add persistent mobile requested-review workspace navigation' (#970) from timmy/969-mobile-requested-review-navigation into main
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 55s
CI / lint (push) Successful in 1m59s
CI / release-candidate (push) Successful in 6s
2026-08-16 14:32:36 +00:00
timmy 31fb3fc8ae feat: add mobile requested-review navigation (Closes #969)
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 55s
CI / lint (pull_request) Successful in 2m10s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 14:28:25 +00:00
timmy a0b3db6895 Merge pull request 'Add persistent mobile update workspace navigation' (#968) from timmy/967-mobile-update-workspace-navigation into main
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 54s
CI / lint (push) Successful in 2m5s
CI / release-candidate (push) Successful in 7s
2026-08-16 13:46:40 +00:00
timmy 6c7b6ebd4d feat: add mobile update workspace navigation (Closes #967)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m3s
CI / lint (pull_request) Successful in 2m25s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 13:42:19 +00:00
timmy 3c86e02efe Merge pull request 'Add persistent mobile pull request workspace navigation' (#966) from timmy/965-mobile-pull-workspace-navigation into main
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 51s
CI / lint (push) Successful in 1m59s
CI / release-candidate (push) Successful in 6s
2026-08-16 13:04:52 +00:00
timmy c48baac1ea test: keep rendered checks out of unit CI
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 54s
CI / lint (pull_request) Successful in 1m55s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 13:01:03 +00:00
timmy 311396cfac feat: add mobile pull workspace navigation (Closes #965)
CI / build-release (pull_request) Has been skipped
CI / browser-journey (pull_request) Has been skipped
CI / lint (pull_request) Failing after 1m52s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 12:55:03 +00:00
timmy 762614b2d3 Merge pull request 'Preserve My Work conversation photo drafts' (#964) from timmy/963-preserve-my-work-photo-drafts into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 56s
CI / lint (push) Successful in 2m3s
CI / release-candidate (push) Successful in 7s
2026-08-16 12:38:07 +00:00
timmy 31f2c71575 feat: preserve My Work photo drafts (Closes #963)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 57s
CI / lint (pull_request) Successful in 2m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 12:34:01 +00:00
timmy 50f1af83f6 Merge pull request 'Add persistent mobile issue detail navigation' (#962) from timmy/961-mobile-issue-detail-navigation into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 56s
CI / lint (push) Successful in 1m59s
CI / release-candidate (push) Successful in 6s
2026-08-16 12:05:55 +00:00
timmy 6c200d7c97 feat: add mobile issue detail navigation (Closes #961)
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 56s
CI / lint (pull_request) Successful in 2m3s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 12:02:02 +00:00
rockachopa 6954ffc050 Merge pull request 'Queue mobile Search replies for offline delivery' (#960) from timmy/959-offline-search-replies into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 54s
CI / lint (push) Successful in 2m2s
CI / release-candidate (push) Successful in 6s
2026-08-16 11:08:30 +00:00
timmy c8a237aba4 test: tolerate container reconnect latency
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 55s
CI / lint (pull_request) Successful in 1m59s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 11:05:04 +00:00
timmy 7fa43a9e50 feat: queue offline Search replies (Closes #959)
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Failing after 1m10s
CI / lint (pull_request) Successful in 2m7s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 11:00:11 +00:00
timmy 2e6c20d68d Preserve mobile Search photo reply drafts across interruptions (#958)
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m0s
CI / lint (push) Successful in 2m1s
CI / release-candidate (push) Successful in 6s
Closes #957
2026-08-16 10:12:08 +00:00
timmy 5af45cda57 feat: preserve Search photo reply drafts (Closes #957)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 57s
CI / lint (pull_request) Successful in 2m10s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 10:07:46 +00:00
timmy cab5802547 Merge pull request 'Attach photo evidence to mobile Search preview replies' (#956) from timmy/955-search-preview-photo-evidence into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 51s
CI / lint (push) Successful in 1m58s
CI / release-candidate (push) Successful in 6s
2026-08-16 09:34:05 +00:00
timmy d3a57f222e feat: attach photos from Search preview replies (Closes #955)
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 51s
CI / lint (pull_request) Successful in 2m0s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 09:30:18 +00:00
timmy 053f32c428 Merge pull request 'Dictate replies from mobile Search preview' (#954) from timmy/953-dictate-search-preview-replies into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 58s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m12s
2026-08-16 08:59:30 +00:00
timmy d63166b581 feat: dictate replies from mobile Search preview (Closes #953)
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 51s
CI / lint (pull_request) Successful in 1m55s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 08:55:49 +00:00
timmy 51710972eb Merge pull request 'Dictate, review, and send mobile conversation replies' (#952) from timmy/951-dictate-mobile-conversation-replies into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 56s
CI / lint (push) Successful in 2m2s
CI / release-candidate (push) Successful in 6s
2026-08-16 08:39:34 +00:00
timmy 0e13872311 feat: dictate mobile conversation replies (Closes #951)
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 59s
CI / lint (pull_request) Successful in 1m59s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 08:35:34 +00:00
timmy eac5342fa5 Merge pull request 'Strip private metadata from mobile photo evidence' (#950) from timmy/949-strip-photo-metadata into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m2s
CI / lint (push) Successful in 2m2s
CI / release-candidate (push) Successful in 7s
2026-08-16 07:58:41 +00:00
timmy a8e931f306 feat: strip metadata from photo evidence (Closes #949)
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 57s
CI / lint (pull_request) Successful in 1m58s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 07:55:04 +00:00
timmy 00fd96f4a4 Merge pull request 'Edit and redact mobile conversation photos before sending' (#948) from timmy/947-edit-redact-conversation-photos into main
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 55s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m0s
2026-08-16 07:32:05 +00:00
timmy 0d5fa13d65 feat: edit photos in conversation replies (Closes #947)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 57s
CI / lint (pull_request) Successful in 2m4s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 07:28:10 +00:00
rockachopa 6270253c24 Merge pull request 'Review and caption mobile conversation photo bundles before sending' (#946) from timmy/945-review-caption-conversation-photo-bundles into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m0s
CI / lint (push) Successful in 2m8s
CI / release-candidate (push) Successful in 6s
2026-08-16 07:02:09 +00:00
timmy 5da5d7cdc4 feat: review conversation photo bundles before sending (Closes #945)
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 1m1s
CI / lint (pull_request) Successful in 2m11s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 06:56:05 +00:00
timmy 55bd353daf Merge pull request 'Send multi-photo evidence bundles in mobile conversation replies' (#944) from timmy/943-mobile-conversation-photo-bundles into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m0s
CI / lint (push) Successful in 2m7s
CI / release-candidate (push) Successful in 7s
2026-08-16 06:13:37 +00:00
timmy c5d31c1972 feat: send mobile conversation photo bundles (Closes #943)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 57s
CI / lint (pull_request) Successful in 2m4s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 06:06:07 +00:00
timmy 843906208c Capture photo evidence in mobile conversation replies (#942)
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m0s
CI / lint (push) Successful in 2m3s
CI / release-candidate (push) Successful in 7s
Closes #941
2026-08-16 05:37:38 +00:00
timmy 2338a07bab feat: capture photo evidence in conversation replies (Closes #941)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m2s
CI / lint (pull_request) Successful in 2m6s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 05:33:34 +00:00
rockachopa 67d8828aea Merge pull request 'Save photo-only mobile captures as untitled Drafts' (#940) from timmy/939-photo-only-untitled-drafts into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 59s
CI / lint (push) Successful in 2m2s
CI / release-candidate (push) Successful in 7s
2026-08-16 05:06:46 +00:00
timmy caa0fbc2fd feat: save photo-only captures as drafts (Closes #939)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m2s
CI / lint (pull_request) Successful in 2m9s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 04:59:08 +00:00
timmy 8a85b344f8 Merge pull request 'Capture camera-first photo evidence from mobile New issue' (#938) from timmy/937-camera-first-photo-evidence into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 59s
CI / lint (push) Successful in 2m7s
CI / release-candidate (push) Successful in 6s
Merge pull request #938 from timmy/937-camera-first-photo-evidence
2026-08-16 04:33:45 +00:00
timmy b4662a175d feat: capture camera-first photo evidence (Closes #937)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m2s
CI / lint (pull_request) Successful in 2m12s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 04:29:44 +00:00
timmy fb08785ad5 Merge pull request 'Keep multi-segment mobile voice dictation free of repeated phrases' (#936) from timmy/935-deduplicate-mobile-voice-results into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 56s
CI / lint (push) Successful in 2m7s
CI / release-candidate (push) Successful in 7s
2026-08-16 04:00:46 +00:00
timmy 2bb155a9e4 fix: deduplicate cumulative voice results (Closes #935)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m1s
CI / lint (pull_request) Successful in 2m3s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 03:56:50 +00:00
timmy c4715fc95c Merge pull request 'Enable same-origin microphone access for voice capture' (#934) from timmy/933-same-origin-microphone into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 58s
CI / lint (push) Successful in 2m10s
CI / release-candidate (push) Successful in 7s
Merge PR #934: enable same-origin microphone access
2026-08-16 03:28:43 +00:00
119 changed files with 9207 additions and 466 deletions

No files matched your search

+4 -2
View File
@@ -15,6 +15,8 @@ jobs:
- uses: actions/setup-python@v5
with: { python-version: "3.11" }
- run: pip install -r requirements.txt
- run: pip install -r requirements-audit.txt
- run: python3 -m pip_audit -r requirements.txt --strict
- run: python3 -m pytest tests/ -q
build-release:
@@ -54,8 +56,8 @@ jobs:
run: |
pip install -r requirements-e2e.txt
python3 -m playwright install --with-deps chromium
- name: Exercise mobile offline filing from the release artifact
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py -q
- name: Exercise packaged mobile work journeys
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q
release-candidate:
runs-on: ubuntu-latest
+11 -6
View File
@@ -19,12 +19,8 @@ threads, create and self-assign issues, discover, claim, and release issue assig
list repository labels and open milestones, set or clear due dates on assigned issues, create issue comments, close assigned issues,
inspect/comment on assigned pull
requests, merge assigned pull requests, and submit pull-request reviews.
Assigned-issue and assigned-pull-request comments can include one PNG, JPEG, or WebP screenshot; each mobile composer automatically optimizes oversized screenshots on-device to fit the 2 MB upload boundary while leaving already-valid files unchanged.
For online delivery, the screenshot uploads before the comment is posted—to the exact assigned issue or pull request—and produces one Markdown comment; validation or upload
failures keep both the typed comment and removable preview available for retry. Offline screenshot comments
admit their text and image bytes to IndexedDB before confirmation, keep only bounded metadata in
localStorage, and use checkpointed upload/comment identities so reconnect retries cannot duplicate
either stage. The mobile **New issue** capture-first stage accepts an ordered evidence bundle of up to
Assigned-issue, assigned-pull-request, and unread-update conversation composers accept an ordered bundle of up to five PNG, JPEG, or WebP photos. Repeated camera captures append to the bundle, the gallery picker accepts multiple images, and each composer automatically optimizes oversized screenshots on-device to fit the 2 MB upload boundary. Before sending, the selected conversation photo can use the same touch editor as New issue evidence to crop, privacy-redact, highlight, or add an arrow; Apply replaces only that flattened derivative while preserving its caption and bundle position, and Cancel leaves the original unchanged.
For online delivery, every photo uploads before the comment is posted to the exact conversation target, producing one ordered Markdown comment or reply; validation or upload failures keep the typed text and removable preview available for retry. Offline photo conversations admit every image Blob to IndexedDB before confirmation, keep only bounded metadata in localStorage, and checkpoint each upload separately so reconnect resumes at the first unconfirmed photo without duplicating an upload, comment, reply, or reply-and-read transition. The mobile **New issue** capture-first stage accepts an ordered evidence bundle of up to
five PNG, JPEG, or WebP screenshots before a repository is chosen, optimizing each image independently
to the 2 MB boundary. **Save to Drafts** durably writes every optimized Blob to IndexedDB before
confirmation, keeps only account-bound attachment metadata in localStorage, and restores the ordered
@@ -79,6 +75,15 @@ Today or interrupting active work. Cancel and browser Back preserve the Search p
confirmation claims only when needed, syncs the Later plan across devices, and returns to the preserved
query, filters, results, and scroll position. If assignment succeeds but Later storage fails, the issue
remains recoverable in My Work and the dashboard reports the partial outcome instead of claiming success.
Commentable mobile Search previews support camera capture and gallery selection for up to five ordered
photos, including captions, crop/annotation/redaction review, and metadata-stripping re-encoding. Operators
can queue photo-only, text-only, or mixed replies without leaving their Search pass, including while offline.
Durable admission account-binds the exact issue/pull kind and stores image bytes in IndexedDB before the UI
clears or advances. Reconnect revalidates the visible Search target through the narrower preview attachment
and comment routes instead of assigned-work routes. Existing per-photo upload operation IDs and confirmed
Markdown checkpoints survive handoff, so retry resumes from the first unfinished photo and the final comment
posts once. **Send & next** advances only after durable admission; a storage failure keeps the current result,
text, evidence, and Search position unchanged.
Named mobile Search views preserve the query, type, status, and optional repository scope. They are
bounded to 20 per confirmed account and synchronize through a revisioned SQLite collection, so another
device can reopen the exact Search with one tap while stale writes surface a conflict instead of silently
+48 -16
View File
@@ -4,7 +4,37 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
);
const pending = new Map();
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker', 'issue-content']);
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'search-reply', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker', 'issue-content']);
function messageAttachments(message) {
const values = Array.isArray(message?.attachments) ? message.attachments :
(Array.isArray(message?.attachment) ? message.attachment : (message?.attachment ? [message.attachment] : []));
return values.filter(Boolean).slice(0, 5);
}
function attachmentMetadata(value) {
const note = String(value.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
return {
filename: String(value.filename || ''),
contentType: String(value.contentType || ''),
stored: true,
...(note ? { note } : {}),
...(value.operationId ? { operationId:String(value.operationId).slice(0, 128) } : {}),
...(value.confirmed?.markdown ? { confirmed:{ markdown:String(value.confirmed.markdown) } } : {}),
};
}
function durableAttachment(value) {
const note = String(value.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
return {
filename: String(value.filename || ''),
contentType: String(value.contentType || ''),
...(note ? { note } : {}),
...(value.operationId ? { operationId:String(value.operationId).slice(0, 128) } : {}),
...(value.confirmed?.markdown ? { confirmed:{ markdown:String(value.confirmed.markdown) } } : {}),
...(value.blob ? { blob:value.blob } : { data:String(value.data || '') }),
};
}
function checklistOperation(value) {
const action = String(value?.action || '');
@@ -55,6 +85,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
function enqueue(message, mirror = true) {
if (!supportedKinds.has(message?.kind)) throw new Error('This action cannot be queued.');
if (message.kind === 'search-reply' && !['issue', 'pull'].includes(message.targetKind)) {
throw new Error('Choose an exact Search result before queueing a reply.');
}
const ownerLogin = String(getOwnerLogin() || '').trim();
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing a message.');
const items = read();
@@ -90,6 +123,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
}
if (items.length >= maxItems) throw new Error('Message outbox is full. Send or discard a queued message first.');
const id = String(requestedOperationId || makeId()).slice(0, 128);
const attachments = messageAttachments(message);
const item = {
id,
operationId: requestedOperationId || id,
@@ -101,14 +135,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
ownerLogin,
status: 'queued',
queuedAt: Number(now()),
...(message.kind === 'search-reply' ? { targetKind:String(message.targetKind) } : {}),
...(message.kind === 'update-reply-read' ? { replyConfirmed: message.replyConfirmed === true } : {}),
...(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read'].includes(message.kind) && message.attachment ? {
attachment: {
filename: String(message.attachment.filename || ''),
contentType: String(message.attachment.contentType || ''),
stored: true,
},
} : {}),
...(['issue-comment', 'pull-comment', 'search-reply', 'update-reply', 'update-reply-read'].includes(message.kind) && attachments.length ?
(attachments.length === 1 ? { attachment:attachmentMetadata(attachments[0]) } :
{ attachments:attachments.map(attachmentMetadata) }) : {}),
...(message.kind === 'pull-review' ? {
decision: String(message.decision || 'comment'),
expectedHeadSha: String(message.expectedHeadSha || ''),
@@ -140,7 +171,8 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
async function enqueueDurably(message) {
const previousItems = read();
const item = enqueue(message, false);
if (message.attachment && ['update-reply', 'update-reply-read'].includes(message.kind) &&
const attachments = messageAttachments(message);
if (attachments.length && ['search-reply', 'update-reply', 'update-reply-read'].includes(message.kind) &&
(!backgroundSync?.reconcile || !backgroundSync?.requestSync)) {
write(read().filter(candidate => candidate.id !== item.id), false);
throw new Error('Screenshot delivery needs IndexedDB. Your reply and screenshot are still here; retry.');
@@ -148,14 +180,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
return { item, background: false, durability: 'foreground-only' };
}
const durableItems = read().map(candidate => candidate.id === item.id && message.attachment ? {
const durableItems = read().map(candidate => candidate.id === item.id && attachments.length ? {
...candidate,
attachment: {
filename: String(message.attachment.filename || ''),
contentType: String(message.attachment.contentType || ''),
...(message.attachment.blob ? { blob: message.attachment.blob } :
{ data: String(message.attachment.data || '') }),
},
...(attachments.length === 1 ? { attachment:durableAttachment(attachments[0]) } :
{ attachments:attachments.map(durableAttachment) }),
} : candidate);
try {
await backgroundSync.reconcile(durableItems, 'authored');
@@ -213,6 +241,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
if (item.kind === 'pull-review') {
return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review';
}
if (item.kind === 'search-reply') {
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
'/preview/comments?kind=' + encodeURIComponent(item.targetKind);
}
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments';
}
+99 -59
View File
@@ -186,10 +186,15 @@ function createIssueSyncStore({
const preservedAttachment = (current?.attachment?.data || current?.attachment?.blob) &&
item?.attachment?.stored && !item.attachment.data && !item.attachment.blob
? { attachment: current.attachment } : {};
const preservedAttachments = current?.operationId === item?.operationId &&
current?.attachments?.every(value => value?.data || value?.blob) &&
item?.attachments?.every(value => value?.stored && !value.data && !value.blob)
? { attachments:current.attachments } : {};
const next = current && current.operationId === item.operationId ? {
...item, ...preservedAttachment,
...item, ...preservedAttachment, ...preservedAttachments,
...(current.deliveredIssue ? { deliveredIssue: current.deliveredIssue } : {}),
...(current.attachmentMarkdown ? { attachmentMarkdown: current.attachmentMarkdown } : {}),
...(current.attachmentMarkdowns ? { attachmentMarkdowns:current.attachmentMarkdowns } : {}),
} : { ...item };
await records.put(next);
return next;
@@ -459,6 +464,13 @@ function createBackgroundIssueSync({
item,
);
}
if (item.kind === 'search-reply') {
return authoredRequest(
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
'/preview/comments?kind=' + encodeURIComponent(item.targetKind),
item,
);
}
return {
url: base + 'api/v1/repos/' + repository + '/issues',
options: {
@@ -508,31 +520,68 @@ function createBackgroundIssueSync({
return '**Screenshot ' + (index + 1) + ' — ' + escaped + '**\n\n' + markdown;
}
function conversationAttachments(item) {
return (Array.isArray(item?.attachments) ? item.attachments : [item?.attachment]).filter(Boolean);
}
async function uploadConversationAttachments(item, url) {
let current = item;
const attachments = conversationAttachments(current);
const confirmedMarkdowns = attachments.map(value => String(value?.confirmed?.markdown || ''));
const firstMissing = confirmedMarkdowns.findIndex(value => !value);
const leadingConfirmed = confirmedMarkdowns.slice(0, firstMissing < 0 ? confirmedMarkdowns.length : firstMissing);
const markdowns = Array.isArray(current.attachmentMarkdowns) ?
current.attachmentMarkdowns.slice(0, attachments.length) :
(current.attachmentMarkdown ? [current.attachmentMarkdown] : leadingConfirmed);
for (let index = markdowns.length; index < attachments.length; index += 1) {
const uploaded = await requestStage(current, url, {
method:'POST',
headers:{
Accept:'application/json',
'Idempotency-Key':String(attachments[index]?.operationId || stageOperationId(
current.operationId, attachments.length === 1 ? 'attachment' : 'attachment-' + index,
)).slice(0, 128),
},
body:attachmentMultipart(attachments[index]),
});
const markdown = String(uploaded?.markdown || '');
if (!markdown) {
const error = new Error('The server did not confirm the screenshot upload.');
error.status = 422;
throw error;
}
markdowns.push(markdown);
await checkpointClaim(current, stored => ({
...stored, attachmentMarkdowns:markdowns.slice(),
...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}),
}));
current = {
...current, attachmentMarkdowns:markdowns.slice(),
...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}),
};
}
return {
current,
markdown:markdowns.map((value, index) =>
evidenceMarkdown(attachments[index], value, index)).join('\n\n'),
};
}
async function deliverReplyRead(item) {
let current = item;
if (!current.replyConfirmed) {
let attachmentMarkdown = current.attachmentMarkdown;
if (current.attachment && !attachmentMarkdown) {
const uploaded = await requestStage(current,
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments', {
method:'POST',
headers:{ Accept:'application/json', 'Idempotency-Key':stageOperationId(current.operationId, 'attachment') },
body:attachmentMultipart(current.attachment),
});
attachmentMarkdown = String(uploaded?.markdown || '');
if (!attachmentMarkdown) {
const error = new Error('The server did not confirm the screenshot upload.');
error.status = 422;
throw error;
}
await checkpointClaim(current, stored => ({ ...stored, attachmentMarkdown }));
current = { ...current, attachmentMarkdown };
let attachmentMarkdown = '';
if (conversationAttachments(current).length) {
const uploaded = await uploadConversationAttachments(current,
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments');
current = uploaded.current;
attachmentMarkdown = uploaded.markdown;
}
const text = String(current.body || '').trim();
const replyBody = attachmentMarkdown ?
(text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown) : text;
const options = authoredRequest('', { ...current, body:replyBody }).options;
if (current.attachment) options.headers['Idempotency-Key'] = stageOperationId(current.operationId, 'reply');
if (conversationAttachments(current).length) options.headers['Idempotency-Key'] = stageOperationId(current.operationId, 'reply');
await requestStage(
current,
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply',
@@ -638,28 +687,9 @@ function createBackgroundIssueSync({
async function deliverScreenshotComment(item) {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
let attachmentMarkdown = item.attachmentMarkdown;
if (!attachmentMarkdown) {
const uploaded = await requestStage(
item,
base + 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/attachments',
{
method: 'POST',
headers: {
Accept: 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
},
body: attachmentMultipart(item.attachment),
},
);
attachmentMarkdown = String(uploaded?.markdown || '');
if (!attachmentMarkdown) {
const error = new Error('The server did not confirm the screenshot upload.');
error.status = 422;
throw error;
}
await checkpointClaim(item, current => ({ ...current, attachmentMarkdown }));
}
const uploaded = await uploadConversationAttachments(item,
base + 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/attachments');
const attachmentMarkdown = uploaded.markdown;
const text = String(item.body || '').trim();
return requestStage(
item,
@@ -675,26 +705,35 @@ function createBackgroundIssueSync({
);
}
async function deliverUpdateScreenshotReply(item) {
async function deliverSearchReply(item) {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
let current = item;
let attachmentMarkdown = current.attachmentMarkdown;
if (!attachmentMarkdown) {
const uploaded = await requestStage(current,
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments', {
method:'POST',
headers:{ Accept:'application/json', 'Idempotency-Key':stageOperationId(current.operationId, 'attachment') },
body:attachmentMultipart(current.attachment),
});
attachmentMarkdown = String(uploaded?.markdown || '');
if (!attachmentMarkdown) {
const error = new Error('The server did not confirm the screenshot upload.');
error.status = 422;
throw error;
}
await checkpointClaim(current, stored => ({ ...stored, attachmentMarkdown }));
current = { ...current, attachmentMarkdown };
let attachmentMarkdown = '';
if (conversationAttachments(current).length) {
const uploaded = await uploadConversationAttachments(current,
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(current.number) +
'/preview/attachments?kind=' + encodeURIComponent(current.targetKind));
current = uploaded.current;
attachmentMarkdown = uploaded.markdown;
}
const text = String(current.body || '').trim();
return requestStage(current,
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(current.number) +
'/preview/comments?kind=' + encodeURIComponent(current.targetKind), {
method:'POST',
headers:{Accept:'application/json','Content-Type':'application/json',
'Idempotency-Key':stageOperationId(current.operationId, 'comment')},
body:JSON.stringify({body:attachmentMarkdown ?
(text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown) : text}),
});
}
async function deliverUpdateScreenshotReply(item) {
const uploaded = await uploadConversationAttachments(item,
base + 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/attachments');
const current = uploaded.current;
const attachmentMarkdown = uploaded.markdown;
const text = String(current.body || '').trim();
return requestStage(current,
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply', {
method:'POST',
@@ -708,8 +747,9 @@ function createBackgroundIssueSync({
const request = deliveryRequest(item);
try {
const delivered = item.kind === 'update-reply-read' ? await deliverReplyRead(item) :
item.kind === 'update-reply' && item.attachment ? await deliverUpdateScreenshotReply(item) :
item.attachment && ['issue-comment', 'pull-comment'].includes(item.kind) ?
item.kind === 'search-reply' ? await deliverSearchReply(item) :
item.kind === 'update-reply' && conversationAttachments(item).length ? await deliverUpdateScreenshotReply(item) :
conversationAttachments(item).length && ['issue-comment', 'pull-comment'].includes(item.kind) ?
await deliverScreenshotComment(item) : item.attachment && !item.kind ?
await deliverIssueCapture(item) : item.attachments?.length && !item.kind ?
await deliverIssueCapture(item) : item.blockers?.length && !item.kind ?
+81
View File
@@ -0,0 +1,81 @@
(function (root, factory) {
const createConversationPhotoDrafts = factory();
if (typeof module === 'object' && module.exports) module.exports = createConversationPhotoDrafts;
else root.createConversationPhotoDrafts = createConversationPhotoDrafts;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
return function createConversationPhotoDrafts({ store, lanes }) {
const states = {};
Object.entries(lanes || {}).forEach(([kind, lane]) => {
states[kind] = { ...lane, target:null, generation:0, restoring:false, pending:Promise.resolve() };
});
function state(kind) {
const value = states[kind];
if (!value) throw new Error('Unknown conversation photo draft lane.');
return value;
}
async function checkpoint(kind) {
const current = state(kind);
if (!current.target || current.restoring) return null;
const target = { ...current.target };
const attachments = await current.controller.serialize();
const save = current.pending.then(() => store.save(target, attachments));
current.pending = save.catch(() => null);
try { return await save; }
catch (error) { current.onError?.(error); throw error; }
}
async function open(kind, target) {
const current = state(kind);
const generation = ++current.generation;
current.target = { ...target };
await current.pending;
const attachments = await store.load(target);
if (generation !== current.generation) return false;
current.controller.clear();
if (attachments?.length) {
current.restoring = true;
try { current.controller.restore(attachments); }
finally { current.restoring = false; }
}
return Boolean(attachments?.length);
}
async function leave(kind) {
const current = state(kind);
const generation = current.generation;
if (!current.target) return true;
try { await checkpoint(kind); }
catch (_error) { return false; }
if (generation === current.generation) {
current.controller.clear();
current.target = null;
current.generation += 1;
}
return true;
}
async function complete(kind) {
const current = state(kind);
if (!current.target) return false;
const target = { ...current.target };
await current.pending;
await store.remove(target);
current.controller.clear();
current.target = null;
current.generation += 1;
return true;
}
async function switchTo(kind, target, isCurrent = () => true) {
if (!await leave(kind) || !isCurrent()) return false;
return open(kind, target);
}
function hasTarget(kind) { return Boolean(state(kind).target); }
return { checkpoint, open, switchTo, leave, complete, hasTarget };
};
});
+122
View File
@@ -0,0 +1,122 @@
(function (root, factory) {
const createConversationReplyDraftStore = factory();
if (typeof module === 'object' && module.exports) module.exports = createConversationReplyDraftStore;
else root.createConversationReplyDraftStore = createConversationReplyDraftStore;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
const dbName = 'stackchain-conversation-reply-drafts-v1';
const storeName = 'drafts';
function requestResult(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result ?? null);
request.onerror = () => reject(request.error || new Error('Conversation photo draft storage failed.'));
});
}
function createTransaction(indexedDB) {
if (!indexedDB) return null;
let databasePromise;
function database() {
if (!databasePromise) databasePromise = new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, 1);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains(storeName)) {
request.result.createObjectStore(storeName, { keyPath:'id' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error('Conversation photo draft storage is unavailable.'));
});
return databasePromise;
}
return async (operation, key, value) => {
const db = await database();
const transaction = db.transaction(storeName, operation === 'get' ? 'readonly' : 'readwrite');
const records = transaction.objectStore(storeName);
if (operation === 'put') return requestResult(records.put(value));
if (operation === 'delete') return requestResult(records.delete(key));
return requestResult(records.get(key));
};
}
function normalizedTarget(target) {
const kind = String(target?.kind || '');
if (kind === 'update') {
const notificationId = Number(target?.notificationId || target?.notification_id || 0);
if (!Number.isInteger(notificationId) || notificationId < 1) {
throw new Error('Open an unread update before saving photo evidence.');
}
return { kind, notificationId };
}
const repository = String(target?.repository || '');
const number = Number(target?.number || 0);
if (!['issue', 'pull'].includes(kind) || !repository || !Number.isInteger(number) || number < 1) {
throw new Error('Open a conversation before saving photo evidence.');
}
return { kind, repository, number };
}
function attachment(value) {
const blob = value?.blob;
const data = String(value?.data || '');
if (!blob && !data) throw new Error('A saved conversation photo is unavailable.');
const note = String(value?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
const operationId = String(value?.operationId || '').slice(0, 128);
const markdown = String(value?.confirmed?.markdown || '');
return {
filename:String(value?.filename || ''), contentType:String(value?.contentType || ''),
...(blob ? { blob } : { data }), ...(note ? { note } : {}),
...(operationId ? { operationId } : {}), ...(markdown ? { confirmed:{ markdown } } : {}),
};
}
return function createConversationReplyDraftStore({
indexedDB = globalThis.indexedDB, transaction, getOwnerLogin = () => '',
} = {}) {
const transact = transaction || createTransaction(indexedDB);
function identity(target) {
const ownerLogin = String(getOwnerLogin() || '').trim();
if (!ownerLogin) throw new Error('Confirm your Gitea account before saving conversation photos.');
const normalized = normalizedTarget(target);
const targetKey = normalized.kind === 'update' ? normalized.notificationId :
normalized.repository + ':' + normalized.number;
return {
ownerLogin, target:normalized,
id:[ownerLogin, normalized.kind, targetKey].map(value => encodeURIComponent(String(value))).join(':'),
};
}
async function save(target, values) {
if (!transact) throw new Error('Photo draft storage needs IndexedDB. Your current photos are still here.');
const { id, ownerLogin, target:normalized } = identity(target);
const list = (Array.isArray(values) ? values : [values]).filter(Boolean).slice(0, 5).map(attachment);
if (!list.length) { await transact('delete', id); return null; }
await transact('put', id, { id, version:1, ownerLogin, ...normalized, attachments:list });
return list;
}
async function load(target) {
if (!transact) return null;
const { id, ownerLogin, target:normalized } = identity(target);
const record = await transact('get', id);
const same = record?.version === 1 && record.ownerLogin === ownerLogin &&
record.kind === normalized.kind && (normalized.kind === 'update' ?
Number(record.notificationId) === normalized.notificationId :
record.repository === normalized.repository && Number(record.number) === normalized.number);
if (!same || !Array.isArray(record.attachments) || !record.attachments.length) return null;
return record.attachments.slice(0, 5).map(attachment);
}
async function remove(target) {
if (!transact) return false;
const { id } = identity(target);
await transact('delete', id);
return true;
}
return { save, load, remove };
};
});
+209 -2
View File
@@ -26,6 +26,7 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
.app-menu { margin-left:auto; }
.app-menu > summary { display:none; }
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
#open-insights { display:none; }
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
.notification-undo { position:fixed; z-index:110; left:50%; bottom:calc(88px + env(safe-area-inset-bottom)); transform:translateX(-50%); box-sizing:border-box; width:min(520px,calc(100vw - 24px)); display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 12px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; box-shadow:0 12px 36px rgba(0,0,0,.5); overflow-wrap:anywhere; }
.notification-undo[hidden] { display:none; }
@@ -45,6 +46,11 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
.active-devices-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.active-devices-header h2, .active-devices-header p { margin-top:0; }
.active-devices-header button, .active-device button, .enrolled-passkey button { min-height:44px; }
.security-section-nav { position:sticky; top:-18px; z-index:2; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin:0 -2px; padding:10px 2px; background:#0b1526; }
.security-section-nav button { min-height:44px; min-width:0; }
.security-section-nav button[aria-current="page"] { border-color:#55d6be; color:#55d6be; }
.security-activity, .security-devices, .enrolled-passkeys { scroll-margin-top:64px; }
.security-devices { margin-top:24px; padding-top:18px; border-top:1px solid #2a496e; }
.active-devices-list { display:grid; gap:10px; margin-top:16px; }
.device-setup-sheet { position:fixed; inset:0; z-index:95; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.device-setup-sheet[hidden] { display:none; }
@@ -59,6 +65,10 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
.device-setup-deadline-controls label { font-size:12px; color:#bfdbfe; }
.device-setup-deadline-controls select, .device-setup-deadline-controls button, #push-deadline-hour, #push-deadline-days { min-height:44px; }
.device-setup-ready { margin:0; padding:12px; border-radius:10px; background:#0f2237; color:#bfdbfe; font-weight:700; }
.device-storage { display:grid; gap:12px; margin:14px 0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#0d1d31; }
.device-storage h3, .device-storage p { margin:0 0 5px; }
.device-storage-actions { display:flex; gap:8px; flex-wrap:wrap; }
.device-storage-actions button { min-height:44px; flex:1 1 180px; }
.device-readiness-card { display:none; min-width:0; margin:10px 0; padding:12px; border:1px solid #3b82b8; border-radius:12px; background:#102b46; overflow-x:hidden; }
.device-readiness-card p { margin:5px 0 0; overflow-wrap:anywhere; }
.device-readiness-summary { color:#bfdbfe; font-weight:700; }
@@ -76,6 +86,7 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
.security-activity-header h3, .security-activity-header p { margin:0 0 6px; }
.security-activity-list { display:grid; gap:8px; margin:12px 0; }
.security-event { padding:12px; border:1px solid #243d5d; border-radius:12px; background:#0d1c30; }
.security-event button { min-height:44px; margin-top:10px; }
.authentication-alert { border-color:#d69e2e; background:#241b09; }
.authentication-alert strong { color:#f6c453; }
.security-event strong, .security-event span { display:block; overflow-wrap:anywhere; }
@@ -91,6 +102,8 @@ button:hover { filter: brightness(1.15); }
.panel[open] > summary { margin-bottom: 8px; }
aside, section { position: relative; z-index: 1; }
main { position: relative; z-index: 1; display: grid; grid-template-columns: 300px 1fr 340px; gap: 14px; padding: 14px; }
.insights-shell { display:contents; }
.insights-header { display:none; }
@media (max-width: 1100px) { main { grid-template-columns: 1fr; } }
.sidebar { display:flex; flex-direction:column; gap: 12px; }
.stack { display:flex; flex-direction:column; gap:10px; }
@@ -201,6 +214,11 @@ textarea { resize: vertical; min-height: 120px; }
.plan-today-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.plan-today-header h2, .plan-today-header p { margin-top:0; }
.plan-today-header button { min-width:44px; min-height:44px; }
.mobile-plan-today-nav { position:sticky; top:0; z-index:5; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:4px; margin:0 -18px 10px; padding:4px 18px; background:rgba(11,21,38,.98); border-block:1px solid #2a496e; }
.mobile-plan-today-nav button { min-width:0; min-height:44px; padding:4px; border-color:transparent; font-size:12px; }
.mobile-plan-today-nav button[aria-current="location"] { color:#bfdbfe; background:#17365a; border-color:#31577f; }
#plan-today-fit, #plan-today-selected, #plan-today-available-work { scroll-margin-top:54px; }
@media (min-width:601px) { .mobile-plan-today-nav { display:none; } }
.plan-today-list button, .plan-today-candidates button { min-height:44px; }
.plan-today-capacity { position:sticky; top:0; z-index:2; margin:8px 0; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#10233a; font-weight:700; }
.plan-today-available { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:10px 0; font-weight:700; }
@@ -253,12 +271,39 @@ textarea { resize: vertical; min-height: 120px; }
.today-recap-totals { margin:12px 0; padding:12px; border-radius:10px; background:#10233d; font-weight:700; }
.today-recap-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.today-recap-actions button { min-height:44px; width:100%; }
.today-wrap-up-sheet { position:fixed; inset:0; z-index:89; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-wrap-up-sheet[hidden] { display:none; }
.today-wrap-up-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
.today-wrap-up-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.today-wrap-up-header h2 { margin:.2rem 0; }
.today-wrap-up-header button { min-height:44px; min-width:44px; }
.today-wrap-up-items { display:grid; gap:8px; margin:14px 0; }
.today-wrap-up-item { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:12px; min-width:0; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#10233d; overflow-wrap:anywhere; }
.today-wrap-up-item span { display:block; }
.today-wrap-up-item label { display:flex; align-items:center; min-height:44px; gap:8px; font-weight:700; }
.today-wrap-up-item input { width:24px; min-height:24px; }
.today-wrap-up-actions { position:sticky; bottom:0; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.today-wrap-up-actions button { min-height:44px; width:100%; }
.today-handoff-dialog { box-sizing:border-box; width:min(620px,100%); max-width:none; max-height:100dvh; margin:auto auto 0; padding:0; color:#e8f1ff; border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
.today-handoff-dialog::backdrop { background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-handoff-panel { max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); }
.today-handoff-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.today-handoff-panel h2 { margin:.2rem 0; }
.today-handoff-panel header button { min-height:44px; min-width:44px; }
.today-handoff-items { display:grid; gap:8px; margin:14px 0; }
.today-handoff-item { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:12px; padding:12px; border:1px solid #29486c; border-radius:12px; background:#101f35; }
.today-handoff-item span { display:block; }
.today-handoff-item label { display:flex; align-items:center; min-height:44px; gap:8px; font-weight:700; }
.today-handoff-item input { width:24px; min-height:24px; }
.today-handoff-actions { position:sticky; bottom:0; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.today-handoff-actions button { min-height:44px; width:100%; }
@media (max-width:420px) {
.today-interruption-actions { grid-template-columns:1fr; }
.today-recap-actions { grid-template-columns:1fr; }
.today-recap-row { grid-template-columns:1fr; }
.today-recap-row input { width:min(100%,8rem); }
.today-recap-variance { grid-column:1; }
.today-wrap-up-item { grid-template-columns:1fr; }
}
.today-readiness-sheet { position:fixed; inset:0; z-index:90; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-readiness-sheet[hidden] { display:none; }
@@ -469,6 +514,30 @@ textarea { resize: vertical; min-height: 120px; }
.issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.issue-sheet-header button { min-height:44px; }
.mobile-issue-detail-nav, .mobile-pull-detail-nav, .mobile-update-detail-nav, .mobile-review-detail-nav { display:none; }
@media (max-width:600px) {
.issue-sheet-panel, .pull-sheet-panel, .update-sheet-panel, .review-sheet-panel { padding-top:max(12px,env(safe-area-inset-top)); }
.mobile-issue-detail-nav, .mobile-pull-detail-nav, .mobile-update-detail-nav, .mobile-review-detail-nav {
position:sticky; top:env(safe-area-inset-top); z-index:6;
display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:4px;
margin:8px -6px 12px; padding:6px;
background:rgba(11,21,38,.98); border-block:1px solid #2a496e;
}
.mobile-issue-detail-nav button, .mobile-pull-detail-nav button, .mobile-update-detail-nav button, .mobile-review-detail-nav button {
min-width:0; min-height:44px; padding:4px; overflow-wrap:anywhere;
border-color:transparent; font-size:12px;
}
.mobile-issue-detail-nav button[aria-current="location"], .mobile-pull-detail-nav button[aria-current="location"], .mobile-update-detail-nav button[aria-current="location"], .mobile-review-detail-nav button[aria-current="location"] {
border-color:#60a5fa; background:#17365a; color:#fff;
}
#issue-overview, #issue-conversation, #issue-comment, #issue-planning { scroll-margin-top:72px; }
#pull-overview, #pull-conversation, #pull-comment, #pull-review { scroll-margin-top:72px; }
#update-conversation, #update-subject-context, #update-reply-workspace { scroll-margin-top:72px; }
#review-overview, #review-files-workspace, #review-feedback, #review-history-workspace { scroll-margin-top:72px; }
}
@media (min-width:601px) { .mobile-issue-detail-nav, .mobile-pull-detail-nav { display:none; } }
@media (min-width:601px) { .mobile-update-detail-nav { display:none; } }
@media (min-width:601px) { .mobile-review-detail-nav { display:none; } }
.completed-filed-actions { position:fixed; right:0; bottom:0; z-index:57; box-sizing:border-box; width:min(560px,100%); display:grid; grid-template-columns:minmax(0,1fr); align-items:center; gap:8px; margin:0; padding:10px 12px calc(10px + env(safe-area-inset-bottom)); border:1px solid #4ade80; border-radius:12px 0 0; background:rgba(11,21,38,.98); overflow-wrap:anywhere; }
.completed-filed-actions[hidden] { display:none; }
.completed-filed-actions button { min-height:44px; min-width:0; }
@@ -528,6 +597,8 @@ textarea { resize: vertical; min-height: 120px; }
.visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
.issue-attachment-controls { display:flex; max-width:100%; }
.issue-attachment-trigger { min-height:44px; display:inline-flex; align-items:center; justify-content:center; padding:8px 12px; border:1px solid #60a5fa; border-radius:10px; color:#dbeafe; font-weight:700; cursor:pointer; }
.conversation-photo-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; min-width:0; width:100%; }
.conversation-photo-actions .issue-attachment-trigger { box-sizing:border-box; width:100%; min-width:0; text-align:center; }
.issue-attachment-preview { display:grid; grid-template-columns:64px minmax(0,1fr); gap:8px 12px; align-items:center; max-width:100%; overflow:hidden; padding:10px; border:1px solid #315781; border-radius:10px; background:#101f34; }
.issue-attachment-preview[hidden] { display:none; }
.issue-attachment-preview img { grid-row:span 2; width:64px; height:64px; object-fit:cover; border-radius:8px; }
@@ -586,9 +657,21 @@ textarea { resize: vertical; min-height: 120px; }
.work-session-nav button { min-height:44px; width:100%; }
.find-work-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.find-work-sheet.open { display:flex; }
.find-work-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
.find-work-panel { width:min(560px,100%); height:100dvh; overflow:auto; overflow-x:hidden; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
.find-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.find-work-header-actions { display:flex; gap:8px; }
.mobile-find-work-nav { position:sticky; top:0; z-index:4; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:6px; padding:6px 0; background:#0b1526; }
.mobile-find-work-nav button { min-height:44px; min-width:0; padding:6px; }
.mobile-find-work-nav button[aria-current="step"] { border-color:#60a5fa; background:#17355b; color:#fff; }
.mobile-find-work-nav button:disabled { opacity:.5; }
.find-work-stage { display:grid; gap:12px; min-width:0; outline:none; }
.find-work-stage[hidden] { display:none; }
.find-work-review { padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; }
.find-work-review > div:first-child, .find-work-review-list { display:grid; gap:8px; }
.find-work-review-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:10px; align-items:center; padding:10px; border:1px solid #2a496e; border-radius:10px; overflow-wrap:anywhere; }
.find-work-review-item > div { display:grid; gap:4px; min-width:0; }
.find-work-review-item button, .find-work-stage-actions button { min-height:44px; }
.find-work-stage-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0 env(safe-area-inset-bottom); background:#101f36; }
.find-work-header button, .find-work-card button, .find-work-card a, .find-work-more { min-height:44px; }
.find-work-search { position:sticky; top:0; z-index:2; display:grid; gap:6px; padding:8px 0; background:#0b1526; }
.find-work-search > div { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }
@@ -628,8 +711,10 @@ textarea { resize: vertical; min-height: 120px; }
.find-work-detail a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.search-preview { position:fixed; inset:0; z-index:60; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.search-preview.open { display:flex; }
.search-preview-panel { box-sizing:border-box; width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; overflow-wrap:anywhere; }
.search-preview-panel { box-sizing:border-box; width:min(560px,100%); height:100dvh; overflow:auto; overflow-x:hidden; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; overflow-wrap:anywhere; }
.search-preview-header { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; }
.mobile-search-preview-nav { display:none; }
.search-preview-overview { display:grid; gap:12px; min-width:0; }
.search-preview-header button, .search-preview-actions button, .search-preview-actions a { min-height:44px; }
.search-preview-body { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
.search-preview-conversation { display:grid; gap:8px; padding-top:8px; border-top:1px solid #2a496e; }
@@ -642,6 +727,9 @@ textarea { resize: vertical; min-height: 120px; }
.search-preview-reply textarea { box-sizing:border-box; width:100%; min-height:110px; resize:vertical; scroll-margin-bottom:calc(190px + env(safe-area-inset-bottom)); }
.search-preview-reply-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.search-preview-reply-actions button { min-height:44px; }
.search-preview-reply .voice-conversation-controls button,
.search-preview-reply .voice-conversation-review-actions button { min-height:44px; }
.search-preview-reply .conversation-photo-actions { max-width:100%; }
.search-preview-navigation { display:grid; grid-template-columns:minmax(0,1fr) auto minmax(0,1fr); align-items:center; gap:8px; }
.search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
.search-preview-actions button, .search-preview-actions a { min-height:44px; box-sizing:border-box; display:flex; align-items:center; justify-content:center; }
@@ -649,6 +737,33 @@ textarea { resize: vertical; min-height: 120px; }
@media (max-width:420px) {
.search-preview-primary-actions { grid-template-columns:1fr; }
}
@media (max-width:600px) {
.search-preview-panel { width:100%; border-left:0; padding:14px; padding-top:max(12px,env(safe-area-inset-top)); }
.mobile-search-preview-nav {
position:sticky; top:env(safe-area-inset-top); z-index:6;
display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:4px;
margin-inline:-14px; padding:4px 14px;
background:rgba(11,21,38,.98); border-block:1px solid #2a496e;
}
.mobile-search-preview-nav button {
min-width:0; min-height:44px; padding:4px; overflow-wrap:anywhere;
border-color:transparent; font-size:12px;
}
.mobile-search-preview-nav button[aria-current="location"] {
border-color:#60a5fa; background:#17365a; color:#fff;
}
.mobile-search-preview-nav button[aria-disabled="true"] { opacity:.55; }
.search-preview-navigation {
position:sticky; bottom:0; z-index:5;
margin-inline:-6px; padding:6px;
padding-bottom:calc(6px + env(safe-area-inset-bottom));
background:rgba(11,21,38,.98); border-block:1px solid #2a496e;
}
#search-preview-overview, #search-preview-conversation,
#search-preview-reply-workspace, #search-preview-actions { scroll-margin-top:72px; }
#search-preview-actions { position:static; }
}
@media (min-width:601px) { .mobile-search-preview-nav { display:none; } }
.search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere; white-space:normal; }
.markdown-content > :first-child { margin-top:0; }
@@ -672,18 +787,28 @@ textarea { resize: vertical; min-height: 120px; }
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; overflow-x:hidden; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
.create-issue-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.create-issue-header button, .create-issue-actions button, .create-issue-capture-actions button { min-height:44px; }
.mobile-create-issue-nav { display:none; }
.create-issue-form { display:grid; gap:12px; }
.create-issue-describe { display:grid; gap:12px; min-width:0; }
.voice-issue-capture { display:grid; gap:8px; min-width:0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#101d31; }
.voice-issue-capture[hidden], .voice-issue-review[hidden], .voice-issue-controls button[hidden] { display:none; }
.voice-issue-heading, .voice-issue-review { display:grid; gap:6px; }
.voice-issue-controls, .voice-issue-review-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.voice-issue-controls button, .voice-issue-review-actions button { min-width:0; min-height:44px; }
#voice-issue-transcript { box-sizing:border-box; width:100%; min-height:96px; resize:vertical; }
.voice-conversation { display:grid; gap:8px; min-width:0; padding:10px; border:1px solid #31577f; border-radius:12px; background:#101d31; }
.voice-conversation[hidden], .voice-conversation-review[hidden], .voice-conversation-controls button[hidden] { display:none; }
.voice-conversation-controls, .voice-conversation-review-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
.voice-conversation-controls button, .voice-conversation-review-actions button { min-height:44px; min-width:0; }
.voice-conversation-review { display:grid; gap:6px; }
.voice-conversation-review textarea { box-sizing:border-box; width:100%; min-height:96px; resize:vertical; }
.create-issue-capture-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
.create-issue-capture-actions[hidden] { display:none; }
.create-issue-filing { display:grid; gap:12px; min-width:0; }
.create-issue-filing[hidden] { display:none; }
.create-issue-attachment { display:grid; gap:8px; min-width:0; }
.photo-evidence-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; min-width:0; }
.photo-evidence-actions .issue-attachment-trigger { box-sizing:border-box; width:100%; min-width:0; text-align:center; }
#create-issue-attachment-preview { grid-template-columns:1fr; }
#create-issue-attachment-image { grid-row:auto; width:100%; height:auto; max-height:320px; }
.issue-evidence-tray { display:flex; gap:8px; max-width:100%; padding:2px 0 6px; overflow-x:auto; overscroll-behavior-inline:contain; scrollbar-width:thin; }
@@ -697,6 +822,14 @@ textarea { resize: vertical; min-height: 120px; }
.issue-evidence-note textarea:focus-visible { outline:2px solid #60a5fa; outline-offset:2px; }
.issue-evidence-review-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.issue-evidence-review-actions button { min-width:44px; min-height:44px; }
.conversation-evidence-review { grid-template-columns:1fr; width:100%; min-width:0; box-sizing:border-box; overflow-x:hidden; }
.conversation-evidence-review > img { grid-row:auto; width:100%; height:auto; max-height:280px; }
.conversation-evidence-review .issue-evidence-tray { overflow-x:auto; }
.conversation-evidence-review .issue-evidence-review-actions { grid-template-columns:repeat(4,minmax(0,1fr)); }
.conversation-evidence-review .issue-evidence-review-actions button { min-width:44px; min-height:44px; width:100%; }
@media (max-width:390px) {
.conversation-evidence-review .issue-evidence-review-actions { grid-template-columns:1fr; }
}
.issue-evidence-editor { position:fixed; inset:0; z-index:70; display:grid; place-items:end center; overflow-x:hidden; background:rgba(2,6,15,.92); }
.issue-evidence-editor[hidden] { display:none; }
.issue-evidence-editor-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; display:grid; gap:12px; padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); background:#0b1526; border:1px solid #2a496e; border-radius:14px 14px 0 0; }
@@ -812,6 +945,12 @@ textarea { resize: vertical; min-height: 120px; }
.app-menu:not([open]) > .app-menu-panel { display:none; }
.app-menu-panel { position:absolute; top:calc(100% + 6px); right:0; width:min(280px,calc(100vw - 20px)); display:grid; gap:8px; padding:10px; border:1px solid #2a496e; border-radius:12px; background:#0b1526; box-shadow:0 18px 45px rgba(0,0,0,.5); }
.app-menu-panel button { min-height:44px; width:100%; }
#open-insights { display:block; }
.insights-shell { display:none; }
.insights-shell[data-mobile-open="true"] { position:fixed; inset:0; z-index:90; display:grid; align-content:start; gap:12px; overflow:auto; overflow-x:hidden; padding:max(12px,env(safe-area-inset-top)) max(12px,env(safe-area-inset-right)) max(12px,env(safe-area-inset-bottom)) max(12px,env(safe-area-inset-left)); background:#050c15; }
.insights-header { position:sticky; top:0; z-index:2; display:flex; align-items:flex-start; justify-content:space-between; gap:12px; padding:10px; border:1px solid #2a496e; border-radius:12px; background:#0b1526; }
.insights-header h2, .insights-header p { margin:0 0 4px; }
.insights-header button { min-height:44px; flex:none; }
.device-setup-panel { width:100%; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); }
.device-setup-step { grid-template-columns:1fr; }
.device-setup-action { width:100%; }
@@ -832,6 +971,8 @@ textarea { resize: vertical; min-height: 120px; }
.card-planning:not([open]) > .card-planning-actions { display:none; }
.card-planning[open] > .card-planning-actions { display:grid; margin-top:8px; }
.plan-today-panel { width:100%; border-left:0; padding:14px; }
.mobile-plan-today-nav { margin-inline:-14px; padding-inline:14px; }
.plan-today-capacity { position:static; }
.plan-today-item { grid-template-columns:1fr; }
.plan-today-item-actions { display:grid; grid-template-columns:repeat(3,1fr); width:100%; }
.plan-today-candidate-actions { grid-template-columns:repeat(2,1fr); width:100%; }
@@ -863,6 +1004,20 @@ textarea { resize: vertical; min-height: 120px; }
.saved-search-row { align-items:stretch; }
#cmd-results { flex:1; min-height:0; overflow-y:auto; max-height:none; overscroll-behavior:contain; padding-bottom:env(safe-area-inset-bottom); }
.create-issue-panel { width:100%; border-left:0; padding:14px; }
.mobile-create-issue-nav {
position:sticky; top:env(safe-area-inset-top); z-index:6;
display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:4px;
margin-inline:-14px; padding:4px 14px;
background:rgba(11,21,38,.98); border-block:1px solid #2a496e;
}
.mobile-create-issue-nav button {
min-width:0; min-height:44px; padding:4px; border-color:transparent; font-size:12px;
}
.mobile-create-issue-nav button[aria-current="location"] {
border-color:#60a5fa; background:#17365a; color:#fff;
}
.mobile-create-issue-nav button[aria-disabled="true"] { opacity:.55; }
#create-issue-describe, #create-issue-evidence, #create-issue-filing { scroll-margin-top:72px; }
.pull-sheet-panel { width:100%; border-left:0; padding:14px; }
.create-issue-panel.composer-keyboard-active,
.issue-sheet-panel.composer-keyboard-active,
@@ -882,6 +1037,14 @@ textarea { resize: vertical; min-height: 120px; }
.mobile-queue-panel { padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
.mobile-queue-panel header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
.mobile-queue-panel h2 { margin:0; }
.mobile-delivery-recovery { box-sizing:border-box; width:100%; max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
.mobile-delivery-recovery::backdrop { background:rgba(3,9,18,.78); }
.mobile-delivery-recovery-panel { box-sizing:border-box; display:grid; gap:12px; width:100%; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); overflow-wrap:anywhere; }
.mobile-delivery-recovery-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.mobile-delivery-recovery-panel h2, .mobile-delivery-recovery-panel p { margin:0; }
.mobile-delivery-recovery-panel button { min-height:48px; }
.mobile-delivery-recovery-panel > #mobile-delivery-recovery-action { width:100%; font-weight:800; }
.delivery-recovery-reason { padding:12px; border:1px solid #31577f; border-radius:12px; background:#0b1c30; }
.mobile-start-day { display:grid; gap:12px; max-width:100%; overflow-wrap:anywhere; margin-top:12px; padding:14px; border:1px solid #31577f; border-radius:14px; background:linear-gradient(135deg,#173b64,#102641); }
.mobile-start-day h3, .mobile-start-day p { margin:0; }
.mobile-start-day p + p { margin-top:4px; }
@@ -912,3 +1075,47 @@ textarea { resize: vertical; min-height: 120px; }
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
/* One-handed live-data recovery on the mobile Home surface. */
.mobile-pull-refresh {
display: none;
}
@media (max-width: 600px) {
.mobile-pull-refresh {
position: sticky;
top: calc(3.25rem + env(safe-area-inset-top));
z-index: 8;
display: block;
width: max-content;
max-width: calc(100vw - 2rem);
margin: 0 auto -2rem;
padding: .45rem .8rem;
border: 1px solid rgba(125, 211, 252, .45);
border-radius: 999px;
background: rgba(11, 18, 32, .96);
color: #dbeafe;
font-size: .78rem;
box-shadow: 0 .35rem 1rem rgba(0, 0, 0, .28);
transform: translateY(.5rem);
pointer-events: none;
}
.mobile-pull-refresh[hidden] {
display: none;
}
.mobile-pull-refresh[data-state="ready"],
.mobile-pull-refresh[data-state="success"] {
border-color: rgba(52, 211, 153, .7);
}
.mobile-pull-refresh[data-state="error"] {
border-color: rgba(251, 113, 133, .75);
}
}
@media (max-width: 600px) and (prefers-reduced-motion: no-preference) {
.mobile-pull-refresh {
transition: transform 120ms ease, border-color 120ms ease;
}
}
+477 -89
View File
@@ -1,4 +1,5 @@
(async function(){
const workspaceLifecycle = await loadWorkspace({ document, window });
const qs = (s, el=document) => el.querySelector(s);
const fmt = (d) => new Date(d).toLocaleString();
const cardPlanning = createCardPlanning(document);
@@ -15,6 +16,36 @@
],
});
mobileComposerViewport.start();
const issueDetailPanel = qs('#issue-sheet .issue-sheet-panel');
const mobileIssueDetailNavigation = createMobileIssueDetailNavigation({
root:issueDetailPanel,
buttons:Object.fromEntries(Array.from(document.querySelectorAll('[data-issue-section]')).map(button => [button.dataset.issueSection, button])),
targets:{
overview:qs('#issue-overview'),
conversation:qs('#issue-conversation'),
reply:qs('#issue-comment'),
actions:qs('#issue-planning'),
},
planning:qs('#issue-planning'),
onSectionChange:(section, options) => workRoute.section(section, options),
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
});
mobileIssueDetailNavigation.start();
const pullDetailPanel = qs('#pull-sheet .pull-sheet-panel');
const mobilePullDetailNavigation = createMobileIssueDetailNavigation({
root:pullDetailPanel,
buttons:Object.fromEntries(Array.from(document.querySelectorAll('[data-pull-section]')).map(button => [button.dataset.pullSection, button])),
targets:{
overview:qs('#pull-overview'),
conversation:qs('#pull-conversation'),
reply:qs('#pull-comment'),
review:qs('#pull-review'),
},
beforeNavigate:{review(target) { target.open = true; }},
onSectionChange:(section, options) => workRoute.section(section, options),
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
});
mobilePullDetailNavigation.start();
[
[qs('.app-menu'), qs('#app-menu-toggle')],
[qs('.work-settings'), qs('#work-settings-toggle')],
@@ -59,7 +90,23 @@
qs('#my-work').scrollIntoView({block:'start'});
qs('#my-work').focus();
}
async function recoverMobileDelivery(item) {
if (!item?.outbox_id || !activeFlushLogin) throw new Error('Sign in again to retry.');
const authored = item.kind === 'authored-outbox';
const result = await (authored ? authoredOutbox : issueOutbox).retry(item.outbox_id, activeFlushLogin);
(authored ? applyAuthoredOutboxResult : applyOutboxResult)(result);
}
let mobileQueueCounts = {};
const mobileDeliveryRecovery = createMobileDeliveryRecovery({
getItems: () => draftInbox.partition(lastDrafts).deliveries,
getIndex: item => lastDrafts.indexOf(item),
activate: recoverMobileDelivery,
beforeOpen: () => selectMobileQueue('draft'),
onComplete: () => {
if (!mobileStartDay.completePhase('delivery')) showMobileQueueCompletion('Delivery', true);
},
});
mobileDeliveryRecovery.start();
function openFiledFollowUp() {
selectMobileQueue('filed');
const target = filedFollowUpTarget(completedFiledReview.visible(lastMyWork));
@@ -73,6 +120,7 @@
return target.kind === 'update' ? 'opened-update' : 'opened-issue';
}
const mobileQueueLauncher = createMobileQueueLauncher({
openDelivery: () => mobileDeliveryRecovery.open(),
openToday: () => mobileWorkEntry.open(),
openAgenda: openAgendaSession,
openUpdates: openUpdateTriage,
@@ -176,6 +224,20 @@
},
});
mobileTaskDock.start();
const mobileInsights = createMobileInsights({
root: qs('#insights-sheet'),
launcher: qs('#open-insights'),
closeButton: qs('#close-insights'),
dock: qs('#mobile-task-dock'),
hud: qs('[data-mobile-today-hud]'),
backgrounds: [qs('header'), qs('#my-work')],
menu: qs('.app-menu'),
history: window.history,
location: window.location,
eventTarget: window,
mediaQuery: window.matchMedia('(max-width: 600px)'),
});
mobileInsights.start();
qs('#empty-work-find').addEventListener('click', () => qs('#find-work').click());
qs('#empty-work-create').addEventListener('click', () => qs('#new-issue').click());
let liveMode = true;
@@ -295,7 +357,9 @@
const marker = `stackchain.today-rollover-reviewed.v1.${encodeURIComponent(planningOwnerLogin)}.${todayRollover.localDate()}`;
if (!localStorage.getItem(marker)) {
localStorage.setItem(marker, '1');
setTimeout(() => openPlanToday(qs('#plan-today')), 0);
setTimeout(() => {
if (!workSession.checkpointed()) openPlanToday(qs('#plan-today'));
}, 0);
}
},
onStatus: (state, detail = {}) => {
@@ -309,19 +373,36 @@
},
});
todaySync.startLifecycle({ window, document });
const todayHandoff = createTodayHandoff({
storage:localStorage,
getLogin:() => planningOwnerLogin,
identity:item => todayWork.identity(item),
items:() => [...todayMyWork, ...activeMyWork],
todayWork,
todaySync,
});
const todayHandoffView = todayHandoff.mount({ qs, escapeHtml,
onComplete:() => refreshMyWorkView(),
});
const promptTodayHandoff = () => todayHandoffView.open();
const laterWork = createLaterWork({
storage: localStorage,
getLogin: () => planningOwnerLogin,
onChange: (action, itemId, wakeAt) => {
if (laterSync.enqueue(action, itemId, wakeAt)) laterSync.flush();
onChange: (action, itemId, wakeAt, handoff) => {
if (laterSync.enqueue(action, itemId, wakeAt, handoff)) laterSync.flush();
},
onExpire: ids => {
onExpire: (ids, handoffs) => {
if (handoffs.length) {
todayHandoff.capture(handoffs);
todayHandoffView.reset();
}
const queued = ids.map(id => laterSync.enqueue('restore', id)).every(Boolean);
if (queued) laterSync.flush();
},
onWake: () => {
qs('#my-work-action-status').textContent = 'Deferred work is ready again.';
refreshMyWorkView();
setTimeout(promptTodayHandoff, 0);
},
});
const laterSync = createLaterSync({
@@ -494,13 +575,133 @@
window.location.hash = '#/my-work/agenda';
qs('#my-work-action-status').textContent = 'Overdue sweep cancelled. No remaining deadline was changed.';
});
let searchReplyAttachmentTarget = null;
let searchReplyRestoreGeneration = 0;
let restoringSearchReplyPhotos = false;
const searchReplyDraftStore = 'indexedDB' in window ? createSearchReplyDraftStore({
indexedDB:window.indexedDB,
getOwnerLogin:() => confirmedOwnerLogin,
}) : null;
const sameSearchReplyTarget = (left, right) => left && right && left.kind === right.kind &&
left.repository === right.repository && Number(left.number) === Number(right.number);
async function persistSearchReplyPhotos() {
const target = searchReplyAttachmentTarget ? { ...searchReplyAttachmentTarget } : null;
if (!target || !searchReplyDraftStore || !searchReplyAttachmentController || restoringSearchReplyPhotos) return;
try {
const attachments = await searchReplyAttachmentController.serialize();
await searchReplyDraftStore.save(target, attachments);
} catch (error) {
qs('#search-preview-reply-status').textContent = error?.message ||
'Photos could not be saved. They remain in this preview; retry before leaving.';
throw error;
}
}
async function restoreSearchReplyPhotos(target) {
if (!searchReplyDraftStore || !target) return;
const generation = ++searchReplyRestoreGeneration;
try {
const attachments = await searchReplyDraftStore.load(target);
if (generation !== searchReplyRestoreGeneration || !sameSearchReplyTarget(target, searchReplyAttachmentTarget)) return;
if (attachments?.length) {
restoringSearchReplyPhotos = true;
try { searchReplyAttachmentController.restore(attachments); }
finally { restoringSearchReplyPhotos = false; }
updateSearchReplyButtons(searchReplyAttachmentController.state());
qs('#search-preview-reply-status').textContent = 'Saved photo evidence restored.';
}
} catch (error) {
if (generation === searchReplyRestoreGeneration) {
qs('#search-preview-reply-status').textContent = error?.message || 'Saved photo evidence could not be restored.';
}
}
}
const updateSearchReplyButtons = state => {
const enabled = Boolean(state) || Boolean(qs('#search-preview-reply').value.trim());
qs('#send-search-preview-reply').disabled = !enabled;
qs('#send-search-preview-reply-next').disabled = !enabled;
};
const searchReplyAttachmentController = issueAttachment.mount({
maxFiles: 5,
input: qs('#search-reply-attachment'),
inputs: [qs('#take-search-reply-photo'), qs('#search-reply-attachment')],
preview: qs('#search-reply-attachment-preview'),
image: qs('#search-reply-attachment-image'),
meta: qs('#search-reply-attachment-meta'),
remove: qs('#remove-search-reply-attachment'),
tray: qs('#search-reply-attachment-tray'),
earlier: qs('#move-search-reply-attachment-earlier'),
later: qs('#move-search-reply-attachment-later'),
note: qs('#search-reply-attachment-note'),
noteLabel: qs('#search-reply-attachment-note-label'),
status: qs('#search-preview-reply-status'),
readyMessage: 'Photo ready to send with this Search reply.',
removedMessage: 'Photo removed. Your Search reply is unchanged.',
onChange: state => {
updateSearchReplyButtons(state);
persistSearchReplyPhotos().catch(() => {});
},
onCheckpoint:() => persistSearchReplyPhotos(),
editor: {
document,
edit: qs('#edit-search-reply-attachment'),
dialog: qs('#issue-evidence-editor'),
canvas: qs('#issue-evidence-editor-canvas'),
exportCanvas: qs('#issue-evidence-editor-export'),
crop: qs('#crop-issue-evidence'), redact: qs('#redact-issue-evidence'),
highlight: qs('#highlight-issue-evidence'), arrow: qs('#arrow-issue-evidence'),
undo: qs('#undo-issue-evidence-edit'), reset: qs('#reset-issue-evidence-edit'),
cancel: qs('#cancel-issue-evidence-edit'), apply: qs('#apply-issue-evidence-edit'),
status: qs('#issue-evidence-editor-status'),
appliedMessage: 'Edited photo flattened and ready to send.',
},
createObjectURL: file => URL.createObjectURL(file),
revokeObjectURL: url => URL.revokeObjectURL(url),
upload: payload => {
const target = searchReplyAttachmentTarget;
if (!target || target.repository !== payload.repository || Number(target.number) !== Number(payload.number)) {
return Promise.reject(new Error('Search result changed. Reopen it before sending this photo.'));
}
const repository = payload.repository.split('/').map(encodeURIComponent).join('/');
return fetchReviewJson(
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(payload.number) +
'/preview/attachments?kind=' + encodeURIComponent(target.kind),
{
method:'POST',
headers:{Accept:'application/json','Idempotency-Key':payload.operation_id},
body:issueAttachment.multipart(payload),
},
);
},
});
const issueAttachmentController = issueAttachment.mount({
maxFiles: 5,
input: qs('#issue-attachment'),
inputs: [qs('#take-issue-comment-photo'), qs('#issue-attachment')],
preview: qs('#issue-attachment-preview'),
image: qs('#issue-attachment-image'),
meta: qs('#issue-attachment-meta'),
remove: qs('#remove-issue-attachment'),
tray: qs('#issue-attachment-tray'),
earlier: qs('#move-issue-attachment-earlier'),
later: qs('#move-issue-attachment-later'),
note: qs('#issue-attachment-note'),
noteLabel: qs('#issue-attachment-note-label'),
status: qs('#issue-comment-status'),
onChange:() => conversationPhotoDrafts.checkpoint('issue').catch(() => {}),
onCheckpoint:() => conversationPhotoDrafts.checkpoint('issue'),
editor: {
document,
edit: qs('#edit-issue-attachment'),
dialog: qs('#issue-evidence-editor'),
canvas: qs('#issue-evidence-editor-canvas'),
exportCanvas: qs('#issue-evidence-editor-export'),
crop: qs('#crop-issue-evidence'), redact: qs('#redact-issue-evidence'),
highlight: qs('#highlight-issue-evidence'), arrow: qs('#arrow-issue-evidence'),
undo: qs('#undo-issue-evidence-edit'), reset: qs('#reset-issue-evidence-edit'),
cancel: qs('#cancel-issue-evidence-edit'), apply: qs('#apply-issue-evidence-edit'),
status: qs('#issue-evidence-editor-status'),
appliedMessage: 'Edited photo flattened and ready to send.',
},
createObjectURL: file => URL.createObjectURL(file),
revokeObjectURL: url => URL.revokeObjectURL(url),
readDataUrl: file => new Promise((resolve, reject) => {
@@ -525,12 +726,34 @@
},
});
const pullAttachmentController = issueAttachment.mount({
maxFiles: 5,
input: qs('#pull-attachment'),
inputs: [qs('#take-pull-comment-photo'), qs('#pull-attachment')],
preview: qs('#pull-attachment-preview'),
image: qs('#pull-attachment-image'),
meta: qs('#pull-attachment-meta'),
remove: qs('#remove-pull-attachment'),
tray: qs('#pull-attachment-tray'),
earlier: qs('#move-pull-attachment-earlier'),
later: qs('#move-pull-attachment-later'),
note: qs('#pull-attachment-note'),
noteLabel: qs('#pull-attachment-note-label'),
status: qs('#pull-comment-status'),
onChange:() => conversationPhotoDrafts.checkpoint('pull').catch(() => {}),
onCheckpoint:() => conversationPhotoDrafts.checkpoint('pull'),
editor: {
document,
edit: qs('#edit-pull-attachment'),
dialog: qs('#issue-evidence-editor'),
canvas: qs('#issue-evidence-editor-canvas'),
exportCanvas: qs('#issue-evidence-editor-export'),
crop: qs('#crop-issue-evidence'), redact: qs('#redact-issue-evidence'),
highlight: qs('#highlight-issue-evidence'), arrow: qs('#arrow-issue-evidence'),
undo: qs('#undo-issue-evidence-edit'), reset: qs('#reset-issue-evidence-edit'),
cancel: qs('#cancel-issue-evidence-edit'), apply: qs('#apply-issue-evidence-edit'),
status: qs('#issue-evidence-editor-status'),
appliedMessage: 'Edited photo flattened and ready to send.',
},
createObjectURL: file => URL.createObjectURL(file),
revokeObjectURL: url => URL.revokeObjectURL(url),
upload: payload => {
@@ -546,14 +769,36 @@
},
});
const updateReplyAttachmentController = issueAttachment.mount({
maxFiles: 5,
input: qs('#update-reply-attachment'),
inputs: [qs('#take-update-reply-photo'), qs('#update-reply-attachment')],
preview: qs('#update-reply-attachment-preview'),
image: qs('#update-reply-attachment-image'),
meta: qs('#update-reply-attachment-meta'),
remove: qs('#remove-update-reply-attachment'),
tray: qs('#update-reply-attachment-tray'),
earlier: qs('#move-update-reply-attachment-earlier'),
later: qs('#move-update-reply-attachment-later'),
note: qs('#update-reply-attachment-note'),
noteLabel: qs('#update-reply-attachment-note-label'),
status: qs('#update-reply-status'),
onChange:() => conversationPhotoDrafts.checkpoint('update').catch(() => {}),
onCheckpoint:() => conversationPhotoDrafts.checkpoint('update'),
readyMessage: 'Screenshot ready to send with this reply.',
removedMessage: 'Screenshot removed. Your reply is unchanged.',
editor: {
document,
edit: qs('#edit-update-reply-attachment'),
dialog: qs('#issue-evidence-editor'),
canvas: qs('#issue-evidence-editor-canvas'),
exportCanvas: qs('#issue-evidence-editor-export'),
crop: qs('#crop-issue-evidence'), redact: qs('#redact-issue-evidence'),
highlight: qs('#highlight-issue-evidence'), arrow: qs('#arrow-issue-evidence'),
undo: qs('#undo-issue-evidence-edit'), reset: qs('#reset-issue-evidence-edit'),
cancel: qs('#cancel-issue-evidence-edit'), apply: qs('#apply-issue-evidence-edit'),
status: qs('#issue-evidence-editor-status'),
appliedMessage: 'Edited photo flattened and ready to send.',
},
createObjectURL: file => URL.createObjectURL(file),
revokeObjectURL: url => URL.revokeObjectURL(url),
upload: payload => fetchReviewJson(
@@ -562,8 +807,48 @@
body:issueAttachment.multipart(payload) },
),
});
const conversationPhotoDraftStore = createConversationReplyDraftStore({
indexedDB:window.indexedDB, getOwnerLogin:() => confirmedOwnerLogin,
});
const photoDraftLane = (controller, status) => ({ controller, onError:error => {
qs(status).textContent = error.message + ' Your photos remain here; retry before leaving.';
} });
const conversationPhotoDrafts = createConversationPhotoDrafts({
store:conversationPhotoDraftStore,
lanes:{
issue:photoDraftLane(issueAttachmentController, '#issue-comment-status'),
pull:photoDraftLane(pullAttachmentController, '#pull-comment-status'),
update:photoDraftLane(updateReplyAttachmentController, '#update-reply-status'),
},
});
const voiceTranscriptStore = createVoiceTranscriptStore();
function mountConversationVoice(kind, draftSelector) {
return createVoiceConversationCapture({
Recognition: window.SpeechRecognition || window.webkitSpeechRecognition,
transcriptStore: voiceTranscriptStore,
getLogin: () => confirmedOwnerLogin,
elements: {
root:qs('#voice-' + kind), start:qs('#start-voice-' + kind),
stop:qs('#stop-voice-' + kind), review:qs('#voice-' + kind + '-review'),
transcript:qs('#voice-' + kind + '-transcript'), append:qs('#append-voice-' + kind),
replace:qs('#replace-with-voice-' + kind), discard:qs('#discard-voice-' + kind),
status:qs('#voice-' + kind + '-status'), draft:qs(draftSelector),
},
});
}
const issueVoiceReply = mountConversationVoice('issue-comment', '#issue-comment');
const pullVoiceReply = mountConversationVoice('pull-comment', '#pull-comment');
const updateVoiceReply = mountConversationVoice('update-reply', '#update-reply');
const searchVoiceReply = mountConversationVoice('search-reply', '#search-preview-reply');
function conversationVoiceTarget(kind, item) {
return kind + ':' + String(item?.repository || '').trim().toLowerCase() + '#' + Number(item?.number);
}
function searchConversationVoiceTarget(item) {
return 'search:' + conversationVoiceTarget(item?.kind, item);
}
const createIssueAttachmentController = issueAttachment.mount({
input: qs('#create-issue-attachment'),
inputs: [qs('#take-create-issue-photo'), qs('#create-issue-attachment')],
preview: qs('#create-issue-attachment-preview'),
image: qs('#create-issue-attachment-image'),
meta: qs('#create-issue-attachment-meta'),
@@ -863,6 +1148,7 @@
}
return offlineToday.warm(confirmedOwnerLogin, todayMyWork);
}
let findWorkNavigation = null;
const findWorkController = createFindWork({
fetchJson: fetchReviewJson,
onItems: renderAvailableIssues,
@@ -877,9 +1163,20 @@
qs('#find-work-selection-status').textContent = state.count ?
state.count + ' issue' + (state.count === 1 ? '' : 's') + ' selected.' : 'No work selected.';
qs('#claim-selected-work').disabled = state.count === 0;
findWorkNavigation?.sync({ selectedCount:state.count });
renderAvailableIssues(findWorkController.items());
},
});
findWorkNavigation = createMobileFindWorkNavigation({
document,
history:window.history,
eventTarget:window,
controller:findWorkController,
todayWork,
openFit:openFindWorkEstimateReview,
});
findWorkNavigation.start();
let findWorkSearchTimer = null;
function updateFindWorkMatchStatus() {
@@ -1121,6 +1418,34 @@
panel: qs('#update-sheet .update-sheet-panel'),
jump: qs('#jump-update-new-activity'),
});
const mobileUpdateDetailNavigation = createMobileUpdateDetailNavigation({
root:qs('#update-sheet .update-sheet-panel'),
buttons:Object.fromEntries(Array.from(document.querySelectorAll('[data-update-section]')).map(button => [button.dataset.updateSection, button])),
targets:{
conversation:qs('#update-conversation'),
context:qs('#update-subject-context'),
reply:qs('#update-reply-workspace'),
},
replyComposer:qs('#update-reply'),
jumpToNewActivity:() => updateReadPosition.jump(),
onSectionChange:(section, options) => workRoute.section(section, options),
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
});
mobileUpdateDetailNavigation.start();
const mobileReviewDetailNavigation = createMobileReviewDetailNavigation({
root:qs('#review-sheet .review-sheet-panel'),
buttons:Object.fromEntries(Array.from(document.querySelectorAll('[data-review-section]')).map(button => [button.dataset.reviewSection, button])),
targets:{
overview:qs('#review-overview'),
files:qs('#review-files-workspace'),
feedback:qs('#review-feedback'),
history:qs('#review-history-workspace'),
},
summaryComposer:qs('#review-summary'),
onSectionChange:(section, options) => workRoute.section(section, options),
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
});
mobileReviewDetailNavigation.start();
const issueDetailPosition = createWorkDetailPosition({ panel: qs('#issue-sheet .issue-sheet-panel') });
const pullDetailPosition = createWorkDetailPosition({ panel: qs('#pull-sheet .pull-sheet-panel') });
const reviewDetailPosition = createWorkDetailPosition({ panel: qs('#review-sheet .review-sheet-panel') });
@@ -1153,12 +1478,13 @@
queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId),
loadSaved: item => offlineWorkStore.loadDetail(confirmedOwnerLogin, item),
onOpen: item => {
if (selectedUpdate && selectedUpdate.notification_id !== item.notification_id) {
updateReplyAttachmentController.clear();
}
selectedUpdate = item;
void conversationPhotoDrafts.switchTo('update',
{ kind:'update', notificationId:item.notification_id }, () => selectedUpdate === item);
void updateVoiceReply.open('update:' + item.notification_id);
selectedUpdateDetail = null;
updateReadPosition.open(String(item.notification_id));
mobileUpdateDetailNavigation.reset();
updateMentions.dismiss();
qs('#update-sheet').classList.add('open');
qs('.update-more-actions').open = false;
@@ -1360,6 +1686,25 @@
workRoute.open(routedWorkItem(item), options);
}
function navigateWorkSection(kind, section) {
if (['issue', 'filed'].includes(kind)) {
mobileIssueDetailNavigation.navigate(section, { focus:false });
} else if (kind === 'pull') {
mobilePullDetailNavigation.navigate(section, { focus:false });
} else if (kind === 'update') {
mobileUpdateDetailNavigation.navigate(section, { focus:false });
} else if (kind === 'review') {
mobileReviewDetailNavigation.navigate(section, { focus:false });
}
}
async function openRoutedWorkSection(item) {
if (item.kind === 'update') await notificationReader.open(item, lastMyWork);
else if (item.kind === 'review') await openReviewSheet(item, reviewTrigger);
else if (item.kind === 'issue' || item.kind === 'filed') await openIssueSheet(item, issueTrigger);
else if (item.kind === 'pull') await openPullSheet(item, pullTrigger);
}
const workRoute = createWorkRoute.createController({
location: window.location,
history: window.history,
@@ -1377,14 +1722,17 @@
qs('#retry-work-route').hidden = true;
qs('#my-work-action-status').textContent = 'Loading shared work item…';
},
onOpen: item => {
onOpen: async item => {
qs('#retry-work-route').hidden = true;
qs('#my-work-action-status').textContent = '';
closeOpenWorkSheets();
if (item.kind === 'update') notificationReader.open(item, lastMyWork);
else if (item.kind === 'review') openReviewSheet(item, reviewTrigger);
else if (item.kind === 'issue' || item.kind === 'filed') openIssueSheet(item, issueTrigger);
else if (item.kind === 'pull') openPullSheet(item, pullTrigger);
await openRoutedWorkSection(item);
const route = createWorkRoute.parse(window.location.hash);
if (route?.section === item.section) navigateWorkSection(item.kind, item.section);
},
onSection: (section, options) => {
const route = createWorkRoute.parse(window.location.hash);
if (options.restore) navigateWorkSection(route?.kind, section);
},
onQueue: openWorkQueueRoute,
onClose: () => {
@@ -1409,6 +1757,9 @@
qs('#retry-work-route').addEventListener('click', () => workRoute.sync());
function closeOpenWorkSheets() {
issueVoiceReply.cancel();
pullVoiceReply.cancel();
updateVoiceReply.cancel();
['#issue-sheet .issue-sheet-panel', '#pull-sheet .pull-sheet-panel', '#review-sheet .review-sheet-panel', '#update-sheet .update-sheet-panel']
.forEach(selector => mobileComposerViewport.close(qs(selector)));
['#issue-sheet', '#pull-sheet', '#review-sheet', '#update-sheet'].forEach(selector =>
@@ -1491,11 +1842,18 @@
interruptionPrompt.resolve(button.dataset.todayInterruption)
);
});
const todayWrapUpView = setupTodayWrapUp({ todayWork, laterWork, todaySync, qs, escapeHtml,
onComplete:() => {
todayRecapView['completeReplan']();
refreshMyWorkView();
warmTodayOffline();
},
});
const todayRecapView = setupTodayRecap(
timer, timerView, todayWork, api, qs, escapeHtml, closeOpenWorkSheets, updateWorkSessionActions,
() => planningOwnerLogin,
identity => [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity) || null,
actualMinutes => openPlanToday(qs('#plan-today'), true, actualMinutes)
actualMinutes => todayWrapUpView.open(todayMyWork, actualMinutes)
);
function updateDetailDeferLabels(active) {
document.querySelectorAll('[data-detail-defer-preset=today]').forEach(button => {
@@ -1930,26 +2288,22 @@
));
}
function closeFindWorkEstimateReview() {
qs('#find-work-estimate-review').hidden = true;
qs('#batch-find-work-actions').hidden = !findWorkController.selection().active;
}
function openFindWorkEstimateReview(items) {
const plan = todayWork.planning();
const planned = Object.values(plan.estimates).reduce((sum, minutes) => sum + minutes, 0);
qs('#find-work-estimate-summary').textContent = formatPlanMinutes(Math.max(0, plan.capacity_minutes - planned)) +
' remaining. Estimate selected work before assigning it.';
const estimatesRequired = Number.isInteger(plan.capacity_minutes) && plan.capacity_minutes > 0;
qs('#find-work-estimate-summary').textContent = estimatesRequired ?
formatPlanMinutes(Math.max(0, plan.capacity_minutes - planned)) +
' remaining. Estimate selected work before assigning it.' :
items.length + ' selected. Confirm to assign and queue this batch in Today.';
qs('#find-work-estimate-list').innerHTML = items.map(item => {
const id = String(item.repository || '') + '#' + String(item.number || '');
return '<div class="find-work-estimate-row"><div><span class="small">' + escapeHtml(id) +
'</span><strong>' + escapeHtml(item.title || 'Untitled work') + '</strong></div><label class="small">' +
'<input type="number" inputmode="numeric" min="5" max="1440" step="5" data-find-work-estimate="' +
escAttr(id) + '" aria-label="Estimate for ' + escAttr(item.title || id) + ' in minutes" /> min</label></div>';
'</span><strong>' + escapeHtml(item.title || 'Untitled work') + '</strong></div>' +
(estimatesRequired ? '<label class="small"><input type="number" inputmode="numeric" min="5" max="1440" step="5" data-find-work-estimate="' +
escAttr(id) + '" aria-label="Estimate for ' + escAttr(item.title || id) + ' in minutes" /> min</label>' : '') + '</div>';
}).join('');
qs('#batch-find-work-actions').hidden = true;
qs('#find-work-estimate-review').hidden = false;
qs('[data-find-work-estimate]')?.focus();
if (estimatesRequired) qs('[data-find-work-estimate]')?.focus();
}
function formatCalendarDueDate(value) {
@@ -2207,7 +2561,7 @@
function renderPlanIssueDependencies(detail, loading = false) {
const preview = planTodayPreview.snapshot();
const assignedIssue = Boolean(selectedIssue && selectedIssueDetail === detail);
const assignedIssue = Boolean(!preview.open && selectedIssue && selectedIssueDetail === detail);
if (!assignedIssue && (!preview.open || preview.item?.kind !== 'issue')) return;
const panel = qs('#issue-blockers');
const list = qs('#issue-blocker-list');
@@ -2650,7 +3004,8 @@
function listDrafts() {
const unfiled = unfiledCaptures.list().map(item => ({
id:'unfiled:' + item.id, capture_id:item.id, kind:'unfiled-issue', ...unfiledDraftSummary(item),
title:item.title, preview:item.body + (item.hasAttachment ? ' · Screenshot attached' : ''),
title:unfiledDraftDisplayTitle(item),
preview:[item.body, item.hasAttachment ? 'Photo evidence attached' : ''].filter(Boolean).join(' · '),
hasAttachment:item.hasAttachment, copy_text:[item.title, item.body].filter(Boolean).join('\n\n'),
updated_at:item.savedAt, quarantined:item.quarantined,
ownership:item.quarantined ? 'Saved by ' + item.ownerLogin +
@@ -2686,6 +3041,7 @@
counts.today = todayMyWork.length;
counts.later = laterMyWork.length;
counts.draft = lastDrafts.length;
counts.delivery = draftInbox.partition(lastDrafts).actionable;
if (!launchFilterResolved) {
selectedWorkFilter = mobileLaunch.chooseFilter({
saved: savedWorkFilter, today: counts.today, attention: counts.attention,
@@ -2701,7 +3057,10 @@
if (element) element.textContent = count;
});
mobileQueueCounts = counts;
mobileStartDay.reconcile({authoritative:authoritativeMyWorkRefresh});
mobileStartDay.reconcile({
authoritative:authoritativeMyWorkRefresh,
authoritativePhases:['delivery'],
});
mobileStartDay.render();
mobileTaskDock.updateQueues(counts);
mobileTaskDock.updateWork(mobileWorkEntry.mode());
@@ -2734,6 +3093,7 @@
}
if (reconcileSession && workSession.active()) workSession.reconcile();
updateWorkSessionActions();
if (todayHandoff.pending().length) setTimeout(promptTodayHandoff, 0);
}
function activeWorkStreams() {
@@ -2826,7 +3186,7 @@
'<div class="draft-actions">' + outboxActions + '</div></article>';
};
const deliverySummary = '<section class="delivery-center" aria-labelledby="delivery-center-title">' +
'<div><h3 id="delivery-center-title">Delivery center</h3>' +
'<div><h3 id="delivery-center-title" tabindex="-1">Delivery center</h3>' +
'<p class="small">Waiting <strong data-delivery-count="waiting">' + deliveryCenter.counts.waiting + '</strong> · ' +
'Sending <strong data-delivery-count="sending">' + deliveryCenter.counts.sending + '</strong> · ' +
'Needs attention <strong data-delivery-count="attention">' + deliveryCenter.counts.attention + '</strong> · ' +
@@ -2845,14 +3205,14 @@
const button = event.currentTarget;
if (!activeFlushLogin || !deliveryCenter.retryable.length) return;
button.disabled = true;
qs('#my-work-action-status').textContent = 'Retrying safe waiting deliveries…';
qs('#my-work-action-status').textContent = 'Retrying waiting deliveries…';
const [issueResult, authoredResult] = await Promise.all([issueOutbox.flush(activeFlushLogin), authoredOutbox.flush(activeFlushLogin)]);
applyOutboxResult(issueResult);
applyAuthoredOutboxResult(authoredResult);
qs('#my-work-action-status').textContent = 'Waiting deliveries retried. Items needing attention were skipped.';
qs('#my-work-action-status').textContent = 'Waiting deliveries retried; attention stayed queued.';
});
list.querySelectorAll('.draft-resume').forEach(button => {
button.addEventListener('click', async () => {
button.onclick=async()=>{
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (!item) return;
if (item.kind === 'unfiled-issue') {
@@ -2861,7 +3221,7 @@
} catch (error) { qs('#my-work-action-status').textContent = error.message; }
} else if (item.kind === 'new-issue') openCreateIssueSheet();
else if (item.route) workRoute.open(item.route);
});
};
});
list.querySelectorAll('.draft-continue').forEach(button => {
button.addEventListener('click', () => {
@@ -2908,7 +3268,7 @@
});
});
list.querySelectorAll('.draft-review-checklist').forEach(button => {
button.addEventListener('click', async () => {
button.onclick=async()=>{
const item = lastDrafts[Number(button.dataset.draftIndex)];
const queued = authoredOutbox.list().find(candidate => candidate.id === item?.outbox_id);
if (!item?.checklist_conflict || !queued || !activeFlushLogin) return;
@@ -2941,7 +3301,7 @@
} finally {
button.disabled = false;
}
});
};
});
list.querySelectorAll('.draft-authorize').forEach(button => {
button.addEventListener('click', async () => {
@@ -2962,12 +3322,12 @@
});
});
list.querySelectorAll('.draft-copy').forEach(button => {
button.addEventListener('click', async () => {
button.onclick=async()=>{
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (!item?.copy_text) return;
await navigator.clipboard.writeText(item.copy_text);
qs('#my-work-action-status').textContent = 'Queued content copied without sending it.';
});
};
});
list.querySelectorAll('.draft-discard').forEach(button => {
button.addEventListener('click', async () => {
@@ -2986,6 +3346,7 @@
qs('#my-work-action-status').textContent = 'Draft discarded.';
});
});
mobileDeliveryRecovery.render();
}
function updateWorkPaginationControls() {
@@ -3512,6 +3873,9 @@
qs('#issue-planning').inert = false;
qs('#issue-handoff').inert = false;
selectedIssue = item;
void conversationPhotoDrafts.switchTo('issue',
{ kind:'issue', repository:item.repository, number:item.number }, () => selectedIssue === item);
void issueVoiceReply.open(conversationVoiceTarget('issue', item));
dismissedChecklistBody = null;
issueMentions.dismiss();
selectedIssueOffline = Boolean(offlineDetail);
@@ -3677,7 +4041,8 @@
return;
}
mobileComposerViewport.close(qs('#issue-sheet .issue-sheet-panel'));
issueAttachmentController.clear();
issueVoiceReply.cancel();
void conversationPhotoDrafts.leave('issue');
qs('#issue-sheet').classList.remove('open');
selectedIssue = null;
selectedIssueOffline = false;
@@ -3829,10 +4194,12 @@
if (!item) return;
if (!await ensurePullWorkflow(trigger)) return;
pullDetailPosition.open(workDetailIdentity('pull', item));
if (!createPullSheet.sameTarget(selectedPull, item)) pullAttachmentController.clear();
qs('#pull-review').inert = false;
qs('#pull-ownership').inert = false;
selectedPull = item;
void conversationPhotoDrafts.switchTo('pull',
{ kind:'pull', repository:item.repository, number:item.number }, () => selectedPull === item);
void pullVoiceReply.open(conversationVoiceTarget('pull', item));
pullMentions.dismiss();
pullTrigger = trigger;
selectedPullDetail = null;
@@ -3897,7 +4264,8 @@
return;
}
mobileComposerViewport.close(qs('#pull-sheet .pull-sheet-panel'));
pullAttachmentController.clear();
pullVoiceReply.cancel();
void conversationPhotoDrafts.leave('pull');
qs('#pull-sheet').classList.remove('open');
selectedPull = null;
selectedPullDetail = null;
@@ -4020,6 +4388,7 @@
}
findingWork = true;
qs('#find-work-sheet').classList.add('open');
findWorkNavigation.open(batchFindWork.pending() > 0);
const retainedItems = findWorkController.items();
if (retainedItems.length) renderAvailableIssues(retainedItems);
else qs('#find-work-list').textContent = '';
@@ -4314,7 +4683,11 @@
}
clearSharedLaunchUrl();
}
qs('#create-issue-title').focus();
const mobileCapture = window.matchMedia?.('(max-width: 600px)').matches === true;
const continuingCapture = Boolean(rUC || captureDraft.title || captureDraft.body || captureDraft.repository);
if (unfiledShouldFocusTitle({mobile:mobileCapture, continuing:continuingCapture})) {
qs('#create-issue-title').focus();
}
}
function setIssueFilingMode(enabled) {
@@ -4487,6 +4860,7 @@
async function openReviewSheet(item, trigger, cachedDetail = null) {
if (!item || !await ensurePullWorkflow(trigger)) return;
reviewDetailPosition.open(workDetailIdentity('review', item));
mobileReviewDetailNavigation.reset();
selectedReview = item;
reviewTrigger = trigger;
offlineReview = Boolean(cachedDetail);
@@ -4622,7 +4996,8 @@
return;
}
mobileComposerViewport.close(qs('#update-sheet .update-sheet-panel'));
updateReplyAttachmentController.clear();
updateVoiceReply.cancel();
void conversationPhotoDrafts.leave('update');
qs('#update-sheet').classList.remove('open');
selectedUpdate = null;
selectedUpdateDetail = null;
@@ -4849,6 +5224,7 @@
qs('#close-search-preview').textContent = searchPreviewReturnKind === 'today-readiness'
? 'Back to blockers' : 'Back to search';
if (state.status === 'closed') {
searchVoiceReply.cancel();
sheet.classList.remove('open');
return;
}
@@ -4864,6 +5240,9 @@
renderSearchPreviewReply(state, null, searchPreview, document);
if (state.status === 'loading') {
searchReplyAttachmentTarget = { ...state.item };
restoreSearchReplyPhotos(searchReplyAttachmentTarget);
searchVoiceReply.open(searchConversationVoiceTarget(state.item));
searchPreviewDetail = null;
qs('#search-preview-key').textContent = state.item.repository + ' #' + state.item.number;
qs('#search-preview-title').textContent = state.item.title || 'Work preview';
@@ -4932,6 +5311,30 @@
{ method:'PATCH', headers:{ Accept:'application/json' } }
),
...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto),
queueReply:async (item,body,operationId) => {
searchReplyAttachmentTarget = item;
const serialized = await searchReplyAttachmentController.serialize();
const attachments = (Array.isArray(serialized) ? serialized : [serialized]).filter(Boolean);
const admitted = await authoredOutbox.enqueueDurably({
kind:'search-reply',targetKind:item.kind,repository:item.repository,number:item.number,
body,operationId,...(attachments.length ? {attachments} : {}),
});
renderOutbox();
return { id:admitted.item.id, queued:true };
},
prepareReply:(item,body) => {
searchReplyAttachmentTarget = item;
return searchReplyAttachmentController.prepareComment(item, body);
},
afterReply:item => {
if (!searchReplyDraftStore) return null;
return searchReplyDraftStore.remove(item);
},
hasAttachments:() => Boolean(searchReplyAttachmentController.state()),
clearAttachments:() => {
searchReplyAttachmentTarget = null;
searchReplyAttachmentController.clear();
},
share: url => createWorkRoute.share(url, navigator, navigator.clipboard),
session:[()=>commandSearchState, commandSearch, item=>taskOverlayHistory.update({preview:item})],
onState: renderSearchPreview,
@@ -5331,15 +5734,13 @@
});
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
qs('#find-work').addEventListener('click', openFindWorkSheet);
qs('#close-find-work').addEventListener('click', closeFindWorkSheet);
qs('#select-find-work').addEventListener('click', () => findWorkController.startSelection());
qs('#fill-find-work-today').addEventListener('click', () => {
const remainingSlots = todayWork.limit - todayWork.read().length;
const outcome = findWorkController.fillSelection(remainingSlots);
qs('#find-work-status').textContent = remainingSlots < 1 ? 'Today is full.' :
outcome.selected ? outcome.selected + ' ranked work selected.' : 'No matching work.';
if (outcome.selected && todayWork.planning().capacity_minutes !== null)
openFindWorkEstimateReview(findWorkController.selectedItems());
if (outcome.selected) findWorkNavigation.go('review');
});
qs('#find-work-search-form').addEventListener('submit', event => event.preventDefault());
qs('#find-work-search').addEventListener('input', event => {
@@ -5365,47 +5766,30 @@
input.dispatchEvent(new Event('input', { bubbles:true }));
input.focus();
});
qs('#cancel-find-work-selection').addEventListener('click', () => findWorkController.cancelSelection());
qs('#claim-selected-work').addEventListener('click', async event => {
if (todayWork.planning().capacity_minutes !== null) {
openFindWorkEstimateReview(findWorkController.selectedItems());
return;
}
event.currentTarget.disabled = true;
const outcome = await batchFindWork.run(findWorkController.selectedItems());
if (outcome.status === 'complete') {
const assignedOnly = outcome.failed.filter(item => item.assigned).length;
const unavailable = outcome.failed.length - assignedOnly;
qs('#find-work-status').textContent = outcome.queued.length + ' queued' +
(unavailable ? ' · ' + unavailable + ' unavailable' : '') +
(assignedOnly ? ' · ' + assignedOnly + ' assigned but not queued' : '') + '.';
if (outcome.failed.length) {
renderAvailableIssues(findWorkController.items());
event.currentTarget.disabled = false;
} else {
findWorkController.cancelSelection();
}
refreshMyWorkView();
} else {
event.currentTarget.disabled = false;
}
});
qs('#cancel-find-work-estimates').addEventListener('click', closeFindWorkEstimateReview);
qs('#confirm-find-work-estimates').addEventListener('click', async event => {
event.currentTarget.disabled = true;
const button = event.currentTarget;
button.disabled = true;
const outcome = await batchFindWork.run(findWorkController.selectedItems(), findWorkEstimateValues());
event.currentTarget.disabled = false;
button.disabled = false;
if (outcome.status === 'estimates-required') {
const first = outcome.invalid[0];
document.querySelector('[data-find-work-estimate="' + CSS.escape(first) + '"]')?.focus();
return;
}
if (outcome.status !== 'complete') return;
const assignedOnly = outcome.failed.filter(item => item.assigned).length;
const unavailable = outcome.failed.length - assignedOnly;
qs('#find-work-status').textContent = outcome.queued.length + ' queued' +
(outcome.failed.length ? ' · ' + outcome.failed.length + ' unavailable.' : '.');
closeFindWorkEstimateReview();
if (!outcome.failed.length) findWorkController.cancelSelection();
(unavailable ? ' · ' + unavailable + ' unavailable' : '') +
(assignedOnly ? ' · ' + assignedOnly + ' assigned but not queued' : '') + '.';
if (!outcome.failed.length) {
findWorkNavigation.complete();
findWorkController.cancelSelection();
} else {
renderAvailableIssues(findWorkController.items());
findWorkNavigation.sync({ selectedCount:findWorkController.selection().count, recovery:true });
}
refreshMyWorkView();
});
qs('#load-more-available').addEventListener('click', async event => {
@@ -6083,7 +6467,7 @@
}
const stillOpen = kind === 'issue' ? selectedIssue === item : selectedPull === item;
if (!stillOpen) return;
attachmentController.clear();
await conversationPhotoDrafts.complete(kind);
if (!result.completed) status.textContent = 'Comment saved, but Today still needs completion.';
else if (result.delivery === 'posted') status.textContent = 'Comment posted.';
else if (result.background) status.textContent = 'Queued for sync when the connection returns.';
@@ -6117,7 +6501,7 @@
const operationId = localStorage.getItem('stackchain.issue-comment.v1:' + selectedIssue.repository + '#' + selectedIssue.number + ':operation');
const admission = await queueIssueScreenshotComment(selectedIssue, body, operationId);
refreshMyWorkView();
issueAttachmentController.clear();
await conversationPhotoDrafts.complete('issue');
qs('#issue-comment-status').textContent = admission.background ?
'Queued with screenshot for sync when the connection returns.' :
'Saved with screenshot for next launch; background delivery unavailable.';
@@ -6131,7 +6515,7 @@
const operationId = localStorage.getItem('stackchain.issue-comment.v1:' + selectedIssue.repository + '#' + selectedIssue.number + ':operation');
const admission = await queueIssueScreenshotComment(selectedIssue, body, operationId);
refreshMyWorkView();
issueAttachmentController.clear();
await conversationPhotoDrafts.complete('issue');
qs('#issue-comment-status').textContent = admission.background ?
'Queued with screenshot for sync when the connection returns.' :
'Saved with screenshot for next launch; background delivery unavailable.';
@@ -6154,7 +6538,7 @@
else renderIssueConversation(conversation);
}
qs('#issue-comment').value = '';
issueAttachmentController.clear();
await conversationPhotoDrafts.complete('issue');
qs('#issue-comment-status').textContent = 'Comment posted.';
} catch (error) {
if (canQueueMessage(error)) {
@@ -6163,9 +6547,9 @@
const admission = await authoredOutbox.enqueueDurably({ kind:'issue-comment', repository:selectedIssue.repository,
number:selectedIssue.number, body:preparedBody, operationId });
refreshMyWorkView();
await conversationPhotoDrafts.complete('issue');
if (admission.background) {
qs('#issue-comment').value = '';
issueAttachmentController.clear();
qs('#issue-comment-status').textContent = 'Queued for sync when the connection returns.';
} else {
qs('#issue-comment-status').textContent = 'Saved for next launch; background delivery unavailable.';
@@ -6424,7 +6808,7 @@
renderPullConversation(pullConversation.append(admission.delivered));
}
qs('#pull-comment').value = '';
pullAttachmentController.clear();
await conversationPhotoDrafts.complete('pull');
qs('#pull-comment-status').textContent = admission.delivered ? 'Comment posted.' :
(admission.background ? 'Queued with screenshot for sync when the connection returns.' :
'Saved with screenshot for next launch; background delivery unavailable.');
@@ -6436,7 +6820,7 @@
if (selectedPull === item && pullConversation) renderPullConversation(pullConversation.append(comment));
pullController.saveDraft(item, '');
if (selectedPull === item) qs('#pull-comment').value = '';
pullAttachmentController.clear();
await conversationPhotoDrafts.complete('pull');
qs('#pull-comment-status').textContent = 'Comment posted.';
} catch (error) {
if (canQueueMessage(error) && !pullAttachmentController.state()) {
@@ -6541,13 +6925,13 @@
updateReplyAttachmentController.setBusy(false);
if (result?.queued) {
qs('#update-reply').value = '';
updateReplyAttachmentController.clear();
await conversationPhotoDrafts.complete('update');
refreshMyWorkView();
qs('#my-work-action-status').textContent = 'Reply queued for sync.';
} else if (result) {
notificationReader.appendReply(result);
qs('#update-reply').value = '';
updateReplyAttachmentController.clear();
await conversationPhotoDrafts.complete('update');
qs('#mark-update-read-next').focus();
} else {
qs('#update-reply').focus();
@@ -6572,7 +6956,7 @@
const operationId = globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random();
try {
const result = await updateReplyReadNext.submit(item, body, operationId, attachment);
if (result?.accepted) updateReplyAttachmentController.clear();
if (result?.accepted) await conversationPhotoDrafts.complete('update');
if (result?.accepted) qs('#my-work-action-status').textContent =
result.delivery === 'posted' ? 'Reply posted and update marked read.' :
'Reply and read acknowledgement queued for sync.';
@@ -6847,6 +7231,7 @@
intervalMs: 8000,
});
function load() { return contextPoller.refresh({ force: true }); }
createMobilePullRefresh({refresh:load}).start();
const liveDataStatusSheet = qs('#live-data-status-sheet');
const liveDataStatusTrigger = qs('#open-live-data-status');
let latestLiveFreshness = {};
@@ -6995,6 +7380,9 @@
flushAuthored: flushAuthoredOutbox,
flushNotificationReads: flushNotificationReadOutbox,
});
const reconnectAfterOnline = () => setTimeout(reconnectOutboxes, 500);
window.addEventListener('online', reconnectAfterOnline);
workspaceLifecycle.replayOnline(reconnectAfterOnline);
async function setOfflineWorkEnabled(enabled) {
keepWorkOffline.checked = enabled;
offlineWorkStore.setEnabled(enabled);
@@ -7052,7 +7440,6 @@
updateDeliveryReceiptControls();
if (!navigator.onLine) await showOfflineStatus();
window.addEventListener('offline', showOfflineStatus);
window.addEventListener('online', reconnectOutboxes);
qs('#refresh').addEventListener('click', load);
qs('#plan-today').addEventListener('click', event => openPlanToday(event.currentTarget));
@@ -7406,6 +7793,7 @@
await controller.enableDeadline();
},
});
await createDeviceStorage.mount(document).start();
await deviceSetup.start();
return deviceSetup;
}
+103
View File
@@ -0,0 +1,103 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else {
root.createDeviceStorage = factory;
root.createDeviceStorage.mount = document => factory({
summary:document.querySelector('#device-storage-summary'),
detail:document.querySelector('#device-storage-detail'),
clearCachesButton:document.querySelector('#clear-device-caches'),
clearAllButton:document.querySelector('#clear-private-device-data'),
localStorage:root.localStorage,
sessionStorage:root.sessionStorage,
caches:root.caches,
storageManager:root.navigator?.storage,
privateDatabases:root.stackchainPrivateDatabases,
inspectPrivateDatabases:root.inspectStackchainPrivateDatabases,
clearPrivateDeviceData:root.stackchainPrivateDeviceData,
});
}
})(typeof self !== 'undefined' ? self : this, function createDeviceStorage(options) {
let privateItemCount = 0;
let privateRecordCount = 0;
let inventoryUnavailable = false;
let fullClearArmed = false;
const ownedCaches = async () => (await options.caches?.keys?.() || [])
.filter(name => name.startsWith('stackchain-dashboard-'));
function ownedStorageCount(storage) {
if (!storage) return 0;
let count = 0;
for (let index = 0; index < storage.length; index += 1) {
if (storage.key(index)?.startsWith('stackchain.')) count += 1;
}
return count;
}
function megabytes(value) {
return Math.round((Number(value) || 0) / (1024 * 1024));
}
async function refresh() {
let estimate = null;
try { estimate = await options.storageManager?.estimate?.(); }
catch (_error) { /* Storage estimates are optional. */ }
options.summary.textContent = estimate?.quota
? `${megabytes(estimate.usage)} MB of ${megabytes(estimate.quota)} MB browser storage used.`
: 'Browser storage usage is unavailable.';
const itemCount = ownedStorageCount(options.localStorage)
+ (options.sessionStorage === options.localStorage ? 0 : ownedStorageCount(options.sessionStorage));
privateItemCount = itemCount;
const inventory = await options.inspectPrivateDatabases?.(options.privateDatabases)
|| { recordCount: 0, unavailable: true };
privateRecordCount = inventory.recordCount;
inventoryUnavailable = inventory.unavailable;
const cacheCount = (await ownedCaches()).length;
options.detail.textContent = `${itemCount} private browser item${itemCount === 1 ? '' : 's'} · `
+ `${inventoryUnavailable ? 'private work status unknown' : `${privateRecordCount} private work record${privateRecordCount === 1 ? '' : 's'}`} · `
+ `${cacheCount} cached app cop${cacheCount === 1 ? 'y' : 'ies'}`;
return { itemCount, privateRecordCount, inventoryUnavailable, cacheCount };
}
async function clearCaches() {
options.clearCachesButton.disabled = true;
try {
await Promise.all((await ownedCaches()).map(name => options.caches.delete(name)));
await refresh();
options.summary.textContent = 'Cached app copies cleared. Private work was kept.';
} catch (error) {
options.summary.textContent = `Cached copies could not be cleared: ${error.message}`;
} finally {
options.clearCachesButton.disabled = false;
}
}
async function clearAll() {
if ((privateItemCount > 0 || privateRecordCount > 0 || inventoryUnavailable) && !fullClearArmed) {
fullClearArmed = true;
options.clearAllButton.textContent = 'Confirm: clear private work';
options.summary.textContent = 'Private drafts or queued work may not be synced. Press confirm to clear them from this device.';
return;
}
options.clearAllButton.disabled = true;
try {
await options.clearPrivateDeviceData();
privateItemCount = 0;
fullClearArmed = false;
options.clearAllButton.textContent = 'Clear all private data';
options.detail.textContent = '0 private browser items · 0 private work records · 0 cached app copies';
options.summary.textContent = 'All Stackchain private data was cleared from this device.';
} catch (error) {
options.summary.textContent = `Private data was not fully cleared: ${error.message}`;
} finally {
options.clearAllButton.disabled = false;
}
}
async function start() {
options.clearCachesButton.addEventListener('click', clearCaches);
options.clearAllButton.addEventListener('click', clearAll);
return refresh();
}
return { refresh, start };
});
+9 -2
View File
@@ -240,7 +240,14 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
}
function partition(items = list()) {
const deliveries = items.filter(item => item.kind === 'issue-outbox' || item.kind === 'authored-outbox');
const isDelivery = item => item.kind === 'issue-outbox' || item.kind === 'authored-outbox';
const needsAction = item => isDelivery(item) && (
item.status === 'attention' || item.status === 'authorization' || item.status === 'completion' ||
item.delivery_state === 'uncertain' || item.quarantined
);
const deliveries = items.filter(isDelivery).sort((left, right) =>
Number(needsAction(right)) - Number(needsAction(left))
);
const drafts = items.filter(item => item.kind !== 'issue-outbox' && item.kind !== 'authored-outbox');
const counts = deliveries.reduce((summary, item) => {
const state = item.status === 'sending' ? 'sending' : (item.status === 'attention' ? 'attention' :
@@ -251,7 +258,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
const retryable = deliveries.filter(item =>
item.status === 'queued' && !item.authorization_required && !item.quarantined && item.delivery_state !== 'uncertain'
);
return { drafts, deliveries, counts, retryable };
return { drafts, deliveries, counts, retryable, actionable:deliveries.filter(needsAction).length };
}
function deliveryLabel(item) {
+387 -97
View File
@@ -16,6 +16,7 @@
<details class="app-menu">
<summary id="app-menu-toggle" aria-label="Open dashboard menu">Menu</summary>
<div class="app-menu-panel">
<button id="open-insights" type="button" aria-controls="insights-sheet">Insights</button>
<button id="refresh">Refresh</button>
<button id="open-palette">Command</button>
<button id="open-device-setup" type="button">Device setup</button>
@@ -47,22 +48,15 @@
<div id="active-devices-sheet" class="active-devices-sheet" hidden>
<section class="active-devices-panel" role="dialog" aria-modal="true" aria-label="Active devices and passkeys">
<div class="active-devices-header">
<div><h2>Active devices</h2><p class="small muted">Review signed-in devices and remove access you no longer trust.</p></div>
<div><h2>Security Center</h2><p class="small muted">Investigate activity, remove untrusted access, and verify the result.</p></div>
<button id="close-active-devices" type="button" aria-label="Close active devices">Close</button>
</div>
<div id="active-devices-status" class="small" role="status" aria-live="polite"></div>
<button id="enroll-passkey" type="button">Add a passkey for this device</button>
<div id="active-devices-list" class="active-devices-list"></div>
<section class="enrolled-passkeys" aria-labelledby="enrolled-passkeys-title">
<div class="enrolled-passkeys-header">
<h3 id="enrolled-passkeys-title">Enrolled passkeys</h3>
<p class="small muted">Passkeys can remain enrolled after a device signs out. Remove credentials you no longer trust.</p>
<p class="small muted">A recovery token will be required after sign-out if you remove this device’s passkey.</p>
</div>
<div id="enrolled-passkeys-status" class="small" role="status" aria-live="polite"></div>
<div id="enrolled-passkeys-list" class="enrolled-passkeys-list"></div>
</section>
<section class="security-activity" aria-labelledby="security-activity-title">
<nav class="security-section-nav" aria-label="Security Center sections">
<button id="security-section-activity" type="button">Activity</button>
<button id="security-section-devices" type="button">Devices</button>
<button id="security-section-passkeys" type="button">Passkeys</button>
</nav>
<section id="security-activity-section" class="security-activity" aria-labelledby="security-activity-title">
<div class="security-activity-header">
<h3 id="security-activity-title">Security activity</h3>
<p class="small muted">Recent sign-ins, device changes, and protected actions.</p>
@@ -71,6 +65,24 @@
<div id="security-activity-list" class="security-activity-list"></div>
<button id="load-more-security-activity" type="button" hidden>Load older activity</button>
</section>
<section id="security-devices-section" class="security-devices" aria-labelledby="security-devices-title">
<div class="security-activity-header">
<h3 id="security-devices-title">Active devices</h3>
<p class="small muted">Review signed-in devices and remove access you no longer trust.</p>
</div>
<div id="active-devices-status" class="small" role="status" aria-live="polite"></div>
<button id="enroll-passkey" type="button">Add a passkey for this device</button>
<div id="active-devices-list" class="active-devices-list"></div>
</section>
<section id="security-passkeys-section" class="enrolled-passkeys" aria-labelledby="enrolled-passkeys-title">
<div class="enrolled-passkeys-header">
<h3 id="enrolled-passkeys-title">Enrolled passkeys</h3>
<p class="small muted">Passkeys can remain enrolled after a device signs out. Remove credentials you no longer trust.</p>
<p class="small muted">A recovery token will be required after sign-out if you remove this device’s passkey.</p>
</div>
<div id="enrolled-passkeys-status" class="small" role="status" aria-live="polite"></div>
<div id="enrolled-passkeys-list" class="enrolled-passkeys-list"></div>
</section>
</section>
</div>
@@ -107,11 +119,23 @@
</div>
</li>
</ol>
<section class="device-storage" aria-labelledby="device-storage-heading">
<div>
<h3 id="device-storage-heading">Private device storage</h3>
<p class="small" id="device-storage-summary" role="status" aria-live="polite">Checking private storage…</p>
<p class="small muted" id="device-storage-detail"></p>
</div>
<div class="device-storage-actions">
<button id="clear-device-caches" type="button">Clear cached copies</button>
<button id="clear-private-device-data" type="button">Clear all private data</button>
</div>
</section>
<p id="device-setup-ready-status" class="device-setup-ready" role="status" aria-live="polite"></p>
</section>
</div>
<main>
<div id="mobile-pull-refresh" class="mobile-pull-refresh" role="status" aria-live="polite" hidden></div>
<section class="panel my-work" id="my-work" tabindex="-1">
<div class="my-work-header">
<div>
@@ -273,6 +297,11 @@
</div>
</div>
</section>
<section class="insights-shell" id="insights-sheet" aria-labelledby="insights-heading">
<div class="insights-header">
<div><h2 id="insights-heading">Insights</h2><p class="small muted">Context, activity, live signals, and workspace tools.</p></div>
<button id="close-insights" type="button">Close</button>
</div>
<aside class="sidebar">
<details class="panel stack" data-panel-key="context" open>
<summary><h2>Context &amp; view</h2></summary>
@@ -334,6 +363,7 @@
<div id="md-preview" style="margin-top:8px;"></div>
</details>
</aside>
</section>
</main>
<div class="plan-today-sheet" id="plan-today-sheet" role="dialog" aria-modal="true" aria-labelledby="plan-today-title" hidden>
@@ -342,6 +372,12 @@
<div><h2 id="plan-today-title">Plan Today</h2><p class="small muted">Choose and order the work you want to finish next.</p></div>
<button id="cancel-plan-today" type="button">Cancel</button>
</div>
<nav class="mobile-plan-today-nav" aria-label="Plan Today sections">
<button type="button" data-plan-today-section="fit" aria-current="location">Fit</button>
<button type="button" data-plan-today-section="today">Today</button>
<button type="button" data-plan-today-section="available">Available</button>
</nav>
<section id="plan-today-fit" aria-label="Fit today's work">
<div class="plan-today-capacity" id="plan-today-capacity" role="status" aria-live="polite">0 of 5 selected</div>
<label class="plan-today-available" for="plan-today-available">Available today
<span><input id="plan-today-available" type="number" inputmode="numeric" min="15" max="1440" step="15" placeholder="Minutes" /> min</span>
@@ -356,11 +392,12 @@
<div class="plan-today-skipped" id="plan-today-skipped"></div>
<div class="small plan-today-error" id="plan-today-error" role="alert"></div>
<button class="discard-recap-replan" id="discard-recap-replan" type="button" hidden>Discard recap feedback</button>
<section aria-labelledby="today-plan-heading">
</section>
<section id="plan-today-selected" aria-labelledby="today-plan-heading">
<h3 id="today-plan-heading">Today, in order</h3>
<div class="plan-today-list" id="plan-today-list"></div>
</section>
<section aria-labelledby="today-candidates-heading">
<section id="plan-today-available-work" aria-labelledby="today-candidates-heading">
<h3 id="today-candidates-heading">Available My Work</h3>
<div class="plan-today-candidates" id="plan-today-candidates"></div>
</section>
@@ -399,13 +436,38 @@
<div id="today-recap-history" class="today-recap-history"></div>
</section>
<div class="today-recap-actions">
<button id="save-today-recap" type="button">Save recap &amp; adjust plan</button>
<button id="save-today-recap" type="button">Save recap &amp; wrap up</button>
<button id="save-log-today-recap" type="button">Log selected time to Gitea</button>
<button id="discard-today-recap" type="button">Keep timer &amp; close</button>
</div>
</section>
</div>
<dialog class="today-handoff-dialog" id="today-handoff-dialog" aria-labelledby="today-handoff-title">
<section class="today-handoff-panel">
<header><div><div class="small">Yesterday’s commitments</div><h2 id="today-handoff-title">Review tomorrow’s plan</h2></div><button id="close-today-handoff" type="button">Not now</button></header>
<p class="small muted">Choose the work you still intend to do Today. Unchecked work stays safely in My Work.</p>
<div id="today-handoff-status" class="small" role="status" aria-live="polite"></div>
<div id="today-handoff-items" class="today-handoff-items"></div>
<div class="today-handoff-actions"><button id="confirm-today-handoff" type="button">Add selected to Today</button></div>
</section>
</dialog>
<div class="today-wrap-up-sheet" id="today-wrap-up-sheet" role="dialog" aria-modal="true" aria-labelledby="today-wrap-up-title" hidden>
<section class="today-wrap-up-panel">
<div class="today-wrap-up-header">
<div><div class="small">End your day intentionally</div><h2 id="today-wrap-up-title">Wrap up Today</h2></div>
<button id="close-today-wrap-up" type="button">Not now</button>
</div>
<p class="small muted">Choose unfinished work to schedule for tomorrow at 09:00. Unchecked work stays in Today.</p>
<div id="today-wrap-up-status" class="small" role="status" aria-live="polite"></div>
<div id="today-wrap-up-items" class="today-wrap-up-items"></div>
<div class="today-wrap-up-actions">
<button id="finish-today-wrap-up" type="button">Finish wrap-up</button>
</div>
</section>
</div>
<div class="today-readiness-sheet" id="today-readiness-sheet" role="dialog" aria-modal="true" aria-labelledby="today-readiness-title" hidden>
<section class="today-readiness-panel">
<div class="today-readiness-header">
@@ -533,9 +595,17 @@
</div>
<button id="close-search-preview" type="button">Back to search</button>
</div>
<div id="search-preview-status" class="small" aria-live="polite">Choose a search result.</div>
<div id="search-preview-meta" class="row"></div>
<div id="search-preview-body" class="search-preview-body markdown-content"></div>
<nav class="mobile-search-preview-nav" aria-label="Search preview sections">
<button type="button" data-search-preview-section="overview">Overview</button>
<button type="button" data-search-preview-section="conversation">Conversation</button>
<button type="button" data-search-preview-section="reply">Reply</button>
<button type="button" data-search-preview-section="actions">Act</button>
</nav>
<section id="search-preview-overview" class="search-preview-overview">
<div id="search-preview-status" class="small" aria-live="polite">Choose a search result.</div>
<div id="search-preview-meta" class="row"></div>
<div id="search-preview-body" class="search-preview-body markdown-content"></div>
</section>
<section id="search-preview-conversation" class="search-preview-conversation" aria-labelledby="search-preview-conversation-title">
<h2 id="search-preview-conversation-title">Current conversation</h2>
<div id="search-preview-comments"></div>
@@ -543,16 +613,53 @@
<button id="retry-search-preview-conversation" type="button" hidden>Retry conversation</button>
<button id="load-older-search-preview-comments" type="button" hidden>Load older messages</button>
</section>
<section class="search-preview-reply" aria-labelledby="search-preview-reply-title" hidden>
<section class="search-preview-reply" id="search-preview-reply-workspace" aria-labelledby="search-preview-reply-title" hidden>
<h2 id="search-preview-reply-title">Reply</h2>
<textarea id="search-preview-reply" maxlength="10000" placeholder="Write a reply"></textarea>
<section class="voice-conversation" id="voice-search-reply" aria-label="Dictate search result reply" hidden>
<div class="voice-conversation-controls">
<button id="start-voice-search-reply" type="button">Dictate reply</button>
<button id="stop-voice-search-reply" type="button" hidden>Stop listening</button>
</div>
<div id="voice-search-reply-status" class="small" aria-live="polite"></div>
<div class="voice-conversation-review" id="voice-search-reply-review" hidden>
<label for="voice-search-reply-transcript">Review transcript</label>
<textarea id="voice-search-reply-transcript" maxlength="10000"></textarea>
<div class="voice-conversation-review-actions">
<button id="append-voice-search-reply" type="button">Use transcript</button>
<button id="replace-with-voice-search-reply" type="button" hidden>Replace draft</button>
<button id="discard-voice-search-reply" type="button">Discard</button>
</div>
</div>
</section>
<div class="issue-attachment-controls conversation-photo-actions">
<label class="issue-attachment-trigger" for="take-search-reply-photo">Take photo</label>
<input class="visually-hidden" id="take-search-reply-photo" type="file" accept="image/*" capture="environment" />
<label class="issue-attachment-trigger" for="search-reply-attachment">Choose existing</label>
<input class="visually-hidden" id="search-reply-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple />
</div>
<div class="issue-attachment-preview conversation-evidence-review" id="search-reply-attachment-preview" hidden>
<img id="search-reply-attachment-image" alt="Selected reply photo preview" />
<span class="small" id="search-reply-attachment-meta"></span>
<div class="issue-evidence-tray" id="search-reply-attachment-tray" role="toolbar" aria-label="Search reply photos" hidden></div>
<label class="issue-evidence-note" for="search-reply-attachment-note">
<span id="search-reply-attachment-note-label">Photo caption (optional)</span>
<textarea id="search-reply-attachment-note" maxlength="240" rows="2" disabled placeholder="What does this photo show?"></textarea>
</label>
<div class="issue-evidence-review-actions" aria-label="Reorder selected photo">
<button id="move-search-reply-attachment-earlier" type="button">Move earlier</button>
<button id="edit-search-reply-attachment" type="button">Edit photo</button>
<button id="remove-search-reply-attachment" type="button">Remove selected</button>
<button id="move-search-reply-attachment-later" type="button">Move later</button>
</div>
</div>
<div id="search-preview-reply-status" class="small" aria-live="polite"></div>
<div class="search-preview-reply-actions">
<button id="send-search-preview-reply" type="button">Send</button>
<button id="send-search-preview-reply-next" type="button">Send &amp; next</button>
</div>
</section>
<div class="search-preview-actions">
<div class="search-preview-actions" id="search-preview-actions">
<nav class="search-preview-navigation" aria-label="Search result navigation" hidden>
<button id="previous-search-result" type="button">Previous</button>
<span id="search-preview-position" aria-live="polite"></span>
@@ -597,6 +704,12 @@
</div>
<button id="close-issue-sheet" type="button">Close sheet</button>
</div>
<nav class="mobile-issue-detail-nav" aria-label="Issue sections">
<button type="button" data-issue-section="overview">Overview</button>
<button type="button" data-issue-section="conversation">Conversation</button>
<button type="button" data-issue-section="reply">Reply</button>
<button type="button" data-issue-section="actions">Plan & actions</button>
</nav>
<div id="issue-sheet-status" class="small" aria-live="polite">Choose an issue.</div>
<section class="completed-filed-actions" id="completed-filed-actions" aria-label="Completed Filed review" hidden>
<span id="completed-filed-progress" role="status" aria-live="polite">Completed Filed issue ready for review.</span>
@@ -607,7 +720,7 @@
</section>
<button class="issue-retry" id="retry-issue-load" type="button" hidden>Reload latest issue</button>
<div class="row"><span id="issue-labels"></span><span class="small" id="issue-assignees"></span></div>
<div class="issue-sheet-content markdown-content" id="issue-sheet-body"></div>
<div id="issue-overview"><div class="issue-sheet-content markdown-content" id="issue-sheet-body"></div></div>
<form class="checklist-step-editor" id="checklist-step-editor" aria-label="Manage checklist step" hidden>
<label for="checklist-step-label">Checklist step</label>
<input id="checklist-step-label" type="text" maxlength="240" autocomplete="off" />
@@ -651,6 +764,7 @@
<button id="cancel-issue-blocker" type="button">Cancel</button>
</div>
</section>
<div id="issue-conversation" tabindex="-1"></div>
<h2>Full conversation</h2>
<div id="issue-comments"></div>
<button class="conversation-more" id="load-older-issue-comments" type="button" hidden>Load older messages</button>
@@ -659,16 +773,44 @@
<section class="issue-comment-composer" aria-labelledby="issue-comment-title">
<h2 id="issue-comment-title">Add comment</h2>
<textarea id="issue-comment" maxlength="10000" placeholder="Write a comment"></textarea>
<section class="voice-conversation" id="voice-issue-comment" aria-label="Dictate issue comment" hidden>
<div class="voice-conversation-controls">
<button id="start-voice-issue-comment" type="button">Dictate reply</button>
<button id="stop-voice-issue-comment" type="button" hidden>Stop listening</button>
</div>
<div id="voice-issue-comment-status" class="small" aria-live="polite"></div>
<div class="voice-conversation-review" id="voice-issue-comment-review" hidden>
<label for="voice-issue-comment-transcript">Review transcript</label>
<textarea id="voice-issue-comment-transcript" maxlength="10000"></textarea>
<div class="voice-conversation-review-actions">
<button id="append-voice-issue-comment" type="button">Use transcript</button>
<button id="replace-with-voice-issue-comment" type="button" hidden>Replace draft</button>
<button id="discard-voice-issue-comment" type="button">Discard</button>
</div>
</div>
</section>
<div class="mention-options" id="issue-comment-mentions" role="listbox" aria-label="Teammates" hidden></div>
<div class="mention-status small" id="issue-comment-mention-status" aria-live="polite"></div>
<div class="issue-attachment-controls">
<input class="visually-hidden" id="issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
<label class="issue-attachment-trigger" for="issue-attachment">Attach screenshot</label>
<div class="issue-attachment-controls conversation-photo-actions">
<label class="issue-attachment-trigger" for="take-issue-comment-photo">Take photo</label>
<input class="visually-hidden" id="take-issue-comment-photo" type="file" accept="image/*" capture="environment" />
<label class="issue-attachment-trigger" for="issue-attachment">Choose existing</label>
<input class="visually-hidden" id="issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple />
</div>
<div class="issue-attachment-preview" id="issue-attachment-preview" hidden>
<div class="issue-attachment-preview conversation-evidence-review" id="issue-attachment-preview" hidden>
<img id="issue-attachment-image" alt="Selected screenshot preview" />
<span class="small" id="issue-attachment-meta"></span>
<button id="remove-issue-attachment" type="button">Remove screenshot</button>
<div class="issue-evidence-tray" id="issue-attachment-tray" role="toolbar" aria-label="Comment photos" hidden></div>
<label class="issue-evidence-note" for="issue-attachment-note">
<span id="issue-attachment-note-label">Photo caption (optional)</span>
<textarea id="issue-attachment-note" maxlength="240" rows="2" disabled placeholder="What does this photo show?"></textarea>
</label>
<div class="issue-evidence-review-actions" aria-label="Reorder selected photo">
<button id="move-issue-attachment-earlier" type="button">Move earlier</button>
<button id="edit-issue-attachment" type="button">Edit photo</button>
<button id="remove-issue-attachment" type="button">Remove selected</button>
<button id="move-issue-attachment-later" type="button">Move later</button>
</div>
</div>
<div class="comment-actions">
<button id="send-issue-comment" type="button">Post comment</button>
@@ -756,42 +898,60 @@
<div><div class="small">Open · unassigned</div><h3 id="find-work-heading">Find work</h3></div>
<div class="find-work-header-actions"><button id="select-find-work" type="button">Select work</button><button id="close-find-work" type="button">Close</button></div>
</div>
<p class="small">Claim an available issue and continue it in My Work.</p>
<form class="find-work-search" id="find-work-search-form" role="search">
<label for="find-work-search">Search repository, #number, or title</label>
<div><input id="find-work-search" type="search" maxlength="100" autocomplete="off" />
<button id="clear-find-work-search" type="button" hidden>Clear</button></div>
<span id="find-work-match-status" class="small" aria-live="polite"></span>
</form>
<details class="find-work-filters" id="find-work-filters">
<summary>Filter work <span id="find-work-filter-count" class="small"></span></summary>
<div class="find-work-filter-panel" aria-label="Filter available work">
<fieldset><legend>Repositories</legend><div id="find-work-repository-filters" class="find-work-filter-options"></div></fieldset>
<fieldset><legend>Labels</legend><div id="find-work-label-filters" class="find-work-filter-options"></div></fieldset>
<button id="clear-find-work-filters" type="button">Clear filters</button>
</div>
</details>
<button class="fill-find-work-today" id="fill-find-work-today" type="button"
aria-describedby="find-work-selection-status">Fill remaining Today slots</button>
<div id="find-work-status" class="small" aria-live="assertive">Open Find Work to load available issues.</div>
<button class="find-work-batch-recovery" id="resume-find-work-batch" type="button"
aria-describedby="find-work-status" hidden>Resume assigning &amp; queueing</button>
<div class="find-work-list" id="find-work-list"></div>
<button class="find-work-more" id="load-more-available" type="button" hidden>Load more available issues</button>
<section class="find-work-estimate-review" id="find-work-estimate-review" aria-labelledby="find-work-estimate-heading" hidden>
<div><strong id="find-work-estimate-heading">Fit this work into Today</strong>
<span class="small" id="find-work-estimate-summary" aria-live="polite"></span></div>
<div id="find-work-estimate-list"></div>
<div class="find-work-estimate-actions">
<button id="cancel-find-work-estimates" type="button">Back</button>
<button id="confirm-find-work-estimates" type="button">Confirm &amp; assign</button>
<p class="small">Choose available issues, review the batch, then fit it into Today.</p>
<nav class="mobile-find-work-nav" aria-label="Find Work steps">
<button type="button" data-find-work-stage="discover">Discover</button>
<button type="button" data-find-work-stage="review" disabled>Review</button>
<button type="button" data-find-work-stage="fit" disabled>Fit &amp; assign</button>
</nav>
<section class="find-work-stage" id="find-work-discover-stage" tabindex="-1" aria-labelledby="find-work-heading">
<form class="find-work-search" id="find-work-search-form" role="search">
<label for="find-work-search">Search repository, #number, or title</label>
<div><input id="find-work-search" type="search" maxlength="100" autocomplete="off" />
<button id="clear-find-work-search" type="button" hidden>Clear</button></div>
<span id="find-work-match-status" class="small" aria-live="polite"></span>
</form>
<details class="find-work-filters" id="find-work-filters">
<summary>Filter work <span id="find-work-filter-count" class="small"></span></summary>
<div class="find-work-filter-panel" aria-label="Filter available work">
<fieldset><legend>Repositories</legend><div id="find-work-repository-filters" class="find-work-filter-options"></div></fieldset>
<fieldset><legend>Labels</legend><div id="find-work-label-filters" class="find-work-filter-options"></div></fieldset>
<button id="clear-find-work-filters" type="button">Clear filters</button>
</div>
</details>
<button class="fill-find-work-today" id="fill-find-work-today" type="button"
aria-describedby="find-work-selection-status">Fill remaining Today slots</button>
<div id="find-work-status" class="small" aria-live="assertive">Open Find Work to load available issues.</div>
<button class="find-work-batch-recovery" id="resume-find-work-batch" type="button"
aria-describedby="find-work-status" hidden>Resume assigning &amp; queueing</button>
<div class="find-work-list" id="find-work-list"></div>
<button class="find-work-more" id="load-more-available" type="button" hidden>Load more available issues</button>
<div class="find-work-batch-actions" id="batch-find-work-actions" hidden>
<span id="find-work-selection-status" class="small" aria-live="polite">No work selected.</span>
<button id="cancel-find-work-selection" type="button">Cancel</button>
<button id="claim-selected-work" type="button" disabled>Review selected</button>
</div>
</section>
<section class="find-work-stage find-work-review" id="find-work-review-stage" tabindex="-1" aria-labelledby="find-work-review-heading" hidden>
<div><strong id="find-work-review-heading">Review selected work</strong>
<span class="small" id="find-work-review-summary" aria-live="polite"></span></div>
<div class="find-work-review-list" id="find-work-review-list"></div>
<div class="find-work-stage-actions">
<button id="back-find-work-discover" type="button">Back to discover</button>
<button id="continue-find-work-fit" type="button">Continue</button>
</div>
</section>
<section class="find-work-stage" id="find-work-fit-stage" tabindex="-1" aria-labelledby="find-work-estimate-heading" hidden>
<div class="find-work-estimate-review" id="find-work-estimate-review">
<div><strong id="find-work-estimate-heading">Fit this work into Today</strong>
<span class="small" id="find-work-estimate-summary" aria-live="polite"></span></div>
<div id="find-work-estimate-list"></div>
<div class="find-work-estimate-actions">
<button id="cancel-find-work-estimates" type="button">Back to review</button>
<button id="confirm-find-work-estimates" type="button">Confirm &amp; assign</button>
</div>
</div>
</section>
<div class="find-work-batch-actions" id="batch-find-work-actions" hidden>
<span id="find-work-selection-status" class="small" aria-live="polite">No work selected.</span>
<button id="cancel-find-work-selection" type="button">Cancel</button>
<button id="claim-selected-work" type="button" disabled>Assign &amp; queue Today</button>
</div>
</section>
</div>
@@ -801,6 +961,11 @@
<h3 id="create-issue-heading">Capture work</h3>
<button id="cancel-new-issue" type="button">Cancel</button>
</div>
<nav class="mobile-create-issue-nav" aria-label="New issue sections">
<button type="button" data-create-issue-section="describe">Describe</button>
<button type="button" data-create-issue-section="evidence">Evidence</button>
<button type="button" data-create-issue-section="file">File</button>
</nav>
<aside class="shared-content-conflict" id="shared-content-conflict" aria-live="assertive" hidden>
<strong>You already have an unfinished issue draft.</strong>
<span class="small">Resume it, or replace its title and description with the content shared to Stackchain.</span>
@@ -810,6 +975,7 @@
</div>
</aside>
<form class="create-issue-form" id="create-issue-form">
<section class="create-issue-describe" id="create-issue-describe">
<label for="create-issue-title">Title
<input id="create-issue-title" type="text" maxlength="255" required autocomplete="off" />
</label>
@@ -841,16 +1007,19 @@
<button id="save-unfiled-issue" type="button">Save to Drafts</button>
<button id="file-new-issue" type="button">File now</button>
</div>
<section class="create-issue-attachment" aria-labelledby="create-issue-attachment-label">
<strong id="create-issue-attachment-label">Evidence screenshots <span class="small">Optional · Up to 5 · PNG, JPEG, or WebP · 2 MB each</span></strong>
<div class="issue-attachment-controls">
<label class="issue-attachment-trigger" for="create-issue-attachment">Add screenshots</label>
</section>
<section class="create-issue-attachment" id="create-issue-evidence" aria-labelledby="create-issue-attachment-label">
<strong id="create-issue-attachment-label">Photo evidence <span class="small">Optional · Up to 5 · PNG, JPEG, or WebP · 2 MB each</span></strong>
<div class="issue-attachment-controls photo-evidence-actions">
<label class="issue-attachment-trigger" for="take-create-issue-photo">Take photo</label>
<input class="visually-hidden" id="take-create-issue-photo" type="file" accept="image/*" capture="environment" />
<label class="issue-attachment-trigger" for="create-issue-attachment">Choose existing</label>
<input class="visually-hidden" id="create-issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple />
</div>
<div class="issue-attachment-preview" id="create-issue-attachment-preview" hidden>
<img id="create-issue-attachment-image" alt="Selected screenshot preview" />
<span class="small" id="create-issue-attachment-meta"></span>
<div class="issue-evidence-tray" id="create-issue-evidence-tray" role="toolbar" aria-label="Evidence screenshots" hidden></div>
<div class="issue-evidence-tray" id="create-issue-evidence-tray" role="toolbar" aria-label="Photo evidence" hidden></div>
<label class="issue-evidence-note" for="create-issue-evidence-note">
<span id="create-issue-evidence-note-label">Evidence note (optional)</span>
<textarea id="create-issue-evidence-note" maxlength="240" rows="2" disabled placeholder="What does this screenshot show?"></textarea>
@@ -941,10 +1110,11 @@
<div id="create-issue-capture-status" class="small" aria-live="assertive"></div>
</form>
</section>
<section class="issue-evidence-editor" id="issue-evidence-editor" role="dialog" aria-modal="true" aria-labelledby="issue-evidence-editor-heading" hidden>
</div>
<section class="issue-evidence-editor" id="issue-evidence-editor" role="dialog" aria-modal="true" aria-labelledby="issue-evidence-editor-heading" hidden>
<div class="issue-evidence-editor-panel">
<header>
<div><h3 id="issue-evidence-editor-heading">Edit screenshot evidence</h3><span class="small">Edits stay on this device until you file.</span></div>
<div><h3 id="issue-evidence-editor-heading">Edit selected photo</h3><span class="small">Edits stay on this device until you send or file.</span></div>
<button id="cancel-issue-evidence-edit" type="button">Cancel</button>
</header>
<canvas id="issue-evidence-editor-canvas" role="img" aria-label="Screenshot editing canvas. Drag to crop, redact, highlight, or point with an arrow."></canvas>
@@ -961,7 +1131,6 @@
<button class="issue-evidence-editor-apply" id="apply-issue-evidence-edit" type="button">Apply flattened edit</button>
</div>
</section>
</div>
<section class="issue-filing-review" id="issue-filing-review" role="dialog" aria-modal="true" aria-labelledby="issue-filing-review-heading" hidden>
<div class="issue-filing-review-panel">
@@ -1024,6 +1193,12 @@
</div>
</div>
<div id="update-triage-progress" class="update-triage-progress small" aria-live="polite" hidden></div>
<nav class="mobile-update-detail-nav" aria-label="Update sections">
<button type="button" data-update-section="activity">New activity</button>
<button type="button" data-update-section="conversation">Conversation</button>
<button type="button" data-update-section="context">Subject context</button>
<button type="button" data-update-section="reply">Reply</button>
</nav>
<div id="update-gesture-status" class="update-gesture-status small" aria-live="polite">Swipe right to keep unread or left to mark read. Buttons remain available.</div>
<div id="update-sheet-status" class="small" aria-live="polite">Choose an update.</div>
<button class="update-retry" id="retry-update-load" type="button" hidden>Retry loading update</button>
@@ -1031,31 +1206,61 @@
<span class="pill" id="update-subject-type">Update</span>
<span class="pill" id="update-subject-state"></span>
</div>
<h2>Full conversation</h2>
<div id="update-comments"></div>
<button class="update-new-activity-jump" id="jump-update-new-activity" type="button" hidden>Jump to new activity</button>
<button class="conversation-more" id="load-older-update-comments" type="button" hidden>Load older messages</button>
<div id="update-conversation-status" class="small" aria-live="assertive"></div>
<button class="update-retry" id="retry-update-conversation" type="button" hidden>Retry conversation</button>
<button class="conversation-actions-retry" id="retry-update-comment-actions" type="button" hidden>Retry comment tools</button>
<details>
<section id="update-conversation" tabindex="-1">
<h2>Full conversation</h2>
<div id="update-comments"></div>
<button class="update-new-activity-jump" id="jump-update-new-activity" type="button" hidden>Jump to new activity</button>
<button class="conversation-more" id="load-older-update-comments" type="button" hidden>Load older messages</button>
<div id="update-conversation-status" class="small" aria-live="assertive"></div>
<button class="update-retry" id="retry-update-conversation" type="button" hidden>Retry conversation</button>
<button class="conversation-actions-retry" id="retry-update-comment-actions" type="button" hidden>Retry comment tools</button>
</section>
<details id="update-subject-context">
<summary><h2>Subject context</h2></summary>
<div class="update-sheet-content muted markdown-content" id="update-subject-body"></div>
</details>
<section class="update-reply" aria-labelledby="update-reply-title">
<section class="update-reply" id="update-reply-workspace" aria-labelledby="update-reply-title">
<h2 id="update-reply-title">Reply</h2>
<label for="update-reply" class="small">Continue this issue or pull-request conversation</label>
<textarea id="update-reply" maxlength="10000" placeholder="Write a reply"></textarea>
<section class="voice-conversation" id="voice-update-reply" aria-label="Dictate update reply" hidden>
<div class="voice-conversation-controls">
<button id="start-voice-update-reply" type="button">Dictate reply</button>
<button id="stop-voice-update-reply" type="button" hidden>Stop listening</button>
</div>
<div id="voice-update-reply-status" class="small" aria-live="polite"></div>
<div class="voice-conversation-review" id="voice-update-reply-review" hidden>
<label for="voice-update-reply-transcript">Review transcript</label>
<textarea id="voice-update-reply-transcript" maxlength="10000"></textarea>
<div class="voice-conversation-review-actions">
<button id="append-voice-update-reply" type="button">Use transcript</button>
<button id="replace-with-voice-update-reply" type="button" hidden>Replace draft</button>
<button id="discard-voice-update-reply" type="button">Discard</button>
</div>
</div>
</section>
<div class="mention-options" id="update-reply-mentions" role="listbox" aria-label="Teammates" hidden></div>
<div class="mention-status small" id="update-reply-mention-status" aria-live="polite"></div>
<div class="issue-attachment-controls">
<input class="visually-hidden" id="update-reply-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
<label class="issue-attachment-trigger" for="update-reply-attachment">Attach screenshot</label>
<div class="issue-attachment-controls conversation-photo-actions">
<label class="issue-attachment-trigger" for="take-update-reply-photo">Take photo</label>
<input class="visually-hidden" id="take-update-reply-photo" type="file" accept="image/*" capture="environment" />
<label class="issue-attachment-trigger" for="update-reply-attachment">Choose existing</label>
<input class="visually-hidden" id="update-reply-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple />
</div>
<div class="issue-attachment-preview" id="update-reply-attachment-preview" hidden>
<div class="issue-attachment-preview conversation-evidence-review" id="update-reply-attachment-preview" hidden>
<img id="update-reply-attachment-image" alt="Selected screenshot preview" />
<span class="small" id="update-reply-attachment-meta"></span>
<button id="remove-update-reply-attachment" type="button">Remove screenshot</button>
<div class="issue-evidence-tray" id="update-reply-attachment-tray" role="toolbar" aria-label="Reply photos" hidden></div>
<label class="issue-evidence-note" for="update-reply-attachment-note">
<span id="update-reply-attachment-note-label">Photo caption (optional)</span>
<textarea id="update-reply-attachment-note" maxlength="240" rows="2" disabled placeholder="What does this photo show?"></textarea>
</label>
<div class="issue-evidence-review-actions" aria-label="Reorder selected photo">
<button id="move-update-reply-attachment-earlier" type="button">Move earlier</button>
<button id="edit-update-reply-attachment" type="button">Edit photo</button>
<button id="remove-update-reply-attachment" type="button">Remove selected</button>
<button id="move-update-reply-attachment-later" type="button">Move later</button>
</div>
</div>
<div class="update-reply-actions">
<button id="send-update-reply" type="button">Send reply</button>
@@ -1106,26 +1311,64 @@
<div><div class="small" id="pull-sheet-key"></div><h3 id="pull-sheet-title">Assigned pull request</h3></div>
<button id="close-pull-sheet" type="button">Close</button>
</div>
<div id="pull-sheet-status" class="small" aria-live="polite">Choose a pull request.</div>
<button class="pull-retry" id="retry-pull-load" type="button" hidden>Retry loading pull request</button>
<div class="pull-sheet-content markdown-content" id="pull-sheet-body"></div>
<h2>Full conversation</h2><div id="pull-comments"></div>
<button class="conversation-more" id="load-older-pull-comments" type="button" hidden>Load older messages</button>
<div id="pull-conversation-status" class="small" aria-live="assertive"></div>
<button class="conversation-actions-retry" id="retry-pull-comment-actions" type="button" hidden>Retry comment tools</button>
<nav class="mobile-detail-nav mobile-pull-detail-nav" aria-label="Pull request sections">
<button type="button" data-pull-section="overview">Overview</button>
<button type="button" data-pull-section="conversation">Conversation</button>
<button type="button" data-pull-section="reply">Reply</button>
<button type="button" data-pull-section="review">Review &amp; merge</button>
</nav>
<div id="pull-overview">
<div id="pull-sheet-status" class="small" aria-live="polite">Choose a pull request.</div>
<button class="pull-retry" id="retry-pull-load" type="button" hidden>Retry loading pull request</button>
<div class="pull-sheet-content markdown-content" id="pull-sheet-body"></div>
</div>
<section id="pull-conversation" tabindex="-1">
<h2>Full conversation</h2><div id="pull-comments"></div>
<button class="conversation-more" id="load-older-pull-comments" type="button" hidden>Load older messages</button>
<div id="pull-conversation-status" class="small" aria-live="assertive"></div>
<button class="conversation-actions-retry" id="retry-pull-comment-actions" type="button" hidden>Retry comment tools</button>
</section>
<section class="pull-comment-composer" aria-labelledby="pull-comment-title">
<h2 id="pull-comment-title">Add comment</h2>
<textarea id="pull-comment" maxlength="10000" placeholder="Write a comment"></textarea>
<section class="voice-conversation" id="voice-pull-comment" aria-label="Dictate pull-request comment" hidden>
<div class="voice-conversation-controls">
<button id="start-voice-pull-comment" type="button">Dictate reply</button>
<button id="stop-voice-pull-comment" type="button" hidden>Stop listening</button>
</div>
<div id="voice-pull-comment-status" class="small" aria-live="polite"></div>
<div class="voice-conversation-review" id="voice-pull-comment-review" hidden>
<label for="voice-pull-comment-transcript">Review transcript</label>
<textarea id="voice-pull-comment-transcript" maxlength="10000"></textarea>
<div class="voice-conversation-review-actions">
<button id="append-voice-pull-comment" type="button">Use transcript</button>
<button id="replace-with-voice-pull-comment" type="button" hidden>Replace draft</button>
<button id="discard-voice-pull-comment" type="button">Discard</button>
</div>
</div>
</section>
<div class="mention-options" id="pull-comment-mentions" role="listbox" aria-label="Teammates" hidden></div>
<div class="mention-status small" id="pull-comment-mention-status" aria-live="polite"></div>
<div class="issue-attachment-controls">
<input class="visually-hidden" id="pull-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
<label class="issue-attachment-trigger" for="pull-attachment">Attach screenshot</label>
<div class="issue-attachment-controls conversation-photo-actions">
<label class="issue-attachment-trigger" for="take-pull-comment-photo">Take photo</label>
<input class="visually-hidden" id="take-pull-comment-photo" type="file" accept="image/*" capture="environment" />
<label class="issue-attachment-trigger" for="pull-attachment">Choose existing</label>
<input class="visually-hidden" id="pull-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple />
</div>
<div class="issue-attachment-preview" id="pull-attachment-preview" hidden>
<div class="issue-attachment-preview conversation-evidence-review" id="pull-attachment-preview" hidden>
<img id="pull-attachment-image" alt="Selected screenshot preview" />
<span class="small" id="pull-attachment-meta"></span>
<button id="remove-pull-attachment" type="button">Remove screenshot</button>
<div class="issue-evidence-tray" id="pull-attachment-tray" role="toolbar" aria-label="Pull-request comment photos" hidden></div>
<label class="issue-evidence-note" for="pull-attachment-note">
<span id="pull-attachment-note-label">Photo caption (optional)</span>
<textarea id="pull-attachment-note" maxlength="240" rows="2" disabled placeholder="What does this photo show?"></textarea>
</label>
<div class="issue-evidence-review-actions" aria-label="Reorder selected photo">
<button id="move-pull-attachment-earlier" type="button">Move earlier</button>
<button id="edit-pull-attachment" type="button">Edit photo</button>
<button id="remove-pull-attachment" type="button">Remove selected</button>
<button id="move-pull-attachment-later" type="button">Move later</button>
</div>
</div>
<div class="comment-actions">
<button id="send-pull-comment" type="button">Post comment</button>
@@ -1182,6 +1425,13 @@
</div>
<button class="review-action" id="close-review-sheet">Close</button>
</div>
<nav class="mobile-review-detail-nav" aria-label="Review sections">
<button type="button" data-review-section="overview">Overview</button>
<button type="button" data-review-section="files">Files</button>
<button type="button" data-review-section="feedback">Feedback</button>
<button type="button" data-review-section="history">History</button>
</nav>
<section id="review-overview">
<div id="review-sheet-status" class="small" aria-live="polite">Choose a review request.</div>
<button class="review-retry" id="retry-review-load" hidden>Retry loading review</button>
<div class="review-sheet-body markdown-content" id="review-sheet-body"></div>
@@ -1191,6 +1441,8 @@
<button class="ci-check-refresh" id="refresh-review-checks" type="button">Refresh checks</button>
<div class="ci-check-list" id="review-check-list"></div>
</details>
</section>
<section id="review-files-workspace">
<h2>Changed files</h2>
<div class="review-display-tools">
<button id="review-wrap-lines" type="button" aria-pressed="false" aria-controls="review-files">Wrap lines</button>
@@ -1211,7 +1463,8 @@
<span class="small" id="review-progress" aria-live="polite">0 of 0 files reviewed</span>
<button id="next-unreviewed-review" disabled>Next unreviewed</button>
</div>
<section class="review-feedback" aria-labelledby="review-feedback-title">
</section>
<section class="review-feedback" id="review-feedback" aria-labelledby="review-feedback-title">
<h2 id="review-feedback-title">Draft feedback</h2>
<label>Intended decision
<select id="review-decision">
@@ -1235,8 +1488,10 @@
<button id="continue-review-to-merge" type="button" hidden>Continue to merge</button>
</div>
</section>
<section id="review-history-workspace">
<h2>Review history</h2>
<div id="review-history" class="muted"></div>
</section>
<div class="review-sheet-actions"><details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date &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>
@@ -1287,6 +1542,7 @@
<div class="mobile-queue-list">
<button data-mobile-queue="today" type="button"><span><strong>Today</strong><small>Planned work</small></span><span data-mobile-queue-count="today">0</span></button>
<button data-mobile-queue="agenda" type="button"><span><strong>Agenda</strong><small>Upcoming deadlines</small></span><span data-mobile-queue-count="agenda">0</span></button>
<button data-mobile-queue="delivery" type="button"><span><strong>Delivery</strong><small>Needs recovery</small></span><span data-mobile-queue-count="delivery">0</span></button>
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
<button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button>
<button data-mobile-queue="filed" type="button"><span><strong>Filed</strong><small>Issues you delegated</small></span><span data-mobile-queue-count="filed">0</span></button>
@@ -1298,6 +1554,20 @@
</section>
</dialog>
<dialog id="mobile-delivery-recovery" class="mobile-delivery-recovery" aria-labelledby="mobile-delivery-recovery-title">
<section class="mobile-delivery-recovery-panel">
<header>
<div><p class="small muted" id="mobile-delivery-recovery-progress"></p><h2 id="mobile-delivery-recovery-title">Recover delivery</h2></div>
<button id="close-mobile-delivery-recovery" type="button">Close</button>
</header>
<p id="mobile-delivery-recovery-destination" class="small"></p>
<p id="mobile-delivery-recovery-reason" class="delivery-recovery-reason"></p>
<p id="mobile-delivery-recovery-status" class="small" role="status" aria-live="polite"></p>
<button id="mobile-delivery-recovery-action" type="button">Recover delivery</button>
<details><summary>Other recovery options</summary><p class="small muted">Close this guide to edit, copy, or discard the saved delivery from Drafts.</p></details>
</section>
</dialog>
<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page"><span id="mobile-work-label">Work</span></button>
<button class="mobile-task-action" data-mobile-task="find" type="button">Find</button>
@@ -1317,6 +1587,9 @@
<script src="static/commands.js"></script>
<script src="static/saved-searches.js"></script>
<script src="static/search-preview.js"></script>
<script src="static/search-reply-draft-store.js"></script>
<script src="static/conversation-reply-draft-store.js"></script>
<script src="static/conversation-photo-drafts.js"></script>
<script src="static/search-defer.js"></script>
<script src="static/widgets.js"></script>
<script src="static/drafts.js"></script>
@@ -1344,6 +1617,8 @@
<script src="static/today-work.js"></script>
<script src="static/today-timer.js"></script>
<script src="static/today-recap.js"></script>
<script src="static/today-wrap-up.js"></script>
<script src="static/today-handoff.js"></script>
<script src="static/today-completion.js"></script>
<script src="static/today-readiness.js"></script>
<script src="static/comment-next.js"></script>
@@ -1372,7 +1647,9 @@
<script src="static/issue-sheet.js"></script>
<script src="static/voice-transcript-store.js"></script>
<script src="static/voice-issue-capture.js"></script>
<script src="static/voice-conversation-capture.js"></script>
<script src="static/create-issue-sheet.js"></script>
<script src="static/mobile-create-issue-nav.js"></script>
<script src="static/create-and-start.js"></script>
<script src="static/assign-and-start.js"></script>
<script src="static/filed-claim.js"></script>
@@ -1384,8 +1661,10 @@
<script src="static/context-poller.js"></script>
<script src="static/live-data-status.js"></script>
<script src="static/mobile-task-dock.js"></script>
<script src="static/mobile-pull-refresh.js"></script>
<script src="static/mobile-work-entry.js"></script>
<script src="static/mobile-queue-launcher.js"></script>
<script src="static/mobile-delivery-recovery.js"></script>
<script src="static/mobile-start-day.js"></script>
<script src="static/update-triage-session.js"></script>
<script src="static/update-review-handoff.js"></script>
@@ -1396,14 +1675,25 @@
<script src="static/update-decision-transaction.js"></script>
<script src="static/agenda-session-launcher.js"></script>
<script src="static/mobile-launch.js"></script>
<script src="static/mobile-insights.js"></script>
<script src="static/mobile-app-shortcuts.js"></script>
<script src="static/install-app.js"></script>
<script src="static/private-data-inventory.js"></script>
<script src="static/private-device-data.js"></script>
<script src="static/device-storage.js"></script>
<script src="static/mobile-device-setup.js"></script>
<script src="static/mobile-search-viewport.js"></script>
<script src="static/mobile-composer-viewport.js"></script>
<script src="static/mention-composer.js"></script>
<script src="static/push-notifications.js"></script>
<script src="static/issue-filing-receipt.js"></script>
<script src="static/mobile-issue-detail-nav.js"></script>
<script src="static/mobile-update-detail-nav.js"></script>
<script src="static/mobile-review-detail-nav.js"></script>
<script src="static/mobile-search-preview-nav.js"></script>
<script src="static/mobile-plan-today-nav.js"></script>
<script src="static/mobile-find-work-nav.js"></script>
<script src="static/workspace-bootstrap.js"></script>
<script src="static/dashboard.js"></script>
</body>
</html>
+55 -20
View File
@@ -41,6 +41,7 @@
const maxFiles = options.maxFiles === MAX_FILES ? MAX_FILES : 1;
const inspectPixels = options.inspectPixels === true;
const optimizeSelectedImage = options.optimizeImage || optimizeImage;
const onCheckpoint = options.onCheckpoint;
const createOperationId = options.createOperationId || (() => {
if (typeof globalThis !== 'undefined' && globalThis.crypto?.randomUUID) {
return globalThis.crypto.randomUUID();
@@ -49,6 +50,13 @@
});
let selected = [];
let selectionGeneration = 0;
let restoring = false;
function changed() {
if (!restoring && typeof onCheckpoint === 'function') {
Promise.resolve().then(onCheckpoint).catch(() => { /* Caller surfaces durable-storage failures. */ });
}
}
function commitSelection(file) {
if (!file || !IMAGE_TYPES.has(file.type) || !Number.isFinite(file.size) ||
@@ -60,6 +68,7 @@
else throw new Error(MAX_FILES_MESSAGE);
}
selected.push({file, note:'', confirmed:null, serialized:null, operationId:createOperationId()});
changed();
return state();
}
@@ -93,6 +102,7 @@
if (!Number.isInteger(position) || position < 0 || position >= selected.length) return state();
selectionGeneration += 1;
selected.splice(position, 1);
changed();
return state();
}
@@ -103,6 +113,7 @@
from >= selected.length || to < 0 || to >= selected.length || from === to) return state();
const [item] = selected.splice(from, 1);
selected.splice(to, 0, item);
changed();
return state();
}
@@ -117,6 +128,7 @@
selected[position] = {
...selected[position], file, confirmed:null, serialized:null, operationId:createOperationId(),
};
changed();
return state();
}
@@ -125,6 +137,7 @@
if (!Number.isInteger(position) || position < 0 || position >= selected.length) return state();
selected[position].note = normalizeNote(value);
selected[position].serialized = null;
changed();
return state();
}
@@ -134,15 +147,18 @@
}
function restore(value) {
if (Array.isArray(value)) {
restoring = true;
try {
if (Array.isArray(value)) {
clear();
if (value.length > maxFiles) throw new Error(MAX_FILES_MESSAGE);
value.forEach(attachment => restoreOne(attachment));
return state();
}
clear();
if (value.length > maxFiles) throw new Error(MAX_FILES_MESSAGE);
value.forEach(attachment => restoreOne(attachment));
restoreOne(value);
return state();
}
clear();
restoreOne(value);
return state();
} finally { restoring = false; }
}
function restoreOne(value) {
@@ -162,6 +178,10 @@
...(blob ? { filename, contentType, blob } : { filename, contentType, data }),
...(item.note ? {note:item.note} : {}),
};
const operationId = String(value?.operationId || '').slice(0, 128);
if (operationId) item.operationId = operationId;
const markdown = String(value?.confirmed?.markdown || '');
if (markdown) item.confirmed = { markdown };
}
function state() {
@@ -178,7 +198,11 @@
filename:item.file.name, contentType:item.file.type, blob:item.file.blob || item.file,
...(item.note ? {note:item.note} : {}),
};
return { ...item.serialized };
return {
...item.serialized,
operationId:item.operationId,
...(item.confirmed?.markdown ? { confirmed:{ markdown:item.confirmed.markdown } } : {}),
};
}
async function serialize() {
@@ -206,6 +230,7 @@
evidence.confirmed = null;
throw new Error('The server did not confirm the screenshot upload.');
}
await onCheckpoint?.();
}
if (evidence.note) {
markdown.push('**Screenshot ' + (markdown.length + 1) + ' — ' + escapeMarkdown(evidence.note) + '**\n\n' +
@@ -220,6 +245,8 @@
}
function mount(options) {
const inputs = Array.from(new Set((options.inputs || [options.input]).filter(Boolean)));
if (!inputs.includes(options.input)) inputs.push(options.input);
const controller = create({
...options, inspectPixels:true,
maxFiles:options.maxFiles || (options.input?.multiple ? MAX_FILES : 1),
@@ -234,7 +261,7 @@
}) : null;
function setBusy(value) {
options.input.disabled = Boolean(value);
inputs.forEach(input => { input.disabled = Boolean(value); });
options.remove.disabled = Boolean(value);
if (options.editor?.edit) options.editor.edit.disabled = Boolean(value) || !controller.state();
review?.setBusy(value);
@@ -248,9 +275,12 @@
if (options.editor?.edit) options.editor.edit.disabled = true;
options.image.src = '';
options.preview.hidden = true;
options.input.value = '';
options.input.disabled = false;
inputs.forEach(input => {
input.value = '';
input.disabled = false;
});
clearSelection();
options.onChange?.(controller.state());
}
function showPreview(file, optimized) {
@@ -264,15 +294,16 @@
(options.readyMessage || 'Screenshot ready to upload with this comment.');
}
options.input.addEventListener('change', event => {
const files = Array.from(event.target.files || []);
function selectFromInput(event) {
const sourceInput = event.target;
const files = Array.from(sourceInput.files || []);
const sequence = ++selectionSequence;
if (!files.length) return;
let first;
try { first = controller.select(files[0]); }
catch (error) {
options.status.textContent = error.message;
options.input.value = '';
sourceInput.value = '';
return;
}
if (!reviewEnabled && files.length === 1 && (!first || typeof first.then !== 'function')) {
@@ -282,14 +313,14 @@
let optimized = Boolean(first && typeof first.then === 'function');
if (optimized) {
options.status.textContent = 'Optimizing screenshot…';
options.input.disabled = true;
inputs.forEach(input => { input.disabled = true; });
}
const selectAll = files.slice(1).reduce((pending, file) => pending.then(async () => {
const result = controller.select(file);
if (result && typeof result.then === 'function') {
optimized = true;
options.status.textContent = 'Optimizing screenshot…';
options.input.disabled = true;
inputs.forEach(input => { input.disabled = true; });
await result;
}
}), Promise.resolve(first));
@@ -306,15 +337,17 @@
options.status.textContent = values.length + ' screenshots ready to file in this order.';
}
}
options.onChange?.(controller.state());
}).catch(error => {
if (sequence === selectionSequence) {
options.status.textContent = error.message;
options.input.value = '';
sourceInput.value = '';
}
}).finally(() => {
if (sequence === selectionSequence) options.input.disabled = false;
if (sequence === selectionSequence) inputs.forEach(input => { input.disabled = false; });
});
});
}
inputs.forEach(input => input.addEventListener('change', selectFromInput));
options.remove.addEventListener('click', () => {
const current = controller.state();
const count = Array.isArray(current) ? current.length : (current ? 1 : 0);
@@ -337,6 +370,7 @@
options.meta.textContent = values.length + ' screenshots ready · latest: ' + latest.filename;
}
options.status.textContent = options.removedMessage || 'Latest screenshot removed. Your text is unchanged.';
options.onChange?.(controller.state());
});
});
@@ -371,7 +405,8 @@
onApplied: async index => {
const value = await controller.serialize();
review.render(value, index);
options.status.textContent = 'Edited screenshot flattened and ready to file.';
options.status.textContent = options.editor.appliedMessage ||
'Edited screenshot flattened and ready to file.';
},
});
options.editor.edit.disabled = !controller.state();
+1
View File
@@ -200,6 +200,7 @@
if (source && model) paint({source, model, canvas:options.canvas, maxDimension:1600});
}
function close() {
if (!source && !model) return;
options.dialog.hidden = true;
if (source && typeof source.close === 'function') source.close();
source = null;
-1
View File
@@ -31,7 +31,6 @@
const dimensionScale = Math.min(1, MAX_DIMENSION / Math.max(width, height));
const byteScale = file.size > MAX_BYTES ? Math.sqrt(MAX_BYTES / file.size) * 0.92 : 1;
let scale = Math.min(pixelScale, dimensionScale, byteScale);
if (scale === 1 && file.size <= MAX_BYTES) return file;
const canvas = makeCanvas();
const context = canvas && canvas.getContext && canvas.getContext('2d');
if (!context) throw new Error('decode');
+9 -5
View File
@@ -160,15 +160,17 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
}
function enqueue(action, itemId, wakeAt = null) {
function enqueue(action, itemId, wakeAt = null, handoff = null) {
if (!['defer', 'restore'].includes(action) || !itemId ||
(action === 'defer' && typeof wakeAt !== 'string')) return false;
(action === 'defer' && typeof wakeAt !== 'string') ||
![null, 'today'].includes(handoff)) return false;
pending().filter(operation => operation.item_id === itemId)
.forEach(operation => removeOperation(operation.operation_id));
const operation = {
operation_id: operationId(), action, item_id: itemId, wake_at: wakeAt,
base_revision: Math.max(0, savedRevision()),
};
if (handoff) operation.handoff = handoff;
const storageKey = key();
if (!storageKey || !storage) return false;
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
@@ -189,9 +191,11 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
const marker = migrationPrefix + storageKey.slice(prefix.length);
try {
if (storage.getItem(marker)) return false;
Object.entries(records || {}).forEach(([itemId, wakeAt]) =>
enqueue('defer', itemId, wakeAt)
);
Object.entries(records || {}).forEach(([itemId, record]) => {
const wakeAt = record && typeof record === 'object' ? record.wake_at : record;
const handoff = record?.handoff === 'today' ? 'today' : null;
enqueue('defer', itemId, wakeAt, handoff);
});
storage.setItem(marker, '1');
return true;
} catch (_error) {
+22 -10
View File
@@ -38,7 +38,7 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
}
}
function defer(item, until) {
function defer(item, until, options = {}) {
const key = storageKey();
const id = identity(item);
const wake = new Date(until);
@@ -46,9 +46,10 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
if (!id || Number.isNaN(wake.getTime()) || wake <= now()) return 'invalid';
const records = read();
const wakeAt = wake.toISOString();
records[id] = wakeAt;
const handoff = options.handoff === 'today' ? 'today' : null;
records[id] = handoff ? { wake_at:wakeAt, handoff } : wakeAt;
if (!write(records)) return 'unavailable';
onChange('defer', id, wakeAt);
onChange('defer', id, wakeAt, handoff);
return 'deferred';
}
@@ -93,9 +94,13 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
function adopt(records) {
if (!records || typeof records !== 'object' || Array.isArray(records)) return false;
const normalized = {};
Object.entries(records).forEach(([id, wake]) => {
Object.entries(records).forEach(([id, record]) => {
const wake = record && typeof record === 'object' ? record.wake_at : record;
const wakeTime = new Date(wake).getTime();
if (id && Number.isFinite(wakeTime)) normalized[id] = new Date(wakeTime).toISOString();
if (id && Number.isFinite(wakeTime)) {
const wakeAt = new Date(wakeTime).toISOString();
normalized[id] = record?.handoff === 'today' ? { wake_at:wakeAt, handoff:'today' } : wakeAt;
}
});
return write(normalized);
}
@@ -119,25 +124,32 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
const wakeTimes = [];
const expired = [];
Object.entries(records).forEach(([id, wake]) => {
const handoffs = [];
Object.entries(records).forEach(([id, record]) => {
const wake = record && typeof record === 'object' ? record.wake_at : record;
const wakeTime = new Date(wake).getTime();
if (!Number.isFinite(wakeTime) || wakeTime <= current) {
if (Number.isFinite(wakeTime)) expired.push(id);
if (Number.isFinite(wakeTime)) {
expired.push(id);
if (record?.handoff === 'today') handoffs.push(id);
}
return;
}
if (pruneMissing && !available.has(id)) return;
retained[id] = new Date(wakeTime).toISOString();
const wakeAt = new Date(wakeTime).toISOString();
retained[id] = record?.handoff === 'today' ? { wake_at:wakeAt, handoff:'today' } : wakeAt;
wakeTimes.push(wakeTime);
});
if (JSON.stringify(retained) !== JSON.stringify(records) && write(retained) && expired.length) {
onExpire(expired);
onExpire(expired, handoffs);
}
schedule(wakeTimes, current);
const deferredIds = new Set(Object.keys(retained));
const active = (items || []).filter(item => !deferredIds.has(identity(item)));
const later = (items || []).flatMap(item => {
const wake = retained[identity(item)];
const record = retained[identity(item)];
const wake = record && typeof record === 'object' ? record.wake_at : record;
return wake ? [{ ...item, deferred_until: wake }] : [];
});
return { active, later };
+4 -2
View File
@@ -4,7 +4,7 @@
}(typeof self !== 'undefined' ? self : this, function createLoginController(options) {
function validShareContinuation(value) {
if (typeof value !== 'string' || !value.startsWith('./?') || value.includes('#')) return './';
const limits = { title: 200, text: 8000, url: 2048, launch: 8, shared: 5, search: 200,
const limits = { title: 200, text: 8000, url: 2048, launch: 8, shared: 6, search: 200,
preview: 200, search_kind: 5, search_state: 6, search_repository: 161 };
const params = new URLSearchParams(value.slice(3));
const entries = Array.from(params.entries());
@@ -35,7 +35,9 @@
const launch = params.get('launch');
const shared = params.get('shared');
if (launch && !['continue', 'new', 'agenda'].includes(launch)) return './';
if (shared !== null && (shared !== 'image' || launch !== 'new')) return './';
if (shared === 'bundle') {
if (launch !== 'new' || entries.some(([name]) => !['launch', 'shared'].includes(name))) return './';
} else if (shared !== null && (shared !== 'image' || launch !== 'new')) return './';
return value;
}
+122
View File
@@ -0,0 +1,122 @@
function createMobileCreateIssueNavigation(options) {
const buttons = options.buttons || {};
const targets = options.targets || {};
const listeners = new Map();
const prefersReducedMotion = options.prefersReducedMotion || (() => false);
const targetNames = new Map(Object.entries(targets).map(([name, target]) => [target, name]));
let filingAvailable = !options.filing?.hidden;
let observer = null;
let navigationLockUntil = 0;
function select(name) {
Object.entries(buttons).forEach(([key, button]) => {
if (!button) return;
if (key === name) button.setAttribute('aria-current', 'location');
else button.removeAttribute('aria-current');
});
}
function setFilingAvailable(available) {
filingAvailable = Boolean(available);
const button = buttons.file;
if (!button) return;
if (filingAvailable) button.removeAttribute('aria-disabled');
else button.setAttribute('aria-disabled', 'true');
}
function navigate(name) {
if (name === 'file') setFilingAvailable(!options.filing?.hidden);
if (name === 'file' && !filingAvailable) return false;
const target = targets[name];
if (!target) return false;
target.scrollIntoView({
block: 'start',
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
});
navigationLockUntil = Date.now() + 500;
select(name);
return true;
}
function reset(available = !options.filing?.hidden) {
setFilingAvailable(available);
select('describe');
}
return {
start() {
Object.entries(buttons).forEach(([name, button]) => {
if (!button || listeners.has(button)) return;
const listener = event => {
event.preventDefault();
navigate(name);
};
listeners.set(button, listener);
button.addEventListener('click', listener);
});
reset();
const observe = options.observe || ((handler, observedTargets, root) => {
if (typeof IntersectionObserver === 'undefined') return null;
const instance = new IntersectionObserver(handler, {
root,
rootMargin: '-20% 0px -60% 0px',
threshold: [0, 0.25, 0.5, 0.75, 1],
});
observedTargets.forEach(target => instance.observe(target));
return instance;
});
observer = observe(entries => {
if (Date.now() < navigationLockUntil) return;
const visible = entries
.filter(entry => entry.isIntersecting && targetNames.has(entry.target))
.filter(entry => targetNames.get(entry.target) !== 'file' || filingAvailable)
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
if (visible) select(targetNames.get(visible.target));
}, Array.from(targetNames.keys()).filter(Boolean), options.root || null);
},
stop() {
listeners.forEach((listener, button) => button.removeEventListener('click', listener));
listeners.clear();
if (observer) observer.disconnect();
observer = null;
},
navigate,
reset,
select,
setFilingAvailable,
};
}
function attachMobileCreateIssueNavigation({ document, window }) {
const bySection = name => document.querySelector('[data-create-issue-section="' + name + '"]');
const filing = document.getElementById('create-issue-filing');
const sheet = document.getElementById('create-issue-sheet');
const navigation = createMobileCreateIssueNavigation({
root:document.querySelector('.create-issue-panel'),
buttons:{describe:bySection('describe'), evidence:bySection('evidence'), file:bySection('file')},
targets:{
describe:document.getElementById('create-issue-describe'),
evidence:document.getElementById('create-issue-evidence'),
file:filing,
},
filing,
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
});
navigation.start();
document.getElementById('file-new-issue').addEventListener('click', () => {
setTimeout(() => navigation.navigate('file'), 0);
});
new MutationObserver(() => {
navigation.setFilingAvailable(!filing.hidden);
if (!filing.hidden && sheet.classList.contains('open')) navigation.navigate('file');
}).observe(filing, {attributes:true, attributeFilter:['hidden']});
new MutationObserver(() => {
if (sheet.classList.contains('open')) navigation.reset(!filing.hidden);
}).observe(sheet, {
attributes:true, attributeFilter:['class'],
});
return navigation;
}
if (typeof module !== 'undefined') module.exports = createMobileCreateIssueNavigation;
else attachMobileCreateIssueNavigation({document, window});
+156
View File
@@ -0,0 +1,156 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileDeliveryRecovery = factory;
})(typeof self !== 'undefined' ? self : this, function createMobileDeliveryRecovery(options) {
const byId = id => typeof document === 'undefined' ? null : document.getElementById(id);
const elements = options.elements || (typeof document === 'undefined' ? null : {
dialog: byId('mobile-delivery-recovery'), close: byId('close-mobile-delivery-recovery'),
title: byId('mobile-delivery-recovery-title'), destination: byId('mobile-delivery-recovery-destination'),
reason: byId('mobile-delivery-recovery-reason'), progress: byId('mobile-delivery-recovery-progress'),
action: byId('mobile-delivery-recovery-action'), status: byId('mobile-delivery-recovery-status'),
});
const priority = {authorization: 0, attention: 1, uncertain: 2, waiting: 3};
function identity(item) {
return String(item?.outbox_id || item?.id || '');
}
function state(item) {
if (item?.status === 'authorization') return 'authorization';
if (item?.status === 'attention' || item?.checklist_conflict || item?.quarantined) return 'attention';
if (item?.delivery_state === 'uncertain') return 'uncertain';
return 'waiting';
}
function primary(item) {
const kind = state(item);
if (kind === 'authorization') {
return item?.outbox_kind === 'issue-close' ? 'Authorize & close' : 'Authorize & send';
}
if (item?.checklist_conflict) return 'Review changes';
if (kind === 'attention') return item?.quarantined ? 'Copy content' : 'Open current item';
if (kind === 'uncertain') return 'Verified not posted — retry';
return 'Retry now';
}
function ordered() {
return [...(options.getItems ? options.getItems() : [])].sort((left, right) => {
const byState = priority[state(left)] - priority[state(right)];
if (byState) return byState;
return identity(left).localeCompare(identity(right));
});
}
const stateLabels = {
authorization: 'Authorization required',
attention: 'Needs attention',
uncertain: 'Delivery uncertain',
waiting: 'Ready to retry',
};
let currentId = '';
let active = false;
let inFlight = null;
function currentItem(items) {
return items.find(item => identity(item) === currentId) || items[0];
}
function snapshot() {
const items = ordered();
const item = currentItem(items);
if (item) currentId = identity(item);
else currentId = '';
const index = item ? items.indexOf(item) : -1;
return {
count: items.length,
current: item ? {
id: identity(item),
state: state(item),
primary: primary(item),
position: index + 1,
total: items.length,
} : null,
};
}
function render() {
const result = snapshot();
if (!elements) return result;
const items = ordered();
const item = currentItem(items);
if (!item) {
if (active) {
active = false;
if (elements.dialog?.open) elements.dialog.close();
if (options.onComplete) options.onComplete();
}
return result;
}
elements.title.textContent = item.title || 'Queued delivery';
elements.destination.textContent = [item.repository, item.details].filter(Boolean).join(' · ') || 'Saved delivery';
elements.reason.textContent = item.last_attempt_error || item.ownership || stateLabels[state(item)];
elements.progress.textContent = 'Delivery ' + result.current.position + ' of ' + result.current.total + ' · ' + stateLabels[state(item)];
elements.action.textContent = primary(item);
if (elements.status) elements.status.textContent = '';
return result;
}
function open() {
const queue = byId('mobile-queue-sheet');
if (queue?.open) queue.close();
if (options.beforeOpen) options.beforeOpen();
currentId = '';
const result = snapshot();
if (!result.current) return 'empty';
active = true;
render();
if (elements?.dialog && !elements.dialog.open) elements.dialog.showModal();
return 'opened';
}
async function attend(item) {
const index = options.getIndex ? options.getIndex(item) : -1;
const selector = item.quarantined ? '.draft-copy' :
item.checklist_conflict ? '.draft-review-checklist' : '.draft-resume';
const action = document.querySelector('#my-work-list ' + selector + '[data-draft-index="' + index + '"]');
if (!action) throw new Error('Delivery changed.');
elements.dialog.close();
action.scrollIntoView();
action.focus();
return await action.onclick();
}
function activate() {
if (inFlight) return inFlight;
const items = ordered();
const item = currentItem(items);
const perform = state(item) === 'attention' ? attend : options.activate;
if (!item) return Promise.resolve(render());
if (elements?.action) elements.action.disabled = true;
if (elements?.status) elements.status.textContent = 'Working…';
inFlight = (async () => {
let failure = null;
try {
await perform(item, state(item));
} catch (error) {
failure = String(error?.message || 'Delivery could not be completed.').trim().slice(0, 160);
} finally {
if (elements?.action) elements.action.disabled = false;
}
const result = render();
if (failure && elements?.status) {
if (active && !elements.dialog.open) elements.dialog.showModal();
elements.status.textContent = failure + ' Try again.';
}
return result;
})().finally(() => { inFlight = null; });
return inFlight;
}
function start() {
if (elements?.action?.addEventListener) elements.action.addEventListener('click', activate);
if (elements?.close?.addEventListener) elements.close.addEventListener('click', () => elements.dialog.close());
}
return {activate, open, render, snapshot, start};
});
+194
View File
@@ -0,0 +1,194 @@
(function (root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.createMobileFindWorkNavigation = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
const stages = ['discover', 'review', 'fit'];
return function createMobileFindWorkNavigation(options) {
const document = options.document;
const buttons = options.buttons || Object.fromEntries(stages.map(name =>
[name, document?.querySelector('[data-find-work-stage="' + name + '"]')]
));
const sections = options.sections || Object.fromEntries(stages.map(name =>
[name, document?.getElementById('find-work-' + name + '-stage')]
));
const history = options.history;
const eventTarget = options.eventTarget;
const onStageChange = options.onStageChange || (() => {});
const listeners = new Map();
let current = 'discover';
let selectedCount = 0;
let recovery = false;
let started = false;
function escape(value) {
return String(value || '').replace(/[&<>"']/g, character =>
({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' })[character]
);
}
function renderReview() {
const reviewList = options.reviewList || document?.getElementById('find-work-review-list');
const reviewSummary = options.reviewSummary || document?.getElementById('find-work-review-summary');
if (!reviewList) return;
const items = options.controller?.selectedItems?.() || options.getSelectedItems?.() || [];
const planning = options.todayWork?.planning?.();
const today = options.todayWork ? {
limit:options.todayWork.limit, count:options.todayWork.read().length,
capacityMinutes:planning.capacity_minutes, estimates:planning.estimates,
} : options.getTodayState?.() || {};
const remainingSlots = Math.max(0, (Number(today.limit) || 0) - (Number(today.count) || 0));
const planned = Object.values(today.estimates || {}).reduce((sum, minutes) => sum + minutes, 0);
const remainingMinutes = today.capacityMinutes === null || today.capacityMinutes === undefined ? null :
Math.max(0, today.capacityMinutes - planned);
const formatMinutes = minutes => [Math.floor(minutes / 60) ? Math.floor(minutes / 60) + 'h' : '',
minutes % 60 ? minutes % 60 + 'm' : ''].filter(Boolean).join(' ') || '0m';
if (reviewSummary) reviewSummary.textContent = items.length + ' selected · ' + remainingSlots +
' Today slot' + (remainingSlots === 1 ? '' : 's') + ' open' +
(remainingMinutes === null ? '' : ' · ' + formatMinutes(remainingMinutes) + ' available') + '.';
reviewList.innerHTML = items.map(item => {
const id = String(item.repository || '') + '#' + String(item.number || '');
return '<article class="find-work-review-item"><div><span class="small">' + escape(id) +
'</span><strong>' + escape(item.title || 'Untitled work') + '</strong></div>' +
'<button type="button" data-find-work-review-remove="' + escape(id) + '">Remove</button></article>';
}).join('');
reviewList.querySelectorAll('[data-find-work-review-remove]').forEach(button =>
button.addEventListener('click', () => {
const item = items.find(candidate => String(candidate.repository || '') + '#' +
String(candidate.number || '') === button.dataset.findWorkReviewRemove);
if (item) (options.controller?.toggleSelection || options.toggleSelection)?.(item);
})
);
}
function allowed(name) {
return stages.includes(name) && (name === 'discover' || selectedCount > 0 || recovery);
}
function paint(name, focus = false, notify = true) {
if (!allowed(name)) name = 'discover';
current = name;
stages.forEach(stage => {
const button = buttons[stage];
const section = sections[stage];
if (button) {
button.disabled = stage !== 'discover' && selectedCount === 0 && !recovery;
if (stage === name) button.setAttribute('aria-current', 'step');
else button.removeAttribute('aria-current');
}
if (section) section.hidden = stage !== name;
});
if (name === 'review') renderReview();
if (name === 'fit') options.openFit?.(options.controller?.selectedItems?.() || []);
if (notify) onStageChange(name);
if (focus) sections[name]?.focus?.();
return name;
}
function stateFor(name) {
const state = { ...(history?.state || {}) };
if (name === 'discover') delete state.findWorkStage;
else state.findWorkStage = name;
return state;
}
function go(name) {
if (!allowed(name) || name === current) return false;
const currentIndex = stages.indexOf(current);
const nextIndex = stages.indexOf(name);
if (nextIndex < currentIndex && history?.go) {
history.go(nextIndex - currentIndex);
return true;
}
if (nextIndex === currentIndex - 1 && history?.back) {
history.back();
return true;
}
history?.pushState?.(stateFor(name), '');
paint(name, true);
return true;
}
function onPopState(event) {
if (event.state?.taskOverlay !== 'find') return;
paint(event.state?.findWorkStage || 'discover', true);
}
function sync(state = {}) {
selectedCount = Math.max(0, Number(state.selectedCount) || 0);
recovery = state.recovery === true;
if (!allowed(current)) paint('discover');
else paint(current, false, false);
}
function open(hasRecovery) {
if (options.controller) {
selectedCount = options.controller.selection().count;
recovery = hasRecovery === true;
}
const restored = history?.state?.taskOverlay === 'find' ? history.state.findWorkStage : null;
return paint(allowed(restored) ? restored : 'discover');
}
function complete() {
selectedCount = 0;
recovery = false;
history?.replaceState?.(stateFor('discover'), '');
return paint('discover');
}
function back() {
if (current === 'discover') return false;
history?.back?.();
return true;
}
return {
start() {
if (started) return;
started = true;
stages.forEach(name => {
const button = buttons[name];
if (!button) return;
const listener = event => { event.preventDefault(); go(name); };
listeners.set(button, listener);
button.addEventListener('click', listener);
});
if (document) {
const bind = (id, action) => {
const button = document.getElementById(id);
if (!button) return;
const listener = event => { event.preventDefault(); action(); };
listeners.set(button, listener);
button.addEventListener('click', listener);
};
bind('select-find-work', () => { complete(); options.controller?.startSelection?.(); });
bind('cancel-find-work-selection', () => { complete(); options.controller?.cancelSelection?.(); });
bind('claim-selected-work', () => go('review'));
bind('continue-find-work-fit', () => go('fit'));
bind('back-find-work-discover', back);
bind('cancel-find-work-estimates', back);
bind('close-find-work', () => history?.go?.(-(stages.indexOf(current) + 1)));
}
eventTarget?.addEventListener?.('popstate', onPopState);
paint('discover');
},
stop() {
listeners.forEach((listener, button) => button.removeEventListener('click', listener));
listeners.clear();
eventTarget?.removeEventListener?.('popstate', onPopState);
started = false;
},
sync,
open,
go,
back,
complete,
renderReview,
stage: () => current,
};
};
});
+74
View File
@@ -0,0 +1,74 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileInsights = factory;
})(typeof self !== 'undefined' ? self : this, function (o) {
const route = '#/insights';
let active = false;
let previous = '#/my-work';
function attr(element, name, value) {
if (value === null) element.removeAttribute(name);
else element.setAttribute(name, value);
}
function render(open, focus = false) {
active = Boolean(open && o.mediaQuery.matches);
attr(o.root, 'data-mobile-open', active ? 'true' : null);
attr(o.root, 'role', active ? 'dialog' : null);
attr(o.root, 'aria-modal', active ? 'true' : null);
attr(o.root, 'aria-hidden', active || !o.mediaQuery.matches ? null : 'true');
o.root.inert = !active && o.mediaQuery.matches;
o.backgrounds.forEach(element => { element.inert = active; });
o.dock.hidden = active;
attr(o.hud, 'data-overlay-hidden', active ? 'true' : null);
if (active) o.closeButton.focus();
else if (focus) o.launcher.focus();
}
function url(hash) {
return (o.location.pathname || '') + (o.location.search || '') + hash;
}
function open() {
if (!o.mediaQuery.matches || active) return false;
previous = o.location.hash && o.location.hash !== route ? o.location.hash : '#/my-work';
if (o.menu) o.menu.open = false;
o.history.pushState({ ...(o.history.state || {}), mobileInsights:true, previousHash:previous }, '', url(route));
render(true);
return true;
}
function sync() {
render(o.location.hash === route, active && o.location.hash !== route);
}
function close() {
if (!active) return false;
if (o.history.state?.mobileInsights) o.history.back();
else {
const state = { ...(o.history.state || {}) };
delete state.mobileInsights;
delete state.previousHash;
o.history.replaceState(state, '', url(previous));
render(false, true);
}
return true;
}
function start() {
o.launcher.addEventListener('click', open);
o.closeButton.addEventListener('click', close);
o.eventTarget.addEventListener('popstate', sync);
o.eventTarget.addEventListener('hashchange', sync);
o.eventTarget.addEventListener('keydown', event => {
if (active && event.key === 'Escape') {
event.preventDefault?.();
close();
}
});
o.mediaQuery.addEventListener?.('change', sync);
sync();
}
return { start, open, close, current:() => active };
});
+77
View File
@@ -0,0 +1,77 @@
function createMobileIssueDetailNavigation(options) {
const buttons = options.buttons || {};
const targets = options.targets || {};
const listeners = new Map();
const prefersReducedMotion = options.prefersReducedMotion || (() => false);
const targetNames = new Map(Object.entries(targets).map(([name, target]) => [target, name]));
let observer = null;
function select(name) {
Object.entries(buttons).forEach(([key, button]) => {
if (!button) return;
if (key === name) button.setAttribute('aria-current', 'location');
else button.removeAttribute('aria-current');
});
}
function navigate(name, navigationOptions = {}) {
const target = targets[name];
if (!target) return false;
const prepare = options.beforeNavigate && options.beforeNavigate[name];
if (prepare) prepare(target);
if (name === 'actions' && options.planning) options.planning.open = true;
target.scrollIntoView({
block: 'start',
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
});
if (name === 'reply' && navigationOptions.focus !== false) target.focus();
select(name);
return true;
}
return {
start() {
Object.entries(buttons).forEach(([name, button]) => {
if (!button || listeners.has(button)) return;
const listener = event => {
event.preventDefault();
navigate(name);
if (options.onSectionChange) options.onSectionChange(name, { replace:false });
};
listeners.set(button, listener);
button.addEventListener('click', listener);
});
select('overview');
const observe = options.observe || ((handler, observedTargets) => {
if (typeof IntersectionObserver === 'undefined') return null;
const instance = new IntersectionObserver(handler, {
root: options.root || null,
rootMargin: '-20% 0px -60% 0px',
threshold: [0, 0.25, 0.5, 0.75, 1],
});
observedTargets.forEach(target => instance.observe(target));
return instance;
});
observer = observe(entries => {
const visible = entries
.filter(entry => entry.isIntersecting && targetNames.has(entry.target))
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
if (visible) {
const name = targetNames.get(visible.target);
select(name);
if (options.onSectionChange) options.onSectionChange(name, { replace:true });
}
}, Array.from(targetNames.keys()).filter(Boolean));
},
stop() {
listeners.forEach((listener, button) => button.removeEventListener('click', listener));
listeners.clear();
if (observer) observer.disconnect();
observer = null;
},
navigate,
select,
};
}
if (typeof module !== 'undefined') module.exports = createMobileIssueDetailNavigation;
+104
View File
@@ -0,0 +1,104 @@
function createMobilePlanTodayNavigation(options) {
const buttons = options.buttons || {};
const targets = options.targets || {};
const root = options.root;
const listeners = new Map();
const prefersReducedMotion = options.prefersReducedMotion || (() => false);
const now = options.now || (() => Date.now());
const targetNames = new Map(Object.entries(targets).map(([name, target]) => [target, name]));
let observer = null;
let navigationLockUntil = 0;
function select(name) {
Object.entries(buttons).forEach(([key, button]) => {
if (!button) return;
if (key === name) button.setAttribute('aria-current', 'location');
else button.removeAttribute('aria-current');
});
}
function navigate(name) {
const target = targets[name];
if (!root || !target) return false;
root.scrollTo({
top: Math.max(0, target.offsetTop - root.offsetTop),
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
});
navigationLockUntil = now() + 500;
select(name);
return true;
}
function reset() {
select('fit');
}
return {
start() {
Object.entries(buttons).forEach(([name, button]) => {
if (!button || listeners.has(button)) return;
const listener = event => {
event.preventDefault();
navigate(name);
};
listeners.set(button, listener);
button.addEventListener('click', listener);
});
reset();
const observe = options.observe || ((handler, observedTargets, observedRoot) => {
if (typeof IntersectionObserver === 'undefined') return null;
const instance = new IntersectionObserver(handler, {
root: observedRoot,
rootMargin: '-20% 0px -60% 0px',
threshold: [0, 0.25, 0.5, 0.75, 1],
});
observedTargets.forEach(target => instance.observe(target));
return instance;
});
observer = observe(entries => {
if (now() < navigationLockUntil) return;
const visible = entries
.filter(entry => entry.isIntersecting && targetNames.has(entry.target))
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
if (visible) select(targetNames.get(visible.target));
}, Array.from(targetNames.keys()).filter(Boolean), root);
},
stop() {
listeners.forEach((listener, button) => button.removeEventListener('click', listener));
listeners.clear();
if (observer) observer.disconnect();
observer = null;
},
navigate,
reset,
select,
};
}
function attachMobilePlanTodayNavigation({document, window}) {
const bySection = name => document.querySelector('[data-plan-today-section="' + name + '"]');
const sheet = document.getElementById('plan-today-sheet');
const panel = document.querySelector('.plan-today-panel');
const navigation = createMobilePlanTodayNavigation({
root: panel,
buttons: {
fit: bySection('fit'),
today: bySection('today'),
available: bySection('available'),
},
targets: {
fit: document.getElementById('plan-today-fit'),
today: document.getElementById('plan-today-selected'),
available: document.getElementById('plan-today-available-work'),
},
prefersReducedMotion: () => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
});
navigation.start();
new MutationObserver(() => {
if (!sheet.hidden) navigation.reset();
}).observe(sheet, {attributes: true, attributeFilter: ['hidden']});
return navigation;
}
if (typeof module !== 'undefined') module.exports = createMobilePlanTodayNavigation;
else attachMobilePlanTodayNavigation({document, window});
+88
View File
@@ -0,0 +1,88 @@
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
else root.createMobilePullRefresh = factory;
})(typeof self !== 'undefined' ? self : this, function createMobilePullRefresh(options) {
const surface = options.surface || document.querySelector('#my-work');
const indicator = options.indicator || document.querySelector('#mobile-pull-refresh');
const threshold = options.threshold || 64;
const isMobile = options.isMobile || (() => window.matchMedia('(max-width: 600px)').matches);
const getScrollY = options.getScrollY || (() => window.scrollY);
const isBlocked = options.isBlocked || (() => Array.from(options.overlays || document.querySelectorAll('[role="dialog"], #whiteboard-modal, #markdown-modal')).some(overlay =>
(overlay.checkVisibility ? overlay.checkVisibility() : !overlay.closest('[hidden]')) &&
(overlay.classList.contains('open') || overlay.getAttribute('aria-modal') === 'true')));
let gesture = null;
let inFlight = null;
function show(state, text) {
indicator.hidden = false;
indicator.dataset.state = state;
indicator.textContent = text;
}
function resetGesture() {
gesture = null;
}
function startsOnExcludedTarget(target) {
if (!target) return false;
if (target.closest?.('button, a, input, textarea, select, summary, [contenteditable="true"], [role="button"]')) return true;
for (let node = target; node && node !== surface; node = node.parentElement) {
if ((node.scrollWidth || 0) > (node.clientWidth || 0)) return true;
}
return false;
}
function onPointerDown(event) {
if (inFlight || event.pointerType !== 'touch' || !isMobile() || getScrollY() > 0 || isBlocked() || startsOnExcludedTarget(event.target)) return;
gesture = {id:event.pointerId, x:event.clientX, y:event.clientY, ready:false};
surface.setPointerCapture?.(event.pointerId);
}
function onPointerMove(event) {
if (!gesture || gesture.id !== event.pointerId) return;
const dx = event.clientX - gesture.x;
const dy = event.clientY - gesture.y;
if (dy <= 0 || Math.abs(dx) > dy) return resetGesture();
event.preventDefault();
gesture.ready = dy >= threshold;
show(gesture.ready ? 'ready' : 'pulling', gesture.ready ? 'Release to refresh' : 'Pull to refresh');
}
async function onPointerUp(event) {
if (!gesture || gesture.id !== event.pointerId) return;
const shouldRefresh = gesture.ready;
resetGesture();
surface.releasePointerCapture?.(event.pointerId);
if (!shouldRefresh || inFlight) return;
show('refreshing', 'Refreshing My Work…');
inFlight = Promise.resolve().then(options.refresh);
try {
await inFlight;
show('success', 'My Work is up to date');
} catch (_) {
show('error', 'Could not refresh · pull to retry');
} finally {
inFlight = null;
}
}
function onPointerCancel() {
resetGesture();
}
return {
start() {
surface.addEventListener('pointerdown', onPointerDown);
surface.addEventListener('pointermove', onPointerMove);
surface.addEventListener('pointerup', onPointerUp);
surface.addEventListener('pointercancel', onPointerCancel);
},
stop() {
surface.removeEventListener('pointerdown', onPointerDown);
surface.removeEventListener('pointermove', onPointerMove);
surface.removeEventListener('pointerup', onPointerUp);
surface.removeEventListener('pointercancel', onPointerCancel);
resetGesture();
},
};
});
+3
View File
@@ -9,10 +9,12 @@
draft: 'No drafts are ready to open.',
};
const continuation = [
['delivery', 'Recover Delivery'],
['attention', 'Start Attention'],
['today', 'Continue Today'],
['update', 'Resume Updates'],
['agenda', 'Open Agenda'],
['filed', 'Review Filed'],
['later', 'Start Later'],
['draft', 'Open Drafts'],
];
@@ -27,6 +29,7 @@
}
function open(name) {
if (name === 'delivery' && options.openDelivery) return options.openDelivery();
if (name === 'today') return options.openToday();
if (name === 'agenda') return options.openAgenda();
if (name === 'update' && options.openUpdates) return options.openUpdates();
+81
View File
@@ -0,0 +1,81 @@
function createMobileReviewDetailNavigation(options) {
const buttons = options.buttons || {};
const targets = options.targets || {};
const listeners = new Map();
const prefersReducedMotion = options.prefersReducedMotion || (() => false);
const targetNames = new Map(Object.entries(targets).map(([name, target]) => [target, name]));
let observer = null;
function select(name) {
Object.entries(buttons).forEach(([key, button]) => {
if (!button) return;
if (key === name) button.setAttribute('aria-current', 'location');
else button.removeAttribute('aria-current');
});
}
function navigate(name, navigationOptions = {}) {
const target = targets[name];
if (!target) return false;
target.scrollIntoView({
block:'start',
behavior:prefersReducedMotion() ? 'auto' : 'smooth',
});
if (name === 'feedback' && options.summaryComposer && navigationOptions.focus !== false) {
options.summaryComposer.focus({ preventScroll:true });
}
select(name);
return true;
}
function reset() {
select('overview');
}
return {
start() {
Object.entries(buttons).forEach(([name, button]) => {
if (!button || listeners.has(button)) return;
const listener = event => {
event.preventDefault();
navigate(name);
if (options.onSectionChange) options.onSectionChange(name, { replace:false });
};
listeners.set(button, listener);
button.addEventListener('click', listener);
});
reset();
const observe = options.observe || ((handler, observedTargets, root) => {
if (typeof IntersectionObserver === 'undefined') return null;
const instance = new IntersectionObserver(handler, {
root:root || null,
rootMargin:'-20% 0px -60% 0px',
threshold:[0, 0.25, 0.5, 0.75, 1],
});
observedTargets.forEach(target => instance.observe(target));
return instance;
});
observer = observe(entries => {
const visible = entries
.filter(entry => entry.isIntersecting && targetNames.has(entry.target))
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
if (visible) {
const name = targetNames.get(visible.target);
select(name);
if (options.onSectionChange) options.onSectionChange(name, { replace:true });
}
}, Array.from(targetNames.keys()).filter(Boolean), options.root || null);
},
stop() {
listeners.forEach((listener, button) => button.removeEventListener('click', listener));
listeners.clear();
if (observer) observer.disconnect();
observer = null;
},
navigate,
reset,
select,
};
}
if (typeof module !== 'undefined') module.exports = createMobileReviewDetailNavigation;
+130
View File
@@ -0,0 +1,130 @@
function createMobileSearchPreviewNavigation(options) {
const buttons = options.buttons || {};
const targets = options.targets || {};
const focusTargets = options.focusTargets || {};
const listeners = new Map();
const prefersReducedMotion = options.prefersReducedMotion || (() => false);
const targetNames = new Map(Object.entries(targets).map(([name, target]) => [target, name]));
let replyAvailable = options.replyAvailable !== false;
let observer = null;
let navigationLockUntil = 0;
function select(name) {
Object.entries(buttons).forEach(([key, button]) => {
if (!button) return;
if (key === name) button.setAttribute('aria-current', 'location');
else button.removeAttribute('aria-current');
});
}
function setReplyAvailable(available) {
replyAvailable = Boolean(available);
const button = buttons.reply;
if (!button) return;
if (replyAvailable) button.removeAttribute('aria-disabled');
else button.setAttribute('aria-disabled', 'true');
}
function navigate(name) {
if (name === 'reply' && !replyAvailable) return false;
const target = targets[name];
if (!target) return false;
target.scrollIntoView({
block: 'start',
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
});
navigationLockUntil = Date.now() + 500;
select(name);
const focusTarget = focusTargets[name] || (name === 'reply' ? target : null);
if (focusTarget) focusTarget.focus({preventScroll:true});
return true;
}
function reset(available = replyAvailable) {
setReplyAvailable(available);
select('overview');
}
return {
start() {
Object.entries(buttons).forEach(([name, button]) => {
if (!button || listeners.has(button)) return;
const listener = event => {
event.preventDefault();
navigate(name);
};
listeners.set(button, listener);
button.addEventListener('click', listener);
});
reset(replyAvailable);
const observe = options.observe || ((handler, observedTargets, root) => {
if (typeof IntersectionObserver === 'undefined') return null;
const instance = new IntersectionObserver(handler, {
root,
rootMargin: '-20% 0px -60% 0px',
threshold: [0, 0.25, 0.5, 0.75, 1],
});
observedTargets.forEach(target => instance.observe(target));
return instance;
});
observer = observe(entries => {
if (Date.now() < navigationLockUntil) return;
const visible = entries
.filter(entry => entry.isIntersecting && targetNames.has(entry.target))
.filter(entry => targetNames.get(entry.target) !== 'reply' || replyAvailable)
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
if (visible) select(targetNames.get(visible.target));
}, Array.from(targetNames.keys()).filter(Boolean), options.root || null);
},
stop() {
listeners.forEach((listener, button) => button.removeEventListener('click', listener));
listeners.clear();
if (observer) observer.disconnect();
observer = null;
},
navigate,
reset,
select,
setReplyAvailable,
};
}
function attachMobileSearchPreviewNavigation({document, window}) {
const bySection = name => document.querySelector('[data-search-preview-section="' + name + '"]');
const preview = document.getElementById('search-preview');
const reply = document.getElementById('search-preview-reply-workspace');
const navigation = createMobileSearchPreviewNavigation({
root:document.querySelector('.search-preview-panel'),
buttons:{
overview:bySection('overview'),
conversation:bySection('conversation'),
reply:bySection('reply'),
actions:bySection('actions'),
},
targets:{
overview:document.getElementById('search-preview-overview'),
conversation:document.getElementById('search-preview-conversation'),
reply,
actions:document.getElementById('search-preview-actions'),
},
focusTargets:{reply:document.getElementById('search-preview-reply')},
replyAvailable:!reply.hidden,
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
});
navigation.start();
new MutationObserver(() => navigation.setReplyAvailable(!reply.hidden)).observe(reply, {
attributes:true, attributeFilter:['hidden'],
});
new MutationObserver(() => {
if (preview.classList.contains('open')) navigation.reset(!reply.hidden);
}).observe(preview, {attributes:true, attributeFilter:['class']});
['previous-search-result', 'next-search-result'].forEach(id => {
document.getElementById(id).addEventListener('click', () => {
setTimeout(() => navigation.reset(!reply.hidden), 0);
});
});
return navigation;
}
if (typeof module !== 'undefined') module.exports = createMobileSearchPreviewNavigation;
else attachMobileSearchPreviewNavigation({document, window});
+11 -4
View File
@@ -5,6 +5,7 @@
const checkpointKey = 'stackchain.mobile-start-day.v1';
const storage = options.storage || (typeof localStorage !== 'undefined' ? localStorage : null);
const reviewOrder = [
['delivery', 'Delivery recovery'],
['agenda', 'Agenda'],
['attention', 'Attention'],
['update', 'Updates'],
@@ -73,13 +74,19 @@
.filter(phase => phase.count > 0);
const total = phases.reduce((sum, phase) => sum + phase.count, 0);
const today = count(counts.today);
const delivery = count(counts.delivery);
const other = total - delivery;
const next = phases.length ? phases[0].name : (today ? 'today' : 'find');
const nextLabel = phases.length ? 'Review ' + phases[0].label : (today ? 'Continue Today' : 'Find Work');
const nextLabel = next === 'delivery' ? 'Review Delivery' :
(phases.length ? 'Review ' + phases[0].label : (today ? 'Continue Today' : 'Find Work'));
return {
total,
next,
label: nextLabel,
summary: total ? total + ' items before Today · ' + today + ' planned' :
summary: delivery ? delivery + (delivery === 1 ? ' delivery needs' : ' deliveries need') +
' action before Today' + (other ? ' · ' + other + ' other ' + (other === 1 ? 'item' : 'items') : '') +
' · ' + today + ' planned' :
total ? total + ' items before Today · ' + today + ' planned' :
(today ? 'Review clear · ' + today + ' planned' : 'Review clear · Today is empty'),
phases,
};
@@ -107,9 +114,9 @@
return true;
}
function reconcile({authoritative = false} = {}) {
function reconcile({authoritative = false, authoritativePhases = []} = {}) {
const saved = checkpoint();
if (!authoritative || !saved?.phase) return false;
if (!saved?.phase || (!authoritative && !authoritativePhases.includes(saved.phase))) return false;
const counts = options.getCounts ? options.getCounts() : {};
if (count(counts[saved.phase]) > 0) return false;
return completePhase(saved.phase);
+5 -4
View File
@@ -66,9 +66,9 @@
function updateWork(mode) {
const text = {
continue:'Continue', resume:'Resume', start:'Start', plan:'Plan', find:'Find',
attention:'Attention', update:'Updates', agenda:'Agenda', later:'Later', draft:'Drafts',
delivery:'Delivery', attention:'Attention', update:'Updates', agenda:'Agenda', filed:'Filed', later:'Later', draft:'Drafts',
}[mode] || 'Work';
const queue = ['Attention', 'Updates', 'Agenda', 'Later', 'Drafts'].includes(text);
const queue = ['Delivery', 'Attention', 'Updates', 'Agenda', 'Filed', 'Later', 'Drafts'].includes(text);
if (options.workLabel) options.workLabel.textContent = text;
if (buttons.work) buttons.work.setAttribute('aria-label', queue
? (mode === 'update' ? 'Resume ' : 'Open ') + text
@@ -91,9 +91,9 @@
}
function updateQueues(counts) {
const names = 'today agenda attention update filed later draft'.split(' ');
const names = 'today agenda delivery attention update filed later draft'.split(' ');
const normalized = Object.fromEntries(names.map(name => [name, Math.max(0, Number(counts?.[name]) || 0)]));
const actionableNames = ['today', 'attention', 'update', 'filed', 'later', 'draft'];
const actionableNames = ['today', 'delivery', 'attention', 'update', 'filed', 'later', 'draft'];
const active = actionableNames.reduce((total, name) => total + (normalized[name] > 0 ? 1 : 0), 0);
Object.entries(options.queueCounts || {}).forEach(([name, element]) => {
element.textContent = String(normalized[name] || 0);
@@ -117,6 +117,7 @@
? 'Queues: no active queues; no upcoming deadlines'
: 'Queues: Today ' + normalized.today
+ ', Agenda ' + normalized.agenda + ' due'
+ ', Delivery ' + normalized.delivery
+ ', Attention ' + normalized.attention
+ ', Updates ' + normalized.update
+ ', Filed ' + normalized.filed
+88
View File
@@ -0,0 +1,88 @@
function createMobileUpdateDetailNavigation(options) {
const buttons = options.buttons || {};
const targets = options.targets || {};
const listeners = new Map();
const prefersReducedMotion = options.prefersReducedMotion || (() => false);
const targetNames = new Map(Object.entries(targets).map(([name, target]) => [target, name]));
let observer = null;
function select(name) {
Object.entries(buttons).forEach(([key, button]) => {
if (!button) return;
if (key === name) button.setAttribute('aria-current', 'location');
else button.removeAttribute('aria-current');
});
}
function navigate(name, navigationOptions = {}) {
if (name === 'activity') {
if (options.jumpToNewActivity) options.jumpToNewActivity();
select(name);
return true;
}
const target = targets[name];
if (!target) return false;
if (name === 'context') target.open = true;
target.scrollIntoView({
block:'start',
behavior:prefersReducedMotion() ? 'auto' : 'smooth',
});
if (name === 'reply' && options.replyComposer && navigationOptions.focus !== false) {
options.replyComposer.focus({ preventScroll:true });
}
select(name);
return true;
}
function reset() {
if (targets.context) targets.context.open = false;
select('activity');
}
return {
start() {
Object.entries(buttons).forEach(([name, button]) => {
if (!button || listeners.has(button)) return;
const listener = event => {
event.preventDefault();
navigate(name);
if (options.onSectionChange) options.onSectionChange(name, { replace:false });
};
listeners.set(button, listener);
button.addEventListener('click', listener);
});
reset();
const observe = options.observe || ((handler, observedTargets) => {
if (typeof IntersectionObserver === 'undefined') return null;
const instance = new IntersectionObserver(handler, {
root:options.root || null,
rootMargin:'-20% 0px -60% 0px',
threshold:[0, 0.25, 0.5, 0.75, 1],
});
observedTargets.forEach(target => instance.observe(target));
return instance;
});
observer = observe(entries => {
const visible = entries
.filter(entry => entry.isIntersecting && targetNames.has(entry.target))
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
if (visible) {
const name = targetNames.get(visible.target);
select(name);
if (options.onSectionChange) options.onSectionChange(name, { replace:true });
}
}, Array.from(targetNames.keys()).filter(Boolean));
},
stop() {
listeners.forEach((listener, button) => button.removeEventListener('click', listener));
listeners.clear();
if (observer) observer.disconnect();
observer = null;
},
navigate,
reset,
select,
};
}
if (typeof module !== 'undefined') module.exports = createMobileUpdateDetailNavigation;
+1 -1
View File
@@ -5,7 +5,7 @@
function mode() {
if (options.isTodayActive()) return 'continue';
const recommended = options.queueLauncher && options.queueLauncher.recommend();
if (recommended && recommended.name !== 'today') return recommended.name;
if (recommended && !['today', 'find'].includes(recommended.name)) return recommended.name;
if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume';
if (options.getTodayCount() > 0) return 'start';
if (options.getEligibleCount() > 0) return 'plan';
+43
View File
@@ -0,0 +1,43 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.inspectStackchainPrivateDatabases = factory(root.indexedDB);
})(typeof globalThis !== 'undefined' ? globalThis : this, function createPrivateDataInspector(indexedDB) {
function requestResult(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error('Private storage inventory failed.'));
request.onblocked = () => reject(new Error('Private storage inventory was blocked.'));
});
}
async function countDatabase(name) {
const database = await requestResult(indexedDB.open(name));
try {
const storeNames = Array.from(database.objectStoreNames);
if (!storeNames.length) return 0;
const transaction = database.transaction(storeNames, 'readonly');
const counts = await Promise.all(storeNames.map(storeName =>
requestResult(transaction.objectStore(storeName).count())
));
return counts.reduce((total, count) => total + Number(count || 0), 0);
} finally {
database.close();
}
}
return async function inspectPrivateDatabases(registeredNames) {
if (!indexedDB?.databases) return { recordCount: 0, unavailable: true };
try {
const existing = new Set((await indexedDB.databases()).map(database => database.name));
const counts = await Promise.all(
registeredNames.filter(name => existing.has(name)).map(countDatabase)
);
return {
recordCount: counts.reduce((total, count) => total + count, 0),
unavailable: false,
};
} catch (_error) {
return { recordCount: 0, unavailable: true };
}
};
});
+2
View File
@@ -4,6 +4,8 @@
'stackchain-offline-work-v2',
'stackchain-unfiled-captures-v1',
'stackchain-voice-transcripts-v1',
'stackchain-search-reply-drafts-v1',
'stackchain-conversation-reply-drafts-v1',
]);
if (typeof module !== 'undefined' && module.exports) module.exports = databases;
else root.stackchainPrivateDatabases = databases;
+1 -1
View File
@@ -55,9 +55,9 @@
}
return async function clearPrivateDeviceData() {
await stopWorkerOutbox();
removeOwnedStorage(localStorage);
if (sessionStorage !== localStorage) removeOwnedStorage(sessionStorage);
await stopWorkerOutbox();
for (const name of privateDatabases) await deletePrivateDatabase(name);
const keys = await caches?.keys?.() || [];
await Promise.all(
+20 -1
View File
@@ -5,7 +5,10 @@ function createReconnectOutboxes({
flushAuthored,
flushNotificationReads,
}) {
return async function reconnectOutboxes() {
let activeReconnect = null;
let followUpRequested = false;
async function reconnectOnce() {
const snapshot = await refresh();
const freshness = snapshot?.freshness?.sections?.context;
const identityFresh = snapshot?.context && !snapshot.context.error &&
@@ -19,6 +22,22 @@ function createReconnectOutboxes({
flushNotificationReads(),
]);
return true;
}
return function reconnectOutboxes() {
if (activeReconnect) {
followUpRequested = true;
return activeReconnect;
}
activeReconnect = (async () => {
let result = false;
do {
followUpRequested = false;
result = await reconnectOnce();
} while (followUpRequested);
return result;
})().finally(() => { activeReconnect = null; });
return activeReconnect;
};
}
+31 -10
View File
@@ -81,15 +81,18 @@
}
input.value = preview.replyDraft();
const replying = state.status === 'replying';
buttons.forEach(button => { button.disabled = replying || !input.value.trim(); });
if (replying) status.textContent = 'Sending reply…';
buttons.forEach(button => {
button.disabled = replying || (!input.value.trim() && !preview.hasReplyAttachments?.());
});
if (replying) status.textContent = 'Saving reply for delivery…';
else if (state.status === 'queued') status.textContent = 'Reply queued. It will send when connected.';
else if (state.status === 'replied') status.textContent = 'Reply posted.';
else if (state.status === 'reply-error') status.textContent =
state.error?.message || 'Reply failed. Your draft is safe; retry when ready.';
};
}
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, queueReply, prepareReply, afterReply, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
if (Array.isArray(session)) {
getSession = session[0];
loadMore = () => session[1].loadMore();
@@ -201,7 +204,11 @@
}
const api = {
hasReplyAttachments() {
return Boolean(hasAttachments?.());
},
open(item) {
if (current && !sameItem(current, item) && hasAttachments?.()) clearAttachments?.();
generation += 1;
const requestGeneration = generation;
current = { ...item };
@@ -225,6 +232,7 @@
});
},
close() {
clearAttachments?.();
generation += 1;
current = null;
onState({ status: 'closed' });
@@ -282,9 +290,11 @@
},
reply({ advance = false } = {}) {
if (replyRequest) return replyRequest;
if (!current || typeof postReply !== 'function') return Promise.reject(new Error('Replying is unavailable.'));
if (!current || (typeof postReply !== 'function' && typeof queueReply !== 'function')) {
return Promise.reject(new Error('Replying is unavailable.'));
}
const body = api.replyDraft().trim();
if (!body) return Promise.reject(new Error('Write a reply first.'));
if (!body && !hasAttachments?.()) return Promise.reject(new Error('Write a reply or add a photo first.'));
const item = { ...current };
const operationKey = replyKey(item, 'operation');
let operationId = stored(operationKey);
@@ -293,13 +303,24 @@
save(operationKey, operationId);
}
publish({ status:'replying', item:current, detail:current, conversation });
replyRequest = postReply(item, body, operationId).then(comment => {
const comments = [...(conversation?.comments || [])];
if (!comments.some(candidate => candidate?.id === comment?.id)) comments.push(comment);
conversation = { status:'ready', comments, olderPage:conversation?.olderPage ?? null };
replyRequest = (typeof queueReply === 'function' ?
Promise.resolve(queueReply(item, body, operationId)) :
Promise.resolve(typeof prepareReply === 'function' ? prepareReply(item, body) : body)
.then(preparedBody => {
if (!String(preparedBody || '').trim()) throw new Error('Write a reply or add a photo first.');
return postReply(item, preparedBody, operationId);
})
).then(async comment => {
await afterReply?.(item);
if (!comment?.queued) {
const comments = [...(conversation?.comments || [])];
if (!comments.some(candidate => candidate?.id === comment?.id)) comments.push(comment);
conversation = { status:'ready', comments, olderPage:conversation?.olderPage ?? null };
}
save(replyKey(item, 'draft'), '');
save(operationKey, '');
publish({ status:'replied', item:current, detail:current, conversation, result:comment });
clearAttachments?.();
publish({ status:comment?.queued ? 'queued' : 'replied', item:current, detail:current, conversation, result:comment });
return advance ? api.next().then(() => comment) : comment;
}).catch(error => {
publish({ status:'reply-error', item:current, detail:current, conversation, error });
+121
View File
@@ -0,0 +1,121 @@
(function (root, factory) {
const createSearchReplyDraftStore = factory();
if (typeof module === 'object' && module.exports) module.exports = createSearchReplyDraftStore;
else root.createSearchReplyDraftStore = createSearchReplyDraftStore;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
const dbName = 'stackchain-search-reply-drafts-v1';
const storeName = 'drafts';
function requestResult(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result ?? null);
request.onerror = () => reject(request.error || new Error('Search reply draft storage failed.'));
});
}
function createTransaction(indexedDB) {
if (!indexedDB) return null;
let databasePromise;
function database() {
if (!databasePromise) databasePromise = new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, 1);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains(storeName)) {
request.result.createObjectStore(storeName, { keyPath:'id' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error('Search reply draft storage is unavailable.'));
});
return databasePromise;
}
return async (operation, key, value) => {
const db = await database();
const transaction = db.transaction(storeName, operation === 'get' ? 'readonly' : 'readwrite');
const records = transaction.objectStore(storeName);
if (operation === 'put') return requestResult(records.put(value));
if (operation === 'delete') return requestResult(records.delete(key));
return requestResult(records.get(key));
};
}
function normalizedTarget(target) {
const kind = String(target?.kind || '');
const repository = String(target?.repository || '');
const number = Number(target?.number || 0);
if (!['issue', 'pull'].includes(kind) || !repository || !Number.isInteger(number) || number < 1) {
throw new Error('Choose a Search result before saving photo evidence.');
}
return { kind, repository, number };
}
function attachment(value) {
const blob = value?.blob;
const data = String(value?.data || '');
if (!blob && !data) throw new Error('A saved Search photo is unavailable.');
const note = String(value?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
const operationId = String(value?.operationId || '').slice(0, 128);
const markdown = String(value?.confirmed?.markdown || '');
return {
filename:String(value?.filename || ''),
contentType:String(value?.contentType || ''),
...(blob ? { blob } : { data }),
...(note ? { note } : {}),
...(operationId ? { operationId } : {}),
...(markdown ? { confirmed:{ markdown } } : {}),
};
}
return function createSearchReplyDraftStore({
indexedDB = globalThis.indexedDB,
transaction,
getOwnerLogin = () => '',
} = {}) {
const transact = transaction || createTransaction(indexedDB);
function identity(target) {
const ownerLogin = String(getOwnerLogin() || '').trim();
if (!ownerLogin) throw new Error('Confirm your Gitea account before saving Search photos.');
const normalized = normalizedTarget(target);
return {
ownerLogin,
target:normalized,
id:[ownerLogin, normalized.kind, normalized.repository, normalized.number]
.map(value => encodeURIComponent(String(value))).join(':'),
};
}
async function save(target, values) {
if (!transact) throw new Error('Photo draft storage needs IndexedDB. Your current photos are still here.');
const { id, ownerLogin, target:normalized } = identity(target);
const list = (Array.isArray(values) ? values : [values]).filter(Boolean).slice(0, 5).map(attachment);
if (!list.length) {
await transact('delete', id);
return null;
}
await transact('put', id, { id, version:1, ownerLogin, ...normalized, attachments:list });
return list;
}
async function load(target) {
if (!transact) return null;
const { id, ownerLogin, target:normalized } = identity(target);
const record = await transact('get', id);
if (record?.version !== 1 || record.ownerLogin !== ownerLogin || record.kind !== normalized.kind ||
record.repository !== normalized.repository || Number(record.number) !== normalized.number ||
!Array.isArray(record.attachments) || !record.attachments.length) return null;
return record.attachments.slice(0, 5).map(attachment);
}
async function remove(target) {
if (!transact) return false;
const { id } = identity(target);
await transact('delete', id);
return true;
}
return { save, load, remove };
};
});
+59 -3
View File
@@ -12,8 +12,49 @@
const activityList = root.document.getElementById('security-activity-list');
const activityStatus = root.document.getElementById('security-activity-status');
const loadMoreActivity = root.document.getElementById('load-more-security-activity');
const sectionButtons = Object.fromEntries(['activity', 'devices', 'passkeys'].map(section => [
section, root.document.getElementById(`security-section-${section}`),
]));
const sections = Object.fromEntries(['activity', 'devices', 'passkeys'].map(section => [
section, root.document.getElementById(`security-${section}-section`),
]));
let activityCursor = null;
const navigate = (section, { history = true } = {}) => {
if (!sections[section]) section = 'activity';
Object.entries(sectionButtons).forEach(([name, button]) => {
if (!button) return;
if (name === section) button.setAttribute('aria-current', 'page');
else button.removeAttribute('aria-current');
});
sections[section]?.scrollIntoView?.({ block: 'start' });
if (history && root.history?.pushState) {
root.history.pushState(
{ ...(root.history.state || {}), stackchainSecuritySection: section },
'',
`${root.location.pathname}${root.location.search || ''}#security/${section}`,
);
}
};
Object.entries(sectionButtons).forEach(([section, button]) => {
button?.addEventListener('click', () => navigate(section));
});
root.document.getElementById('close-active-devices')?.addEventListener('click', () => {
if (root.history?.state?.stackchainSecuritySection) root.history.back();
});
root.addEventListener?.('popstate', event => {
const section = event.state?.stackchainSecuritySection;
if (section && !devicesSheet.hidden) {
navigate(section, { history: false });
return;
}
if (!devicesSheet.hidden) {
devicesSheet.hidden = true;
devicesButton?.focus();
}
});
const renderDevices = async () => {
devicesStatus.textContent = 'Loading active devices…';
devicesList.replaceChildren();
@@ -40,7 +81,9 @@
revoke.textContent = device.current ? 'Sign out' : 'Revoke';
revoke.addEventListener('click', async () => {
if (device.current) await boundary.signOut();
else if (await boundary.revokeActiveDevice(device)) await renderDevices();
else if (await boundary.revokeActiveDevice(device)) {
await Promise.all([renderDevices(), renderSecurityActivity()]);
}
});
row.append(details, revoke);
devicesList.append(row);
@@ -82,6 +125,7 @@
: 'Passkey removed.';
await renderPasskeys();
if (outcome.session_revoked) await renderDevices();
await renderSecurityActivity();
} else {
remove.disabled = false;
passkeysStatus.textContent = `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`;
@@ -122,7 +166,11 @@
const details = root.document.createElement('span');
details.className = 'small muted';
details.textContent = formatted.detail;
row.append(title, details);
const action = root.document.createElement('button');
action.type = 'button';
action.textContent = 'Review devices';
action.addEventListener('click', () => navigate('devices'));
row.append(title, details, action);
activityList.append(row);
});
}
@@ -150,6 +198,13 @@
.filter(value => typeof value === 'string' && value).join(' · ');
details.textContent = `${new Date(event.created_at * 1000).toLocaleString()}${context ? ' · ' + context : ''}`;
row.append(title, details);
if (event.kind === 'passkey_counter_anomaly') {
const action = root.document.createElement('button');
action.type = 'button';
action.textContent = 'Review passkeys';
action.addEventListener('click', () => navigate('passkeys'));
row.append(action);
}
activityList.append(row);
});
activityCursor = page.next_cursor;
@@ -169,6 +224,7 @@
if (!devicesSheet) return;
devicesSheet.hidden = false;
root.document.getElementById('close-active-devices')?.focus();
navigate('activity', { history: root.history?.state?.stackchainSecuritySection !== 'activity' });
return Promise.all([renderDevices(), renderPasskeys(), renderSecurityActivity()]);
};
devicesButton?.addEventListener('click', open);
@@ -186,5 +242,5 @@
enrollPasskey.disabled = false;
}
});
return { open, renderDevices, renderPasskeys, renderSecurityActivity };
return { open, navigate, renderDevices, renderPasskeys, renderSecurityActivity };
});
+51 -6
View File
@@ -1,7 +1,7 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/private-data-registry.js');
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v108';
const CACHE = 'stackchain-dashboard-shell-v117';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
@@ -16,12 +16,16 @@ const SHELL = [
BASE + 'static/icons/stackchain-512.png',
BASE + 'static/session.js',
BASE + 'static/feature-loader.js',
BASE + 'static/workspace-bootstrap.js',
BASE + 'static/conversation-action-hydrator.js',
BASE + 'static/security-center.js',
BASE + 'static/markdown.js',
BASE + 'static/commands.js',
BASE + 'static/saved-searches.js',
BASE + 'static/search-preview.js',
BASE + 'static/search-reply-draft-store.js',
BASE + 'static/conversation-reply-draft-store.js',
BASE + 'static/conversation-photo-drafts.js',
BASE + 'static/search-defer.js',
BASE + 'static/widgets.js',
BASE + 'static/drafts.js',
@@ -48,6 +52,8 @@ const SHELL = [
BASE + 'static/today-work.js',
BASE + 'static/today-timer.js',
BASE + 'static/today-recap.js',
BASE + 'static/today-wrap-up.js',
BASE + 'static/today-handoff.js',
BASE + 'static/today-completion.js',
BASE + 'static/today-readiness.js',
BASE + 'static/comment-next.js',
@@ -74,10 +80,19 @@ const SHELL = [
BASE + 'static/issue-attachment.js',
BASE + 'static/issue-filing-review.js',
BASE + 'static/issue-sheet.js',
BASE + 'static/mobile-issue-detail-nav.js',
BASE + 'static/mobile-update-detail-nav.js',
BASE + 'static/mobile-review-detail-nav.js',
BASE + 'static/mobile-search-preview-nav.js',
BASE + 'static/mobile-plan-today-nav.js',
BASE + 'static/mobile-find-work-nav.js',
BASE + 'static/mobile-pull-refresh.js',
BASE + 'static/checklist-conflict.js',
BASE + 'static/voice-transcript-store.js',
BASE + 'static/voice-issue-capture.js',
BASE + 'static/voice-conversation-capture.js',
BASE + 'static/create-issue-sheet.js',
BASE + 'static/mobile-create-issue-nav.js',
BASE + 'static/create-and-start.js',
BASE + 'static/assign-and-start.js',
BASE + 'static/filed-claim.js',
@@ -91,6 +106,7 @@ const SHELL = [
BASE + 'static/mobile-task-dock.js',
BASE + 'static/mobile-work-entry.js',
BASE + 'static/mobile-queue-launcher.js',
BASE + 'static/mobile-delivery-recovery.js',
BASE + 'static/mobile-start-day.js',
BASE + 'static/update-triage-session.js',
BASE + 'static/update-review-handoff.js',
@@ -101,8 +117,12 @@ const SHELL = [
BASE + 'static/update-decision-transaction.js',
BASE + 'static/agenda-session-launcher.js',
BASE + 'static/mobile-launch.js',
BASE + 'static/mobile-insights.js',
BASE + 'static/mobile-app-shortcuts.js',
BASE + 'static/install-app.js',
BASE + 'static/private-data-inventory.js',
BASE + 'static/private-device-data.js',
BASE + 'static/device-storage.js',
BASE + 'static/mobile-device-setup.js',
BASE + 'static/mobile-search-viewport.js',
BASE + 'static/mobile-composer-viewport.js',
@@ -356,15 +376,40 @@ self.addEventListener('install', event => {
event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL)).then(() => self.skipWaiting()));
});
async function warmOptionalFeature(cache, staleCacheNames, asset) {
for (const cacheName of staleCacheNames) {
const staleCache = await caches.open(cacheName);
const cached = await staleCache.match(asset);
if (cached) {
await cache.put(asset, cached);
return;
}
}
await cache.add(asset);
}
async function cachedOptionalFeature(request) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE);
await cache.put(request, response.clone());
}
return response;
}
self.addEventListener('activate', event => {
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE)
.map(key => caches.delete(key))
const staleCacheNames = keys.filter(
key => key.startsWith('stackchain-dashboard-') && key !== CACHE
);
const cache = await caches.open(CACHE);
await Promise.allSettled(OPTIONAL_FEATURES.map(asset => cache.add(asset)));
await Promise.allSettled(
OPTIONAL_FEATURES.map(asset => warmOptionalFeature(cache, staleCacheNames, asset))
);
await Promise.all(staleCacheNames.map(key => caches.delete(key)));
await self.clients.claim();
})());
});
@@ -606,6 +651,6 @@ self.addEventListener('fetch', event => {
return;
}
if (url.origin === self.location.origin && OPTIONAL_FEATURES.includes(url.pathname)) {
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
event.respondWith(cachedOptionalFeature(request));
}
});
+154
View File
@@ -0,0 +1,154 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createTodayHandoff = factory;
})(typeof self !== 'undefined' ? self : this, function createTodayHandoff(options) {
const prefix = 'stackchain.today-handoff.v1.';
const storage = options.storage || null;
const selected = new Set();
const excluded = new Set();
function key() {
const login = String(options.getLogin?.() || '').trim().toLowerCase();
return login ? prefix + encodeURIComponent(login) : '';
}
function pending() {
const storageKey = key();
if (!storageKey || !storage) return [];
try {
const value = JSON.parse(storage.getItem(storageKey) || '[]');
return Array.isArray(value) ? value.filter(id => typeof id === 'string' && id) : [];
} catch (_) {
return [];
}
}
function write(ids) {
const storageKey = key();
if (!storageKey || !storage) return false;
const unique = [...new Set(ids.filter(id => typeof id === 'string' && id))];
try {
if (unique.length) storage.setItem(storageKey, JSON.stringify(unique));
else storage.removeItem(storageKey);
return true;
} catch (_) {
return false;
}
}
function capture(ids) {
const merged = [...pending()];
(ids || []).forEach(id => {
if (typeof id === 'string' && id && !merged.includes(id)) merged.push(id);
if (typeof id === 'string' && id) {
selected.add(id);
excluded.delete(id);
}
});
return write(merged);
}
function review(items) {
const byIdentity = new Map((items || options.items?.() || []).map(item => [options.identity(item), item]));
return pending().flatMap(identity => {
const item = byIdentity.get(identity);
if (!excluded.has(identity)) selected.add(identity);
return item ? [{ identity, item, selected:selected.has(identity) }] : [];
});
}
function choose(identity, include) {
if (!pending().includes(identity)) return false;
if (include) {
selected.add(identity);
excluded.delete(identity);
} else {
selected.delete(identity);
excluded.add(identity);
}
return true;
}
function render(items, list, escapeHtml) {
const rows = review(items);
list.innerHTML = rows.map(row => {
const title = String(row.item.title || row.identity).slice(0, 180);
const context = String(row.item.key || row.item.repository || 'Work item').slice(0, 180);
return '<div class="today-handoff-item"><span><strong>' + escapeHtml(title) + '</strong>' +
'<span class="small muted">' + escapeHtml(context) + '</span></span><label><input type="checkbox" ' +
'data-today-handoff="' + escapeHtml(row.identity) + '"' + (row.selected ? ' checked' : '') +
'> Today</label></div>';
}).join('');
list.querySelectorAll('[data-today-handoff]').forEach(input => input.addEventListener('change', () =>
choose(input.dataset.todayHandoff, input.checked)));
return rows;
}
async function commit(items) {
const rows = review(items);
const remaining = pending();
let scheduled = 0;
let blocked = 0;
for (const row of rows) {
if (!selected.has(row.identity)) continue;
if (options.todayWork.contains(row.item)) {
const index = remaining.indexOf(row.identity);
if (index >= 0) remaining.splice(index, 1);
selected.delete(row.identity);
continue;
}
const added = options.todayWork.add(row.item);
if (added !== 'added') {
blocked += 1;
continue;
}
if (!options.todaySync.enqueue('add', row.identity)) {
options.todayWork.remove?.(row.item);
blocked += 1;
continue;
}
const index = remaining.indexOf(row.identity);
if (index >= 0) remaining.splice(index, 1);
selected.delete(row.identity);
scheduled += 1;
}
write(remaining);
if (scheduled) await options.todaySync.flush();
return { scheduled, blocked, remaining:remaining.length };
}
function mount({ qs, escapeHtml, onComplete = () => {} }) {
const list = qs('#today-handoff-items');
let prompted = false;
function open() {
if (prompted) return false;
const rows = render(null, list, escapeHtml);
if (!rows.length) return false;
qs('#today-handoff-status').textContent = rows.length +
(rows.length === 1 ? ' commitment is ready to review.' : ' commitments are ready to review.');
const dialog = qs('#today-handoff-dialog');
if (!dialog.open) dialog.showModal();
prompted = true;
requestAnimationFrame(() => (list.querySelector('input') || qs('#confirm-today-handoff')).focus());
return true;
}
qs('#close-today-handoff').addEventListener('click', () => qs('#today-handoff-dialog').close());
qs('#confirm-today-handoff').addEventListener('click', async event => {
const button = event.currentTarget;
button.disabled = true;
qs('#today-handoff-status').textContent = 'Saving Today’s plan…';
try {
const result = await commit();
if (result.blocked) qs('#today-handoff-status').textContent =
'Today is full. ' + result.remaining + ' commitment remains safe in My Work.';
else qs('#today-handoff-dialog').close();
onComplete(result);
} finally {
button.disabled = false;
}
});
return { open, reset:() => { prompted = false; } };
}
return { capture, choose, commit, mount, pending, render, review };
});
+5 -5
View File
@@ -217,7 +217,7 @@ async function saveTodayRecapTime(payload) {
}
function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, escapeHtml,
describeWork = () => null, adjustPlan = () => {} }) {
describeWork = () => null, openWrapUp = () => {} }) {
const minutes = value => String(Math.max(0, Number(value) || 0)) + 'm';
const render = () => {
const draft = recap.snapshot();
@@ -290,7 +290,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
qs('#today-recap-status').textContent = 'Recap saved to your account.';
await loadHistory(); render(); button.hidden = true;
close();
adjustPlan(handoff.actual_minutes);
openWrapUp(handoff.actual_minutes);
} catch (error) {
qs('#today-recap-status').textContent = error.message || 'Recap could not be saved. Your timer is unchanged.';
} finally { button.disabled = false; }
@@ -310,7 +310,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
qs('#today-recap-status').textContent = 'Selected time logged to Gitea.';
await loadHistory(); render(); button.hidden = true;
close();
adjustPlan(actualMinutes);
openWrapUp(actualMinutes);
} catch (error) {
qs('#today-recap-status').textContent = error.message || 'Time could not be logged. Your recap is ready to retry.';
render();
@@ -345,8 +345,8 @@ function openTodayRecapAfterSession(view, timer, timerView, workFilter, qs) {
}
function setupTodayRecap(timer, timerView, todayWork, api, qs, escapeHtml, closeSheets, updateActions, getLogin,
describeWork, adjustPlan) {
const options = { timer, timerView, todayWork, api, qs, escapeHtml, describeWork, adjustPlan };
describeWork, openWrapUp) {
const options = { timer, timerView, todayWork, api, qs, escapeHtml, describeWork, openWrapUp };
const recap = createTodayRecap({
save:saveTodayRecap, saveAndLog:saveTodayRecapTime,
clear:() => timer.clearRecap(), storage:localStorage, getLogin,
+122
View File
@@ -0,0 +1,122 @@
function createTodayWrapUp({ todayWork, laterWork, todaySync }) {
let items = [];
const selected = new Set();
function open(candidates) {
const current = new Set(todayWork.read());
const byIdentity = new Map((candidates || []).map(item => [todayWork.identity(item), item]));
items = todayWork.read().flatMap(identity =>
current.has(identity) && byIdentity.has(identity) ? [byIdentity.get(identity)] : []
);
selected.clear();
return snapshot();
}
function choose(identity, schedule) {
if (!items.some(item => todayWork.identity(item) === identity)) return false;
if (schedule) selected.add(identity);
else selected.delete(identity);
return true;
}
function snapshot() {
return items.map(item => ({
item,
identity:todayWork.identity(item),
schedule:selected.has(todayWork.identity(item)),
}));
}
async function finish() {
const wake = laterWork.presetUntil('tomorrow');
let scheduled = 0;
for (const item of items) {
const identity = todayWork.identity(item);
if (!selected.has(identity) || !todayWork.contains(item)) continue;
const deferred = laterWork.defer(item, wake, {handoff:'today'});
if (deferred !== 'deferred') throw new Error('Tomorrow could not be saved. Your Today plan is unchanged.');
if (!todaySync.enqueue('remove', identity)) {
laterWork.restore?.(item);
throw new Error('Today sync could not be queued. Your Today plan is unchanged.');
}
if (todayWork.contains(item) && !todayWork.remove(item)) {
throw new Error('Today could not be updated. Retry wrap-up.');
}
scheduled += 1;
}
await todaySync.flush();
return { scheduled, left:items.length - scheduled, wake_at:wake.toISOString() };
}
return { open, choose, snapshot, finish };
}
function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () => {}, onClose = () => {} }) {
let actualMinutes = {};
function close() {
qs('#today-wrap-up-sheet').hidden = true;
document.body.classList.remove('task-overlay-open');
onClose();
}
function render() {
const rows = controller.snapshot();
qs('#today-wrap-up-items').innerHTML = rows.map(row => {
const title = String(row.item.title || row.identity).slice(0, 180);
const context = String(row.item.key || row.item.repository || 'Work item').slice(0, 180);
return '<div class="today-wrap-up-item"><span><strong>' + escapeHtml(title) + '</strong>' +
'<span class="small muted">' + escapeHtml(context) + '</span></span><label><input type="checkbox" ' +
'data-wrap-up-identity="' + escapeHtml(row.identity) + '"' + (row.schedule ? ' checked' : '') +
'> Tomorrow 09:00</label></div>';
}).join('') || '<p class="small">Nothing unfinished remains in Today.</p>';
qs('#today-wrap-up-items').querySelectorAll('[data-wrap-up-identity]').forEach(input => {
input.addEventListener('change', () => controller.choose(input.dataset.wrapUpIdentity, input.checked));
});
}
function open(items, recapActualMinutes = {}) {
actualMinutes = { ...recapActualMinutes };
controller.open(items);
qs('#today-wrap-up-status').textContent = '';
render();
qs('#today-wrap-up-sheet').hidden = false;
document.body.classList.add('task-overlay-open');
requestAnimationFrame(() => (qs('#today-wrap-up-items input') || qs('#finish-today-wrap-up')).focus());
}
async function finish(button) {
button.disabled = true;
qs('#today-wrap-up-status').textContent = 'Saving tomorrow’s plan…';
try {
const result = await controller.finish();
qs('#my-work-action-status').textContent = result.scheduled + ' scheduled for tomorrow · ' + result.left + ' left in Today.';
close();
onComplete(result, actualMinutes);
} catch (error) {
qs('#today-wrap-up-status').textContent = error.message || 'Wrap-up could not be saved. Retry when ready.';
} finally {
button.disabled = false;
}
}
function bind() {
qs('#close-today-wrap-up').addEventListener('click', close);
qs('#finish-today-wrap-up').addEventListener('click', event => finish(event.currentTarget));
}
return { open, close, finish, render, bind };
}
function setupTodayWrapUp(options) {
const controller = createTodayWrapUp(options);
const view = createTodayWrapUpView({ ...options, controller });
view.bind();
return view;
}
if (typeof module !== 'undefined' && module.exports) {
createTodayWrapUp.createView = createTodayWrapUpView;
createTodayWrapUp.setup = setupTodayWrapUp;
module.exports = createTodayWrapUp;
}
+17 -6
View File
@@ -18,6 +18,14 @@ function unfiledDraftSummary(item) {
};
}
function unfiledDraftDisplayTitle(item) {
return String(item?.title || '').trim() || (item?.hasAttachment ? 'Untitled photo draft' : 'Untitled draft');
}
function unfiledShouldFocusTitle({mobile = false, continuing = false} = {}) {
return !mobile || continuing;
}
function unfiledSavedMessage(item) {
return item.repository ? 'Saved planned Draft. Resume on any signed-in device.' :
'Saved to Drafts. Choose a repository when you’re ready to file it.';
@@ -49,7 +57,8 @@ function createUnfiledCaptures({
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
return record.items.filter(item =>
item && typeof item.id === 'string' && typeof item.ownerLogin === 'string' &&
typeof item.title === 'string' && item.title.trim() && typeof item.body === 'string'
typeof item.title === 'string' && (item.title.trim() || item.hasAttachment === true) &&
typeof item.body === 'string'
);
} catch (_error) { return []; }
}
@@ -122,9 +131,6 @@ function createUnfiledCaptures({
function prepare(note) {
const title = String(note?.title || '').trim().slice(0, 255);
const body = String(note?.body || '').trim().slice(0, 10000);
if (!title) throw new Error('Add a title before saving.');
const ownerLogin = String(getCaptureLogin() || '').trim();
if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.');
const attachment = note?.attachment;
const attachments = note?.attachments;
if (Array.isArray(attachments) && attachments.length > 5) {
@@ -139,6 +145,9 @@ function createUnfiledCaptures({
const hasAttachment = Boolean(attachment?.blob && attachment?.filename &&
['image/png', 'image/jpeg', 'image/webp'].includes(String(attachment?.contentType || '')));
if (attachment && !hasAttachment) throw new Error('The screenshot is unavailable. Choose it again before saving.');
if (!title && !hasAttachment && !validAttachments) throw new Error('Add a title or photo before saving.');
const ownerLogin = String(getCaptureLogin() || '').trim();
if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.');
if ((hasAttachment || validAttachments) && !attachmentStore) {
throw new Error('Screenshot storage is unavailable. Your capture is still open; retry after reloading.');
}
@@ -344,7 +353,7 @@ function createUnfiledCaptures({
} : {}),
...(evidence.length ? {hasAttachment:true, attachmentCount:evidence.length} : {}),
};
if (!item.id || !item.title.trim() || !Number.isFinite(item.savedAt)) continue;
if (!item.id || (!item.title.trim() && !evidence.length) || !Number.isFinite(item.savedAt)) continue;
if (evidence.length) {
await attachmentStore.put(item.id, {attachments:evidence.map(entry => ({
filename:String(entry.filename), contentType:String(entry.content_type),
@@ -382,7 +391,7 @@ function createUnfiledCaptures({
} : {}),
...(evidence.length ? {hasAttachment:true, attachmentCount:evidence.length} : {}),
};
if (!item.id || !item.title.trim() || !Number.isFinite(item.savedAt)) continue;
if (!item.id || (!item.title.trim() && !evidence.length) || !Number.isFinite(item.savedAt)) continue;
if (evidence.length) {
stagedEvidence.set(item.id, {attachments:evidence.map(entry => ({
filename:String(entry.filename), contentType:String(entry.content_type),
@@ -422,4 +431,6 @@ function createUnfiledCaptures({
if (typeof module !== 'undefined' && module.exports) {
module.exports = createUnfiledCaptures;
module.exports.summary = unfiledDraftSummary;
module.exports.displayTitle = unfiledDraftDisplayTitle;
module.exports.shouldFocusTitle = unfiledShouldFocusTitle;
}
+124
View File
@@ -0,0 +1,124 @@
function createVoiceConversationCapture({
Recognition, elements, transcriptStore = null, getLogin = () => '',
createEvent = name => new Event(name, {bubbles:true}),
}) {
const supported = typeof Recognition === 'function';
let recognition = null;
let target = '';
let generation = 0;
elements.root.hidden = !supported;
if (!supported) {
elements.status.textContent = 'Voice capture is unavailable; type the reply instead.';
}
const normalize = value => String(value || '').replace(/\s+/g, ' ').trim();
const login = () => String(getLogin() || '').trim();
function showReview(value, recovered = false) {
const transcript = normalize(value).slice(0, 10000);
elements.transcript.value = transcript;
elements.review.hidden = false;
elements.append.textContent = elements.draft.value.trim() ? 'Append to draft' : 'Use transcript';
elements.replace.hidden = !elements.draft.value.trim();
elements.status.textContent = recovered ?
'Recovered transcript. Review it before using or discarding it.' :
'Transcript ready. Review it before using it.';
if (!recovered && transcriptStore && login() && target) {
void transcriptStore.save(login(), transcript, target);
}
}
async function open(nextTarget) {
cancel();
target = String(nextTarget || '').trim();
const opening = ++generation;
elements.transcript.value = '';
elements.review.hidden = true;
elements.status.textContent = '';
if (!supported || !target || !transcriptStore || !login()) return;
const recovered = normalize(await transcriptStore.load(login(), target)).slice(0, 10000);
if (opening === generation && recovered) showReview(recovered, true);
}
function clearCheckpoint() {
if (transcriptStore && login() && target) void transcriptStore.clear(login(), target);
}
function commit(replace) {
const transcript = normalize(elements.transcript.value).slice(0, 10000);
elements.draft.value = replace ? transcript :
[elements.draft.value.trim(), transcript].filter(Boolean).join('\n\n').slice(0, 10000);
elements.draft.dispatchEvent(createEvent('input'));
clearCheckpoint();
elements.review.hidden = true;
elements.status.textContent = 'Transcript added. Review the reply before sending.';
}
elements.start.addEventListener('click', () => {
if (!supported || !target) return;
const sessionGeneration = generation;
const currentRecognition = new Recognition();
recognition = currentRecognition;
recognition.continuous = true;
recognition.interimResults = false;
recognition.onresult = event => {
if (recognition !== currentRecognition || generation !== sessionGeneration) return;
const finalText = Array.from(event.results || []).slice(event.resultIndex ?? 0)
.filter(result => result.isFinal)
.map(result => result[0]?.transcript || '').join(' ');
if (normalize(finalText)) {
showReview([elements.transcript.value, finalText].filter(Boolean).join(' '));
}
};
recognition.onerror = event => {
if (recognition !== currentRecognition || generation !== sessionGeneration) return;
elements.start.hidden = false;
elements.stop.hidden = true;
elements.status.textContent = ['not-allowed', 'service-not-allowed'].includes(event.error) ?
'Microphone permission was denied. Your draft and transcript are unchanged.' :
'Voice capture stopped. Your draft and transcript are unchanged.';
};
recognition.onend = () => {
if (recognition !== currentRecognition || generation !== sessionGeneration) return;
recognition = null;
elements.start.hidden = false;
elements.stop.hidden = true;
};
try {
recognition.start();
} catch (_) {
recognition = null;
elements.start.hidden = false;
elements.stop.hidden = true;
elements.status.textContent = 'Voice capture could not start. Your draft and transcript are unchanged.';
return;
}
elements.start.hidden = true;
elements.stop.hidden = false;
elements.status.textContent = 'Listening… Tap stop when you are finished.';
});
elements.stop.addEventListener('click', () => {
recognition?.stop();
elements.status.textContent = 'Finishing transcript…';
});
elements.append.addEventListener('click', () => commit(false));
elements.replace.addEventListener('click', () => commit(true));
elements.discard.addEventListener('click', () => {
clearCheckpoint();
elements.transcript.value = '';
elements.review.hidden = true;
elements.status.textContent = 'Transcript discarded.';
});
function cancel() {
generation += 1;
recognition?.abort();
recognition = null;
elements.start.hidden = false;
elements.stop.hidden = true;
}
return {supported, open, cancel};
}
if (typeof module !== 'undefined') module.exports = {createVoiceConversationCapture};
+1
View File
@@ -43,6 +43,7 @@ function createVoiceIssueCapture({
recognition.interimResults = false;
recognition.onresult = event => {
const finalText = Array.from(event.results || [])
.slice(event.resultIndex ?? 0)
.filter(result => result.isFinal)
.map(result => result[0]?.transcript || '').join(' ').replace(/\s+/g, ' ').trim();
if (finalText) {
+12 -6
View File
@@ -58,23 +58,29 @@
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, MAX_TRANSCRIPT_LENGTH);
}
function storageKey(login, target = '') {
const account = normalizeLogin(login);
const scope = String(target || '').trim().toLowerCase();
return account && scope ? account + '\u0000' + scope : account;
}
function createVoiceTranscriptStore({transaction = createIndexedDbTransaction(defaultIndexedDB)} = {}) {
async function load(login) {
const key = normalizeLogin(login);
async function load(login, target = '') {
const key = storageKey(login, target);
if (!key || !transaction) return '';
const record = await transaction(store => store.get(key));
return normalizeTranscript(record?.transcript);
}
async function save(login, transcript) {
const key = normalizeLogin(login);
async function save(login, transcript, target = '') {
const key = storageKey(login, target);
const value = normalizeTranscript(transcript);
if (!key || !value || !transaction) return;
await transaction(store => store.put({login:key, transcript:value}), 'readwrite');
}
async function clear(login) {
const key = normalizeLogin(login);
async function clear(login, target = '') {
const key = storageKey(login, target);
if (!key || !transaction) return;
await transaction(store => store.delete(key), 'readwrite');
}
+43 -9
View File
@@ -7,6 +7,13 @@
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
const queueFilters = ['today', 'agenda', 'attention', 'filed', 'update', 'later', 'draft'];
const sections = {
issue: ['overview', 'conversation', 'reply', 'actions'],
filed: ['overview', 'conversation', 'reply', 'actions'],
pull: ['overview', 'conversation', 'reply', 'review'],
review: ['overview', 'files', 'feedback', 'history'],
update: ['activity', 'conversation', 'context', 'reply'],
};
function positiveInteger(value) {
const number = Number(value);
@@ -23,27 +30,37 @@
if (queue === 'agenda' && parts[3] === 'protect-today' && parts.length === 4) {
return { kind: 'queue', filter: 'agenda', action: 'protect-today' };
}
if (parts[2] === 'update' && parts.length === 4) {
if (parts[2] === 'update' && [4, 5].includes(parts.length)) {
const notificationId = positiveInteger(parts[3]);
return notificationId ? { kind: 'update', notification_id: notificationId } : null;
const section = parts[4];
if (!notificationId || (section && !sections.update.includes(section))) return null;
return { kind: 'update', notification_id: notificationId, ...(section ? { section } : {}) };
}
if (!['issue', 'filed', 'pull', 'review'].includes(parts[2]) || parts.length !== 6) return null;
if (!['issue', 'filed', 'pull', 'review'].includes(parts[2]) || ![6, 7].includes(parts.length)) return null;
if (!repositoryPart.test(parts[3]) || !repositoryPart.test(parts[4])) return null;
const number = positiveInteger(parts[5]);
return number ? { kind: parts[2], repository: parts[3] + '/' + parts[4], number } : null;
const section = parts[6];
if (!number || (section && !sections[parts[2]].includes(section))) return null;
return {
kind: parts[2], repository: parts[3] + '/' + parts[4], number,
...(section ? { section } : {}),
};
}
function serialize(item) {
if (item?.kind === 'update') {
const notificationId = positiveInteger(item.notification_id);
return notificationId ? '#/my-work/update/' + notificationId : '';
if (!notificationId || (item.section && !sections.update.includes(item.section))) return '';
return '#/my-work/update/' + notificationId + (item.section ? '/' + item.section : '');
}
const kind = item?.is_filed && !item?.is_assigned ? 'filed' : item?.kind;
if (!['issue', 'filed', 'pull', 'review'].includes(kind)) return '';
const repository = String(item.repository || '').split('/');
const number = positiveInteger(item.number);
if (repository.length !== 2 || !repository.every(part => repositoryPart.test(part)) || !number) return '';
return '#/my-work/' + kind + '/' + repository.join('/') + '/' + number;
if (item.section && !sections[kind].includes(item.section)) return '';
return '#/my-work/' + kind + '/' + repository.join('/') + '/' + number +
(item.section ? '/' + item.section : '');
}
function sameRoute(item, route) {
@@ -59,7 +76,7 @@
function createController({
location, history, eventTarget, onOpen, onClose, onInvalid,
onQueue = function () {},
onQueue = function () {}, onSection = function () {},
resolve, onResolving = function () {}, onError = function () {},
}) {
let items = [];
@@ -86,7 +103,7 @@
return;
}
active = fragment;
onOpen({ ...item, kind: route.kind });
onOpen({ ...item, kind: route.kind, ...(route.section ? { section: route.section } : {}) });
}).catch(error => {
if (request !== resolution || String(location.hash || '') !== fragment) return;
resolving = '';
@@ -128,10 +145,16 @@
return;
}
if (active === fragment) return;
const activeRoute = parse(active);
if (activeRoute && sameRoute(item, activeRoute)) {
active = fragment;
onSection(route.section || sections[route.kind][0], { restore: true });
return;
}
resolution += 1;
resolving = '';
active = fragment;
onOpen({ ...item, kind: route.kind });
onOpen({ ...item, kind: route.kind, ...(route.section ? { section: route.section } : {}) });
}
return {
@@ -156,6 +179,17 @@
onOpen(item);
return true;
},
section(name, options = {}) {
const route = parse(location.hash);
if (!route || route.kind === 'queue' || !sections[route.kind].includes(name)) return false;
if ((route.section || sections[route.kind][0]) === name) return true;
const fragment = serialize({ ...route, section: name });
const method = options.replace ? 'replaceState' : 'pushState';
history[method]({ workRoute: fragment }, '', fragment);
active = fragment;
onSection(name, { restore: false });
return true;
},
queue(filter) {
if (!queueFilters.includes(filter)) return false;
const name = filter + (['update', 'draft'].includes(filter) ? 's' : '');
+41
View File
@@ -0,0 +1,41 @@
async function loadWorkspace({ document, window = null, createLoader = createFeatureLoader }) {
let cameOnline = false;
let replayed = false;
const captureOnline = () => { cameOnline = true; };
window?.addEventListener('online', captureOnline);
const status = document.querySelector('#my-work-action-status');
const url = document.querySelector(
'meta[name="stackchain-feature-today-timer"]'
)?.content || '';
const loader = createLoader({
document,
urls: { 'today-timer': url },
});
if (status) status.textContent = 'Starting workspace…';
try {
await loader.load('today-timer');
if (status) status.textContent = '';
return {
replayOnline(callback) {
if (replayed) return;
replayed = true;
window?.removeEventListener('online', captureOnline);
if (cameOnline) callback();
},
};
} catch (error) {
window?.removeEventListener('online', captureOnline);
if (window?.location?.reload) {
if (cameOnline) window.location.reload();
else window.addEventListener('online', () => window.location.reload(), { once: true });
}
if (status) {
status.textContent = window
? 'Workspace could not load. Reconnect to retry automatically, or reload now.'
: 'Workspace could not load. Check your connection, then reload to retry.';
}
throw error;
}
}
if (typeof module !== 'undefined' && module.exports) module.exports = loadWorkspace;
+1
View File
@@ -0,0 +1 @@
pip-audit==2.10.1
+2 -1
View File
@@ -1,7 +1,8 @@
fastapi==0.133.1
httpx==0.28.1
pydantic==2.13.4
python-multipart==0.0.22
Pillow==12.3.0
python-multipart==0.0.32
pytest==9.1.1
pywebpush==2.1.2
rjsmin==1.2.5
+12 -13
View File
@@ -21,22 +21,26 @@ COMMONJS_BROWSER_BRANCH = re.compile(
)
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
FEATURE_SOURCES = {
"comment-actions": ("static/conversation.js", "static/comment-actions.js"),
"comment-actions": ("static/comment-actions.js",),
"issue-capture": (
"static/voice-transcript-store.js", "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/update-follow-up.js", "static/shared-image-capture.js",
"static/voice-transcript-store.js", "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/mobile-create-issue-nav.js", "static/update-follow-up.js", "static/shared-image-capture.js",
),
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
"push-notifications": ("static/push-notifications.js",),
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
"device-setup": (
"static/install-app.js", "static/private-data-inventory.js", "static/private-device-data.js",
"static/device-storage.js", "static/mobile-device-setup.js",
),
"security-center": ("static/security-center.js",),
"today-timer": (
"static/today-completion.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
"static/conversation.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-handoff.js",
"static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
"static/search-batch-plan.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
"static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
"static/search-batch-plan.js", "static/mention-composer.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
"static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/mobile-issue-detail-nav.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
),
}
CACHE_DECLARATION = re.compile(
@@ -113,7 +117,6 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
dashboard_html = dashboard_html.replace("</head>", feature_metadata + "\n</head>")
dashboard_html = dashboard_html.replace(
"</body>",
f'<script src="{feature_bundles["today-timer"].runtime_name}"></script>\n'
f'<script src="{core.runtime_name}"></script>\n</body>',
)
@@ -121,15 +124,11 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
for source in sources:
if source != WORKER_RUNTIME_SOURCE:
worker = worker.replace(f" BASE + '{source}',\n", "")
eager_feature = feature_bundles["today-timer"]
optional_features = {
name: bundle for name, bundle in feature_bundles.items() if name != "today-timer"
}
optional_features = feature_bundles
worker = worker.replace(
" BASE + 'static/dashboard.css',\n",
" BASE + 'static/dashboard.css',\n"
+ f" BASE + '{core.runtime_name}',\n"
+ f" BASE + '{eager_feature.runtime_name}',\n",
+ f" BASE + '{core.runtime_name}',\n",
)
worker = worker.replace(
"const OPTIONAL_FEATURES = [\n",
+26
View File
@@ -1364,6 +1364,32 @@ async def upload_assigned_issue_attachment(
return {"name": name, "url": url, "size": size}
async def upload_preview_attachment(
repository: str,
number: int,
filename: str,
content_type: str,
content: bytes,
) -> dict:
"""Upload evidence after the caller verifies an exact Search preview target."""
response = await _get_client().post(
f"/api/v1/repos/{repository}/issues/{number}/assets",
headers=_auth(),
params={"name": filename},
files={"attachment": (filename, content, content_type)},
)
response.raise_for_status()
attachment = response.json()
if not isinstance(attachment, dict):
raise ValueError("Gitea attachment response was not an object")
name = attachment.get("name")
url = _safe_web_url(attachment.get("browser_download_url"))
size = attachment.get("size")
if not isinstance(name, str) or not name or not url or not isinstance(size, int):
raise ValueError("Gitea did not confirm the attachment")
return {"name": name, "url": url, "size": size}
async def upload_assigned_pull_attachment(
repository: str,
number: int,
+38
View File
@@ -0,0 +1,38 @@
"""Authoritative image decoding and metadata removal for evidence uploads."""
from __future__ import annotations
import io
from PIL import Image, ImageOps
MAX_IMAGE_PIXELS = 12 * 1024 * 1024
def sanitize_image(content_type: str, content: bytes) -> bytes:
"""Apply orientation and return metadata-free bytes in the claimed format."""
format_names = {
"image/png": "PNG",
"image/jpeg": "JPEG",
"image/webp": "WEBP",
}
try:
with Image.open(io.BytesIO(content)) as source:
claimed_format = format_names[content_type]
if source.format != claimed_format:
raise ValueError("image content does not match the selected image type")
if getattr(source, "n_frames", 1) != 1:
raise ValueError("animated image evidence is not supported")
if source.width * source.height > MAX_IMAGE_PIXELS:
raise ValueError("attachment must not exceed 12 megapixels")
image = ImageOps.exif_transpose(source)
image.load()
output = io.BytesIO()
save_options = {"quality": 90} if claimed_format in {"JPEG", "WEBP"} else {}
image.save(output, format=claimed_format, **save_options)
return output.getvalue()
except ValueError:
raise
except (KeyError, OSError) as exc:
raise ValueError("attachment must be a valid static image") from exc
+8 -2
View File
@@ -131,6 +131,7 @@ class LaterStore:
item_id: str,
*,
wake_at: str | None = None,
handoff: str | None = None,
base_revision: int | None = None,
) -> dict:
result = self.apply_batch(login, [{
@@ -138,6 +139,7 @@ class LaterStore:
"action": action,
"item_id": item_id,
"wake_at": wake_at,
"handoff": handoff,
"base_revision": base_revision,
}])
return {"revision": result["revision"], "records": result["records"]}
@@ -181,6 +183,9 @@ class LaterStore:
wake_at = operation.get("wake_at")
if action == "defer":
wake_at = self._validate_wake_at(wake_at)
handoff = operation.get("handoff")
if handoff not in {None, "today"}:
raise ValueError("unsupported Later handoff")
duplicate = connection.execute(
"SELECT 1 FROM later_operations WHERE login = ? AND operation_id = ?",
(login, operation_id),
@@ -204,8 +209,9 @@ class LaterStore:
before = records.get(item_id)
if action == "defer":
records[item_id] = wake_at
changed = before != wake_at
record = {"wake_at": wake_at, "handoff": handoff} if handoff else wake_at
records[item_id] = record
changed = before != record
else:
changed = item_id in records
records.pop(item_id, None)
+87 -1
View File
@@ -41,6 +41,7 @@ from src.gitea_proxy import (
repos,
)
from src.idempotency import IdempotencyLedger, IdempotencyLedgerBusy
from src.image_sanitizer import sanitize_image
from src.login_attempt_store import LoginAttemptStore, LoginAttemptStoreError, client_source
from src.live_snapshot_store import (
LiveSnapshotMetadata,
@@ -609,6 +610,7 @@ class LaterOperation(BaseModel):
action: Literal["defer", "restore"]
item_id: str = Field(min_length=1, max_length=500)
wake_at: str | None = Field(default=None, max_length=100)
handoff: Literal["today"] | None = None
base_revision: int | None = Field(default=None, ge=0)
@@ -697,13 +699,20 @@ class UnfiledDraftFilingPlan(BaseModel):
class UnfiledDraft(BaseModel):
id: str = Field(min_length=1, max_length=100, pattern=r"^[A-Za-z0-9_-]+$")
title: str = Field(min_length=1, max_length=255)
title: str = Field(default="", max_length=255)
body: str = Field(default="", max_length=10_000)
saved_at: int = Field(ge=0)
filing_plan: UnfiledDraftFilingPlan | None = None
blockers: list[UnfiledDraftBlocker] = Field(default_factory=list, max_length=5)
evidence: list[UnfiledDraftEvidence] = Field(default_factory=list, max_length=5)
@model_validator(mode="after")
def require_title_or_evidence(self):
self.title = self.title.strip()
if not self.title and not self.evidence:
raise ValueError("title or evidence is required")
return self
class UnfiledDraftCollection(BaseModel):
revision: int = Field(ge=0)
@@ -789,6 +798,13 @@ def _validate_binary_attachment(filename: str, content_type: str, content: bytes
return _validate_attachment_content(content_type, content)
async def _sanitize_attachment(content_type: str, content: bytes) -> bytes:
sanitized = await asyncio.to_thread(sanitize_image, content_type, content)
if len(sanitized) > 2 * 1024 * 1024:
raise ValueError("sanitized screenshot must be 2 MB or smaller")
return sanitized
class IssueCreation(BaseModel):
title: str = Field(min_length=1, max_length=255)
body: str = Field(default="", max_length=10_000)
@@ -2613,6 +2629,7 @@ async def update_later_plan(payload: LaterOperation | LaterOperationBatch):
payload.action,
payload.item_id,
wake_at=payload.wake_at,
handoff=payload.handoff,
base_revision=payload.base_revision,
)
except ValueError as error:
@@ -3129,6 +3146,72 @@ async def comment_on_global_search_preview(
return JSONResponse(result, status_code=201)
@app.post(
"/api/v1/repos/{owner}/{repo}/issues/{number}/preview/attachments",
status_code=201,
)
async def attach_to_global_search_preview(
request: Request,
owner: str,
repo: str,
number: int = PathParam(gt=0),
kind: Literal["issue", "pull"] = Query(),
idempotency_key: str | None = Header(default=None, max_length=128),
) -> JSONResponse:
repository = f"{owner}/{repo}"
try:
form = await request.form()
uploaded = form.get("file")
if not isinstance(uploaded, UploadFile):
raise ValueError("screenshot file is required")
filename = str(uploaded.filename or "")
content_type = str(uploaded.content_type or "")
content = _validate_binary_attachment(filename, content_type, await uploaded.read())
content = await _sanitize_attachment(content_type, content)
except (ValueError, ValidationError) as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
async def upload_attachment():
preview = await gitea_proxy.work_preview(repository, kind, number)
if (
preview.get("repository") != repository
or preview.get("kind") != kind
or preview.get("number") != number
or preview.get("commentable") is not True
):
raise HTTPException(status_code=404, detail="Search result not found")
result = await gitea_proxy.upload_preview_attachment(
repository, number, filename, content_type, content
)
safe_name = (
result["name"].replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]")
.replace("\r", " ").replace("\n", " ")
)
safe_url = result["url"].replace("<", "%3C").replace(">", "%3E")
result["markdown"] = f"![{safe_name}](<{safe_url}>)"
return result
try:
result = await _run_idempotent_authored_action(
upload_attachment(),
idempotency_key=idempotency_key,
fingerprint=(
"search-preview-attachment", repository, kind, number, filename,
content_type, hashlib.sha256(content).hexdigest(),
),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "The photo could not be uploaded. Your reply is safe; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result, status_code=201)
@app.get("/api/v1/work-route")
async def resolve_work_route(
kind: Literal["issue", "filed", "pull", "review", "update"] = Query(),
@@ -4265,6 +4348,7 @@ async def attach_to_notification(
filename = attachment.filename
content_type = attachment.content_type
content = attachment.content()
content = await _sanitize_attachment(content_type, content)
except (ValueError, ValidationError) as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@@ -5248,6 +5332,7 @@ async def attach_to_assigned_issue(
filename = attachment.filename
content_type = attachment.content_type
content = attachment.content()
content = await _sanitize_attachment(content_type, content)
except (ValueError, ValidationError) as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
async def upload_attachment():
@@ -5316,6 +5401,7 @@ async def attach_to_assigned_pull(
filename = attachment.filename
content_type = attachment.content_type
content = attachment.content()
content = await _sanitize_attachment(content_type, content)
except (ValueError, ValidationError) as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
+3 -1
View File
@@ -161,7 +161,7 @@ class UnfiledDraftStore:
title = raw.get("title")
body = raw.get("body", "")
saved_at = raw.get("saved_at")
if not isinstance(title, str) or not title.strip() or len(title.strip()) > 255:
if not isinstance(title, str) or len(title.strip()) > 255:
raise ValueError("title is invalid")
if not isinstance(body, str) or len(body) > 10_000:
raise ValueError("body is invalid")
@@ -214,6 +214,8 @@ class UnfiledDraftStore:
**({"note": note} if note else {}),
"data": data,
})
if not title.strip() and not clean_evidence:
raise ValueError("title or evidence is required")
seen.add(draft_id)
normalized.append({
"id": draft_id,
+110 -2
View File
@@ -4,7 +4,8 @@ from __future__ import annotations
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlsplit
from threading import Event
from urllib.parse import parse_qs, urlsplit
REPOSITORY = {
@@ -16,6 +17,34 @@ REPOSITORY = {
"owner": {"login": "acme"},
}
USER = {"id": 1, "login": "timmy", "full_name": "Timmy"}
AVAILABLE_ISSUES = [
{
"id": 101,
"number": 41,
"title": "Ship mobile capture",
"body": "Complete the capture flow on a phone.",
"state": "open",
"html_url": "http://127.0.0.1/acme/mobile/issues/41",
"repository": REPOSITORY,
"user": USER,
"assignees": [],
"labels": [{"name": "P1"}],
"updated_at": "2026-08-15T12:00:00Z",
},
{
"id": 102,
"number": 42,
"title": "Polish desktop filters",
"body": "Keep the desktop filter controls clear.",
"state": "open",
"html_url": "http://127.0.0.1/acme/mobile/issues/42",
"repository": REPOSITORY,
"user": USER,
"assignees": [],
"labels": [],
"updated_at": "2026-08-15T11:00:00Z",
},
]
class FakeGiteaServer(ThreadingHTTPServer):
@@ -23,6 +52,10 @@ class FakeGiteaServer(ThreadingHTTPServer):
super().__init__(address, FakeGiteaHandler)
self.created_issues: list[dict] = []
self.issue_creation_enabled = False
self.issue_creation_ready = Event()
self.assigned_issue_numbers = [issue["number"] for issue in AVAILABLE_ISSUES]
self.comments: list[tuple[int, str]] = []
self.requests: list[tuple[str, str]] = []
class FakeGiteaHandler(BaseHTTPRequestHandler):
@@ -42,7 +75,10 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
self.wfile.write(body)
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
path = urlsplit(self.path).path
parsed = urlsplit(self.path)
path = parsed.path
query = parse_qs(parsed.query)
self.server.requests.append(("GET", self.path))
if path == "/api/v1/user":
self._json(200, USER)
elif path == "/api/v1/user/repos":
@@ -51,6 +87,35 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
self._json(200, REPOSITORY)
elif path == "/api/v1/repos/search":
self._json(200, {"data": [REPOSITORY], "ok": True})
elif path == "/api/v1/repos/issues/search":
is_assigned_scan = query.get("assigned") == ["true"] and query.get("type") == ["issues"]
is_available_scan = not any(
name in query for name in ("assigned", "created", "review_requested", "q")
) and query.get("type") == ["issues"]
assigned = [
{**issue, "assignees": [USER]}
for issue in AVAILABLE_ISSUES
if issue["number"] in self.server.assigned_issue_numbers
]
available = [
issue for issue in AVAILABLE_ISSUES
if issue["number"] not in self.server.assigned_issue_numbers
]
issues = assigned if is_assigned_scan else available if is_available_scan else []
self._json(200, issues, **{"X-Total-Count": str(len(issues))})
elif path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/comments"):
self._json(200, [], **{"X-Total-Count": "0"})
elif path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/dependencies"):
self._json(200, [])
elif path.startswith("/api/v1/repos/acme/mobile/issues/"):
try:
number = int(path.rsplit("/", 1)[-1])
except ValueError:
number = 0
issue = next((item for item in AVAILABLE_ISSUES if item["number"] == number), None)
if issue and number in self.server.assigned_issue_numbers:
issue = {**issue, "assignees": [USER]}
self._json(200, issue) if issue else self._json(404, {"message": "not found"})
elif path.startswith("/api/v1/"):
self._json(200, [], **{"X-Total-Count": "0"})
else:
@@ -58,11 +123,34 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
path = urlsplit(self.path).path
self.server.requests.append(("POST", self.path))
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length) or b"{}")
if path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/comments"):
try:
number = int(path.split("/")[-2])
except ValueError:
self._json(404, {"message": "not found"})
return
body = str(payload.get("body", ""))
self.server.comments.append((number, body))
self._json(
201,
{
"id": len(self.server.comments),
"body": body,
"user": USER,
"created_at": "2026-08-16T20:00:00Z",
"updated_at": "2026-08-16T20:00:00Z",
"html_url": f"http://127.0.0.1/acme/mobile/issues/{number}#issuecomment-1",
},
)
return
if path != "/api/v1/repos/acme/mobile/issues":
self._json(404, {"message": "not found"})
return
if not self.server.issue_creation_enabled:
self.server.issue_creation_ready.wait(timeout=45)
if not self.server.issue_creation_enabled:
self._json(503, {"message": "release journey is still offline"})
return
@@ -84,3 +172,23 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
"updated_at": "2026-08-15T12:00:00Z",
},
)
def do_PATCH(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
path = urlsplit(self.path).path
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length) or b"{}")
if not path.startswith("/api/v1/repos/acme/mobile/issues/"):
self._json(404, {"message": "not found"})
return
try:
number = int(path.rsplit("/", 1)[-1])
except ValueError:
self._json(404, {"message": "not found"})
return
issue = next((item for item in AVAILABLE_ISSUES if item["number"] == number), None)
if issue is None:
self._json(404, {"message": "not found"})
return
assignee = payload.get("assignee")
self.server.assigned_issue_numbers.append(number)
self._json(200, {**issue, "assignees": [{"login": assignee}] if assignee else []})
+114
View File
@@ -0,0 +1,114 @@
from __future__ import annotations
import os
import threading
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("packaged Find Work journey runs only in its gated CI job", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
from fake_gitea import FakeGiteaServer
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
def test_release_artifact_guides_mobile_find_work_through_assignment(tmp_path: Path):
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
fake = FakeGiteaServer(("127.0.0.1", 0))
fake.assigned_issue_numbers = []
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
fake_thread.start()
fake_url = f"http://127.0.0.1:{fake.server_port}"
browser_errors: list[str] = []
failed_responses: list[str] = []
try:
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
context = browser.new_context(
viewport={"width": 390, "height": 844},
ignore_https_errors=True,
)
page = context.new_page()
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
page.on("console", lambda message: browser_errors.append(message.text) if message.type == "error" else None)
page.on(
"response",
lambda response: failed_responses.append(f"{response.status} {response.url}")
if response.status >= 400
else None,
)
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Find Work release phone")
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
page.locator('[data-mobile-task="find"]').click()
expect(page.locator("#find-work-sheet")).to_have_class("find-work-sheet open")
expect(page.locator(".find-work-card")).to_have_count(2)
expect(page.locator("#find-work-status")).to_contain_text("2 of 2 available")
navigation = page.locator(".mobile-find-work-nav")
expect(navigation).to_be_visible()
for control in navigation.locator("button").all():
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.locator("#select-find-work").click()
choices = page.locator("[data-find-work-select]")
expect(choices).to_have_count(2)
choices.nth(0).check()
choices.nth(1).check()
page.locator("#claim-selected-work").click()
expect(navigation.locator('[data-find-work-stage="review"]')).to_have_attribute(
"aria-current", "step"
)
expect(page.locator("#find-work-review-list .find-work-review-item")).to_have_count(2)
expect(page.locator("#find-work-review-summary")).to_contain_text("2 selected")
page.locator("[data-find-work-review-remove]").nth(1).click()
expect(page.locator("#find-work-review-list .find-work-review-item")).to_have_count(1)
expect(page.locator("#find-work-review-summary")).to_contain_text("1 selected")
page.locator("#continue-find-work-fit").click()
expect(navigation.locator('[data-find-work-stage="fit"]')).to_have_attribute(
"aria-current", "step"
)
expect(page.locator("#find-work-estimate-list")).to_contain_text("Ship mobile capture")
for control in page.locator("#find-work-fit-stage button").all():
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
page.locator("#confirm-find-work-estimates").click()
expect(navigation.locator('[data-find-work-stage="discover"]')).to_have_attribute(
"aria-current", "step"
)
page.wait_for_timeout(1_000)
status = page.locator("#find-work-status").inner_text()
assert "1 queued" in status, {
"status": status,
"browser_errors": browser_errors,
"failed_responses": failed_responses,
"assigned": fake.assigned_issue_numbers,
"today": page.evaluate("localStorage.getItem('stackchain.today-work.v1.timmy')"),
}
assert fake.assigned_issue_numbers == [41]
today = page.evaluate("JSON.parse(localStorage.getItem('stackchain.today-work.v1.timmy') || '[]')")
assert today == ["issue:acme/mobile:41:"]
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
assert browser_errors == []
assert failed_responses == []
browser.close()
finally:
fake.shutdown()
fake.server_close()
fake_thread.join(timeout=5)
@@ -0,0 +1,252 @@
from __future__ import annotations
import os
import threading
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("packaged mobile Home bootstrap runs only in its gated CI job", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
from fake_gitea import FakeGiteaServer
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
tmp_path: Path, width: int, height: int
):
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
fake = FakeGiteaServer(("127.0.0.1", 0))
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
fake_thread.start()
fake_url = f"http://127.0.0.1:{fake.server_port}"
browser_errors: list[str] = []
failed_responses: list[str] = []
workspace_requests: list[str] = []
live_requests: list[str] = []
try:
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
context = browser.new_context(
viewport={"width": width, "height": height}, ignore_https_errors=True
)
page = context.new_page()
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
page.on(
"console",
lambda message: browser_errors.append(message.text)
if message.type == "error"
else None,
)
page.on(
"response",
lambda response: failed_responses.append(f"{response.status} {response.url}")
if response.status >= 400
else None,
)
page.on(
"request",
lambda request: workspace_requests.append(request.url)
if "feature-today-timer-" in request.url
else None,
)
page.on(
"request",
lambda request: live_requests.append(request.url)
if "/api/v1/live" in request.url
else None,
)
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Home bootstrap release phone")
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
expect(page.locator("#my-work-status")).to_contain_text("2")
initial_live_requests = len(live_requests)
page.evaluate(
"""
() => {
window.scrollTo(0, 0);
const surface = document.querySelector('#my-work');
surface.setPointerCapture = () => {};
surface.releasePointerCapture = () => {};
const pointer = (type, y) => surface.dispatchEvent(new PointerEvent(type, {
bubbles:true, pointerId:41, pointerType:'touch', clientX:100, clientY:y,
}));
pointer('pointerdown', 10);
pointer('pointermove', 90);
}
"""
)
expect(page.locator("#mobile-pull-refresh")).to_have_text("Release to refresh")
page.evaluate(
"document.querySelector('#my-work').dispatchEvent(new PointerEvent('pointerup', "
"{bubbles:true, pointerId:41, pointerType:'touch', clientX:100, clientY:90}))"
)
expect(page.locator("#mobile-pull-refresh")).to_have_text("My Work is up to date")
assert len(live_requests) == initial_live_requests + 1
dock = page.locator("#mobile-task-dock")
expect(dock).to_be_visible()
expect(dock.locator("button")).to_have_count(5)
for control in dock.locator("button").all():
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
page.locator('[data-mobile-task="queues"]').click()
delivery_queue = page.locator('[data-mobile-queue="delivery"]')
expect(delivery_queue).to_be_visible()
expect(delivery_queue).to_contain_text("Delivery")
delivery_bounds = delivery_queue.bounding_box()
assert delivery_bounds and delivery_bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.locator("#close-mobile-queues").click()
page.locator("#app-menu-toggle").click()
page.locator("#open-insights").click()
expect(page.locator("#insights-sheet")).to_be_visible()
expect(page.locator("#insights-heading")).to_have_text("Insights")
page.locator("#close-insights").click()
expect(page.locator("#insights-sheet")).to_be_hidden()
expect(page.locator("#my-work")).to_be_visible()
expect(dock).to_be_visible()
page.locator("#app-menu-toggle").click()
page.locator("#open-device-setup").click()
expect(page.locator("#device-setup-sheet")).to_be_visible()
expect(page.locator("#device-storage-heading")).to_have_text("Private device storage")
expect(page.locator("#device-storage-detail")).to_contain_text("private work records")
for selector in ("#clear-device-caches", "#clear-private-device-data"):
bounds = page.locator(selector).bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.locator("#close-device-setup").click()
expect(page.locator("#device-setup-sheet")).to_be_hidden()
page.locator("#active-devices").click()
expect(page.locator("#active-devices-sheet")).to_be_visible()
expect(page.locator("#security-activity-section")).to_be_visible()
security_nav = page.locator(".security-section-nav")
expect(security_nav).to_be_visible()
for control in security_nav.locator("button").all():
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
page.locator("#security-section-devices").click()
expect(page.locator("#security-section-devices")).to_have_attribute("aria-current", "page")
page.go_back()
expect(page.locator("#security-section-activity")).to_have_attribute("aria-current", "page")
page.go_back()
expect(page.locator("#active-devices-sheet")).to_be_hidden()
expect(page.locator("#active-devices")).to_be_focused()
assert len(workspace_requests) == 1, workspace_requests
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
assert browser_errors == []
assert failed_responses == []
browser.close()
finally:
fake.shutdown()
fake.server_close()
fake_thread.join(timeout=5)
def test_release_artifact_keeps_mobile_delivery_recovery_single_flight(tmp_path: Path):
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
fake = FakeGiteaServer(("127.0.0.1", 0))
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
fake_thread.start()
fake_url = f"http://127.0.0.1:{fake.server_port}"
browser_errors: list[str] = []
try:
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
page = browser.new_page(viewport={"width": 390, "height": 844})
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Delivery recovery release phone")
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
page.evaluate(
"""
() => {
window.__deliveryAttempts = 0;
window.__deliverySettled = new Promise(resolve => { window.__finishDelivery = resolve; });
window.__releaseRecovery = createMobileDeliveryRecovery({
getItems: () => [{outbox_id:'release-retry', delivery_state:'waiting', title:'Post release note'}],
activate: async () => {
window.__deliveryAttempts += 1;
await window.__deliverySettled;
},
});
window.__releaseRecovery.open();
window.__firstRecovery = window.__releaseRecovery.activate();
window.__secondRecovery = window.__releaseRecovery.activate();
}
"""
)
action = page.locator("#mobile-delivery-recovery-action")
expect(page.locator("#mobile-delivery-recovery")).to_be_visible()
expect(action).to_be_disabled()
expect(page.locator("#mobile-delivery-recovery-status")).to_have_text("Working…")
assert page.evaluate("window.__deliveryAttempts") == 1
bounds = action.bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.evaluate("window.__finishDelivery()")
page.evaluate("Promise.all([window.__firstRecovery, window.__secondRecovery])")
expect(action).to_be_enabled()
assert page.evaluate("window.__deliveryAttempts") == 1
page.evaluate(
"""
() => {
const destination = document.createElement('button');
destination.type = 'button';
destination.className = 'draft-resume';
destination.dataset.draftIndex = '0';
destination.textContent = 'Open current review';
window.__attentionAttempts = 0;
window.__attentionSettled = new Promise(resolve => { window.__finishAttention = resolve; });
destination.onclick = async () => {
window.__attentionAttempts += 1;
await window.__attentionSettled;
return true;
};
document.querySelector('#my-work-list').append(destination);
window.__attentionRecovery = createMobileDeliveryRecovery({
getItems: () => [{outbox_id:'review-attention', status:'attention', title:'Review feedback'}],
getIndex: () => 0,
});
window.__attentionRecovery.open();
window.__firstAttention = window.__attentionRecovery.activate();
window.__secondAttention = window.__attentionRecovery.activate();
}
"""
)
expect(page.locator("#mobile-delivery-recovery")).to_be_hidden()
expect(page.locator("#my-work-list .draft-resume")).to_be_focused()
assert page.evaluate("window.__attentionAttempts") == 1
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.evaluate("window.__finishAttention()")
page.evaluate("Promise.all([window.__firstAttention, window.__secondAttention])")
assert page.evaluate("window.__attentionAttempts") == 1
assert browser_errors == []
browser.close()
finally:
fake.shutdown()
fake.server_close()
fake_thread.join(timeout=5)
+55 -2
View File
@@ -186,6 +186,29 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
assert page.evaluate("() => navigator.serviceWorker.controller !== null")
new_action.click()
expect(page.locator("#create-issue-sheet")).to_have_class("create-issue-sheet open")
assert page.evaluate("document.activeElement?.id") != "create-issue-title"
create_navigation = page.locator(".mobile-create-issue-nav")
expect(create_navigation).to_be_visible()
create_navigation_buttons = create_navigation.locator("button")
assert create_navigation_buttons.count() == 3
for control in create_navigation_buttons.all():
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
expect(create_navigation.locator('[data-create-issue-section="describe"]')).to_have_attribute(
"aria-current", "location"
)
expect(create_navigation.locator('[data-create-issue-section="file"]')).to_have_attribute(
"aria-disabled", "true"
)
create_navigation.locator('[data-create-issue-section="evidence"]').click()
expect(create_navigation.locator('[data-create-issue-section="evidence"]')).to_have_attribute(
"aria-current", "location"
)
for control in page.locator(".photo-evidence-actions .issue-attachment-trigger").all():
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
expect(page.locator("#voice-issue-capture")).to_be_visible()
page.locator("#start-voice-issue-capture").click()
page.evaluate("""([title, body]) => {
@@ -200,9 +223,19 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
expect(page.locator("#create-issue-body")).to_have_value(BODY)
page.locator("#file-new-issue").click()
expect(page.locator("#create-issue-filing")).to_be_visible()
expect(create_navigation.locator('[data-create-issue-section="file"]')).to_have_attribute(
"aria-current", "location"
)
expect(create_navigation.locator('[data-create-issue-section="file"]')).not_to_have_attribute(
"aria-disabled", "true"
)
page.locator("#create-issue-repository").select_option("acme/mobile")
expect(page.locator("#submit-new-issue")).to_be_enabled()
# Page offline emulation can leave service-worker requests connected.
# Abort the mutation at the browser boundary too, matching a real
# transport outage without poisoning server-side idempotency state.
context.route("**/api/v1/repos/acme/mobile/issues", lambda route: route.abort())
context.set_offline(True)
page.locator("#submit-new-issue").click()
expect(page.locator("#issue-filing-review")).to_be_visible()
@@ -238,13 +271,32 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
browser_errors.clear() # Chromium reports expected network errors while the context is offline.
failed_responses.clear()
fake.issue_creation_enabled = True
fake.issue_creation_ready.set()
context.unroute("**/api/v1/repos/acme/mobile/issues")
context.set_offline(False)
page.evaluate("window.dispatchEvent(new Event('online'))")
for _ in range(80):
# Containerized Actions runners can take longer than a local browser to wake the
# service worker and complete its identity handshake after reconnect.
for _ in range(160):
if fake.created_issues:
break
page.wait_for_timeout(250)
assert fake.created_issues == [{"title": TITLE, "body": BODY, "assignee": "timmy"}]
local_debug = page.evaluate("localStorage.getItem('stackchain.issue-outbox.v1')")
durable_debug = indexed_issue_records(page)
assert fake.created_issues == [
{"title": TITLE, "body": BODY, "assignee": "timmy"}
], (
f"url={page.url} ready={page.evaluate('document.readyState')} "
f"status={page.locator('#my-work-action-status').inner_text()!r} "
f"errors={browser_errors[-5:]!r} responses={failed_responses[-10:]!r} "
f"local={local_debug!r} durable={durable_debug!r} "
f"requests={fake.requests[-20:]!r}"
)
# Deferred workspace startup can report its already-issued offline request
# after the pre-reconnect clear; retain every non-offline browser error.
browser_errors[:] = [
error for error in browser_errors if "ERR_INTERNET_DISCONNECTED" not in error
]
for _ in range(40):
durable_completion = indexed_issue_records(page)
@@ -267,6 +319,7 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
browser.close()
finally:
fake.issue_creation_ready.set()
fake.shutdown()
fake.server_close()
fake_thread.join(timeout=5)
@@ -0,0 +1,73 @@
from __future__ import annotations
import os
import re
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("rendered mobile Search Preview checks run only in the browser gate", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
ROOT = Path(__file__).resolve().parents[2]
FRONTEND = ROOT / "frontend"
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
def test_rendered_mobile_search_preview_navigation_preserves_reading_space_and_draft(width, height):
html = re.sub(r'<script src="static/[^"]+"></script>', "", (FRONTEND / "index.html").read_text())
with sync_playwright() as playwright:
browser = playwright.chromium.launch()
page = browser.new_page(viewport={"width": width, "height": height})
page.set_content(html)
page.add_style_tag(path=FRONTEND / "dashboard.css")
page.locator("#search-preview").evaluate("node => node.classList.add('open')")
page.locator("#search-preview-reply-workspace").evaluate("node => node.hidden = false")
page.locator(".search-preview-navigation").evaluate("node => node.hidden = false")
page.locator("#search-preview-overview").evaluate("node => node.style.minHeight = '500px'")
page.locator("#search-preview-conversation").evaluate("node => node.style.minHeight = '500px'")
page.locator("#search-preview-reply-workspace").evaluate("node => node.style.minHeight = '500px'")
page.locator("#search-preview-actions").evaluate("node => node.style.minHeight = '300px'")
page.add_script_tag(path=FRONTEND / "mobile-search-preview-nav.js")
navigation = page.locator(".mobile-search-preview-nav")
expect(navigation).to_be_visible()
buttons = navigation.locator("button")
assert buttons.count() == 4
for button in buttons.all():
bounds = button.bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
assert page.locator(".search-preview-panel").evaluate("node => node.scrollWidth <= node.clientWidth")
page.locator("#search-preview-reply").fill("Keep this reply while navigating")
navigation.locator('[data-search-preview-section="conversation"]').click()
expect(navigation.locator('[data-search-preview-section="conversation"]')).to_have_attribute(
"aria-current", "location"
)
navigation.locator('[data-search-preview-section="reply"]').click()
expect(page.locator("#search-preview-reply")).to_be_focused()
assert page.locator("#search-preview-reply").input_value() == "Keep this reply while navigating"
navigation.locator('[data-search-preview-section="actions"]').click()
expect(navigation.locator('[data-search-preview-section="actions"]')).to_have_attribute(
"aria-current", "location"
)
continuation = page.locator(".search-preview-navigation")
assert continuation.evaluate("node => getComputedStyle(node).position") == "sticky"
assert continuation.evaluate("node => getComputedStyle(node).bottom") == "0px"
assert page.locator("#search-preview-actions").evaluate("node => getComputedStyle(node).position") == "static"
page.locator("#next-search-result").click()
expect(navigation.locator('[data-search-preview-section="overview"]')).to_have_attribute(
"aria-current", "location"
)
assert page.locator("#search-preview-reply").input_value() == "Keep this reply while navigating"
page.set_viewport_size({"width": 800, "height": 700})
expect(navigation).to_be_hidden()
browser.close()
@@ -0,0 +1,101 @@
from __future__ import annotations
import os
import threading
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("packaged Plan Today handoff journey runs only in its gated CI job", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
from fake_gitea import FakeGiteaServer
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path: Path):
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
fake = FakeGiteaServer(("127.0.0.1", 0))
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
fake_thread.start()
fake_url = f"http://127.0.0.1:{fake.server_port}"
browser_errors: list[str] = []
failed_responses: list[str] = []
try:
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
context = browser.new_context(viewport={"width": 390, "height": 844}, ignore_https_errors=True)
page = context.new_page()
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
page.on("console", lambda message: browser_errors.append(message.text) if message.type == "error" else None)
page.on(
"response",
lambda response: failed_responses.append(f"{response.status} {response.url}")
if response.status >= 400
else None,
)
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Plan Today release phone")
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
expect(page.locator("#my-work-status")).to_contain_text("2")
page.locator('[data-mobile-task="work"]').click()
expect(page.locator("#plan-today-sheet")).to_be_visible()
expect(page.locator("#plan-today-candidates .plan-today-item")).to_have_count(2)
navigation = page.locator(".mobile-plan-today-nav")
expect(navigation).to_be_visible()
for control in navigation.locator("button").all():
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
page.locator("#plan-today-available").fill("90")
page.locator("#plan-today-available").press("Tab")
page.locator("#build-today-plan").click()
missing_estimates = page.locator("[data-plan-missing-estimate]")
expect(missing_estimates).to_have_count(2)
for _ in range(2):
missing_estimates.nth(0).fill("30")
missing_estimates.nth(0).press("Tab")
expect(page.locator("#plan-today-list .plan-today-item")).to_have_count(2)
estimates = page.locator("[data-plan-estimate]")
expect(estimates).to_have_count(2)
page.locator("#save-and-start-today").click()
try:
expect(page.locator("#plan-today-sheet")).to_be_hidden(timeout=5_000)
except AssertionError as error:
raise AssertionError(page.evaluate("({hidden:document.querySelector('#plan-today-sheet').hidden, state:history.state, bodyClass:document.body.className})")) from error
expect(page.locator("#issue-sheet")).to_have_class("issue-sheet open")
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
expect(page.locator("#send-issue-comment-next")).to_be_visible()
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
expect(page.locator("#plan-today-sheet")).to_be_hidden()
page.locator('[data-issue-section="reply"]').click()
page.locator("#issue-comment").fill("Handoff complete; continuing with the next Today item.")
page.locator("#send-issue-comment-next").click()
expect(page.locator("#issue-sheet-title")).to_have_text("Polish desktop filters")
expect(page.locator("#issue-comment")).to_have_value("")
expect(page.locator("[data-mobile-today-hud]")).to_have_attribute("data-overlay-hidden", "true")
expect(page.locator("[data-mobile-today-hud]")).to_contain_text("Polish desktop filters")
assert fake.comments == [(41, "Handoff complete; continuing with the next Today item.")]
today = page.evaluate("JSON.parse(localStorage.getItem('stackchain.today-work.v1.timmy') || '[]')")
assert today == ["issue:acme/mobile:42:"]
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
assert browser_errors == []
assert failed_responses == []
browser.close()
finally:
fake.shutdown()
fake.server_close()
fake_thread.join(timeout=5)
@@ -0,0 +1,99 @@
from __future__ import annotations
import os
import threading
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("packaged Today wrap-up journey runs only in its gated CI job", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
from fake_gitea import FakeGiteaServer
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
def test_release_artifact_renders_and_applies_mobile_today_wrap_up(
tmp_path: Path, width: int, height: int
):
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
fake = FakeGiteaServer(("127.0.0.1", 0))
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
fake_thread.start()
fake_url = f"http://127.0.0.1:{fake.server_port}"
browser_errors: list[str] = []
try:
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
context = browser.new_context(
viewport={"width": width, "height": height}, ignore_https_errors=True
)
page = context.new_page()
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
page.on(
"console",
lambda message: browser_errors.append(message.text)
if message.type == "error"
else None,
)
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Today wrap-up release phone")
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
page.evaluate(
"""
() => {
const items=[
{kind:'issue',repository:'acme/mobile',number:41,title:'Ship mobile capture',key:'acme/mobile#41'},
{kind:'issue',repository:'acme/mobile',number:42,title:'Polish desktop filters',key:'acme/mobile#42'},
];
const identity=item => `${item.kind}:${item.repository}:${item.number}:`;
const today=items.map(identity); const later={}; const operations=[];
const todayWork={identity,read:()=>[...today],contains:item=>today.includes(identity(item)),remove:item=>{
const index=today.indexOf(identity(item)); if(index<0)return false; today.splice(index,1); return true;
}};
const laterWork={presetUntil:()=>new Date('2026-08-17T09:00:00Z'),defer:(item,wake)=>{
later[identity(item)]=wake.toISOString(); return 'deferred';
}};
const todaySync={enqueue:(action,id)=>{operations.push([action,id]);return true;},flush:()=>Promise.resolve(true)};
const controller=createTodayWrapUp({todayWork,laterWork,todaySync});
const view=createTodayWrapUpView({controller,qs:selector=>document.querySelector(selector),escapeHtml:value=>String(value)});
window.__wrapTest={view,today,later,operations};
view.open(items,{});
}
"""
)
sheet = page.locator("#today-wrap-up-sheet")
expect(sheet).to_be_visible()
expect(page.locator(".today-wrap-up-item")).to_have_count(2)
for control in sheet.locator("button, label").all():
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.locator('[data-wrap-up-identity="issue:acme/mobile:41:"]').check()
page.evaluate("window.__wrapTest.view.finish(document.querySelector('#finish-today-wrap-up'))")
expect(sheet).to_be_hidden()
result = page.evaluate(
"({today:window.__wrapTest.today,later:window.__wrapTest.later,operations:window.__wrapTest.operations})"
)
assert result == {
"today": ["issue:acme/mobile:42:"],
"later": {"issue:acme/mobile:41:": "2026-08-17T09:00:00.000Z"},
"operations": [["remove", "issue:acme/mobile:41:"]],
}
assert browser_errors == []
browser.close()
finally:
fake.shutdown()
fake.server_close()
fake_thread.join(timeout=5)
@@ -0,0 +1,65 @@
from __future__ import annotations
import os
import threading
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("packaged next-day Today handoff runs only in its gated CI job", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
from fake_gitea import FakeGiteaServer
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
def test_release_artifact_reviews_wrap_up_commitments_on_a_phone(tmp_path: Path):
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
assert len(archives) == 1
fake = FakeGiteaServer(("127.0.0.1", 0))
thread = threading.Thread(target=fake.serve_forever, daemon=True)
thread.start()
errors: list[str] = []
try:
with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright:
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
page = browser.new_page(viewport={"width": 390, "height": 844})
page.on("pageerror", lambda error: errors.append(error.stack or str(error)))
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Tomorrow review phone")
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
page.evaluate("""localStorage.setItem('stackchain.today-handoff.v1.timmy', JSON.stringify([
'issue:acme/mobile:41:', 'issue:acme/mobile:42:'
]))""")
page.reload(wait_until="networkidle")
dialog = page.locator("#today-handoff-dialog")
expect(dialog).to_be_visible()
expect(page.locator("#today-handoff-items .today-handoff-item")).to_have_count(2)
for control in dialog.locator("button, input").all():
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 24
buttons = dialog.locator("button")
for control in buttons.all():
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
page.locator('[data-today-handoff="issue:acme/mobile:42:"]').uncheck()
page.locator("#confirm-today-handoff").click()
expect(dialog).to_be_hidden()
assert page.evaluate("JSON.parse(localStorage.getItem('stackchain.today-work.v1.timmy'))") == [
"issue:acme/mobile:41:"
]
assert page.evaluate("JSON.parse(localStorage.getItem('stackchain.today-handoff.v1.timmy'))") == [
"issue:acme/mobile:42:"
]
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
assert errors == []
browser.close()
finally:
fake.shutdown()
fake.server_close()
thread.join(timeout=5)
+106
View File
@@ -77,6 +77,80 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persi
assert output["remaining"] == []
def test_durable_conversation_photo_captions_stay_paired_without_local_binary_storage():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map(); const mirrors = [];
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const outbox = createAuthoredOutbox({{
storage, getOwnerLogin:()=> 'timmy',
backgroundSync:{{reconcile:async items=>mirrors.push(items),requestSync:async()=>{{}}}},
}});
const photo=(filename,note)=>({{filename,contentType:'image/png',blob:new Blob([filename]),note}});
(async()=>{{
await outbox.enqueueDurably({{
kind:'issue-comment',repository:'o/r',number:9,body:'Before and after',operationId:'bundle-note',
attachments:[photo('before.png','Before repair'),photo('after.png','After *repair*')],
}});
const metadata=JSON.parse(values.get('stackchain.authored-outbox.v1')).items[0].attachments;
const durable=mirrors[0][0].attachments;
process.stdout.write(JSON.stringify({{
metadata,durable:durable.map(x=>({{filename:x.filename,note:x.note,text:x.data||null,hasBlob:x.blob instanceof Blob}})),
}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = run_node(script)
assert output["metadata"] == [
{"filename": "before.png", "contentType": "image/png", "stored": True, "note": "Before repair"},
{"filename": "after.png", "contentType": "image/png", "stored": True, "note": "After *repair*"},
]
assert output["durable"] == [
{"filename": "before.png", "note": "Before repair", "text": None, "hasBlob": True},
{"filename": "after.png", "note": "After *repair*", "text": None, "hasBlob": True},
]
def test_search_preview_reply_is_durably_bound_to_exact_kind_with_upload_checkpoints():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map(); const mirrors = [];
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const outbox = createAuthoredOutbox({{
storage, getOwnerLogin:()=> 'timmy',
backgroundSync:{{reconcile:async items=>mirrors.push(items),requestSync:async()=>{{}}}},
}});
(async()=>{{
const result=await outbox.enqueueDurably({{
kind:'search-reply',targetKind:'pull',repository:'stackchain/web',number:42,
body:'Ready offline.',operationId:'search-42',attachments:[{{
filename:'proof.jpg',contentType:'image/jpeg',blob:new Blob(['proof']),note:'After repair',
operationId:'photo-stage-1',confirmed:{{markdown:'![proof](saved)'}},
}}],
}});
const metadata=outbox.list()[0]; const durable=mirrors[0][0];
process.stdout.write(JSON.stringify({{
item:result.item,metadata,
durable:{{targetKind:durable.targetKind,operationId:durable.attachment.operationId,
markdown:durable.attachment.confirmed?.markdown,hasBlob:durable.attachment.blob instanceof Blob}},
}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = run_node(script)
assert output["item"]["kind"] == "search-reply"
assert output["item"]["targetKind"] == "pull"
assert output["metadata"]["attachment"] == {
"filename": "proof.jpg", "contentType": "image/jpeg", "stored": True,
"note": "After repair", "operationId": "photo-stage-1",
"confirmed": {"markdown": "![proof](saved)"},
}
assert output["durable"] == {
"targetKind": "pull", "operationId": "photo-stage-1",
"markdown": "![proof](saved)", "hasBlob": True,
}
def test_authored_outbox_persists_and_delivers_revision_checked_issue_content():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
@@ -804,3 +878,35 @@ const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy',background
"filename": "proof.webp", "contentType": "image/webp", "stored": True
}
assert output["durable"] == {"isBlob": True, "text": "PULL-PRIVATE-BYTES"}
def test_conversation_photo_bundle_keeps_only_metadata_in_localstorage_and_all_blobs_in_indexeddb():
script = f"""
const createAuthoredOutbox=require({json.dumps(str(OUTBOX))});
const values=new Map();const mirrors=[];
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy',backgroundSync:{{
reconcile:async items=>mirrors.push(items),requestSync:async()=>{{}},
}}}});
const photo=(name,bytes)=>({{filename:name,contentType:'image/jpeg',blob:new Blob([bytes],{{type:'image/jpeg'}})}});
(async()=>{{
await outbox.enqueueDurably({{kind:'update-reply-read',notificationId:943,body:'Before and after',
operationId:'reply-bundle-943',attachments:[photo('before.jpg','PRIVATE-BEFORE'),photo('after.jpg','PRIVATE-AFTER')]}});
const local=values.get('stackchain.authored-outbox.v1');
const durable=mirrors[0][0].attachments;
process.stdout.write(JSON.stringify({{local,metadata:JSON.parse(local).items[0].attachments,
durable:await Promise.all(durable.map(async value=>({{filename:value.filename,isBlob:value.blob instanceof Blob,text:await value.blob.text()}})))}}));
}})();
"""
output = run_node(script)
assert "PRIVATE-BEFORE" not in output["local"]
assert "PRIVATE-AFTER" not in output["local"]
assert output["metadata"] == [
{"filename": "before.jpg", "contentType": "image/jpeg", "stored": True},
{"filename": "after.jpg", "contentType": "image/jpeg", "stored": True},
]
assert output["durable"] == [
{"filename": "before.jpg", "isBlob": True, "text": "PRIVATE-BEFORE"},
{"filename": "after.jpg", "isBlob": True, "text": "PRIVATE-AFTER"},
]
+127
View File
@@ -19,6 +19,55 @@ def run_node(script: str) -> dict:
return json.loads(completed.stdout)
def test_search_reply_reconnect_uses_preview_authorization_and_resumes_photo_checkpoints():
script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
let item={{id:'search-42',operationId:'search-42',ownerLogin:'timmy',status:'queued',outboxLane:'authored',
kind:'search-reply',targetKind:'pull',repository:'stackchain/web',number:42,body:'Ready offline.',attachments:[
{{filename:'one.jpg',contentType:'image/jpeg',blob:new Blob(['one']),operationId:'photo-one',confirmed:{{markdown:'![one](saved)'}}}},
{{filename:'two.jpg',contentType:'image/jpeg',blob:new Blob(['two']),operationId:'photo-two'}},
]}};
const calls=[];let commentAttempts=0;
const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,transform)=>{{item=transform(item);}},
complete:async()=>{{item=null;}},release:async()=>{{item={{...item,status:'queued'}};}},
fail:async()=>{{}},countBlocked:async()=>0}};
const fetchJson=async(url,options={{}})=>{{
if(url==='api/v1/background-identity')return{{login:'timmy'}};
const file=options.body instanceof FormData?options.body.get('file'):null;
calls.push({{url,key:options.headers?.['Idempotency-Key'],filename:file?.name||'',
body:file?null:JSON.parse(options.body)}});
if(file)return{{markdown:'![two](uploaded)'}};
if(commentAttempts++===0){{const error=new Error('offline');error.status=503;throw error;}}
return{{id:91,body:'Ready offline.'}};
}};
(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson}});let first='';
try{{await sync.flush();}}catch(error){{first=error.message;}}
const checkpoint={{...item}};const second=await sync.flush();
process.stdout.write(JSON.stringify({{calls,first,checkpoint,second}}));
}})();
"""
output = run_node(script)
assert output["first"] == "offline"
assert output["checkpoint"]["attachmentMarkdowns"] == [
"![one](saved)", "![two](uploaded)"
]
uploads = [call for call in output["calls"] if call["filename"]]
assert [(call["filename"], call["key"]) for call in uploads] == [
("two.jpg", "photo-two")
]
comments = [call for call in output["calls"] if not call["filename"]]
assert [call["url"] for call in comments] == [
"api/v1/repos/stackchain/web/issues/42/preview/comments?kind=pull",
"api/v1/repos/stackchain/web/issues/42/preview/comments?kind=pull",
]
assert [call["key"] for call in comments] == ["search-42:comment"] * 2
assert comments[-1]["body"] == {
"body": "Ready offline.\n\n![one](saved)\n\n![two](uploaded)"
}
assert output["second"]["confirmed"] == [{"id": 91, "body": "Ready offline."}]
def test_closed_app_sync_delivers_matching_issue_once_with_original_idempotency_key():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
@@ -445,6 +494,56 @@ const fetchJson=async(url,options={{}})=>{{
assert output["second"]["confirmed"] == [{"id": 91}]
def test_conversation_photo_bundle_resumes_at_first_unconfirmed_upload_and_posts_one_ordered_comment():
script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
let item={{id:'message-bundle',operationId:'message-bundle',ownerLogin:'timmy',status:'queued',
kind:'issue-comment',repository:'stackchain/dashboard',number:943,body:'Before and after',
attachments:[
{{filename:'before.jpg',contentType:'image/jpeg',blob:new Blob(['before'],{{type:'image/jpeg'}})}},
{{filename:'after.jpg',contentType:'image/jpeg',blob:new Blob(['after'],{{type:'image/jpeg'}})}},
]}};
const calls=[];let afterAttempts=0;
const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,transform)=>{{item=transform(item);}},
complete:async()=>{{item=null;}},release:async()=>{{item={{...item,status:'queued'}};}},
fail:async()=>{{}},countBlocked:async()=>0}};
const fetchJson=async(url,options={{}})=>{{
if(url==='api/v1/background-identity')return{{login:'timmy'}};
const file=options.body instanceof FormData?options.body.get('file'):null;
calls.push({{url,key:options.headers?.['Idempotency-Key'],filename:file?.name||'',
body:options.body instanceof FormData?null:JSON.parse(options.body)}});
if(file){{
if(file.name==='after.jpg' && afterAttempts++===0){{const error=new Error('offline');error.status=503;throw error;}}
return{{markdown:'!['+file.name+'](url/'+file.name+')'}};
}}
return{{id:943}};
}};
(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson}});let firstError='';
try{{await sync.flush();}}catch(error){{firstError=error.message;}}
const checkpoint={{...item}};const second=await sync.flush();
process.stdout.write(JSON.stringify({{calls,firstError,checkpoint,second}}));
}})();
"""
output = run_node(script)
assert output["firstError"] == "offline"
assert output["checkpoint"]["attachmentMarkdowns"] == [
"![before.jpg](url/before.jpg)"
]
uploads = [call for call in output["calls"] if call["filename"]]
assert [(call["filename"], call["key"]) for call in uploads] == [
("before.jpg", "message-bundle:attachment-0"),
("after.jpg", "message-bundle:attachment-1"),
("after.jpg", "message-bundle:attachment-1"),
]
comments = [call for call in output["calls"] if call["url"].endswith("/comments")]
assert len(comments) == 1
assert comments[0]["body"] == {
"body": "Before and after\n\n![before.jpg](url/before.jpg)\n\n![after.jpg](url/after.jpg)"
}
assert output["second"]["confirmed"] == [{"id": 943}]
def test_pull_screenshot_retry_uploads_to_pull_and_reuses_checkpoint_before_one_comment():
script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
@@ -865,6 +964,34 @@ const transaction=work=>{{const run=tail.then(()=>work({{getAll:async()=>[...rec
assert output["attachmentMarkdown"] == "![a](url)"
def test_foreground_bundle_upsert_preserves_indexeddb_blobs_and_upload_checkpoints():
script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
const records=new Map();let tail=Promise.resolve();
const transaction=work=>{{const run=tail.then(()=>work({{getAll:async()=>[...records.values()].map(value=>({{...value}})),put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id)}}));tail=run.catch(()=>{{}});return run;}};
(async()=>{{
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
await store.upsert({{id:'bundle',operationId:'same',ownerLogin:'timmy',status:'queued',attachments:[
{{filename:'a.jpg',contentType:'image/jpeg',blob:new Blob(['a-bytes'])}},
{{filename:'b.jpg',contentType:'image/jpeg',blob:new Blob(['b-bytes'])}},
]}});
await store.update('bundle',item=>({{...item,attachmentMarkdowns:['![a](url/a)']}}));
const result=await store.upsert({{id:'bundle',operationId:'same',ownerLogin:'timmy',status:'queued',attachments:[
{{filename:'a.jpg',contentType:'image/jpeg',stored:true}},
{{filename:'b.jpg',contentType:'image/jpeg',stored:true}},
]}});
process.stdout.write(JSON.stringify({{texts:await Promise.all(result.attachments.map(value=>value.blob?.text())),
markdowns:result.attachmentMarkdowns}}));
}})();
"""
output = run_node(script)
assert output == {
"texts": ["a-bytes", "b-bytes"],
"markdowns": ["![a](url/a)"],
}
def test_indexeddb_store_closes_on_version_change_and_reopens_afterward():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
+5 -3
View File
@@ -9,6 +9,7 @@ HTML = Path(__file__).parents[1] / "frontend" / "index.html"
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
FIND_WORK_NAV = Path(__file__).parents[1] / "frontend" / "mobile-find-work-nav.js"
def run_node(script):
@@ -437,14 +438,15 @@ def test_mobile_find_work_exposes_accessible_batch_controls_and_offline_asset():
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
worker = WORKER.read_text()
navigation = FIND_WORK_NAV.read_text()
assert 'id="select-find-work"' in html
assert 'id="batch-find-work-actions"' in html
assert 'id="claim-selected-work"' in html
assert 'aria-live="polite"' in html
assert "createBatchFindWork({" in dashboard
assert "findWorkController.startSelection()" in dashboard
assert "findWorkController.toggleSelection(item)" in dashboard
assert "options.controller?.startSelection?.()" in navigation
assert "options.controller?.toggleSelection" in navigation
assert ".find-work-batch-actions" in css
assert "position:sticky" in css
assert "env(safe-area-inset-bottom)" in css
@@ -472,7 +474,7 @@ def test_mobile_find_work_exposes_fill_today_action_with_capacity_handoff():
assert 'id="fill-find-work-today"' in html
assert 'aria-describedby="find-work-selection-status"' in html
assert "findWorkController.fillSelection(remainingSlots)" in dashboard
assert "openFindWorkEstimateReview(findWorkController.selectedItems())" in dashboard
assert "openFit:openFindWorkEstimateReview" in dashboard
assert ".fill-find-work-today" in css and "min-height:44px" in css
+26 -1
View File
@@ -17,6 +17,23 @@ def test_ci_installs_declared_requirements_before_tests():
assert install < tests
def test_ci_audits_pinned_runtime_dependencies_before_tests_and_release_build():
text = WORKFLOW.read_text()
lint = text[text.index(" lint:") : text.index(" build-release:")]
build = text[text.index(" build-release:") : text.index(" browser-journey:")]
audit_install = lint.index("pip install -r requirements-audit.txt")
audit = lint.index("python3 -m pip_audit -r requirements.txt --strict")
tests = lint.index("python3 -m pytest tests/ -q")
assert audit_install < audit < tests
assert "needs: lint" in build
def test_runtime_multipart_parser_uses_advisory_fixed_release():
requirements = Path("requirements.txt").read_text().splitlines()
assert "python-multipart==0.0.32" in requirements
def test_release_promotion_waits_for_tests_and_bundle():
text = WORKFLOW.read_text()
release = text[text.index(" release-candidate:") :]
@@ -38,5 +55,13 @@ def test_release_promotion_waits_for_artifact_mobile_offline_journey():
assert "pip install -r requirements-e2e.txt" in browser
assert "python3 -m playwright install --with-deps chromium" in browser
assert 'STACKCHAIN_RUN_RELEASE_E2E: "1"' in browser
assert "python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py -q" in browser
assert (
"python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py "
"tests/e2e/test_mobile_search_preview_navigation.py "
"tests/e2e/test_mobile_find_work_release.py "
"tests/e2e/test_mobile_home_bootstrap_release.py "
"tests/e2e/test_mobile_today_handoff_release.py "
"tests/e2e/test_mobile_today_wrap_up_release.py "
"tests/e2e/test_mobile_wrap_up_handoff_release.py -q"
) in browser
assert "needs: [lint, build-release, browser-journey]" in release
+27
View File
@@ -389,6 +389,33 @@ process.stdout.write(JSON.stringify({{
}
def test_search_preview_queue_and_next_advances_only_after_durable_admission():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async()=>{{
const items=[1,2].map(number=>({{repository:'stackchain/api',number,kind:'issue'}}));
const values=new Map();let admit;const navigated=[];const states=[];let cleared=0;
const preview=createSearchPreview({{
fetchJson:async item=>item,mutate:async()=>{{}},
queueReply:(item,body,operationId)=>new Promise(resolve=>{{admit=()=>resolve({{id:operationId,queued:true}});}}),
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
createOperationId:()=> 'offline-search-1',hasAttachments:()=>false,clearAttachments:()=>{{cleared+=1;}},
getSession:()=>({{items,more:false}}),onNavigate:item=>navigated.push(item.number),onState:state=>states.push(state),
}});
await preview.open(items[0]);preview.saveReplyDraft('Queue safely.');
const pending=preview.reply({{advance:true}});await Promise.resolve();const before=[...navigated];admit();await pending;
process.stdout.write(JSON.stringify({{before,navigated,cleared,draft:values.size,
queued:states.some(state=>state.status==='queued')}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"before": [], "navigated": [2], "cleared": 1, "draft": 0, "queued": True
}
def test_search_preview_reply_retry_keeps_draft_and_operation_until_server_confirmation():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
+1 -1
View File
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v108" in worker
assert "stackchain-dashboard-shell-v117" in worker
+164
View File
@@ -0,0 +1,164 @@
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
STORE = ROOT / "frontend" / "conversation-reply-draft-store.js"
COORDINATOR = ROOT / "frontend" / "conversation-photo-drafts.js"
DASHBOARD = ROOT / "frontend" / "dashboard.js"
INDEX = ROOT / "frontend" / "index.html"
REGISTRY = ROOT / "frontend" / "private-data-registry.js"
WORKER = ROOT / "frontend" / "service-worker.js"
BUNDLE = ROOT / "src" / "frontend_bundle.py"
def run_node(script: str) -> str:
return subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout
def test_store_isolates_complete_photo_bundles_by_account_and_conversation():
script = f"""
const createStore = require({json.dumps(str(STORE))});
const records = new Map();
let login = 'timmy';
const transaction = async (operation, key, value) => {{
if (operation === 'put') records.set(key, structuredClone(value));
if (operation === 'get') return records.has(key) ? structuredClone(records.get(key)) : null;
if (operation === 'delete') records.delete(key);
}};
const store = createStore({{transaction, getOwnerLogin:()=>login}});
const issue = {{kind:'issue', repository:'stackchain/dashboard', number:963}};
const pull = {{kind:'pull', repository:'stackchain/dashboard', number:963}};
const update = {{kind:'update', notificationId:4815}};
const photos = [
{{filename:'before.webp', contentType:'image/webp', blob:new Blob(['before']), note:'before', operationId:'op-1'}},
{{filename:'after.webp', contentType:'image/webp', blob:new Blob(['after']), note:'after', operationId:'op-2', confirmed:{{markdown:'![after](saved)'}}}},
];
(async()=>{{
await store.save(issue, photos);
await store.save(pull, [photos[1]]);
await store.save(update, [photos[0]]);
const restored = await store.load(issue);
const pullDraft = await store.load(pull);
const updateDraft = await store.load(update);
login = 'alexander';
const wrongAccount = await store.load(issue);
process.stdout.write(JSON.stringify({{
restored:await Promise.all(restored.map(async photo=>({{...photo,blobText:await photo.blob.text(),blob:undefined}}))),
pullCount:pullDraft.length, updateCount:updateDraft.length, wrongAccount, recordCount:records.size,
}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = json.loads(run_node(script))
assert output == {
"restored": [
{
"filename": "before.webp",
"contentType": "image/webp",
"note": "before",
"operationId": "op-1",
"blobText": "before",
},
{
"filename": "after.webp",
"contentType": "image/webp",
"note": "after",
"operationId": "op-2",
"confirmed": {"markdown": "![after](saved)"},
"blobText": "after",
},
],
"pullCount": 1,
"updateCount": 1,
"wrongAccount": None,
"recordCount": 3,
}
def test_coordinator_ignores_stale_restores_and_clears_only_after_durable_completion():
script = f"""
const createCoordinator = require({json.dumps(str(COORDINATOR))});
const waits = new Map();
const removed = [];
const restored = [];
let current = [{{filename:'current.webp',contentType:'image/webp',blob:new Blob(['current'])}}];
const store = {{
load:target=>new Promise(resolve=>waits.set(target.number, resolve)),
save:async()=>null,
remove:async target=>removed.push(target.number || target.notificationId),
}};
const controller = {{
serialize:async()=>current,
restore:value=>restored.push(value[0].filename),
clear:()=>{{current=[];}},
}};
const drafts = createCoordinator({{store, lanes:{{issue:{{controller,onError:()=>{{}}}}}}}});
(async()=>{{
const first = drafts.open('issue', {{kind:'issue',repository:'stackchain/dashboard',number:1}});
const second = drafts.open('issue', {{kind:'issue',repository:'stackchain/dashboard',number:2}});
await Promise.resolve();
waits.get(1)([{{filename:'stale.webp',contentType:'image/webp',blob:new Blob(['stale'])}}]);
waits.get(2)([{{filename:'fresh.webp',contentType:'image/webp',blob:new Blob(['fresh'])}}]);
await Promise.all([first, second]);
await drafts.complete('issue');
process.stdout.write(JSON.stringify({{restored,removed,hasTarget:drafts.hasTarget('issue')}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = json.loads(run_node(script))
assert output == {"restored": ["fresh.webp"], "removed": [2], "hasTarget": False}
def test_coordinator_checkpoints_previous_target_before_switching_conversations():
script = f"""
const createCoordinator = require({json.dumps(str(COORDINATOR))});
const events = [];
let current = [{{filename:'first.webp',contentType:'image/webp',blob:new Blob(['first'])}}];
const store = {{
load:async target=>target.number === 2 ? [{{filename:'second.webp',contentType:'image/webp',blob:new Blob(['second'])}}] : null,
save:async (target,attachments)=>events.push('save:' + target.number + ':' + attachments[0].filename),
remove:async()=>null,
}};
const controller = {{
serialize:async()=>current,
restore:value=>{{current=value;events.push('restore:' + value[0].filename);}},
clear:()=>{{current=[];}},
}};
const drafts = createCoordinator({{store, lanes:{{issue:{{controller}}}}}});
(async()=>{{
await drafts.open('issue', {{kind:'issue',repository:'stackchain/dashboard',number:1}});
current=[{{filename:'first.webp',contentType:'image/webp',blob:new Blob(['first'])}}];
await drafts.switchTo('issue', {{kind:'issue',repository:'stackchain/dashboard',number:2}});
process.stdout.write(JSON.stringify(events));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
assert json.loads(run_node(script)) == ["save:1:first.webp", "restore:second.webp"]
def test_my_work_wires_durable_photo_drafts_into_all_conversation_boundaries():
dashboard = DASHBOARD.read_text()
html = INDEX.read_text()
registry = REGISTRY.read_text()
worker = WORKER.read_text()
bundle = BUNDLE.read_text()
assert '<script src="static/conversation-reply-draft-store.js"></script>' in html
assert '<script src="static/conversation-photo-drafts.js"></script>' in html
assert "'stackchain-conversation-reply-drafts-v1'" in registry
assert "BASE + 'static/conversation-reply-draft-store.js'" in worker
assert "BASE + 'static/conversation-photo-drafts.js'" in worker
assert '"static/conversation-reply-draft-store.js"' in bundle
assert '"static/conversation-photo-drafts.js"' in bundle
assert "conversationPhotoDrafts.switchTo('issue'," in dashboard
assert "conversationPhotoDrafts.switchTo('pull'," in dashboard
assert "conversationPhotoDrafts.switchTo('update'," in dashboard
assert "conversationPhotoDrafts.leave('issue')" in dashboard
assert "conversationPhotoDrafts.leave('pull')" in dashboard
assert "conversationPhotoDrafts.leave('update')" in dashboard
assert "await conversationPhotoDrafts.complete(kind)" in dashboard
assert "await conversationPhotoDrafts.complete('issue')" in dashboard
assert "await conversationPhotoDrafts.complete('pull')" in dashboard
assert "await conversationPhotoDrafts.complete('update')" in dashboard
+17
View File
@@ -350,6 +350,7 @@ process.stdout.write(JSON.stringify(state));
assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
]
assert result["remaining"] == ["gitea.preference"]
assert result["replaced"] == [
@@ -376,6 +377,7 @@ process.stdout.write(JSON.stringify(state));
assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
@@ -507,6 +509,7 @@ process.stdout.write(JSON.stringify(state));
assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
@@ -530,6 +533,7 @@ process.stdout.write(JSON.stringify(state));
assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
@@ -626,6 +630,7 @@ process.stdout.write(JSON.stringify(state));
assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["assigned"] == "/dashboard/login"
@@ -845,10 +850,22 @@ async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
assert 'aria-labelledby="enrolled-passkeys-title"' in html
assert 'id="enrolled-passkeys-list"' in html
assert 'A recovery token will be required after sign-out if you remove this device’s passkey.' in html
assert 'class="security-section-nav" aria-label="Security Center sections"' in html
assert 'id="security-section-activity"' in html
assert 'id="security-section-devices"' in html
assert 'id="security-section-passkeys"' in html
assert 'id="security-activity-section"' in html
assert 'id="security-devices-section"' in html
assert 'id="security-passkeys-section"' in html
assert html.index('id="security-activity-section"') < html.index('id="security-devices-section"')
assert 'aria-labelledby="security-activity-title"' in html
assert 'id="security-activity-list"' in html
assert '>Load older activity</button>' in html
assert '#sign-out-all { min-height:44px; }' in html
assert '.security-section-nav { position:sticky;' in html
assert '.security-section-nav button { min-height:44px;' in html
assert '.security-event button { min-height:44px;' in html
assert '.security-activity, .security-devices, .enrolled-passkeys { scroll-margin-top:' in html
def test_security_activity_names_confirmed_gitea_time_logging():
+153
View File
@@ -0,0 +1,153 @@
import json
import subprocess
from pathlib import Path
MODULE = Path(__file__).parents[1] / "frontend" / "device-storage.js"
def run_scenario(script: str) -> dict:
harness = r"""
const createDeviceStorage = require(__MODULE__);
class Target {
constructor() { this.listeners={}; this.textContent=''; this.hidden=false; this.disabled=false; this.dataset={}; }
addEventListener(name, callback) { (this.listeners[name] ||= []).push(callback); }
async dispatch(name) { for (const callback of this.listeners[name] || []) await callback(); }
}
const summary=new Target(), detail=new Target(), clearCachesButton=new Target(), clearAllButton=new Target();
let purgeCalls=0, purgeError='';
let inventory={recordCount:0, unavailable:false};
const localStorage={
values:new Map([['stackchain.draft.1','private'],['other.preference','keep']]),
get length(){return this.values.size;},
key(index){return Array.from(this.values.keys())[index] || null;},
};
const caches={
values:new Set(['stackchain-dashboard-shell-a1','other-app']),
async keys(){return Array.from(this.values);},
async delete(key){return this.values.delete(key);},
};
const controller=createDeviceStorage({
summary, detail, clearCachesButton, clearAllButton, localStorage, caches,
privateDatabases:['stackchain-background-outbox-v1','stackchain-offline-work-v2'],
inspectPrivateDatabases:async()=>inventory,
storageManager:{estimate:async()=>({usage:2 * 1024 * 1024, quota:10 * 1024 * 1024})},
clearPrivateDeviceData:async()=>{purgeCalls++;if(purgeError)throw new Error(purgeError);},
});
(async()=>{ __SCENARIO__ })().catch(error=>{console.error(error);process.exit(1);});
""".replace("__MODULE__", json.dumps(str(MODULE))).replace("__SCENARIO__", script)
completed = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
return json.loads(completed.stdout)
def test_inventory_reports_private_categories_and_quota_without_content():
result = run_scenario("""
await controller.refresh();
process.stdout.write(JSON.stringify({summary:summary.textContent,detail:detail.textContent}));
""")
assert result == {
"summary": "2 MB of 10 MB browser storage used.",
"detail": "1 private browser item · 0 private work records · 1 cached app copy",
}
def test_cache_only_cleanup_preserves_private_work_and_other_apps():
result = run_scenario("""
await controller.start();
await clearCachesButton.dispatch('click');
process.stdout.write(JSON.stringify({
caches:Array.from(caches.values),
storage:Array.from(localStorage.values.keys()),
status:summary.textContent,
detail:detail.textContent,
}));
""")
assert result == {
"caches": ["other-app"],
"storage": ["stackchain.draft.1", "other.preference"],
"status": "Cached app copies cleared. Private work was kept.",
"detail": "1 private browser item · 0 private work records · 0 cached app copies",
}
def test_full_cleanup_requires_explicit_second_press_when_private_work_exists():
result = run_scenario("""
await controller.start();
await clearAllButton.dispatch('click');
const warning={calls:purgeCalls,label:clearAllButton.textContent,status:summary.textContent};
await clearAllButton.dispatch('click');
process.stdout.write(JSON.stringify({warning,calls:purgeCalls,status:summary.textContent,detail:detail.textContent}));
""")
assert result == {
"warning": {
"calls": 0,
"label": "Confirm: clear private work",
"status": "Private drafts or queued work may not be synced. Press confirm to clear them from this device.",
},
"calls": 1,
"status": "All Stackchain private data was cleared from this device.",
"detail": "0 private browser items · 0 private work records · 0 cached app copies",
}
def test_indexeddb_only_work_requires_confirmation_before_purge():
result = run_scenario("""
localStorage.values.clear();
inventory={recordCount:3, unavailable:false};
await controller.start();
await clearAllButton.dispatch('click');
process.stdout.write(JSON.stringify({
calls:purgeCalls,
label:clearAllButton.textContent,
status:summary.textContent,
detail:detail.textContent,
}));
""")
assert result == {
"calls": 0,
"label": "Confirm: clear private work",
"status": "Private drafts or queued work may not be synced. Press confirm to clear them from this device.",
"detail": "0 private browser items · 3 private work records · 1 cached app copy",
}
def test_unavailable_indexeddb_inventory_requires_confirmation_before_purge():
result = run_scenario("""
localStorage.values.clear();
inventory={recordCount:0, unavailable:true};
await controller.start();
await clearAllButton.dispatch('click');
process.stdout.write(JSON.stringify({
calls:purgeCalls,
label:clearAllButton.textContent,
detail:detail.textContent,
}));
""")
assert result == {
"calls": 0,
"label": "Confirm: clear private work",
"detail": "0 private browser items · private work status unknown · 1 cached app copy",
}
def test_full_cleanup_reports_blocked_deletion_without_claiming_success():
result = run_scenario("""
purgeError='IndexedDB deletion was blocked.';
await controller.start();
await clearAllButton.dispatch('click');
let escaped='';
try { await clearAllButton.dispatch('click'); } catch (error) { escaped=error.message; }
process.stdout.write(JSON.stringify({escaped,status:summary.textContent,disabled:clearAllButton.disabled}));
""")
assert result == {
"escaped": "",
"status": "Private data was not fully cleared: IndexedDB deletion was blocked.",
"disabled": False,
}
+35
View File
@@ -213,6 +213,37 @@ process.stdout.write(JSON.stringify({{item,partition:inbox.partition()}}));
assert output["partition"]["retryable"] == []
def test_delivery_partition_counts_human_action_and_orders_it_before_waiting_work():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
const values = new Map([
['stackchain.issue-outbox.v1', JSON.stringify({{version:3,items:[
{{id:'waiting',repository:'o/r',title:'Waiting',body:'',ownerLogin:'timmy',status:'queued',queuedAt:500}},
{{id:'uncertain',repository:'o/r',title:'Verify',body:'',ownerLogin:'timmy',status:'queued',deliveryState:'uncertain',queuedAt:100}},
{{id:'sending',repository:'o/r',title:'Sending',body:'',ownerLogin:'timmy',status:'sending',queuedAt:400}},
{{id:'attention',repository:'o/r',title:'Fix me',body:'',ownerLogin:'timmy',status:'attention',queuedAt:200}},
]}})],
['stackchain.authored-outbox.v1', JSON.stringify({{version:2,items:[
{{id:'authorize',kind:'pull-review',repository:'o/r',number:8,body:'Review',ownerLogin:'timmy',status:'authorization',queuedAt:300}},
]}})],
]);
const storage={{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const partition=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).partition();
process.stdout.write(JSON.stringify({{
actionable:partition.actionable,
titles:partition.deliveries.map(item=>item.title),
waiting:partition.counts.waiting,
sending:partition.counts.sending,
}}));
"""
assert run_node(script) == {
"actionable": 3,
"titles": ["o/r#8", "Fix me", "Verify", "Waiting", "Sending"],
"waiting": 2,
"sending": 1,
}
def test_draft_inbox_exposes_created_issue_waiting_for_create_and_start_continuation():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
@@ -347,6 +378,10 @@ async def test_mobile_dashboard_renders_delivery_center_separately_and_retries_w
assert "item.last_attempt_error ? '<span class=\"delivery-attempt small\">Last attempt '" in html
assert '.delivery-center { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;' in html
assert '.delivery-center button { min-height:44px;' in html
assert "counts.delivery = draftInbox.partition(lastDrafts).actionable;" in html
assert "openDelivery: () => mobileDeliveryRecovery.open()" in html
assert "getItems: () => draftInbox.partition(lastDrafts).deliveries" in html
assert "authoritativePhases:['delivery']" in html
@pytest.mark.anyio
+11 -12
View File
@@ -26,8 +26,8 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
assert first.runtime_gzip_bytes == second.runtime_gzip_bytes
assert gzip.decompress(first.runtime_gzip_bytes) == first.runtime_bytes
assert PAGE_SCRIPT.findall(first.dashboard_html) == []
assert first.dashboard_html.count("<script src=") == 2
assert f'<script src="{first.feature_bundles["today-timer"].runtime_name}"></script>' in first.dashboard_html
assert first.dashboard_html.count("<script src=") == 1
assert f'<script src="{first.feature_bundles["today-timer"].runtime_name}"></script>' not in first.dashboard_html
assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
assert first.runtime_name.startswith("runtime-")
assert first.runtime_name.endswith(".js")
@@ -51,10 +51,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "device-setup",
"today-timer", "security-center",
}
assert (
f'<script src="{first.feature_bundles["today-timer"].runtime_name}"></script>\n'
f'<script src="{first.runtime_name}"></script>'
) in first.dashboard_html
assert first.dashboard_html.count("<script src=") == 1
assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
capture = first.feature_bundles["issue-capture"]
pull_workflow = first.feature_bundles["pull-workflow"]
assert b"function createIssueCapture" not in first.runtime_bytes
@@ -72,6 +70,10 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert b"function createAssignAndStart" in first.feature_bundles["today-timer"].runtime_bytes
assert b"function createQueueToday" not in first.runtime_bytes
assert b"function createQueueToday" in first.feature_bundles["today-timer"].runtime_bytes
assert b"function createConversationPager" in first.feature_bundles["today-timer"].runtime_bytes
assert b"function createConversationPager" not in first.feature_bundles["comment-actions"].runtime_bytes
assert b"function createMobileDeliveryRecovery" not in first.runtime_bytes
assert b"function createMobileDeliveryRecovery" in first.feature_bundles["today-timer"].runtime_bytes
assert b"gitea_time_logged" not in first.runtime_bytes
assert b"gitea_time_logged" in security_center.runtime_bytes
# Core mobile workflows stay below 99 KiB, including synced Search views and Update decisions.
@@ -87,13 +89,10 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
"const OPTIONAL_FEATURES = [", 1
)
optional_block = optional_block.split("];", 1)[0]
assert f"BASE + '{first.feature_bundles['today-timer'].runtime_name}'" in shell_block
assert f"BASE + '{first.feature_bundles['today-timer'].runtime_name}'" not in shell_block
for name, bundle in first.feature_bundles.items():
if name == "today-timer":
assert f"BASE + '{bundle.runtime_name}'" not in optional_block
else:
assert f"BASE + '{bundle.runtime_name}'" not in shell_block
assert f"BASE + '{bundle.runtime_name}'" in optional_block
assert f"BASE + '{bundle.runtime_name}'" not in shell_block
assert f"BASE + '{bundle.runtime_name}'" in optional_block
changed_frontend = tmp_path / "frontend"
shutil.copytree(FRONTEND, changed_frontend)
+72
View File
@@ -1,11 +1,19 @@
import asyncio
import io
import httpx
import pytest
from PIL import Image
from src import gitea_proxy, main
def png_bytes(color="red"):
output = io.BytesIO()
Image.new("RGB", (2, 2), color).save(output, format="PNG")
return output.getvalue()
@pytest.mark.anyio
async def test_global_search_endpoint_scopes_every_page_to_an_exact_repository(monkeypatch):
requested = []
@@ -325,6 +333,70 @@ async def test_global_search_preview_reply_rejects_a_mismatched_target_before_co
assert comments == []
@pytest.mark.anyio
async def test_global_search_preview_attachment_is_idempotent_for_exact_visible_target(monkeypatch):
main._idempotency_ledger.clear()
previews = []
uploads = []
async def preview(repository, kind, number):
previews.append((repository, kind, number))
return {"repository": repository, "kind": kind, "number": number, "commentable": True}
async def upload(repository, number, filename, content_type, content):
uploads.append((repository, number, filename, content_type, content))
return {"name": filename, "url": "https://forge.example/evidence/photo.png", "size": len(content)}
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
monkeypatch.setattr(main.gitea_proxy, "upload_preview_attachment", upload)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "search-photo-pull-42"}
image = png_bytes()
files = {"file": ("photo.png", image, "image/png")}
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
first = await client.post(
"/api/v1/repos/stackchain/api/issues/42/preview/attachments?kind=pull",
files=files,
headers=headers,
)
replay = await client.post(
"/api/v1/repos/stackchain/api/issues/42/preview/attachments?kind=pull",
files=files,
headers=headers,
)
assert first.status_code == replay.status_code == 201
assert replay.json() == first.json()
assert first.json()["markdown"] == "![photo.png](<https://forge.example/evidence/photo.png>)"
assert previews == [("stackchain/api", "pull", 42)]
assert uploads == [("stackchain/api", 42, "photo.png", "image/png", image)]
@pytest.mark.anyio
async def test_global_search_preview_attachment_rejects_kind_mismatch_before_upload(monkeypatch):
main._idempotency_ledger.clear()
uploads = []
async def preview(repository, kind, number):
return {"repository": repository, "kind": "issue", "number": number}
async def upload(*args):
uploads.append(args)
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
monkeypatch.setattr(main.gitea_proxy, "upload_preview_attachment", upload)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/issues/42/preview/attachments?kind=pull",
files={"file": ("photo.png", png_bytes(), "image/png")},
headers={"Idempotency-Key": "search-photo-mismatch-42"},
)
assert response.status_code == 404
assert uploads == []
@pytest.mark.anyio
async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results():
requests = []
+63
View File
@@ -0,0 +1,63 @@
import io
from pathlib import Path
import pytest
from PIL import Image
from src.image_sanitizer import MAX_IMAGE_PIXELS, sanitize_image
def test_runtime_dependencies_install_the_image_decoder():
requirements = (Path(__file__).parents[1] / "requirements.txt").read_text().splitlines()
assert any(line.startswith("Pillow==") for line in requirements)
def encoded_image(format_name, size=(3, 2), *, exif=None):
image = Image.new("RGB", size)
image.putdata(
[(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 0), (0, 255, 255), (255, 0, 255)]
)
output = io.BytesIO()
image.save(output, format=format_name, exif=exif or b"")
return output.getvalue()
def test_sanitize_image_applies_orientation_and_removes_jpeg_metadata():
exif = Image.Exif()
exif[274] = 6
exif[ GPS_TAG := 34853 ] = {1: "N", 2: (1, 1, 1)}
source = encoded_image("JPEG", exif=exif)
sanitized = sanitize_image("image/jpeg", source)
with Image.open(io.BytesIO(sanitized)) as result:
assert result.size == (2, 3)
assert not result.getexif()
assert GPS_TAG not in result.info
assert result.format == "JPEG"
def test_sanitize_image_rejects_bytes_that_do_not_match_claimed_type():
source = encoded_image("PNG")
with pytest.raises(ValueError, match="does not match"):
sanitize_image("image/jpeg", source)
def test_sanitize_image_rejects_over_twelve_megapixels_before_decoding():
image = Image.new("RGB", (4096, 3073))
output = io.BytesIO()
image.save(output, format="PNG")
with pytest.raises(ValueError, match="12 megapixels"):
sanitize_image("image/png", output.getvalue())
def test_sanitize_image_rejects_animated_webp():
first = Image.new("RGB", (2, 2), "red")
second = Image.new("RGB", (2, 2), "blue")
output = io.BytesIO()
first.save(output, format="WEBP", save_all=True, append_images=[second], duration=100)
with pytest.raises(ValueError, match="animated"):
sanitize_image("image/webp", output.getvalue())
+236 -6
View File
@@ -89,6 +89,119 @@ controller.select(image('replacement.png'));
assert len({key for _, key in output["calls"]}) == 5
def test_mobile_conversation_composers_accept_five_ordered_photos():
html = INDEX.read_text()
dashboard = DASHBOARD.read_text()
for input_id in (
"issue-attachment", "pull-attachment", "update-reply-attachment",
):
tag = re.search(rf'<input[^>]+id="{input_id}"[^>]*>', html)
assert tag, input_id
assert " multiple" in tag.group(0), input_id
for controller in (
"issueAttachmentController", "pullAttachmentController",
"updateReplyAttachmentController",
):
mount = re.search(
rf"const {controller} = issueAttachment\.mount\(\{{(.*?)\n \}}\);",
dashboard,
re.DOTALL,
)
assert mount, controller
assert "maxFiles: 5" in mount.group(1), controller
def test_mobile_search_preview_exposes_and_mounts_photo_evidence_controls():
html = INDEX.read_text()
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
assert 'for="take-search-reply-photo">Take photo</label>' in html
assert 'id="take-search-reply-photo" type="file" accept="image/*" capture="environment"' in html
assert 'for="search-reply-attachment">Choose existing</label>' in html
assert 'id="search-reply-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple' in html
mount = re.search(
r"const searchReplyAttachmentController = issueAttachment\.mount\(\{(.*?)\n \}\);",
dashboard,
re.DOTALL,
)
assert mount
assert "maxFiles: 5" in mount.group(1)
assert "'/preview/attachments?kind='" in mount.group(1)
assert "queueReply:async (item,body,operationId)" in dashboard
assert "kind:'search-reply',targetKind:item.kind" in dashboard
assert "authoredOutbox.enqueueDurably" in dashboard
assert ".search-preview-reply .conversation-photo-actions" in css
def test_search_preview_can_send_photo_only_and_advances_after_confirmed_comment():
search_preview = Path(__file__).parents[1] / "frontend" / "search-preview.js"
script = f"""
const create = require({json.dumps(str(search_preview))});
const posted=[]; const prepared=[]; let cleared=0; let navigated=[]; let attached=true;
const item={{kind:'issue',repository:'stackchain/api',number:42}};
const next={{kind:'pull',repository:'stackchain/web',number:9}};
const api=create({{
fetchJson:async value=>({{...value,commentable:true}}),
postReply:async (target,body,key)=>{{posted.push([target.number,body,key]);return {{id:91,body}};}},
prepareReply:async (target,body)=>{{prepared.push([target.number,body]);return '![photo](<safe-url>)';}},
hasAttachments:()=>attached,
clearAttachments:()=>{{cleared += 1;attached=false;}},
storage:{{getItem:()=>'',setItem:()=>{{}},removeItem:()=>{{}}}},
createOperationId:()=> 'reply-photo-42',
getSession:()=>({{items:[item,next],more:false}}),
onNavigate:value=>navigated.push(value.number),
onState:()=>{{}},
}});
(async()=>{{
await api.open(item);
await api.reply({{advance:true}});
process.stdout.write(JSON.stringify({{posted,prepared,cleared,navigated}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = json.loads(run_node(script))
assert output == {
"posted": [[42, "![photo](<safe-url>)", "reply-photo-42"]],
"prepared": [[42, ""]],
"cleared": 1,
"navigated": [9],
}
def test_mobile_conversation_composers_review_reorder_remove_and_caption_every_photo():
html = INDEX.read_text()
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
composers = (
("issue-attachment", "issueAttachmentController"),
("pull-attachment", "pullAttachmentController"),
("update-reply-attachment", "updateReplyAttachmentController"),
)
for attachment_id, controller in composers:
assert f'id="{attachment_id}-tray"' in html
assert f'id="{attachment_id}-note" maxlength="240"' in html
assert f'id="move-{attachment_id}-earlier"' in html
assert f'id="remove-{attachment_id}"' in html
assert f'id="move-{attachment_id}-later"' in html
mount = re.search(
rf"const {controller} = issueAttachment\.mount\(\{{(.*?)\n \}}\);",
dashboard,
re.DOTALL,
)
assert mount, controller
assert f"tray: qs('#{attachment_id}-tray')" in mount.group(1)
assert f"earlier: qs('#move-{attachment_id}-earlier')" in mount.group(1)
assert f"later: qs('#move-{attachment_id}-later')" in mount.group(1)
assert f"note: qs('#{attachment_id}-note')" in mount.group(1)
assert ".conversation-evidence-review" in css
assert ".conversation-evidence-review .issue-evidence-review-actions button" in css
assert "min-height:44px" in css
def test_mobile_evidence_bundle_reorders_selected_image_for_serialization_and_upload():
script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
@@ -280,6 +393,52 @@ const canvas = {{
assert output["name"] == "camera.jpg"
def test_browser_optimizer_reencodes_bounded_images_to_strip_embedded_metadata():
script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
const results = [];
(async()=>{{
for (const type of ['image/jpeg', 'image/png', 'image/webp']) {{
const original = new Blob(['visible-pixels\\nEXIF GPS device-secret'], {{type}});
original.name = 'camera.' + type.split('/')[1];
let draws = 0;
const canvas = {{
width: 0, height: 0,
getContext: () => ({{drawImage: () => {{draws += 1;}}}}),
toBlob: (callback, outputType) => callback(new Blob(['sanitized-pixels'], {{type:outputType}})),
}};
const result = await attachment.optimizeImage(original, {{
createImageBitmap: async () => ({{width:640,height:480,close:()=>{{}}}}),
createCanvas: () => canvas,
}});
results.push({{
type:result.type,name:result.name,text:await result.text(),
sameObject:result===original,draws,width:canvas.width,height:canvas.height,
}});
}}
process.stdout.write(JSON.stringify(results));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = json.loads(run_node(script))
assert output == [
{
"type": image_type,
"name": filename,
"text": "sanitized-pixels",
"sameObject": False,
"draws": 1,
"width": 640,
"height": 480,
}
for image_type, filename in (
("image/jpeg", "camera.jpeg"),
("image/png", "camera.png"),
("image/webp", "camera.webp"),
)
]
def test_browser_optimizer_downscales_small_file_when_decoded_pixels_exceed_budget():
script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
@@ -543,7 +702,7 @@ def test_new_issue_sheet_captures_screenshot_into_durable_outbox():
assert 'id="remove-create-issue-attachment"' in html
assert 'id="create-issue-evidence-tray"' in html
assert 'role="toolbar"' in html
assert 'aria-label="Evidence screenshots"' in html
assert 'aria-label="Photo evidence"' in html
assert 'id="move-create-issue-attachment-earlier"' in html
assert 'id="move-create-issue-attachment-later"' in html
assert 'id="create-issue-evidence-note-label"' in html
@@ -570,6 +729,75 @@ def test_new_issue_sheet_captures_screenshot_into_durable_outbox():
assert "overflow-x:hidden" in css
def test_mobile_new_issue_camera_and_library_render_as_one_photo_evidence_bundle():
html = INDEX.read_text()
source = DASHBOARD.read_text()
css = CSS.read_text()
assert 'id="take-create-issue-photo"' in html
assert 'accept="image/*" capture="environment"' in html
assert 'for="take-create-issue-photo">Take photo</label>' in html
assert 'for="create-issue-attachment">Choose existing</label>' in html
assert 'id="create-issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple' in html
assert "inputs: [qs('#take-create-issue-photo'), qs('#create-issue-attachment')]" in source
controls = css.split(".photo-evidence-actions", 1)[1].split("}", 1)[0]
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in controls
assert "min-width:0" in controls
def test_mobile_conversation_composers_offer_camera_and_library_through_one_attachment_controller():
html = INDEX.read_text()
source = DASHBOARD.read_text()
css = CSS.read_text()
composers = (
("take-issue-comment-photo", "issue-attachment", "issueAttachmentController"),
("take-pull-comment-photo", "pull-attachment", "pullAttachmentController"),
("take-update-reply-photo", "update-reply-attachment", "updateReplyAttachmentController"),
)
for camera_id, library_id, controller in composers:
assert f'id="{camera_id}" type="file" accept="image/*" capture="environment"' in html
assert f'for="{camera_id}">Take photo</label>' in html
assert f'for="{library_id}">Choose existing</label>' in html
mount = source.split(f"const {controller} = issueAttachment.mount({{", 1)[1].split("});", 1)[0]
assert f"inputs: [qs('#{camera_id}'), qs('#{library_id}')]" in mount
controls = css.split(".conversation-photo-actions", 1)[1].split("}", 1)[0]
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in controls
assert "min-width:0" in controls
assert ".conversation-photo-actions .issue-attachment-trigger" in css
assert "width:100%" in css.split(".conversation-photo-actions .issue-attachment-trigger", 1)[1].split("}", 1)[0]
script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
class Element {{
constructor() {{ this.listeners={{}};this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false; }}
addEventListener(type,fn) {{ this.listeners[type]=fn; }}
dispatch(type) {{ return this.listeners[type]({{target:this}}); }}
}}
const camera=new Element(),library=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element();
library.multiple=false;
const controller=attachment.mount({{
input:library,inputs:[camera,library],preview,image,meta,remove,status,
optimizeImage:async file=>file,
createObjectURL:blob=>'blob:'+blob.name,revokeObjectURL:()=>{{}},upload:async()=>{{}},
}});
const photo=name=>{{const blob=new Blob([name],{{type:'image/jpeg'}});blob.name=name;return blob;}};
;(async()=>{{
camera.files=[photo('camera-one.jpg')]; await camera.dispatch('change');
const afterCamera=controller.state();
camera.files=[]; await camera.dispatch('change');
const afterCancel=controller.state();
library.files=[photo('library-two.jpg')]; await library.dispatch('change');
process.stdout.write(JSON.stringify({{afterCamera,afterCancel,afterLibrary:controller.state()}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = json.loads(run_node(script))
assert output["afterCamera"]["name"] == "camera-one.jpg"
assert output["afterCancel"] == output["afterCamera"]
assert output["afterLibrary"]["name"] == "library-two.jpg"
def test_queued_screenshot_is_hydrated_before_the_issue_editor_opens_and_failure_is_retryable():
source = DASHBOARD.read_text()
handler = re.search(
@@ -751,7 +979,8 @@ def test_issue_comment_actions_upload_binary_multipart_before_posting_and_clear_
assert "'/attachments'" in source
assert "issueAttachmentController.prepareComment(item, body)" in source
assert "issueAttachmentController.prepareComment(selectedIssue, body)" in source
assert source.count("issueAttachmentController.clear();") >= 2
assert "await conversationPhotoDrafts.complete('issue')" in source
assert "await conversationPhotoDrafts.complete(kind)" in source
assert "'Idempotency-Key': payload.operation_id" in source
assert "body: issueAttachment.multipart(payload)" in source
@@ -763,7 +992,7 @@ def test_closing_issue_sheet_cannot_carry_a_screenshot_to_another_issue():
source,
re.DOTALL,
).group("body")
assert "issueAttachmentController.clear();" in close_body
assert "conversationPhotoDrafts.leave('issue')" in close_body
def test_attachment_runtime_is_available_in_the_offline_app_shell():
@@ -806,16 +1035,17 @@ def test_assigned_pull_composer_offers_screenshot_preview_remove_and_reuses_opti
assert 'id="pull-attachment-image"' in html
assert 'id="remove-pull-attachment"' in html
assert "const pullAttachmentController = issueAttachment.mount({" in source
assert "pullAttachmentController.clear();" in source
assert "await conversationPhotoDrafts.complete('pull')" in source
def test_retrying_same_pull_load_preserves_screenshot_but_target_change_clears_it():
def test_retrying_same_pull_load_restores_only_the_exact_target_draft():
source = DASHBOARD.read_text()
open_body = source.split("async function openPullSheet(item, trigger, offlineDetail = null) {", 1)[1].split(
"\n function closePullSheet", 1
)[0]
assert "if (!createPullSheet.sameTarget(selectedPull, item)) pullAttachmentController.clear();" in open_body
assert "conversationPhotoDrafts.switchTo('pull'," in open_body
assert "() => selectedPull === item" in open_body
def test_pull_screenshot_comment_uploads_or_durably_admits_before_clearing_draft():
+63 -3
View File
@@ -1,13 +1,49 @@
import asyncio
import base64
import io
import time
import httpx
import pytest
from PIL import Image, PngImagePlugin
from src import gitea_proxy, main
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"mobile screenshot"
def make_png(color="red", size=(2, 2)):
output = io.BytesIO()
Image.new("RGB", size, color).save(output, format="PNG")
return output.getvalue()
PNG_BYTES = make_png()
def png_with_private_metadata():
output = io.BytesIO()
metadata = PngImagePlugin.PngInfo()
metadata.add_text("Location", "private coordinates")
Image.new("RGB", (2, 2), "red").save(output, format="PNG", pnginfo=metadata)
return output.getvalue()
@pytest.mark.anyio
async def test_attachment_sanitization_keeps_event_loop_responsive(monkeypatch):
def slow_sanitizer(_content_type, content):
time.sleep(0.1)
return content
monkeypatch.setattr(main, "sanitize_image", slow_sanitizer)
started = time.monotonic()
ticks = []
async def tick():
await asyncio.sleep(0.01)
ticks.append(time.monotonic() - started)
await asyncio.gather(main._sanitize_attachment("image/png", b"image"), tick())
assert ticks[0] < 0.05
@pytest.fixture(autouse=True)
@@ -58,6 +94,30 @@ async def test_attachment_endpoint_uploads_valid_screenshot_to_assigned_issue(mo
]
@pytest.mark.anyio
async def test_attachment_endpoint_strips_metadata_before_upstream(monkeypatch):
uploaded = []
source = png_with_private_metadata()
async def upload(_repository, _number, filename, content_type, content):
uploaded.append(content)
return {"name": filename, "url": "https://forge.example/a.png", "size": len(content)}
monkeypatch.setattr(main.gitea_proxy, "upload_assigned_issue_attachment", upload)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/issues/17/attachments",
files={"file": ("evidence.png", source, "image/png")},
)
assert response.status_code == 201
assert uploaded and uploaded[0] != source
with Image.open(io.BytesIO(uploaded[0])) as sanitized:
assert sanitized.info.get("Location") is None
assert sanitized.getpixel((0, 0)) == (255, 0, 0)
@pytest.mark.anyio
async def test_attachment_endpoint_accepts_binary_multipart_without_base64_expansion(monkeypatch):
calls = []
@@ -186,7 +246,7 @@ async def test_attachment_endpoint_rejects_changed_upload_for_used_key(monkeypat
json={
"filename": "checkout.png",
"content_type": "image/png",
"data": base64.b64encode(PNG_BYTES + b" changed").decode("ascii"),
"data": base64.b64encode(make_png("blue")).decode("ascii"),
},
headers={"Idempotency-Key": "attachment-conflict-471"},
)
@@ -198,7 +258,7 @@ async def test_attachment_endpoint_rejects_changed_upload_for_used_key(monkeypat
@pytest.mark.anyio
async def test_attachment_endpoint_admits_a_normal_phone_screenshot(monkeypatch):
screenshot = b"\x89PNG\r\n\x1a\n" + (b"x" * (100 * 1024))
screenshot = make_png(size=(390, 844))
async def upload(_repository, _number, filename, _content_type, content):
return {
+32
View File
@@ -151,3 +151,35 @@ def test_mobile_editor_dialog_is_accessible_touch_sized_and_available_offline():
assert "min-height:44px" in css
assert "max-width:100%" in css
assert "overflow-x:hidden" in css
def test_mobile_conversation_photo_editor_is_available_in_every_composer():
html = INDEX.read_text()
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
css = CSS.read_text()
composers = (
("issue-attachment", "issueAttachmentController"),
("pull-attachment", "pullAttachmentController"),
("update-reply-attachment", "updateReplyAttachmentController"),
)
for attachment_id, controller in composers:
assert f'id="edit-{attachment_id}" type="button">Edit photo</button>' in html
mount = dashboard.split(
f"const {controller} = issueAttachment.mount({{", 1
)[1].split("\n });", 1)[0]
assert f"edit: qs('#edit-{attachment_id}')" in mount
assert "dialog: qs('#issue-evidence-editor')" in mount
assert "appliedMessage: 'Edited photo flattened and ready to send.'" in mount
assert '<h3 id="issue-evidence-editor-heading">Edit selected photo</h3>' in html
assert "Edits stay on this device until you send or file." in html
assert (
'</form>\n </section>\n</div>\n'
'<section class="issue-evidence-editor" id="issue-evidence-editor"'
) in html, "the shared editor must not be hidden inside the New issue sheet"
assert (
".conversation-evidence-review .issue-evidence-review-actions "
"{ grid-template-columns:repeat(4,minmax(0,1fr)); }"
) in css
assert "@media (max-width:390px)" in css
+24
View File
@@ -65,6 +65,30 @@ def test_deferrals_are_durable_revisioned_idempotent_and_account_scoped(tmp_path
) == {"revision": 2, "records": {}}
def test_next_day_today_handoff_is_preserved_without_changing_legacy_deferrals(tmp_path):
store = LaterStore(tmp_path / "later.sqlite3")
legacy = store.apply(
"timmy", "legacy", "defer", "issue:r:1:",
wake_at="2026-08-17T09:00:00.000Z",
)
handoff = store.apply(
"timmy", "wrap-up", "defer", "issue:r:2:",
wake_at="2026-08-17T09:00:00.000Z",
handoff="today",
)
assert legacy["records"]["issue:r:1:"] == "2026-08-17T09:00:00.000Z"
assert handoff["records"] == {
"issue:r:1:": "2026-08-17T09:00:00.000Z",
"issue:r:2:": {
"wake_at": "2026-08-17T09:00:00.000Z",
"handoff": "today",
},
}
assert LaterStore(store.path).get("timmy") == handoff
def test_initialized_later_reads_remain_available_during_a_planning_write(tmp_path):
path = tmp_path / "later.sqlite3"
store = LaterStore(path, timeout=0.05)
+92 -4
View File
@@ -97,6 +97,35 @@ sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
assert result["pending"] == []
def test_today_handoff_intent_replays_through_account_sync():
script = f"""
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
const values=new Map(); const requests=[];
const sync=createLaterSync({{
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
getLogin:()=> 'timmy',createOperationId:()=> 'wrap-up-op',
fetchJson:async(_url,options={{}})=>{{
const body=JSON.parse(options.body); requests.push(body);
return {{revision:1,records:{{[body.operations[0].item_id]:{{wake_at:body.operations[0].wake_at,handoff:body.operations[0].handoff}}}},accepted_operation_ids:['wrap-up-op'],duplicate_operation_ids:[],rejected_operations:[]}};
}},onRemoteRecords:records=>{{globalThis.records=records}},onStatus:()=>{{}},
}});
sync.enqueue('defer','issue:r:2:','2026-08-17T09:00:00.000Z','today');
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{requests,records:globalThis.records}}));}})();
"""
result = run_node(script)
assert result["requests"] == [{"operations": [{
"operation_id": "wrap-up-op",
"action": "defer",
"item_id": "issue:r:2:",
"wake_at": "2026-08-17T09:00:00.000Z",
"handoff": "today",
"base_revision": 0,
}]}]
assert result["records"] == {"issue:r:2:": {
"wake_at": "2026-08-17T09:00:00.000Z", "handoff": "today",
}}
def test_later_queue_expires_ancient_edits_before_replay():
script = f"""
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
@@ -294,6 +323,26 @@ sync.startLifecycle({{window:{{addEventListener:(n,h)=>handlers[n]=h}},document:
}
def test_unsynced_wrap_up_handoff_migrates_after_browser_restart():
script = f"""
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
const values=new Map();const requests=[];
const sync=createLaterSync({{
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
getLogin:()=> 'timmy',createOperationId:()=> 'migrated-handoff',
fetchJson:async(_url,options={{}})=>{{const body=JSON.parse(options.body);requests.push(body);return {{revision:1,records:{{}},accepted_operation_ids:['migrated-handoff'],duplicate_operation_ids:[],rejected_operations:[]}}}},
onRemoteRecords:()=>{{}},onStatus:()=>{{}},
}});
sync.migrate({{'issue:r:1:':{{wake_at:'2026-08-17T09:00:00.000Z',handoff:'today'}}}});
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify(requests));}})();
"""
assert run_node(script) == [{"operations": [{
"operation_id": "migrated-handoff", "action": "defer",
"item_id": "issue:r:1:", "wake_at": "2026-08-17T09:00:00.000Z",
"handoff": "today", "base_revision": 0,
}]}]
def test_later_work_emits_local_changes_adopts_remote_truth_and_retires_expiry():
script = f"""
const createLaterWork=require({json.dumps(str(LATER_WORK))});
@@ -324,6 +373,45 @@ process.stdout.write(JSON.stringify({{changes,expired,remote:remote.later,awake:
assert result["expired"] == [["issue:stackchain/api:17:"]]
def test_later_work_preserves_synced_today_handoff_until_it_wakes():
script = f"""
const createLaterWork=require({json.dumps(str(LATER_WORK))});
const values=new Map(); const changes=[]; const expired=[];
let clock=new Date('2026-08-16T20:00:00Z');
const item={{kind:'issue',repository:'stackchain/dashboard',number:992,title:'Commitment'}};
const work=createLaterWork({{
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
getLogin:()=> 'timmy',now:()=>clock,setTimer:()=>1,clearTimer:()=>{{}},
onChange:(action,id,wakeAt,handoff)=>changes.push([action,id,wakeAt,handoff||null]),
onExpire:(ids,handoffs)=>expired.push([ids,handoffs]),
}});
work.defer(item,new Date('2026-08-17T09:00:00Z'),{{handoff:'today'}});
const stored=work.read();
clock=new Date('2026-08-17T09:00:01Z');
const awake=work.partition([item]);
process.stdout.write(JSON.stringify({{stored,changes,expired,awake:awake.active}}));
"""
result = run_node(script)
assert result["stored"] == {
"issue:stackchain/dashboard:992:": {
"wake_at": "2026-08-17T09:00:00.000Z",
"handoff": "today",
}
}
assert result["changes"] == [[
"defer", "issue:stackchain/dashboard:992:",
"2026-08-17T09:00:00.000Z", "today",
]]
assert result["expired"] == [[
["issue:stackchain/dashboard:992:"],
["issue:stackchain/dashboard:992:"],
]]
assert result["awake"] == [{
"kind": "issue", "repository": "stackchain/dashboard",
"number": 992, "title": "Commitment",
}]
@pytest.mark.anyio
async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
html = await dashboard()
@@ -332,9 +420,9 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
assert 'id="later-sync-status"' in html
assert "const laterSync = createLaterSync({" in html
assert "onRemoteRecords: records =>" in html
assert "onChange: (action, itemId, wakeAt) =>" in html
assert "onExpire: ids =>" in html
assert "laterSync.enqueue(action, itemId, wakeAt)" in html
assert "onChange: (action, itemId, wakeAt, handoff) =>" in html
assert "onExpire: (ids, handoffs) =>" in html
assert "laterSync.enqueue(action, itemId, wakeAt, handoff)" in html
assert "ids.map(id => laterSync.enqueue('restore', id)).every(Boolean)" in html
assert "laterSync.migrate(laterWork.read());" in html
assert "laterSync.flush();" in html
@@ -347,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v108" in source
assert "stackchain-dashboard-shell-v117" in source
assert "BASE + 'static/later-sync.js'" in source
+45
View File
@@ -569,6 +569,51 @@ async function destination(continuation) {{
}
def test_token_and_passkey_login_resume_only_the_private_share_bundle():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
const credential = {{
id:'credential-id', type:'public-key', rawId:Uint8Array.from([1]).buffer,
response:{{authenticatorData:Uint8Array.from([2]).buffer,
clientDataJSON:Uint8Array.from([3]).buffer,
signature:Uint8Array.from([4]).buffer,userHandle:null}},
}};
async function destination(continuation, authentication) {{
let replaced = null;
const controller = createLoginController({{
form:{{reset:()=>{{}}}}, status:{{textContent:''}}, button:{{disabled:false}},
passkeyButton:{{disabled:false}}, credentials:{{get:async()=>credential}},
fetchImpl:async url => url.endsWith('/options')
? new Response(JSON.stringify({{challenge:'AQID',allowCredentials:[]}}),
{{status:200,headers:{{'Content-Type':'application/json'}}}})
: new Response('{{}}',{{status:200}}),
location:{{replace:value=>replaced=value}}, continuation,
}});
if (authentication === 'passkey') await controller.signInWithPasskey('Phone');
else await controller.submit('operator-token');
return replaced;
}}
(async()=>process.stdout.write(JSON.stringify({{
token:await destination('./?launch=new&shared=bundle','token'),
passkey:await destination('./?launch=new&shared=bundle','passkey'),
extra:await destination('./?launch=new&shared=bundle&title=leak','token'),
duplicate:await destination('./?launch=new&shared=bundle&shared=bundle','token'),
wrongLaunch:await destination('./?launch=agenda&shared=bundle','token'),
}})))().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
assert json.loads(result.stdout) == {
"token": "./?launch=new&shared=bundle",
"passkey": "./?launch=new&shared=bundle",
"extra": "./",
"duplicate": "./",
"wrongLaunch": "./",
}
@pytest.mark.anyio
async def test_login_page_loads_rate_limit_controller():
html = await login()
+1 -1
View File
@@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v108" in worker
assert "stackchain-dashboard-shell-v117" in worker
+1 -1
View File
@@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v108" in worker
assert "stackchain-dashboard-shell-v117" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
@@ -0,0 +1,106 @@
import json
import subprocess
from pathlib import Path
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-create-issue-nav.js"
FRONTEND = CONTROLLER.parent
def run_navigation(scenario: str) -> dict:
script = f"""
const createNavigation = require({json.dumps(str(CONTROLLER))});
class FakeElement {{
constructor(name) {{ this.name=name; this.listeners={{}}; this.attributes={{}}; this.scrolls=[]; this.hidden=false; }}
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
removeEventListener(name) {{ delete this.listeners[name]; }}
click() {{ this.listeners.click?.({{preventDefault() {{}}}}); }}
setAttribute(name,value) {{ this.attributes[name]=value; }}
removeAttribute(name) {{ delete this.attributes[name]; }}
scrollIntoView(options) {{ this.scrolls.push(options); }}
}}
{scenario}
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_mobile_create_issue_navigation_gates_file_and_navigates_with_reduced_motion():
result = run_navigation("""
const buttons=Object.fromEntries(['describe','evidence','file'].map(name=>[name,new FakeElement(name)]));
const targets=Object.fromEntries(['describe','evidence','file'].map(name=>[name,new FakeElement(name)]));
targets.file.hidden=true;
const navigation=createNavigation({buttons,targets,filing:targets.file,prefersReducedMotion:()=>true});
navigation.start();
const initiallyDisabled=buttons.file.attributes['aria-disabled'];
buttons.file.click();
const beforeReveal=targets.file.scrolls.length;
targets.file.hidden=false;
navigation.setFilingAvailable(true);
buttons.file.click();
const selected=Object.fromEntries(Object.entries(buttons).map(([name,button])=>[name,button.attributes['aria-current']||null]));
navigation.stop();
process.stdout.write(JSON.stringify({initiallyDisabled,beforeReveal,scrolls:targets.file.scrolls,selected,listeners:Object.values(buttons).map(button=>Object.keys(button.listeners).length)}));
""")
assert result == {
"initiallyDisabled": "true",
"beforeReveal": 0,
"scrolls": [{"block": "start", "behavior": "auto"}],
"selected": {"describe": None, "evidence": None, "file": "location"},
"listeners": [0, 0, 0],
}
def test_mobile_create_issue_navigation_tracks_visible_panel_sections_and_resets():
result = run_navigation("""
const buttons=Object.fromEntries(['describe','evidence','file'].map(name=>[name,new FakeElement(name)]));
const targets=Object.fromEntries(['describe','evidence','file'].map(name=>[name,new FakeElement(name)]));
let callback; let observedRoot; let disconnected=0;
const root=new FakeElement('panel');
const navigation=createNavigation({buttons,targets,root,filing:targets.file,observe(handler,observed,rootOption){callback=handler; observedRoot=rootOption; return {disconnect(){disconnected++;}};}});
navigation.start();
callback([{target:targets.describe,isIntersecting:true,intersectionRatio:.2},{target:targets.evidence,isIntersecting:true,intersectionRatio:.8}]);
navigation.reset(false);
const selected=Object.fromEntries(Object.entries(buttons).map(([name,button])=>[name,button.attributes['aria-current']||null]));
navigation.stop();
process.stdout.write(JSON.stringify({selected,fileDisabled:buttons.file.attributes['aria-disabled'],observedRoot:observedRoot.name,disconnected}));
""")
assert result == {
"selected": {"describe": "location", "evidence": None, "file": None},
"fileDisabled": "true",
"observedRoot": "panel",
"disconnected": 1,
}
def test_new_issue_sheet_ships_mobile_navigation_in_lazy_offline_feature():
html = (FRONTEND / "index.html").read_text()
css = (FRONTEND / "dashboard.css").read_text()
dashboard_js = (FRONTEND / "dashboard.js").read_text()
navigation_js = CONTROLLER.read_text()
bundle = (FRONTEND.parent / "src" / "frontend_bundle.py").read_text()
assert '<nav class="mobile-create-issue-nav"' in html
assert 'aria-label="New issue sections"' in html
for name, label in (("describe", "Describe"), ("evidence", "Evidence"), ("file", "File")):
assert f'data-create-issue-section="{name}"' in html
assert f">{label}</button>" in html
assert 'id="create-issue-describe"' in html
assert 'id="create-issue-attachment"' in html
assert 'id="create-issue-filing"' in html
assert '<script src="static/mobile-create-issue-nav.js"></script>' in html
assert '"static/mobile-create-issue-nav.js"' in bundle
assert "attachMobileCreateIssueNavigation({document, window})" in navigation_js
assert "attachMobileCreateIssueNavigation({document, window})" not in dashboard_js
assert "attributeFilter:['hidden']" in navigation_js
assert "setTimeout(() => navigation.navigate('file'), 0)" in navigation_js
assert "navigation.navigate('file')" in navigation_js
assert "navigation.reset(" in navigation_js
assert ".mobile-create-issue-nav" in css
assert "position:sticky" in css
assert "min-height:44px" in css
assert "grid-template-columns:repeat(3,minmax(0,1fr))" in css
assert "env(safe-area-inset-top)" in css
+350
View File
@@ -0,0 +1,350 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
ROOT = Path(__file__).resolve().parents[1]
RECOVERY = ROOT / "frontend" / "mobile-delivery-recovery.js"
def run_node(script: str) -> dict:
result = subprocess.run(
["node", "-e", script],
cwd=ROOT,
text=True,
capture_output=True,
check=False,
)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_recovery_orders_deliveries_by_safest_required_intervention():
script = f"""
const createRecovery = require({json.dumps(str(RECOVERY))});
const recovery = createRecovery({{
getItems: () => [
{{outbox_id:'waiting', delivery_state:'waiting', title:'Waiting'}},
{{outbox_id:'uncertain', delivery_state:'uncertain', title:'Uncertain'}},
{{outbox_id:'attention', status:'attention', title:'Attention'}},
{{outbox_id:'authorization', status:'authorization', title:'Authorization'}},
],
}});
process.stdout.write(JSON.stringify(recovery.snapshot()));
"""
assert run_node(script) == {
"count": 4,
"current": {
"id": "authorization",
"state": "authorization",
"primary": "Authorize & send",
"position": 1,
"total": 4,
},
}
def test_recovery_advances_only_after_the_current_delivery_is_resolved():
script = f"""
const createRecovery = require({json.dumps(str(RECOVERY))});
(async () => {{
let items = [
{{outbox_id:'authorize', status:'authorization', title:'Approve review'}},
{{outbox_id:'retry', delivery_state:'waiting', title:'Post comment'}},
];
let resolves = false;
const recovery = createRecovery({{
getItems: () => items,
activate: async item => {{
if (resolves) items = items.filter(candidate => candidate.outbox_id !== item.outbox_id);
return resolves;
}},
}});
recovery.open();
const retained = await recovery.activate();
resolves = true;
const advanced = await recovery.activate();
process.stdout.write(JSON.stringify({{retained, advanced}}));
}})();
"""
assert run_node(script) == {
"retained": {
"count": 2,
"current": {
"id": "authorize",
"state": "authorization",
"primary": "Authorize & send",
"position": 1,
"total": 2,
},
},
"advanced": {
"count": 1,
"current": {
"id": "retry",
"state": "waiting",
"primary": "Retry now",
"position": 1,
"total": 1,
},
},
}
def test_recovery_keeps_one_attempt_locked_until_delivery_settles():
script = f"""
const createRecovery = require({json.dumps(str(RECOVERY))});
(async () => {{
let finish;
let attempts = 0;
const settled = new Promise(resolve => {{finish = resolve}});
const element = () => ({{textContent:'', disabled:false}});
const elements = {{
dialog: {{open:false, showModal(){{this.open=true}}, close(){{this.open=false}}}},
title:element(), destination:element(), reason:element(), progress:element(),
action:element(), status:element(),
}};
const recovery = createRecovery({{
getItems: () => [{{outbox_id:'retry', delivery_state:'waiting', title:'Post comment'}}],
activate: async () => {{attempts += 1; await settled; return true}},
elements,
}});
recovery.open();
const first = recovery.activate();
const second = recovery.activate();
await Promise.resolve();
const pending = {{attempts, disabled:elements.action.disabled, status:elements.status.textContent}};
finish();
await Promise.all([first, second]);
process.stdout.write(JSON.stringify({{
pending, attempts, disabled:elements.action.disabled,
}}));
}})();
"""
assert run_node(script) == {
"pending": {"attempts": 1, "disabled": True, "status": "Working…"},
"attempts": 1,
"disabled": False,
}
def test_attention_handoff_closes_modal_focuses_destination_and_awaits_work():
script = f"""
const createRecovery = require({json.dumps(str(RECOVERY))});
(async () => {{
let finish;
let handoffs = 0;
const settled = new Promise(resolve => {{finish = resolve}});
const destination = {{
focused:false, scrolled:false,
focus(){{this.focused=true}}, scrollIntoView(){{this.scrolled=true}},
async onclick(){{handoffs += 1; await settled; return true}},
}};
global.document = {{
getElementById: () => null,
querySelector: selector => selector.includes('draft-resume') ? destination : null,
}};
const element = () => ({{textContent:'', disabled:false}});
const elements = {{
dialog: {{open:false, showModal(){{this.open=true}}, close(){{this.open=false}}}},
title:element(), destination:element(), reason:element(), progress:element(),
action:element(), status:element(),
}};
const recovery = createRecovery({{
getItems: () => [{{outbox_id:'review', status:'attention', title:'Review feedback'}}],
getIndex: () => 3,
elements,
}});
recovery.open();
const first = recovery.activate();
const second = recovery.activate();
await Promise.resolve();
const pending = {{
dialogOpen:elements.dialog.open, disabled:elements.action.disabled,
focused:destination.focused, scrolled:destination.scrolled, handoffs,
}};
finish();
await Promise.all([first, second]);
process.stdout.write(JSON.stringify({{pending, handoffs, dialogOpen:elements.dialog.open}}));
}})();
"""
assert run_node(script) == {
"pending": {
"dialogOpen": False,
"disabled": True,
"focused": True,
"scrolled": True,
"handoffs": 1,
},
"handoffs": 1,
"dialogOpen": False,
}
def test_failed_attention_handoff_reopens_the_same_retryable_item():
script = f"""
const createRecovery = require({json.dumps(str(RECOVERY))});
(async () => {{
const destination = {{
focus(){{}}, scrollIntoView(){{}},
async onclick(){{throw new Error('Latest checklist unavailable')}},
}};
global.document = {{
getElementById: () => null,
querySelector: () => destination,
}};
const element = () => ({{textContent:'', disabled:false}});
const elements = {{
dialog: {{open:false, showModal(){{this.open=true}}, close(){{this.open=false}}}},
title:element(), destination:element(), reason:element(), progress:element(),
action:element(), status:element(),
}};
const recovery = createRecovery({{
getItems: () => [{{outbox_id:'conflict', checklist_conflict:true, title:'Checklist conflict'}}],
getIndex: () => 0,
elements,
}});
recovery.open();
const result = await recovery.activate();
process.stdout.write(JSON.stringify({{
result, dialogOpen:elements.dialog.open, status:elements.status.textContent,
action:elements.action.textContent,
}}));
}})();
"""
assert run_node(script) == {
"result": {
"count": 1,
"current": {
"id": "conflict",
"state": "attention",
"primary": "Review changes",
"position": 1,
"total": 1,
},
},
"dialogOpen": True,
"status": "Latest checklist unavailable Try again.",
"action": "Review changes",
}
def test_recovery_retains_failed_delivery_with_actionable_status():
script = f"""
const createRecovery = require({json.dumps(str(RECOVERY))});
(async () => {{
const element = () => ({{textContent:'', disabled:false}});
const elements = {{
dialog: {{open:false, showModal(){{this.open=true}}, close(){{this.open=false}}}},
title:element(), destination:element(), reason:element(), progress:element(),
action:element(), status:element(),
}};
const recovery = createRecovery({{
getItems: () => [{{outbox_id:'retry', delivery_state:'waiting', title:'Post comment'}}],
activate: async () => {{throw new Error('Network unavailable')}},
elements,
}});
recovery.open();
const result = await recovery.activate();
process.stdout.write(JSON.stringify({{
result, disabled:elements.action.disabled, status:elements.status.textContent,
}}));
}})();
"""
assert run_node(script) == {
"result": {
"count": 1,
"current": {
"id": "retry",
"state": "waiting",
"primary": "Retry now",
"position": 1,
"total": 1,
},
},
"disabled": False,
"status": "Network unavailable Try again.",
}
def test_recovery_renders_delivery_context_and_hands_off_when_cleared():
script = f"""
const createRecovery = require({json.dumps(str(RECOVERY))});
(async () => {{
let items = [{{
outbox_id:'close-7', status:'authorization', outbox_kind:'issue-close',
title:'Close resolved incident', repository:'stackchain/ops',
details:'#7', last_attempt_error:'Fresh authorization expired',
}}];
let completed = 0;
const element = () => ({{textContent:'', hidden:false}});
const elements = {{
dialog: {{open:false, showModal(){{this.open=true}}, close(){{this.open=false}}}},
title: element(), destination: element(), reason: element(), progress: element(),
action: element(), status: element(),
}};
const recovery = createRecovery({{
getItems: () => items,
activate: async () => {{items = []; return true}},
onComplete: () => {{completed += 1}},
elements,
}});
const opened = recovery.open();
const rendered = {{
opened, dialogOpen:elements.dialog.open, title:elements.title.textContent,
destination:elements.destination.textContent, reason:elements.reason.textContent,
progress:elements.progress.textContent, action:elements.action.textContent,
}};
const cleared = await recovery.activate();
process.stdout.write(JSON.stringify({{rendered, cleared, completed, dialogOpen:elements.dialog.open}}));
}})();
"""
assert run_node(script) == {
"rendered": {
"opened": "opened",
"dialogOpen": True,
"title": "Close resolved incident",
"destination": "stackchain/ops · #7",
"reason": "Fresh authorization expired",
"progress": "Delivery 1 of 1 · Authorization required",
"action": "Authorize & close",
},
"cleared": {"count": 0, "current": None},
"completed": 1,
"dialogOpen": False,
}
@pytest.mark.anyio
async def test_dashboard_packages_phone_safe_guided_delivery_recovery():
html = await dashboard()
service_worker = (ROOT / "frontend" / "service-worker.js").read_text()
bundle = (ROOT / "src" / "frontend_bundle.py").read_text()
assert '<dialog id="mobile-delivery-recovery"' in html
assert 'aria-labelledby="mobile-delivery-recovery-title"' in html
assert 'id="mobile-delivery-recovery-action"' in html
assert '<script src="static/mobile-delivery-recovery.js"></script>' in html
assert "const mobileDeliveryRecovery = createMobileDeliveryRecovery({" in html
assert "activate: recoverMobileDelivery," in html
assert "button.onclick=async()=>" in html
assert "await (authored ? authoredOutbox : issueOutbox).retry(item.outbox_id, activeFlushLogin)" in html
assert "(authored ? applyAuthoredOutboxResult : applyOutboxResult)(result)" in html
assert "openDelivery: () => mobileDeliveryRecovery.open()" in html
assert "mobileStartDay.completePhase('delivery')" in html
assert "BASE + 'static/mobile-delivery-recovery.js'" in service_worker
assert '"static/mobile-delivery-recovery.js"' in bundle
assert ".mobile-delivery-recovery-panel" in html
assert "min-height:48px" in html
assert "env(safe-area-inset-bottom)" in html
+10 -1
View File
@@ -197,6 +197,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
root = MODULE.parents[1]
html = (root / "frontend" / "index.html").read_text()
dashboard = (root / "frontend" / "dashboard.js").read_text()
storage_module = (root / "frontend" / "device-storage.js").read_text()
worker = (root / "frontend" / "service-worker.js").read_text()
css = (root / "frontend" / "dashboard.css").read_text()
@@ -208,13 +209,21 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert 'id="device-setup-sheet"' in html
assert 'aria-labelledby="device-setup-heading"' in html
assert all(f'id="device-setup-{step}"' in html for step in ("install", "offline", "push", "deadline"))
assert 'id="device-storage-summary"' in html
assert 'id="device-storage-detail"' in html
assert 'id="clear-device-caches"' in html
assert 'id="clear-private-device-data"' in html
assert '<script src="static/private-device-data.js"></script>' in html
assert '<script src="static/device-storage.js"></script>' in html
assert 'id="device-setup-deadline-hour"' in html
assert '<script src="static/mobile-device-setup.js"></script>' in html
assert "createMobileDeviceSetup.mount({" in dashboard
assert "createDeviceStorage.mount(document)" in dashboard
assert "clearPrivateDeviceData:root.stackchainPrivateDeviceData" in storage_module
assert "promptStorage:localStorage" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker
assert "stackchain-dashboard-shell-v108" in worker
assert "stackchain-dashboard-shell-v117" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css
+135
View File
@@ -0,0 +1,135 @@
import json
import subprocess
from pathlib import Path
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-find-work-nav.js"
FRONTEND = CONTROLLER.parent
def run_navigation(scenario: str) -> dict:
script = f"""
const createNavigation = require({json.dumps(str(CONTROLLER))});
class FakeElement {{
constructor(name) {{ this.name=name; this.listeners={{}}; this.attributes={{}}; this.hidden=false; this.disabled=false; this.focused=0; }}
addEventListener(name, callback) {{ (this.listeners[name] ||= []).push(callback); }}
removeEventListener(name, callback) {{ this.listeners[name]=(this.listeners[name]||[]).filter(item=>item!==callback); }}
click() {{ for (const callback of this.listeners.click||[]) callback({{preventDefault(){{}}}}); }}
setAttribute(name,value) {{ this.attributes[name]=value; }}
removeAttribute(name) {{ delete this.attributes[name]; }}
focus() {{ this.focused++; }}
}}
class FakeEvents {{
constructor() {{ this.listeners={{}}; }}
addEventListener(name, callback) {{ (this.listeners[name] ||= []).push(callback); }}
removeEventListener(name, callback) {{ this.listeners[name]=(this.listeners[name]||[]).filter(item=>item!==callback); }}
emit(name,event) {{ for (const callback of this.listeners[name]||[]) callback(event); }}
}}
{scenario}
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_find_work_navigation_gates_review_and_fit_until_work_is_selected():
result = run_navigation("""
const names=['discover','review','fit'];
const buttons=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
const sections=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
const events=new FakeEvents();
const history={state:{taskOverlay:'find'}, pushes:[], backs:0,
pushState(state){this.state=state;this.pushes.push(state);}, back(){this.backs++;}};
const navigation=createNavigation({buttons,sections,history,eventTarget:events,onStageChange:()=>{}});
navigation.start();
const initial={stage:navigation.stage(),reviewDisabled:buttons.review.disabled,fitDisabled:buttons.fit.disabled,hidden:Object.fromEntries(names.map(name=>[name,sections[name].hidden]))};
navigation.sync({selectedCount:2,recovery:false});
buttons.review.click();
buttons.fit.click();
process.stdout.write(JSON.stringify({initial,stage:navigation.stage(),pushes:history.pushes,selected:Object.fromEntries(names.map(name=>[name,buttons[name].attributes['aria-current']||null]))}));
""")
assert result == {
"initial": {
"stage": "discover",
"reviewDisabled": True,
"fitDisabled": True,
"hidden": {"discover": False, "review": True, "fit": True},
},
"stage": "fit",
"pushes": [
{"taskOverlay": "find", "findWorkStage": "review"},
{"taskOverlay": "find", "findWorkStage": "fit"},
],
"selected": {"discover": None, "review": None, "fit": "step"},
}
def test_browser_back_moves_fit_to_review_to_discover_without_losing_selection():
result = run_navigation("""
const names=['discover','review','fit'];
const buttons=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
const sections=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
const events=new FakeEvents(); const changes=[];
const history={state:{taskOverlay:'find'},pushState(state){this.state=state;},back(){}};
const navigation=createNavigation({buttons,sections,history,eventTarget:events,onStageChange:stage=>changes.push(stage)});
navigation.start(); navigation.sync({selectedCount:1,recovery:false}); navigation.go('review'); navigation.go('fit');
events.emit('popstate',{state:{taskOverlay:'find',findWorkStage:'review'}});
const afterFirst=navigation.stage();
events.emit('popstate',{state:{taskOverlay:'find'}});
process.stdout.write(JSON.stringify({afterFirst,afterSecond:navigation.stage(),changes,reviewDisabled:buttons.review.disabled,fitDisabled:buttons.fit.disabled}));
""")
assert result == {
"afterFirst": "review",
"afterSecond": "discover",
"changes": ["discover", "review", "fit", "review", "discover"],
"reviewDisabled": False,
"fitDisabled": False,
}
def test_reopening_restores_progress_but_completion_resets_to_discovery():
result = run_navigation("""
const names=['discover','review','fit'];
const buttons=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
const sections=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
const events=new FakeEvents();
const history={state:{taskOverlay:'find',findWorkStage:'review'},replaced:[],pushState(state){this.state=state;},replaceState(state){this.state=state;this.replaced.push(state);},back(){}};
const navigation=createNavigation({buttons,sections,history,eventTarget:events,onStageChange:()=>{}});
navigation.start(); navigation.sync({selectedCount:1,recovery:false}); navigation.open();
const restored=navigation.stage();
navigation.complete();
process.stdout.write(JSON.stringify({restored,completed:navigation.stage(),state:history.state,replaced:history.replaced}));
""")
assert result == {
"restored": "review",
"completed": "discover",
"state": {"taskOverlay": "find"},
"replaced": [{"taskOverlay": "find"}],
}
def test_find_work_ships_guided_mobile_flow_in_the_offline_bundle():
html = (FRONTEND / "index.html").read_text()
css = (FRONTEND / "dashboard.css").read_text()
bundle = (FRONTEND.parent / "src" / "frontend_bundle.py").read_text()
service_worker = (FRONTEND / "service-worker.js").read_text()
dashboard = (FRONTEND / "dashboard.js").read_text()
assert '<nav class="mobile-find-work-nav"' in html
assert 'aria-label="Find Work steps"' in html
for name, label in (("discover", "Discover"), ("review", "Review"), ("fit", "Fit &amp; assign")):
assert f'data-find-work-stage="{name}"' in html
assert f">{label}</button>" in html
assert f'id="find-work-{name}-stage"' in html
assert 'id="find-work-review-list"' in html
assert 'id="continue-find-work-fit"' in html
assert '<script src="static/mobile-find-work-nav.js"></script>' in html
assert '"static/mobile-find-work-nav.js"' in bundle
assert "static/mobile-find-work-nav.js" in service_worker
assert ".mobile-find-work-nav" in css
assert "min-height:44px" in css
assert "createMobileFindWorkNavigation" in dashboard
assert "findWorkNavigation?.sync" in dashboard
+178
View File
@@ -0,0 +1,178 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-insights.js"
def run_node(script: str) -> dict:
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
return json.loads(completed.stdout)
def test_mobile_insights_opens_as_addressable_modal_and_hides_home_chrome():
script = f"""
const createInsights = require({json.dumps(str(CONTROLLER))});
const listeners = {{}};
const element = () => ({{
hidden:false, inert:false, attributes:{{}}, focuses:0, listeners:{{}},
setAttribute(name,value) {{ this.attributes[name] = String(value); }},
removeAttribute(name) {{ delete this.attributes[name]; }},
addEventListener(name,fn) {{ this.listeners[name] = fn; }},
focus() {{ this.focuses += 1; }},
}});
const root = element(); const launcher = element(); const closeButton = element();
const dock = element(); const hud = element(); const home = element(); const header = element();
const menu = {{open:true}};
const location = {{pathname:'/dashboard/', search:'', hash:'#/my-work/today'}};
const history = {{state:null, pushes:[], pushState(state,unused,url) {{
this.state=state; this.pushes.push(url); location.hash=url.slice(url.indexOf('#'));
}}, replaceState() {{}}, back() {{}}}};
const controller = createInsights({{
root, launcher, closeButton, dock, hud, backgrounds:[header,home], menu, history, location,
eventTarget:{{addEventListener(name,fn) {{ listeners[name]=fn; }}}},
mediaQuery:{{matches:true, addEventListener() {{}}}},
}});
controller.start();
launcher.listeners.click();
process.stdout.write(JSON.stringify({{
hash:location.hash, pushes:history.pushes, open:root.attributes['data-mobile-open'],
role:root.attributes.role, modal:root.attributes['aria-modal'], rootInert:root.inert,
homeInert:home.inert, headerInert:header.inert, dockHidden:dock.hidden,
hudHidden:hud.attributes['data-overlay-hidden'], closeFocuses:closeButton.focuses,
menuOpen:menu.open,
}}));
"""
assert run_node(script) == {
"hash": "#/insights",
"pushes": ["/dashboard/#/insights"],
"open": "true",
"role": "dialog",
"modal": "true",
"rootInert": False,
"homeInert": True,
"headerInert": True,
"dockHidden": True,
"hudHidden": "true",
"closeFocuses": 1,
"menuOpen": False,
}
def test_direct_insights_route_closes_locally_and_restores_launcher_focus():
script = f"""
const createInsights = require({json.dumps(str(CONTROLLER))});
const listeners = {{}};
const element = () => ({{
hidden:false, inert:false, attributes:{{}}, focuses:0, listeners:{{}},
setAttribute(name,value) {{ this.attributes[name] = String(value); }},
removeAttribute(name) {{ delete this.attributes[name]; }},
addEventListener(name,fn) {{ this.listeners[name] = fn; }},
focus() {{ this.focuses += 1; }},
}});
const root=element(), launcher=element(), closeButton=element(), dock=element(), hud=element(), home=element();
const location={{pathname:'/dashboard/', search:'', hash:'#/insights'}};
const history={{state:null, backs:0, replacements:[], pushState() {{}}, back() {{ this.backs += 1; }},
replaceState(state,unused,url) {{ this.state=state; this.replacements.push(url); location.hash=url.slice(url.indexOf('#')); }}
}};
const controller=createInsights({{
root,launcher,closeButton,dock,hud,backgrounds:[home],history,location,
eventTarget:{{addEventListener(name,fn) {{ listeners[name]=fn; }}}},
mediaQuery:{{matches:true,addEventListener() {{}}}},
}});
controller.start();
closeButton.listeners.click();
process.stdout.write(JSON.stringify({{
current:controller.current(), hash:location.hash, backs:history.backs,
replacements:history.replacements, rootInert:root.inert, homeInert:home.inert,
dockHidden:dock.hidden, hudHidden:hud.attributes['data-overlay-hidden'] || null,
launcherFocuses:launcher.focuses,
}}));
"""
assert run_node(script) == {
"current": False,
"hash": "#/my-work",
"backs": 0,
"replacements": ["/dashboard/#/my-work"],
"rootInert": True,
"homeInert": False,
"dockHidden": False,
"hudHidden": None,
"launcherFocuses": 1,
}
def test_browser_back_closes_insights_without_resetting_the_work_route():
script = f"""
const createInsights = require({json.dumps(str(CONTROLLER))});
const listeners={{}};
const element=()=>({{hidden:false,inert:false,attributes:{{}},focuses:0,listeners:{{}},setAttribute(n,v){{this.attributes[n]=String(v);}},removeAttribute(n){{delete this.attributes[n];}},addEventListener(n,f){{this.listeners[n]=f;}},focus(){{this.focuses+=1;}}}});
const root=element(),launcher=element(),closeButton=element(),dock=element(),hud=element(),home=element();
const location={{pathname:'/dashboard/',search:'',hash:'#/my-work/update'}};
const history={{state:null,pushState(state,unused,url){{this.state=state;location.hash=url.slice(url.indexOf('#'));}},replaceState(){{}},back(){{}}}};
const controller=createInsights({{root,launcher,closeButton,dock,hud,backgrounds:[home],history,location,eventTarget:{{addEventListener(n,f){{listeners[n]=f;}}}},mediaQuery:{{matches:true,addEventListener(){{}}}}}});
controller.start(); launcher.listeners.click();
location.hash='#/my-work/update'; history.state=null; listeners.popstate?.({{state:null}});
process.stdout.write(JSON.stringify({{current:controller.current(),hash:location.hash,launcherFocuses:launcher.focuses,homeInert:home.inert,dockHidden:dock.hidden}}));
"""
assert run_node(script) == {
"current": False,
"hash": "#/my-work/update",
"launcherFocuses": 1,
"homeInert": False,
"dockHidden": False,
}
def test_escape_requests_back_navigation_only_while_insights_is_open():
script = f"""
const createInsights=require({json.dumps(str(CONTROLLER))});
const listeners={{}};
const element=()=>({{hidden:false,inert:false,attributes:{{}},listeners:{{}},setAttribute(n,v){{this.attributes[n]=String(v);}},removeAttribute(n){{delete this.attributes[n];}},addEventListener(n,f){{this.listeners[n]=f;}},focus(){{}}}});
const root=element(),launcher=element(),closeButton=element(),dock=element(),hud=element();
const location={{pathname:'/dashboard/',search:'',hash:'#/my-work'}};
const history={{state:null,backs:0,pushState(state,unused,url){{this.state=state;location.hash=url.slice(url.indexOf('#'));}},replaceState(){{}},back(){{this.backs+=1;}}}};
const controller=createInsights({{root,launcher,closeButton,dock,hud,backgrounds:[],history,location,eventTarget:{{addEventListener(n,f){{listeners[n]=f;}}}},mediaQuery:{{matches:true,addEventListener(){{}}}}}});
controller.start(); launcher.listeners.click();
let prevented=0; listeners.keydown?.({{key:'Escape',preventDefault(){{prevented+=1;}}}}); listeners.keydown?.({{key:'Enter',preventDefault(){{prevented+=1;}}}});
process.stdout.write(JSON.stringify({{backs:history.backs,prevented}}));
"""
assert run_node(script) == {"backs": 1, "prevented": 1}
@pytest.mark.anyio
async def test_mobile_home_progressively_discloses_secondary_panels_as_insights():
html = await dashboard()
assert 'id="open-insights"' in html
assert 'id="insights-sheet"' in html
assert 'id="close-insights"' in html
assert html.index('id="my-work"') < html.index('id="insights-sheet"')
assert html.index('id="insights-sheet"') < html.index('data-panel-key="context"')
assert html.index('data-panel-key="markdown"') < html.index('</section>\n</main>')
assert '<script src="static/mobile-insights.js"></script>' in html
assert "createMobileInsights({" in html
assert "mediaQuery: window.matchMedia('(max-width: 600px)')" in html
assert ".insights-shell { display:contents; }" in html
assert "#open-insights { display:none; }" in html
assert '.insights-shell[data-mobile-open="true"]' in html
assert "position:fixed; inset:0;" in html
assert "overflow-x:hidden" in html
assert ".insights-header button { min-height:44px;" in html
def test_mobile_insights_rolls_into_the_offline_shell():
worker = (CONTROLLER.parent / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v117" in worker
assert "BASE + 'static/mobile-insights.js'" in worker
@@ -0,0 +1,185 @@
import json
import subprocess
from pathlib import Path
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-issue-detail-nav.js"
FRONTEND = CONTROLLER.parent
def test_mobile_issue_navigation_opens_and_focuses_each_destination_without_forced_motion():
script = f"""
const createNavigation = require({json.dumps(str(CONTROLLER))});
class FakeElement {{
constructor(name) {{
this.name = name;
this.listeners = {{}};
this.attributes = {{}};
this.open = false;
this.focuses = 0;
this.scrolls = [];
}}
addEventListener(name, callback) {{ this.listeners[name] = callback; }}
removeEventListener(name) {{ delete this.listeners[name]; }}
click() {{ this.listeners.click({{currentTarget:this, preventDefault() {{}}}}); }}
setAttribute(name, value) {{ this.attributes[name] = value; }}
removeAttribute(name) {{ delete this.attributes[name]; }}
focus() {{ this.focuses += 1; }}
scrollIntoView(options) {{ this.scrolls.push(options); }}
}}
const buttons = Object.fromEntries(['overview','conversation','reply','actions'].map(name => [name,new FakeElement(name)]));
const targets = Object.fromEntries(['overview','conversation','reply','actions'].map(name => [name,new FakeElement(name)]));
const navigation = createNavigation({{
buttons,
targets,
planning:targets.actions,
prefersReducedMotion:() => true,
}});
navigation.start();
buttons.overview.click();
buttons.conversation.click();
buttons.reply.click();
buttons.actions.click();
const currentAfterActions = Object.fromEntries(Object.entries(buttons).map(([name,button]) => [name,button.attributes['aria-current'] || null]));
navigation.stop();
process.stdout.write(JSON.stringify({{
overview:targets.overview.scrolls,
conversation:targets.conversation.scrolls,
reply:targets.reply.scrolls,
replyFocuses:targets.reply.focuses,
actions:targets.actions.scrolls,
planningOpen:targets.actions.open,
currentAfterActions,
listenersAfterStop:Object.values(buttons).map(button => Object.keys(button.listeners).length),
}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"overview": [{"block": "start", "behavior": "auto"}],
"conversation": [{"block": "start", "behavior": "auto"}],
"reply": [{"block": "start", "behavior": "auto"}],
"replyFocuses": 1,
"actions": [{"block": "start", "behavior": "auto"}],
"planningOpen": True,
"currentAfterActions": {
"overview": None,
"conversation": None,
"reply": None,
"actions": "location",
},
"listenersAfterStop": [0, 0, 0, 0],
}
def test_mobile_issue_navigation_tracks_the_visible_section_and_disconnects_observer():
script = f"""
const createNavigation = require({json.dumps(str(CONTROLLER))});
const makeElement = name => ({{
name, attributes:{{}},
addEventListener() {{}}, removeEventListener() {{}},
setAttribute(key,value) {{ this.attributes[key] = value; }},
removeAttribute(key) {{ delete this.attributes[key]; }},
}});
const buttons = Object.fromEntries(['overview','conversation','reply','actions'].map(name => [name,makeElement(name)]));
const targets = Object.fromEntries(['overview','conversation','reply','actions'].map(name => [name,makeElement(name)]));
let callback;
let disconnected = 0;
const navigation = createNavigation({{
buttons, targets,
observe(handler, observed) {{
callback = handler;
return {{disconnect() {{ disconnected += 1; }}}};
}},
}});
navigation.start();
callback([
{{target:targets.overview,isIntersecting:true,intersectionRatio:.2}},
{{target:targets.reply,isIntersecting:true,intersectionRatio:.8}},
]);
const current = Object.fromEntries(Object.entries(buttons).map(([name,button]) => [name,button.attributes['aria-current'] || null]));
navigation.stop();
process.stdout.write(JSON.stringify({{current,disconnected}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"current": {
"overview": None,
"conversation": None,
"reply": "location",
"actions": None,
},
"disconnected": 1,
}
def test_mobile_issue_navigation_distinguishes_taps_scrolls_and_keyboard_safe_restore():
script = f"""
const createNavigation = require({json.dumps(str(CONTROLLER))});
class FakeElement {{
constructor(name) {{ this.name=name; this.listeners={{}}; this.attributes={{}}; this.focuses=0; }}
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
removeEventListener() {{}}
click() {{ this.listeners.click({{preventDefault() {{}}}}); }}
setAttribute(name, value) {{ this.attributes[name]=value; }}
removeAttribute(name) {{ delete this.attributes[name]; }}
scrollIntoView() {{}}
focus() {{ this.focuses += 1; }}
}}
const names=['overview','conversation','reply','actions'];
const buttons=Object.fromEntries(names.map(name => [name,new FakeElement(name)]));
const targets=Object.fromEntries(names.map(name => [name,new FakeElement(name)]));
const changes=[];
let observer;
const navigation=createNavigation({{
buttons, targets,
onSectionChange:(name, options) => changes.push([name, options.replace]),
observe(handler) {{ observer=handler; return {{disconnect() {{}}}}; }},
}});
navigation.start();
buttons.reply.click();
observer([{{target:targets.actions,isIntersecting:true,intersectionRatio:1}}]);
navigation.navigate('reply', {{focus:false}});
process.stdout.write(JSON.stringify({{changes,replyFocuses:targets.reply.focuses,current:buttons.reply.attributes['aria-current']}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"changes": [["reply", False], ["actions", True]],
"replyFocuses": 1,
"current": "location",
}
def test_issue_sheet_ships_a_mobile_only_safe_area_navigation_rail():
html = (FRONTEND / "index.html").read_text()
css = (FRONTEND / "dashboard.css").read_text()
dashboard_js = (FRONTEND / "dashboard.js").read_text()
assert '<nav class="mobile-issue-detail-nav"' in html
assert 'aria-label="Issue sections"' in html
for name, label in (
("overview", "Overview"),
("conversation", "Conversation"),
("reply", "Reply"),
("actions", "Plan & actions"),
):
assert f'data-issue-section="{name}"' in html
assert f">{label}</button>" in html
assert 'id="issue-overview"' in html
assert 'id="issue-conversation"' in html
assert 'id="issue-comment"' in html
assert 'id="issue-planning"' in html
assert '<script src="static/mobile-issue-detail-nav.js"></script>' in html
assert "createMobileIssueDetailNavigation({" in dashboard_js
assert "window.matchMedia('(prefers-reduced-motion: reduce)').matches" in dashboard_js
assert "@media (max-width:600px)" in css
assert ".mobile-issue-detail-nav" in css
assert "position:sticky" in css
assert "min-height:44px" in css
assert "env(safe-area-inset-top)" in css
assert "@media (min-width:601px)" in css
+103
View File
@@ -0,0 +1,103 @@
import json
import subprocess
from pathlib import Path
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-plan-today-nav.js"
FRONTEND = CONTROLLER.parent
def run_navigation(scenario: str) -> dict:
script = f"""
const createNavigation = require({json.dumps(str(CONTROLLER))});
class FakeElement {{
constructor(name, offsetTop=0) {{
this.name=name; this.offsetTop=offsetTop; this.listeners={{}}; this.attributes={{}};
this.scrolls=[]; this.hidden=false;
}}
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
removeEventListener(name) {{ delete this.listeners[name]; }}
click() {{ this.listeners.click?.({{preventDefault() {{}}}}); }}
setAttribute(name,value) {{ this.attributes[name]=value; }}
removeAttribute(name) {{ delete this.attributes[name]; }}
scrollTo(options) {{ this.scrolls.push(options); }}
}}
{scenario}
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_mobile_plan_today_navigation_scrolls_the_planner_and_tracks_sections():
result = run_navigation("""
const names=['fit','today','available'];
const buttons=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
const targets={fit:new FakeElement('fit',120),today:new FakeElement('today',620),available:new FakeElement('available',1120)};
const root=new FakeElement('panel',100);
let callback; let observedRoot; let disconnected=0; let now=1000;
const navigation=createNavigation({
buttons,targets,root,prefersReducedMotion:()=>true,now:()=>now,
observe(handler,observed,rootOption){callback=handler; observedRoot=rootOption; return {disconnect(){disconnected++;}};},
});
navigation.start();
buttons.today.click();
now=1600;
callback([{target:targets.today,isIntersecting:true,intersectionRatio:.2},{target:targets.available,isIntersecting:true,intersectionRatio:.8}]);
const selected=Object.fromEntries(Object.entries(buttons).map(([name,button])=>[name,button.attributes['aria-current']||null]));
navigation.stop();
process.stdout.write(JSON.stringify({scrolls:root.scrolls,selected,observedRoot:observedRoot.name,disconnected,listeners:Object.values(buttons).map(button=>Object.keys(button.listeners).length)}));
""")
assert result == {
"scrolls": [{"top": 520, "behavior": "auto"}],
"selected": {"fit": None, "today": None, "available": "location"},
"observedRoot": "panel",
"disconnected": 1,
"listeners": [0, 0, 0],
}
def test_mobile_plan_today_navigation_preserves_selection_until_a_fresh_open():
result = run_navigation("""
const names=['fit','today','available'];
const buttons=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
const targets=Object.fromEntries(names.map((name,index)=>[name,new FakeElement(name,index*400)]));
const root=new FakeElement('panel');
const navigation=createNavigation({buttons,targets,root,prefersReducedMotion:()=>false,observe:()=>null});
navigation.start();
buttons.available.click();
const afterRerender=Object.fromEntries(Object.entries(buttons).map(([name,button])=>[name,button.attributes['aria-current']||null]));
navigation.reset();
const afterOpen=Object.fromEntries(Object.entries(buttons).map(([name,button])=>[name,button.attributes['aria-current']||null]));
process.stdout.write(JSON.stringify({scrolls:root.scrolls,afterRerender,afterOpen}));
""")
assert result == {
"scrolls": [{"top": 800, "behavior": "smooth"}],
"afterRerender": {"fit": None, "today": None, "available": "location"},
"afterOpen": {"fit": "location", "today": None, "available": None},
}
def test_plan_today_ships_mobile_navigation_in_the_offline_bundle():
html = (FRONTEND / "index.html").read_text()
css = (FRONTEND / "dashboard.css").read_text()
bundle = (FRONTEND.parent / "src" / "frontend_bundle.py").read_text()
service_worker = (FRONTEND / "service-worker.js").read_text()
assert '<nav class="mobile-plan-today-nav"' in html
assert 'aria-label="Plan Today sections"' in html
for name, label in (("fit", "Fit"), ("today", "Today"), ("available", "Available")):
assert f'data-plan-today-section="{name}"' in html
assert f">{label}</button>" in html
assert 'id="plan-today-fit"' in html
assert 'id="plan-today-selected"' in html
assert 'id="plan-today-available-work"' in html
assert '<script src="static/mobile-plan-today-nav.js"></script>' in html
assert '"static/mobile-plan-today-nav.js"' in bundle
assert "static/mobile-plan-today-nav.js" in service_worker
assert ".mobile-plan-today-nav" in css
assert "grid-template-columns:repeat(3,minmax(0,1fr))" in css
assert "min-height:44px" in css
assert "@media (min-width:601px)" in css
@@ -0,0 +1,86 @@
import json
import subprocess
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
CONTROLLER = FRONTEND / "mobile-issue-detail-nav.js"
def test_pull_navigation_prepares_review_before_scrolling_and_focuses_reply():
script = f"""
const createNavigation = require({json.dumps(str(CONTROLLER))});
class FakeElement {{
constructor(name) {{ this.name=name; this.listeners={{}}; this.attributes={{}}; this.open=false; this.focuses=0; this.scrolls=[]; }}
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
removeEventListener(name) {{ delete this.listeners[name]; }}
setAttribute(name, value) {{ this.attributes[name]=value; }}
removeAttribute(name) {{ delete this.attributes[name]; }}
focus() {{ this.focuses += 1; }}
scrollIntoView(options) {{ this.scrolls.push({{...options, openWhenScrolled:this.open}}); }}
}}
const buttons = Object.fromEntries(['overview','conversation','reply','review'].map(name => [name,new FakeElement(name)]));
const targets = Object.fromEntries(['overview','conversation','reply','review'].map(name => [name,new FakeElement(name)]));
let prepared = [];
const navigation = createNavigation({{
buttons, targets,
beforeNavigate:{{review(target) {{ prepared.push('review'); target.open=true; }}}},
prefersReducedMotion:() => true,
}});
navigation.start();
navigation.navigate('reply');
navigation.navigate('review');
process.stdout.write(JSON.stringify({{
replyFocuses:targets.reply.focuses,
reviewScrolls:targets.review.scrolls,
prepared,
current:Object.fromEntries(Object.entries(buttons).map(([name,button]) => [name,button.attributes['aria-current'] || null])),
}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"replyFocuses": 1,
"reviewScrolls": [{"block": "start", "behavior": "auto", "openWhenScrolled": True}],
"prepared": ["review"],
"current": {
"overview": None,
"conversation": None,
"reply": None,
"review": "location",
},
}
def test_pull_sheet_wires_four_workspace_destinations_to_existing_lazy_review():
html = (FRONTEND / "index.html").read_text()
dashboard_js = (FRONTEND / "dashboard.js").read_text()
assert '<nav class="mobile-detail-nav mobile-pull-detail-nav"' in html
assert 'aria-label="Pull request sections"' in html
for name, label in (
("overview", "Overview"),
("conversation", "Conversation"),
("reply", "Reply"),
("review", "Review &amp; merge"),
):
assert f'data-pull-section="{name}"' in html
assert f">{label}</button>" in html
assert 'id="pull-overview"' in html
assert 'id="pull-conversation"' in html
assert 'id="pull-comment"' in html
assert 'id="pull-review"' in html
assert "document.querySelectorAll('[data-pull-section]')" in dashboard_js
assert "beforeNavigate:{review(target) { target.open = true; }}" in dashboard_js
def test_pull_workspace_rail_has_phone_containment_and_desktop_visibility_contracts():
css = (FRONTEND / "dashboard.css").read_text()
assert "@media (max-width:600px)" in css
assert ".mobile-issue-detail-nav, .mobile-pull-detail-nav" in css
assert "position:sticky; top:env(safe-area-inset-top)" in css
assert "grid-template-columns:repeat(4,minmax(0,1fr))" in css
assert "min-width:0; min-height:44px" in css
assert "#pull-overview, #pull-conversation, #pull-comment, #pull-review { scroll-margin-top:72px; }" in css
assert "@media (min-width:601px) { .mobile-issue-detail-nav, .mobile-pull-detail-nav { display:none; } }" in css
+188
View File
@@ -0,0 +1,188 @@
import json
import subprocess
from pathlib import Path
import pytest
from src.frontend_bundle import build_frontend
from tests.dashboard_bundle import dashboard
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-pull-refresh.js"
def run_scenario(source: str) -> dict:
script = f"""
const createPullRefresh = require({json.dumps(str(CONTROLLER))});
class FakeElement {{
constructor() {{
this.listeners = {{}};
this.dataset = {{}};
this.hidden = true;
this.textContent = '';
}}
addEventListener(name, callback) {{ this.listeners[name] = callback; }}
removeEventListener(name) {{ delete this.listeners[name]; }}
setPointerCapture() {{}}
releasePointerCapture() {{}}
}}
const surface = new FakeElement();
const indicator = new FakeElement();
const plain = {{closest: () => null, parentElement: null, scrollWidth: 100, clientWidth: 100}};
const event = (x, y, target=plain, id=1) => ({{
pointerId:id, pointerType:'touch', clientX:x, clientY:y, target,
preventDefault() {{ this.prevented = true; }},
}});
{source}
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_mobile_pull_refresh_runs_one_refresh_after_vertical_threshold_and_announces_success():
result = run_scenario("""
let calls = 0;
const controller = createPullRefresh({
surface, indicator, threshold:64,
refresh: async () => { calls += 1; },
isMobile: () => true,
getScrollY: () => 0,
isBlocked: () => false,
});
controller.start();
surface.listeners.pointerdown(event(100, 10));
surface.listeners.pointermove(event(102, 90));
const ready = {hidden:indicator.hidden, state:indicator.dataset.state, text:indicator.textContent};
const completion = surface.listeners.pointerup(event(102, 90));
Promise.resolve(completion).then(() => process.stdout.write(JSON.stringify({
calls, ready,
final:{hidden:indicator.hidden, state:indicator.dataset.state, text:indicator.textContent},
})));
""")
assert result == {
"calls": 1,
"ready": {"hidden": False, "state": "ready", "text": "Release to refresh"},
"final": {"hidden": False, "state": "success", "text": "My Work is up to date"},
}
def test_mobile_pull_refresh_ignores_ineligible_gestures_and_short_pulls():
result = run_scenario("""
let calls = 0;
let mobile = true;
let scrollY = 0;
let blocked = false;
const controller = createPullRefresh({
surface, indicator, threshold:64,
refresh: async () => { calls += 1; },
isMobile: () => mobile,
getScrollY: () => scrollY,
isBlocked: () => blocked,
});
controller.start();
async function drag(fromX, fromY, toX, toY, target=plain) {
surface.listeners.pointerdown(event(fromX, fromY, target));
surface.listeners.pointermove(event(toX, toY, target));
await surface.listeners.pointerup(event(toX, toY, target));
}
(async () => {
await drag(100, 10, 102, 50);
await drag(100, 10, 180, 30);
mobile = false; await drag(100, 10, 100, 100);
mobile = true; scrollY = 1; await drag(100, 10, 100, 100);
scrollY = 0; blocked = true; await drag(100, 10, 100, 100);
blocked = false;
const interactive = {closest: selector => selector ? interactive : null, parentElement:null, scrollWidth:100, clientWidth:100};
await drag(100, 10, 100, 100, interactive);
const scroller = {closest: () => null, parentElement:null, scrollWidth:300, clientWidth:100};
await drag(100, 10, 100, 100, scroller);
process.stdout.write(JSON.stringify({calls}));
})();
""")
assert result == {"calls": 0}
def test_mobile_pull_refresh_stays_single_flight_and_keeps_refreshing_status():
result = run_scenario("""
let calls = 0;
let finish;
const pending = new Promise(resolve => { finish = resolve; });
const controller = createPullRefresh({
surface, indicator, threshold:64,
refresh: () => { calls += 1; return pending; },
isMobile: () => true,
getScrollY: () => 0,
isBlocked: () => false,
});
controller.start();
function pull(id) {
surface.listeners.pointerdown(event(100, 10, plain, id));
surface.listeners.pointermove(event(100, 90, plain, id));
return surface.listeners.pointerup(event(100, 90, plain, id));
}
const first = pull(1);
Promise.resolve().then(async () => {
const second = pull(2);
const during = {calls, state:indicator.dataset.state, text:indicator.textContent};
finish();
await first;
await second;
process.stdout.write(JSON.stringify({calls, during, final:indicator.dataset.state}));
});
""")
assert result == {
"calls": 1,
"during": {"calls": 1, "state": "refreshing", "text": "Refreshing My Work…"},
"final": "success",
}
def test_mobile_pull_refresh_announces_failure_and_allows_a_retry():
result = run_scenario("""
let calls = 0;
const controller = createPullRefresh({
surface, indicator, threshold:64,
refresh: async () => { calls += 1; if (calls === 1) throw new Error('offline secret'); },
isMobile: () => true,
getScrollY: () => 0,
isBlocked: () => false,
});
controller.start();
async function pull(id) {
surface.listeners.pointerdown(event(100, 10, plain, id));
surface.listeners.pointermove(event(100, 90, plain, id));
try { await surface.listeners.pointerup(event(100, 90, plain, id)); } catch (_) {}
}
(async () => {
await pull(1);
const failed = {state:indicator.dataset.state, text:indicator.textContent};
await pull(2);
process.stdout.write(JSON.stringify({calls, failed, final:indicator.dataset.state}));
})();
""")
assert result == {
"calls": 2,
"failed": {"state": "error", "text": "Could not refresh · pull to retry"},
"final": "success",
}
@pytest.mark.anyio
async def test_mobile_pull_refresh_is_wired_to_live_refresh_and_packaged_offline():
html = await dashboard()
frontend = CONTROLLER.parent
build = build_frontend(frontend)
today_bundle = build.feature_bundles["today-timer"].runtime_bytes.decode()
assert 'id="mobile-pull-refresh"' in html
assert 'role="status"' in html
assert html.index('static/mobile-pull-refresh.js') < html.index('static/dashboard.js')
assert "createMobilePullRefresh" in html
assert "contextPoller.refresh({ force: true })" in html
assert "createMobilePullRefresh" in today_bundle
assert "liveDataStatus" in today_bundle
assert build.feature_bundles["today-timer"].runtime_name in build.service_worker_source
@@ -0,0 +1,178 @@
import json
import subprocess
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
CONTROLLER = FRONTEND / "mobile-review-detail-nav.js"
def test_review_navigation_moves_between_sections_and_focuses_feedback():
script = f"""
const createNavigation = require({json.dumps(str(CONTROLLER))});
class FakeElement {{
constructor(name) {{
this.name=name; this.listeners={{}}; this.attributes={{}}; this.focuses=0; this.scrolls=[];
}}
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
removeEventListener(name) {{ delete this.listeners[name]; }}
setAttribute(name, value) {{ this.attributes[name]=value; }}
removeAttribute(name) {{ delete this.attributes[name]; }}
focus(options) {{ this.focuses += 1; this.focusOptions=options; }}
scrollIntoView(options) {{ this.scrolls.push(options); }}
}}
const names=['overview','files','feedback','history'];
const buttons=Object.fromEntries(names.map(name => [name,new FakeElement(name)]));
const targets=Object.fromEntries(names.map(name => [name,new FakeElement(name)]));
const summaryComposer=new FakeElement('summary');
const navigation=createNavigation({{
buttons, targets, summaryComposer,
prefersReducedMotion:() => true,
}});
navigation.start();
navigation.navigate('files');
navigation.navigate('feedback');
const currentAfterFeedback=Object.fromEntries(Object.entries(buttons).map(([name,button]) => [name,button.attributes['aria-current'] || null]));
navigation.reset();
const currentAfterReset=Object.fromEntries(Object.entries(buttons).map(([name,button]) => [name,button.attributes['aria-current'] || null]));
navigation.stop();
process.stdout.write(JSON.stringify({{
files:targets.files.scrolls,
feedback:targets.feedback.scrolls,
feedbackFocuses:summaryComposer.focuses,
focusOptions:summaryComposer.focusOptions,
currentAfterFeedback,
currentAfterReset,
listenersAfterStop:Object.values(buttons).map(button => Object.keys(button.listeners).length),
}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"files": [{"block": "start", "behavior": "auto"}],
"feedback": [{"block": "start", "behavior": "auto"}],
"feedbackFocuses": 1,
"focusOptions": {"preventScroll": True},
"currentAfterFeedback": {
"overview": None,
"files": None,
"feedback": "location",
"history": None,
},
"currentAfterReset": {
"overview": "location",
"files": None,
"feedback": None,
"history": None,
},
"listenersAfterStop": [0, 0, 0, 0],
}
def test_review_navigation_tracks_the_visible_section_inside_the_review_panel():
script = f"""
const createNavigation = require({json.dumps(str(CONTROLLER))});
const makeElement=name => ({{
name, attributes:{{}},
addEventListener() {{}}, removeEventListener() {{}},
setAttribute(key,value) {{ this.attributes[key]=value; }},
removeAttribute(key) {{ delete this.attributes[key]; }},
}});
const buttons=Object.fromEntries(['overview','files','feedback','history'].map(name => [name,makeElement(name)]));
const targets=Object.fromEntries(['overview','files','feedback','history'].map(name => [name,makeElement(name)]));
let callback;
let observedRoot=null;
let disconnected=0;
const panel=makeElement('panel');
const navigation=createNavigation({{
buttons, targets, root:panel,
observe(handler, observedTargets, root) {{
callback=handler; observedRoot=root;
return {{disconnect() {{ disconnected += 1; }}}};
}},
}});
navigation.start();
callback([
{{target:targets.files,isIntersecting:true,intersectionRatio:.25}},
{{target:targets.feedback,isIntersecting:true,intersectionRatio:.8}},
]);
const current=Object.fromEntries(Object.entries(buttons).map(([name,button]) => [name,button.attributes['aria-current'] || null]));
navigation.stop();
process.stdout.write(JSON.stringify({{current,rootMatches:observedRoot === panel,disconnected}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"current": {
"overview": None,
"files": None,
"feedback": "location",
"history": None,
},
"rootMatches": True,
"disconnected": 1,
}
def test_review_navigation_routes_taps_and_scrolls_without_focusing_on_restore():
script = f"""
const createNavigation=require({json.dumps(str(CONTROLLER))});
const element=name => ({{name,listeners:{{}},attributes:{{}},focuses:0,
addEventListener(key,fn) {{ this.listeners[key]=fn; }}, removeEventListener() {{}},
setAttribute(key,value) {{ this.attributes[key]=value; }}, removeAttribute(key) {{ delete this.attributes[key]; }},
scrollIntoView() {{}}, focus() {{ this.focuses += 1; }},
}});
const names=['overview','files','feedback','history'];
const buttons=Object.fromEntries(names.map(name => [name,element(name)]));
const targets=Object.fromEntries(names.map(name => [name,element(name)]));
const composer=element('composer'); const changes=[]; let observer;
const navigation=createNavigation({{buttons,targets,summaryComposer:composer,
onSectionChange:(name,options)=>changes.push([name,options.replace]),
observe(handler) {{ observer=handler; return {{disconnect() {{}}}}; }},
}});
navigation.start();
buttons.feedback.listeners.click({{preventDefault() {{}}}});
observer([{{target:targets.history,isIntersecting:true,intersectionRatio:1}}]);
navigation.navigate('feedback',{{focus:false}});
process.stdout.write(JSON.stringify({{changes,focuses:composer.focuses,current:buttons.feedback.attributes['aria-current']}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"changes": [["feedback", False], ["history", True]],
"focuses": 1,
"current": "location",
}
def test_review_sheet_wires_a_mobile_safe_area_navigation_rail_and_offline_asset():
html = (FRONTEND / "index.html").read_text()
css = (FRONTEND / "dashboard.css").read_text()
dashboard_js = (FRONTEND / "dashboard.js").read_text()
service_worker = (FRONTEND / "service-worker.js").read_text()
assert '<nav class="mobile-review-detail-nav" aria-label="Review sections">' in html
for name, label in (
("overview", "Overview"),
("files", "Files"),
("feedback", "Feedback"),
("history", "History"),
):
assert f'data-review-section="{name}"' in html
assert f">{label}</button>" in html
assert 'id="review-overview"' in html
assert 'id="review-files-workspace"' in html
assert 'id="review-feedback"' in html
assert 'id="review-history-workspace"' in html
assert '<script src="static/mobile-review-detail-nav.js"></script>' in html
assert "createMobileReviewDetailNavigation({" in dashboard_js
assert "mobileReviewDetailNavigation.reset();" in dashboard_js
assert ".mobile-review-detail-nav" in css
assert "position:sticky" in css
assert "min-height:44px" in css
assert "env(safe-area-inset-top)" in css
assert "@media (min-width:601px)" in css
assert "BASE + 'static/mobile-review-detail-nav.js'" in service_worker
@@ -0,0 +1,135 @@
import json
import subprocess
from pathlib import Path
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-search-preview-nav.js"
FRONTEND = CONTROLLER.parent
def run_navigation(scenario: str) -> dict:
script = f"""
const createNavigation = require({json.dumps(str(CONTROLLER))});
class FakeElement {{
constructor(name) {{
this.name=name; this.listeners={{}}; this.attributes={{}}; this.scrolls=[];
this.focuses=0; this.hidden=false;
}}
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
removeEventListener(name) {{ delete this.listeners[name]; }}
click() {{ this.listeners.click?.({{preventDefault() {{}}}}); }}
setAttribute(name,value) {{ this.attributes[name]=value; }}
removeAttribute(name) {{ delete this.attributes[name]; }}
scrollIntoView(options) {{ this.scrolls.push(options); }}
focus(options) {{ this.focuses += 1; this.focusOptions=options; }}
}}
{scenario}
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_mobile_search_preview_navigation_completes_the_workflow_and_gates_reply():
result = run_navigation("""
const names=['overview','conversation','reply','actions'];
const buttons=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
const targets=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
const navigation=createNavigation({buttons,targets,prefersReducedMotion:()=>true});
navigation.start();
navigation.setReplyAvailable(false);
buttons.reply.click();
const unavailableScrolls=targets.reply.scrolls.length;
navigation.setReplyAvailable(true);
buttons.conversation.click();
buttons.reply.click();
buttons.actions.click();
const selected=Object.fromEntries(Object.entries(buttons).map(([name,button])=>[name,button.attributes['aria-current']||null]));
navigation.stop();
process.stdout.write(JSON.stringify({
unavailableScrolls,
replyDisabled:buttons.reply.attributes['aria-disabled']||null,
conversation:targets.conversation.scrolls,
reply:targets.reply.scrolls,
replyFocuses:targets.reply.focuses,
actions:targets.actions.scrolls,
selected,
listeners:Object.values(buttons).map(button=>Object.keys(button.listeners).length),
}));
""")
assert result == {
"unavailableScrolls": 0,
"replyDisabled": None,
"conversation": [{"block": "start", "behavior": "auto"}],
"reply": [{"block": "start", "behavior": "auto"}],
"replyFocuses": 1,
"actions": [{"block": "start", "behavior": "auto"}],
"selected": {
"overview": None,
"conversation": None,
"reply": None,
"actions": "location",
},
"listeners": [0, 0, 0, 0],
}
def test_mobile_search_preview_navigation_tracks_panel_sections_and_resets_for_next_result():
result = run_navigation("""
const names=['overview','conversation','reply','actions'];
const buttons=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
const targets=Object.fromEntries(names.map(name=>[name,new FakeElement(name)]));
const root=new FakeElement('panel');
let callback; let observedRoot; let disconnected=0;
const navigation=createNavigation({
buttons,targets,root,
observe(handler,observed,rootOption){callback=handler; observedRoot=rootOption; return {disconnect(){disconnected++;}};},
});
navigation.start();
callback([{target:targets.overview,isIntersecting:true,intersectionRatio:.2},{target:targets.conversation,isIntersecting:true,intersectionRatio:.8}]);
navigation.reset(false);
const selected=Object.fromEntries(Object.entries(buttons).map(([name,button])=>[name,button.attributes['aria-current']||null]));
navigation.stop();
process.stdout.write(JSON.stringify({selected,replyDisabled:buttons.reply.attributes['aria-disabled'],observedRoot:observedRoot.name,disconnected}));
""")
assert result == {
"selected": {"overview": "location", "conversation": None, "reply": None, "actions": None},
"replyDisabled": "true",
"observedRoot": "panel",
"disconnected": 1,
}
def test_search_preview_ships_mobile_navigation_in_the_offline_bundle():
html = (FRONTEND / "index.html").read_text()
css = (FRONTEND / "dashboard.css").read_text()
navigation_js = CONTROLLER.read_text()
bundle = (FRONTEND.parent / "src" / "frontend_bundle.py").read_text()
service_worker = (FRONTEND / "service-worker.js").read_text()
assert '<nav class="mobile-search-preview-nav"' in html
assert 'aria-label="Search preview sections"' in html
for name, label in (
("overview", "Overview"),
("conversation", "Conversation"),
("reply", "Reply"),
("actions", "Act"),
):
assert f'data-search-preview-section="{name}"' in html
assert f">{label}</button>" in html
assert 'id="search-preview-overview"' in html
assert 'id="search-preview-reply-workspace"' in html
assert 'id="search-preview-actions"' in html
assert '<script src="static/mobile-search-preview-nav.js"></script>' in html
assert '"static/mobile-search-preview-nav.js"' in bundle
assert "attachMobileSearchPreviewNavigation({document, window})" in navigation_js
assert "navigation.reset(!reply.hidden)" in navigation_js
assert "attributeFilter:['hidden']" in navigation_js
assert "static/mobile-search-preview-nav.js" in service_worker
assert ".mobile-search-preview-nav" in css
assert "grid-template-columns:repeat(4,minmax(0,1fr))" in css
assert "min-height:44px" in css
assert "env(safe-area-inset-top)" in css
assert "#search-preview-actions { position:static" in css
+41 -2
View File
@@ -72,6 +72,44 @@ process.stdout.write(JSON.stringify({{first, second, ready, opened}}));
}
def test_prepare_today_puts_actionable_delivery_recovery_before_other_work():
script = f"""
const createStartDay = require({json.dumps(str(START_DAY))});
let counts = {{delivery:2, agenda:1, attention:0, update:0, filed:0, today:4}};
const opened = [];
const handoffs = [];
const saved = new Map();
const controller = createStartDay({{
getCounts: () => counts,
getLogin: () => 'timmy',
getDay: () => '2026-08-17',
storage: {{getItem:key=>saved.get(key)||null,setItem:(key,value)=>saved.set(key,value),removeItem:key=>saved.delete(key)}},
openQueue: name => opened.push(name),
onHandoff: state => handoffs.push(state.next),
}});
const blocked = controller.briefing();
controller.startNext();
counts = {{delivery:0, agenda:1, attention:0, update:0, filed:0, today:4}};
const handed = controller.reconcile({{authoritative:true}});
process.stdout.write(JSON.stringify({{blocked, handed, opened, handoffs}}));
"""
output = run_node(script)
assert output["blocked"] == {
"total": 3,
"next": "delivery",
"label": "Review Delivery",
"summary": "2 deliveries need action before Today · 1 other item · 4 planned",
"phases": [
{"name": "delivery", "label": "Delivery recovery", "count": 2},
{"name": "agenda", "label": "Agenda", "count": 1},
],
}
assert output["handed"] is True
assert output["opened"] == ["delivery"]
assert output["handoffs"] == ["agenda"]
def test_start_day_view_renders_refreshed_phases_and_launches_primary_action():
script = f"""
const createStartDay = require({json.dumps(str(START_DAY))});
@@ -273,7 +311,8 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
assert "mobileStartDay.completePhase('agenda')" in html
assert "mobileStartDay.completePhase('update')" in html
assert "mobileStartDay.completePhase('filed')" in html
assert "mobileStartDay.reconcile({authoritative:authoritativeMyWorkRefresh})" in html
assert "authoritative:authoritativeMyWorkRefresh" in html
assert "authoritativePhases:['delivery']" in html
assert "mobileStartDay.finish()" in html
assert "openQueue: name =>" in html
assert "if (sheet.open) sheet.close();" in html
@@ -283,4 +322,4 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
assert ".mobile-start-day-finish { min-height:44px;" in html
assert "max-width:100%; overflow-wrap:anywhere;" in html
assert "BASE + 'static/mobile-start-day.js'" in service_worker
assert "stackchain-dashboard-shell-v108" in service_worker
assert "stackchain-dashboard-shell-v117" in service_worker
+112 -7
View File
@@ -85,6 +85,30 @@ process.stdout.write(JSON.stringify({{modes, calls}}));
}
def test_mobile_work_entry_plans_eligible_work_before_find_fallback():
script = f"""
const createEntry = require({json.dumps(str(ENTRY))});
const calls = [];
const entry = createEntry({{
isTodayActive: () => false,
isTodayResumable: () => false,
getTodayCount: () => 0,
getEligibleCount: () => 2,
queueLauncher: {{recommend: () => ({{name:'find'}}), open: () => {{}}}},
continueToday: () => {{}},
resumeToday: () => {{}},
startToday: () => {{}},
planToday: () => calls.push('plan'),
findWork: () => calls.push('find'),
}});
process.stdout.write(JSON.stringify({{mode:entry.open(), calls}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {"mode": "plan", "calls": ["plan"]}
def test_mobile_task_dock_routes_actions_hides_for_overlays_and_restores_focus():
script = f"""
const createDock = require({json.dumps(str(DOCK))});
@@ -331,7 +355,7 @@ sheet.close = function () {{ this.open = false; this.listeners.close?.(); }};
const close = new FakeElement();
const badge = new FakeElement();
const deadline = new FakeElement();
const rows = Object.fromEntries(['today','agenda','attention','update','filed','later','draft','recaps'].map(name => [name, new FakeElement()]));
const rows = Object.fromEntries(['today','agenda','delivery','attention','update','filed','later','draft','recaps'].map(name => [name, new FakeElement()]));
const counts = Object.fromEntries(Object.keys(rows).map(name => [name, new FakeElement()]));
const selected = [];
const utilities = [];
@@ -342,7 +366,7 @@ const dock = createDock({{
observe() {{}},
}});
dock.start();
dock.updateQueues({{today:2, agenda:5, attention:1, update:5, filed:2, later:3, draft:4}});
dock.updateQueues({{today:2, agenda:5, delivery:1, attention:1, update:5, filed:2, later:3, draft:4}});
queues.click();
const opened = sheet.open;
rows.update.click();
@@ -387,9 +411,9 @@ process.stdout.write(JSON.stringify({{
"utilities": ["recaps:trigger"],
"badge": "0 active",
"populated": {
"badge": "6 active",
"badge": "7 active",
"badgeHidden": False,
"badgeLabel": "Queues: Today 2, Agenda 5 due, Attention 1, Updates 5, Filed 2, Later 3, Drafts 4; 6 active queues",
"badgeLabel": "Queues: Today 2, Agenda 5 due, Delivery 1, Attention 1, Updates 5, Filed 2, Later 3, Drafts 4; 7 active queues",
"deadline": "5 due",
"deadlineHidden": False,
"agendaDue": "true",
@@ -398,13 +422,13 @@ process.stdout.write(JSON.stringify({{
"badge": "1 active",
"badgeHidden": False,
"deadlineHidden": True,
"badgeLabel": "Queues: Today 0, Agenda 0 due, Attention 0, Updates 7, Filed 0, Later 0, Drafts 0; 1 active queue",
"badgeLabel": "Queues: Today 0, Agenda 0 due, Delivery 0, Attention 0, Updates 7, Filed 0, Later 0, Drafts 0; 1 active queue",
},
"clearedBadgeHidden": True,
"clearedDeadlineHidden": True,
"clearedAgendaDue": None,
"clearedBadgeLabel": "Queues: no active queues; no upcoming deadlines",
"counts": {"today": "0", "agenda": "0", "attention": "0", "update": "0", "filed": "0", "later": "0", "draft": "0", "recaps": "0"},
"counts": {"today": "0", "agenda": "0", "delivery": "0", "attention": "0", "update": "0", "filed": "0", "later": "0", "draft": "0", "recaps": "0"},
"updateLabel": "Updates, 0 unread conversations",
"queueFocuses": 3,
"columnState": None,
@@ -461,6 +485,82 @@ process.stdout.write(JSON.stringify({{result, calls}}));
}
def test_mobile_queue_launcher_uses_dedicated_delivery_recovery_flow():
script = f"""
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
const calls = [];
const launcher = createLauncher({{
openDelivery: () => {{ calls.push('delivery-recovery'); return 'opened'; }},
selectFilter: name => calls.push('generic-filter:' + name),
firstAction: () => {{ throw new Error('generic card launch must not run'); }},
}});
const result = launcher.open('delivery');
process.stdout.write(JSON.stringify({{result, calls}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {"result": "opened", "calls": ["delivery-recovery"]}
def test_mobile_work_prioritizes_and_revalidates_actionable_delivery_recovery():
script = f"""
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
const calls = [];
let counts = {{delivery:2, attention:3, today:1, update:4}};
const launcher = createLauncher({{
getCounts: () => counts,
openDelivery: () => {{ calls.push('delivery-recovery'); return 'opened-delivery'; }},
openUpdates: () => {{ calls.push('updates'); return 'opened-updates'; }},
selectFilter: name => calls.push('filter:' + name),
firstAction: () => null,
announce: () => {{}},
openFindWork: () => calls.push('find'),
}});
const delivery = launcher.recommend();
const opened = launcher.continueWork();
counts = {{delivery:0, attention:0, today:0, update:4}};
const afterRecovery = launcher.recommend();
process.stdout.write(JSON.stringify({{delivery, opened, afterRecovery, calls}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"delivery": {"name": "delivery", "count": 2, "label": "Recover Delivery (2)"},
"opened": "opened-delivery",
"afterRecovery": {"name": "update", "count": 4, "label": "Resume Updates (4)"},
"calls": ["delivery-recovery"],
}
def test_mobile_work_continues_into_filed_follow_up_before_later_work():
script = f"""
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
const calls = [];
let counts = {{attention:0, today:0, update:0, agenda:0, filed:2, later:4, draft:3}};
const launcher = createLauncher({{
getCounts: () => counts,
openFiled: () => {{ calls.push('filed-follow-up'); return 'opened-filed'; }},
selectFilter: name => calls.push('filter:' + name),
firstAction: () => null,
announce: () => {{}},
openFindWork: () => calls.push('find'),
}});
const recommendation = launcher.recommend();
const opened = launcher.continueWork();
process.stdout.write(JSON.stringify({{recommendation, opened, calls}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"recommendation": {"name": "filed", "count": 2, "label": "Review Filed (2)"},
"opened": "opened-filed",
"calls": ["filed-follow-up"],
}
def test_mobile_queue_launcher_recommends_and_revalidates_cross_queue_continuation():
script = f"""
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
@@ -531,7 +631,7 @@ const work = {{attributes: {{}}, setAttribute(name, value) {{ this.attributes[na
const workLabel = {{textContent:''}};
const dock = createDock({{nav:{{}}, buttons:{{work}}, workLabel}});
const labels = {{}};
for (const mode of ['attention','update','agenda','later','draft']) {{
for (const mode of ['delivery','attention','update','agenda','filed','later','draft']) {{
dock.updateWork(mode);
labels[mode] = [workLabel.textContent, work.attributes['aria-label']];
}}
@@ -541,9 +641,11 @@ process.stdout.write(JSON.stringify(labels));
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"delivery": ["Delivery", "Open Delivery"],
"attention": ["Attention", "Open Attention"],
"update": ["Updates", "Resume Updates"],
"agenda": ["Agenda", "Open Agenda"],
"filed": ["Filed", "Open Filed"],
"later": ["Later", "Open Later"],
"draft": ["Drafts", "Open Drafts"],
}
@@ -576,6 +678,9 @@ async def test_dashboard_renders_and_wires_mobile_queue_switcher():
assert "counts.agenda = agendaItems.length;" in html
assert 'data-agenda-group' in html
assert 'data-mobile-queue="attention"' in html
assert 'data-mobile-queue="delivery"' in html
assert '<strong>Delivery</strong><small>Needs recovery</small>' in html
assert 'data-mobile-queue-count="delivery"' in html
assert 'data-mobile-queue="update"' in html
assert '<strong>Updates</strong><small>Unread conversations</small>' in html
assert 'data-mobile-queue="filed"' in html
@@ -0,0 +1,185 @@
import json
import subprocess
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
CONTROLLER = FRONTEND / "mobile-update-detail-nav.js"
def test_update_navigation_reuses_new_activity_opens_context_and_focuses_reply():
script = f"""
const createNavigation = require({json.dumps(str(CONTROLLER))});
class FakeElement {{
constructor(name) {{
this.name=name; this.listeners={{}}; this.attributes={{}}; this.open=false;
this.focuses=0; this.scrolls=[];
}}
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
removeEventListener(name) {{ delete this.listeners[name]; }}
setAttribute(name, value) {{ this.attributes[name]=value; }}
removeAttribute(name) {{ delete this.attributes[name]; }}
focus() {{ this.focuses += 1; }}
scrollIntoView(options) {{ this.scrolls.push({{...options, openWhenScrolled:this.open}}); }}
}}
const names=['activity','conversation','context','reply'];
const buttons=Object.fromEntries(names.map(name => [name,new FakeElement(name)]));
const targets=Object.fromEntries(['conversation','context','reply'].map(name => [name,new FakeElement(name)]));
const replyComposer=new FakeElement('composer');
let activityJumps=0;
const navigation=createNavigation({{
buttons, targets, replyComposer,
jumpToNewActivity() {{ activityJumps += 1; }},
prefersReducedMotion:() => true,
}});
navigation.start();
navigation.navigate('conversation');
navigation.navigate('context');
const contextOpenAfterNavigation=targets.context.open;
navigation.navigate('reply');
navigation.navigate('activity');
const currentAfterActivity=Object.fromEntries(Object.entries(buttons).map(([name,button]) => [name,button.attributes['aria-current'] || null]));
navigation.reset();
const contextOpenAfterReset=targets.context.open;
const currentAfterReset=Object.fromEntries(Object.entries(buttons).map(([name,button]) => [name,button.attributes['aria-current'] || null]));
navigation.stop();
process.stdout.write(JSON.stringify({{
activityJumps,
conversation:targets.conversation.scrolls,
context:targets.context.scrolls,
contextOpen:contextOpenAfterNavigation,
contextOpenAfterReset,
reply:targets.reply.scrolls,
replyFocuses:replyComposer.focuses,
currentAfterActivity,
currentAfterReset,
listenersAfterStop:Object.values(buttons).map(button => Object.keys(button.listeners).length),
}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"activityJumps": 1,
"conversation": [{"block": "start", "behavior": "auto", "openWhenScrolled": False}],
"context": [{"block": "start", "behavior": "auto", "openWhenScrolled": True}],
"contextOpen": True,
"contextOpenAfterReset": False,
"reply": [{"block": "start", "behavior": "auto", "openWhenScrolled": False}],
"replyFocuses": 1,
"currentAfterActivity": {
"activity": "location",
"conversation": None,
"context": None,
"reply": None,
},
"currentAfterReset": {
"activity": "location",
"conversation": None,
"context": None,
"reply": None,
},
"listenersAfterStop": [0, 0, 0, 0],
}
def test_update_navigation_tracks_visible_sections_and_disconnects_observer():
script = f"""
const createNavigation = require({json.dumps(str(CONTROLLER))});
const makeElement=name => ({{
name, attributes:{{}},
addEventListener() {{}}, removeEventListener() {{}},
setAttribute(key,value) {{ this.attributes[key]=value; }},
removeAttribute(key) {{ delete this.attributes[key]; }},
}});
const buttons=Object.fromEntries(['activity','conversation','context','reply'].map(name => [name,makeElement(name)]));
const targets=Object.fromEntries(['conversation','context','reply'].map(name => [name,makeElement(name)]));
let callback;
let disconnected=0;
const navigation=createNavigation({{
buttons, targets,
observe(handler) {{ callback=handler; return {{disconnect() {{ disconnected += 1; }}}}; }},
}});
navigation.start();
callback([
{{target:targets.conversation,isIntersecting:true,intersectionRatio:.2}},
{{target:targets.reply,isIntersecting:true,intersectionRatio:.8}},
]);
const current=Object.fromEntries(Object.entries(buttons).map(([name,button]) => [name,button.attributes['aria-current'] || null]));
navigation.stop();
process.stdout.write(JSON.stringify({{current,disconnected}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"current": {
"activity": None,
"conversation": None,
"context": None,
"reply": "location",
},
"disconnected": 1,
}
def test_update_navigation_routes_taps_and_scrolls_without_focusing_on_restore():
script = f"""
const createNavigation=require({json.dumps(str(CONTROLLER))});
const element=name => ({{name,listeners:{{}},attributes:{{}},focuses:0,open:false,
addEventListener(key,fn) {{ this.listeners[key]=fn; }}, removeEventListener() {{}},
setAttribute(key,value) {{ this.attributes[key]=value; }}, removeAttribute(key) {{ delete this.attributes[key]; }},
scrollIntoView() {{}}, focus() {{ this.focuses += 1; }},
}});
const names=['activity','conversation','context','reply'];
const buttons=Object.fromEntries(names.map(name => [name,element(name)]));
const targets=Object.fromEntries(['conversation','context','reply'].map(name => [name,element(name)]));
const composer=element('composer'); const changes=[]; let observer;
const navigation=createNavigation({{buttons,targets,replyComposer:composer,jumpToNewActivity() {{}},
onSectionChange:(name,options)=>changes.push([name,options.replace]),
observe(handler) {{ observer=handler; return {{disconnect() {{}}}}; }},
}});
navigation.start();
buttons.reply.listeners.click({{preventDefault() {{}}}});
observer([{{target:targets.context,isIntersecting:true,intersectionRatio:1}}]);
navigation.navigate('reply',{{focus:false}});
process.stdout.write(JSON.stringify({{changes,focuses:composer.focuses,current:buttons.reply.attributes['aria-current']}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"changes": [["reply", False], ["context", True]],
"focuses": 1,
"current": "location",
}
def test_update_sheet_wires_a_mobile_only_safe_area_navigation_rail():
html = (FRONTEND / "index.html").read_text()
css = (FRONTEND / "dashboard.css").read_text()
dashboard_js = (FRONTEND / "dashboard.js").read_text()
service_worker = (FRONTEND / "service-worker.js").read_text()
assert '<nav class="mobile-update-detail-nav" aria-label="Update sections">' in html
for name, label in (
("activity", "New activity"),
("conversation", "Conversation"),
("context", "Subject context"),
("reply", "Reply"),
):
assert f'data-update-section="{name}"' in html
assert f">{label}</button>" in html
assert 'id="update-conversation"' in html
assert 'id="update-subject-context"' in html
assert 'id="update-reply-workspace"' in html
assert '<script src="static/mobile-update-detail-nav.js"></script>' in html
assert "createMobileUpdateDetailNavigation({" in dashboard_js
assert "jumpToNewActivity:() => updateReadPosition.jump()" in dashboard_js
assert "mobileUpdateDetailNavigation.reset();" in dashboard_js
assert ".mobile-update-detail-nav" in css
assert "position:sticky" in css
assert "min-height:44px" in css
assert "env(safe-area-inset-top)" in css
assert "@media (min-width:601px)" in css
assert "BASE + 'static/mobile-update-detail-nav.js'" in service_worker
+81 -1
View File
@@ -1477,6 +1477,68 @@ process.stdout.write(JSON.stringify({{calls, hash:location.hash}}));
assert output["hash"] == "#/my-work/review/stackchain/dashboard/10"
def test_work_route_sections_are_validated_back_safe_and_do_not_reopen_the_item():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const listeners = {{}};
const location = {{hash:'#/my-work/issue/stackchain/api/17'}};
const calls = [];
const stack = [location.hash];
let cursor = 0;
const history = {{
pushState(state, _, hash) {{ stack.splice(cursor + 1); stack.push(hash); cursor += 1; location.hash = hash; calls.push(['push', hash]); }},
replaceState(state, _, hash) {{ stack[cursor] = hash; location.hash = hash; calls.push(['replace', hash]); }},
back() {{ cursor -= 1; location.hash = stack[cursor]; listeners.popstate(); }},
}};
const controller = routes.createController({{
location, history,
eventTarget: {{addEventListener(name, fn) {{ listeners[name] = fn; }}}},
onOpen: item => calls.push(['open', item.number, item.section || null]),
onSection: (section, options) => calls.push(['section', section, options.restore]),
onClose() {{}}, onInvalid() {{}},
}});
controller.start();
controller.setItems([{{kind:'issue', repository:'stackchain/api', number:17}}]);
controller.section('reply');
controller.section('actions', {{replace:true}});
history.back();
process.stdout.write(JSON.stringify({{
calls,
hash:location.hash,
valid:[
routes.parse('#/my-work/issue/stackchain/api/17/reply'),
routes.parse('#/my-work/update/42/context'),
],
invalid:[
routes.parse('#/my-work/issue/stackchain/api/17/files'),
routes.parse('#/my-work/update/42/overview'),
routes.parse('#/my-work/review/stackchain/api/17/feedback/extra'),
],
}}));
"""
result = subprocess.run(
["node", "-e", script], capture_output=True, text=True
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"calls": [
["open", 17, None],
["push", "#/my-work/issue/stackchain/api/17/reply"],
["section", "reply", False],
["replace", "#/my-work/issue/stackchain/api/17/actions"],
["section", "actions", False],
["section", "overview", True],
],
"hash": "#/my-work/issue/stackchain/api/17",
"valid": [
{"kind": "issue", "repository": "stackchain/api", "number": 17, "section": "reply"},
{"kind": "update", "notification_id": 42, "section": "context"},
],
"invalid": [None, None, None],
}
def test_updates_inbox_route_survives_hydration_and_detail_back_navigation():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
@@ -1690,7 +1752,7 @@ const calls = [];
async def test_dashboard_opens_delegated_filings_with_follow_up_only_capabilities():
html = await dashboard()
assert "else if (item.kind === 'issue' || item.kind === 'filed') openIssueSheet(item, issueTrigger);" in html
assert "else if (item.kind === 'issue' || item.kind === 'filed') await openIssueSheet(item, issueTrigger);" in html
assert "const readOnly = issueController.readOnly(item);" in html
assert "qs('#issue-sheet').classList.toggle('read-only', readOnly);" in html
assert "#issue-sheet.read-only .issue-comment-composer" not in html
@@ -1730,6 +1792,24 @@ async def test_dashboard_wires_addressable_work_sheets_back_navigation_and_share
assert "openDeliveryReceiptRoute" not in html
@pytest.mark.anyio
async def test_dashboard_wires_mobile_section_routes_to_each_detail_navigation():
html = await dashboard()
assert html.count(
"onSectionChange:(section, options) => workRoute.section(section, options)"
) == 4
assert "function navigateWorkSection(kind, section)" in html
assert "mobileIssueDetailNavigation.navigate(section, { focus:false })" in html
assert "mobilePullDetailNavigation.navigate(section, { focus:false })" in html
assert "mobileUpdateDetailNavigation.navigate(section, { focus:false })" in html
assert "mobileReviewDetailNavigation.navigate(section, { focus:false })" in html
assert "onSection: (section, options) => {" in html
assert "if (options.restore) navigateWorkSection(route?.kind, section);" in html
assert "await openRoutedWorkSection(item);" in html
assert "navigateWorkSection(item.kind, item.section);" in html
def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity():
payload = {
"user": {"login": "timmy"},
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more