Compare commits

...
Author SHA1 Message Date
timmy ebb7a78cab feat: finish onboarding with active Today (Closes #1144)
CI / browser-journey (pull_request) Successful in 4m3s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m48s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 18:51:15 +00:00
rockachopa b4a5401b22 Merge pull request 'Preserve timed break across reload' (#1143)
CI / browser-journey (push) Successful in 4m2s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 3m3s
Closes #1142
2026-08-19 18:32:30 +00:00
timmy 870edda123 Preserve timed break when active work reopens
CI / browser-journey (pull_request) Successful in 3m34s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m55s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 18:08:35 +00:00
rockachopa 156d4b0ffc Merge pull request 'Guide new mobile operators to their first task' (#1141) from timmy/1140-first-task-activation into main
CI / browser-journey (push) Failing after 3m36s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 2m54s
2026-08-19 17:41:31 +00:00
timmy 304e4a8a24 feat: guide first mobile task activation (Closes #1140)
CI / browser-journey (pull_request) Successful in 3m29s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m8s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 17:33:26 +00:00
rockachopa 8731682090 Merge pull request 'Pause Today while checking Live Data Status' (#1139) from timmy/1138-live-data-status-detour into main
CI / browser-journey (push) Successful in 3m29s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m13s
2026-08-19 16:44:43 +00:00
timmy 348f43f1bc feat: pause Today for live data status (Closes #1138)
CI / browser-journey (pull_request) Successful in 3m23s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m8s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 16:35:09 +00:00
rockachopa 161563d6f5 Merge pull request 'Contain focus inside mobile Security Center' (#1137)
CI / browser-journey (push) Successful in 3m25s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m10s
Closes #1136
2026-08-19 15:45:00 +00:00
timmy 931c82361b feat: contain Security Center focus (Closes #1136)
CI / browser-journey (pull_request) Successful in 3m22s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m55s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 15:34:47 +00:00
rockachopa 61b332e814 Merge pull request 'Contain focus inside mobile Device Setup' (#1135) from timmy/1134-device-setup-focus-containment into main
CI / browser-journey (push) Successful in 3m21s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m59s
2026-08-19 14:44:26 +00:00
timmy 4b3e53f4d0 feat: contain mobile Device Setup focus (Closes #1134)
CI / browser-journey (pull_request) Successful in 3m24s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 2m54s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 14:31:38 +00:00
rockachopa f3c9e89920 Merge pull request 'Recover mobile notification setup after permission is blocked' (#1133) from timmy/1132-notification-permission-recovery into main
CI / browser-journey (push) Failing after 3m23s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 2m58s
2026-08-19 13:43:48 +00:00
timmy 1248647aa4 feat: recover blocked notification setup (Closes #1132)
CI / browser-journey (pull_request) Successful in 3m22s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m52s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 13:36:20 +00:00
rockachopa b3832d107f Merge pull request 'Post reviewed Today summaries to a Gitea status thread' (#1131) from timmy/1130-post-today-summary into main
CI / browser-journey (push) Successful in 3m23s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m56s
2026-08-19 12:45:00 +00:00
timmy a0da8e9fb2 feat: post reviewed Today summaries to Gitea (Closes #1130)
CI / browser-journey (pull_request) Successful in 3m20s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 2m51s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 12:36:46 +00:00
timmy 8794200c2a Merge pull request 'Protect private work before mobile sign-out' (#1129) from timmy/1128-safe-sign-out-review into main
CI / browser-journey (push) Successful in 3m20s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m55s
2026-08-19 11:45:23 +00:00
timmy c2526969b1 feat: protect private work before sign out (Closes #1128)
CI / browser-journey (pull_request) Successful in 3m16s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m53s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 11:37:27 +00:00
rockachopa 3b4f6b39a5 Merge pull request 'Encrypt synchronized Saved Searches at rest' (#1127) from timmy/1126-encrypt-saved-searches into main
CI / browser-journey (push) Successful in 3m2s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m43s
2026-08-19 11:03:52 +00:00
timmy a9cf88b3ff security: encrypt synchronized Saved Searches (Closes #1126)
CI / browser-journey (pull_request) Successful in 3m12s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 2m49s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 10:57:01 +00:00
rockachopa 3d4be0a913 Merge pull request 'Recover mobile workspace loading in place' (#1125) from timmy/1124-workspace-load-recovery into main
CI / browser-journey (push) Successful in 2m58s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m36s
2026-08-19 10:21:01 +00:00
timmy 9252807364 fix: recover mobile workspace loading in place (Closes #1124)
CI / browser-journey (pull_request) Successful in 3m1s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m50s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 10:10:52 +00:00
rockachopa db85bfa5f9 Merge pull request 'Encrypt authored-action idempotency ledger at rest' (#1123)
CI / browser-journey (push) Successful in 2m55s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m44s
Closes #1122
2026-08-19 09:35:55 +00:00
timmy 3822ec4536 security: encrypt idempotency ledger at rest (Closes #1122)
CI / browser-journey (pull_request) Successful in 2m57s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 2m49s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 09:29:16 +00:00
rockachopa b278367e3b Merge pull request 'Verify Web Push delivery health per device' (#1121) from timmy/1120-push-delivery-health into main
CI / browser-journey (push) Successful in 3m10s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m47s
2026-08-19 09:06:55 +00:00
timmy 13e73bd342 feat: verify per-device Push delivery health (Closes #1120)
CI / browser-journey (pull_request) Successful in 3m1s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m53s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 09:00:06 +00:00
rockachopa 1047f3ecdc Merge pull request 'Encrypt Security activity journal at rest' (#1119) from timmy/1118-encrypt-security-activity into main
CI / browser-journey (push) Successful in 2m53s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m39s
2026-08-19 07:49:14 +00:00
timmy f09c922c46 test: model admitted blocker receipt deterministically
CI / browser-journey (pull_request) Successful in 2m56s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m38s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 07:42:46 +00:00
timmy be9ce8ede6 security: remove plaintext journal metadata columns 2026-08-19 07:37:35 +00:00
timmy 7f2681c1c6 security: encrypt Security activity journal (Closes #1118)
CI / browser-journey (pull_request) Failing after 3m7s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 2m39s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 07:25:49 +00:00
rockachopa 91fdd958c2 Merge pull request 'Fetch the mobile workspace concurrently with the core runtime' (#1117) from timmy/1116-parallel-workspace-fetch into main
CI / browser-journey (push) Successful in 2m55s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m51s
2026-08-19 06:52:25 +00:00
timmy b70a2fb607 test: accept queued or delivered blocker receipt
CI / browser-journey (pull_request) Successful in 3m5s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m33s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 06:46:05 +00:00
timmy 950d52d147 perf: parallelize mobile workspace fetch (Closes #1116)
CI / browser-journey (pull_request) Failing after 2m56s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m50s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 06:25:26 +00:00
timmy d054cd23ed Merge pull request 'Encrypt synchronized Later plans at rest' (#1115) from timmy/1114-encrypt-later-state into main
CI / browser-journey (push) Successful in 2m39s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m45s
2026-08-19 05:38:47 +00:00
timmy f4d8d64b9d security: encrypt synchronized Later state (Closes #1114)
CI / browser-journey (pull_request) Successful in 2m51s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 2m43s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 05:32:20 +00:00
timmy 9763bb410b Merge pull request 'Encrypt synchronized Today journeys at rest' (#1113) from timmy/1112-encrypt-today-state into main
CI / browser-journey (push) Successful in 2m40s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m33s
2026-08-19 04:41:16 +00:00
timmy db3ca83f29 security: encrypt synchronized Today state (Closes #1112)
CI / browser-journey (pull_request) Successful in 2m43s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m36s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 04:34:55 +00:00
rockachopa c219f130c8 Merge pull request 'Protect offline work from browser storage eviction' (#1111) from timmy/1110-protect-offline-storage into main
CI / browser-journey (push) Successful in 2m54s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m52s
2026-08-19 03:43:54 +00:00
timmy dc4f457b21 feat: protect offline work from storage eviction (Closes #1110)
CI / browser-journey (pull_request) Successful in 2m52s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m47s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 03:36:48 +00:00
rockachopa 9a471b7903 Merge pull request 'Encrypt Web Push subscriptions at rest' (#1109) from timmy/1108-encrypt-push-subscriptions into main
CI / browser-journey (push) Successful in 2m46s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m38s
2026-08-19 02:41:03 +00:00
timmy 67d3242f37 security: encrypt Web Push subscriptions at rest (Closes #1108)
CI / browser-journey (pull_request) Successful in 2m47s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 2m44s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 02:34:32 +00:00
timmy fb08bc5ce5 Merge pull request 'Encrypt worker-shared dashboard snapshots at rest' (#1107) from timmy/1106-encrypt-worker-snapshots into main
CI / browser-journey (push) Successful in 2m48s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m52s
2026-08-19 02:04:52 +00:00
timmy 9d9b28af8e security: encrypt worker-shared snapshots (Closes #1106)
CI / browser-journey (pull_request) Successful in 2m55s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m48s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 01:58:22 +00:00
rockachopa 8ad02933c9 Merge pull request 'Review and resume snoozed mobile deadline reminders' (#1105) from timmy/1104-review-snoozed-deadlines into main
CI / browser-journey (push) Successful in 2m50s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m49s
Review and resume snoozed mobile deadline reminders (#1105)
2026-08-19 01:09:49 +00:00
timmy d5dc3f27db feat: review snoozed deadline reminders (Closes #1104)
CI / browser-journey (pull_request) Successful in 2m47s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m46s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 01:02:06 +00:00
rockachopa 625d937f64 Merge pull request 'Snooze mobile deadline reminders from the lock screen' (#1103) from timmy/1102-deadline-reminder-snooze into main
CI / browser-journey (push) Successful in 2m45s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m39s
2026-08-19 00:08:03 +00:00
timmy 2bd20130b6 feat: snooze mobile deadline reminders (Closes #1102)
CI / browser-journey (pull_request) Successful in 2m58s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m39s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 00:01:38 +00:00
rockachopa fdf6ae3ddd Merge pull request 'Rotate synchronized Draft encryption keys without downtime' (#1101) from timmy/1100-draft-key-rotation into main
CI / browser-journey (push) Successful in 2m42s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m31s
2026-08-18 23:11:07 +00:00
timmy fc9ef1f8ae feat: rotate synchronized Draft encryption keys (Closes #1100)
CI / browser-journey (pull_request) Successful in 2m35s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m39s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 23:04:30 +00:00
timmy 8cd1d1f684 Merge pull request 'Encrypt synchronized unfiled drafts at rest' (#1099) from timmy/1098-encrypt-unfiled-drafts into main
CI / browser-journey (push) Successful in 3m2s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m46s
2026-08-18 22:27:53 +00:00
timmy e79c457774 test: inject draft encryption key into release journey
CI / browser-journey (pull_request) Successful in 2m56s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m48s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 22:15:09 +00:00
timmy d3f53b5a38 security: encrypt synchronized unfiled drafts (Closes #1098)
CI / browser-journey (pull_request) Failing after 2m53s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 2m58s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 22:07:02 +00:00
rockachopa 19a1184b1c Merge pull request 'Pin Web Push connections to validated public addresses' (#1097) from timmy/1096-pin-web-push-connections into main
CI / browser-journey (push) Successful in 2m54s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 9s
CI / lint (push) Successful in 2m46s
2026-08-18 21:34:22 +00:00
timmy ad40cb5fb9 fix: pin Web Push connections to validated addresses (Closes #1096)
CI / browser-journey (pull_request) Successful in 2m57s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m52s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 21:26:32 +00:00
rockachopa a38934b099 Merge pull request 'Track multiple mobile merges through release' (#1095) from timmy/1094-release-watchlist into main
CI / browser-journey (push) Successful in 3m17s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m8s
2026-08-18 20:49:38 +00:00
timmy 3cf33869d6 feat: track multiple mobile releases (Closes #1094)
CI / browser-journey (pull_request) Successful in 3m14s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m20s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 20:40:59 +00:00
timmy 41195f4c75 Merge pull request 'Track exact release progress after mobile merges' (#1093) from timmy/1092-mobile-release-receipt into main
CI / browser-journey (push) Successful in 3m2s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m5s
2026-08-18 19:49:08 +00:00
timmy 6db0802461 feat: track exact post-merge release progress (Closes #1092)
CI / browser-journey (pull_request) Successful in 3m28s
CI / build-release (pull_request) Successful in 9s
CI / lint (pull_request) Successful in 3m13s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 19:41:00 +00:00
rockachopa 087300f9c3 Merge pull request 'Share a privacy-reviewed summary after mobile Today wrap-up' (#1091) from timmy/1090-mobile-today-summary into main
CI / browser-journey (push) Successful in 3m17s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m6s
2026-08-18 19:01:10 +00:00
timmy abc736d32e fix: isolate Today summary drafts by account
CI / browser-journey (pull_request) Successful in 3m7s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 18:53:21 +00:00
timmy b3a359760c feat: share private Today summaries (Closes #1090)
CI / browser-journey (pull_request) Failing after 3m9s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m20s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 18:39:01 +00:00
rockachopa 5f86d48e6f Merge pull request 'Review and export mobile Agenda deadlines as a calendar snapshot' (#1089) from timmy/1088-agenda-calendar-export into main
CI / browser-journey (push) Successful in 2m55s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 9s
CI / lint (push) Successful in 3m2s
2026-08-18 17:48:38 +00:00
timmy 1c17fcf337 feat: export Agenda calendar snapshots (Closes #1088)
CI / browser-journey (pull_request) Successful in 3m18s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m3s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 17:37:25 +00:00
rockachopa 1c6f5c685f Merge pull request 'Pause Today during mobile Security Center' (#1087) from timmy/1086-pause-today-security-center into main
CI / browser-journey (push) Successful in 2m42s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m17s
2026-08-18 16:49:47 +00:00
timmy 473f592615 feat: pause Today during mobile Security Center (Closes #1086)
CI / browser-journey (pull_request) Successful in 2m59s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 3m27s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 16:41:59 +00:00
rockachopa aba4b55ca0 Merge pull request 'Pause active Today timing during mobile Device Setup' (#1085) from timmy/1084-pause-today-device-setup into main
CI / browser-journey (push) Successful in 2m41s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 10s
CI / lint (push) Successful in 3m13s
2026-08-18 15:39:39 +00:00
timmy 4a53a95b2d feat: pause Today during mobile device setup (Closes #1084)
CI / browser-journey (pull_request) Successful in 3m0s
CI / build-release (pull_request) Successful in 9s
CI / lint (pull_request) Successful in 3m24s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 15:30:55 +00:00
timmy a6f34fbfde Merge pull request 'Pause active Today timing while reviewing mobile Insights' (#1083) from timmy/1082-pause-today-insights into main
CI / browser-journey (push) Successful in 3m0s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 10s
CI / lint (push) Successful in 3m15s
2026-08-18 14:54:49 +00:00
timmy 472371d5a8 feat: pause Today during mobile Insights (Closes #1082)
CI / browser-journey (pull_request) Successful in 2m45s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 3m18s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 14:44:08 +00:00
rockachopa d7b8cd5e71 Pause active Today timing across mobile Find Work and Queues (#1081)
CI / browser-journey (push) Successful in 2m47s
CI / build-release (push) Successful in 10s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m8s
Closes #1080
2026-08-18 14:02:05 +00:00
timmy 58cee5ea16 feat: pause Today during mobile work detours (Closes #1080)
CI / browser-journey (pull_request) Successful in 2m45s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m12s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 13:51:40 +00:00
rockachopa 38efd4eb7c Merge pull request 'Pause active Today timing during mobile Search' (#1079) from timmy/1078-pause-today-search into main
CI / browser-journey (push) Successful in 2m48s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 9s
CI / lint (push) Successful in 2m54s
2026-08-18 12:54:06 +00:00
timmy d4910b4398 feat: pause Today during mobile Search (Closes #1078)
CI / browser-journey (pull_request) Successful in 2m21s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m5s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 12:45:08 +00:00
rockachopa ff34416c3c Merge pull request 'Pause Today during quick capture and return to the active task' (#1077) from timmy/1076-pause-today-quick-capture into main
CI / browser-journey (push) Successful in 2m35s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m13s
2026-08-18 11:15:52 +00:00
timmy f8c2541d30 feat: pause Today during quick capture (Closes #1076)
CI / browser-journey (pull_request) Successful in 2m35s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m23s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 11:06:44 +00:00
rockachopa d9749e3a6f Compact the active Today HUD into a mobile command bar (#1075)
CI / browser-journey (push) Successful in 2m14s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m56s
Closes #1074
2026-08-18 09:43:07 +00:00
timmy b77be4f5b0 feat: compact active Today controls on mobile (Closes #1074)
CI / browser-journey (pull_request) Successful in 2m17s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m46s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 09:36:34 +00:00
rockachopa fb7bfe0a5d Merge pull request 'Keep dashboard Gitea links on the configured forge origin' (#1073) from timmy/1072-configured-forge-link-boundary into main
CI / browser-journey (push) Successful in 2m9s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m41s
2026-08-18 09:04:35 +00:00
timmy 79f6cecab4 fix: keep Gitea links on configured forge (Closes #1072)
CI / browser-journey (pull_request) Successful in 2m8s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m47s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 08:58:35 +00:00
rockachopa 0472e88d91 Merge pull request 'Open a focused blocker flow from the active Today HUD' (#1071)
CI / browser-journey (push) Successful in 2m12s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m43s
2026-08-18 08:31:44 +00:00
timmy f710b2ce1d feat: open focused blockers from Today HUD (Closes #1070)
CI / browser-journey (pull_request) Successful in 2m14s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m41s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 08:25:27 +00:00
rockachopa 027ded1f8f Merge pull request 'Recover admitted Today blockers across reloads' (#1069) from timmy/1068-recover-today-blocker-reload into main
CI / browser-journey (push) Successful in 2m7s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m42s
2026-08-18 07:40:38 +00:00
timmy 3c8e2e2720 fix: recover Today blocker planning after reload (Closes #1068)
CI / browser-journey (pull_request) Successful in 2m6s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m42s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 07:27:30 +00:00
rockachopa 1bd87fc33e Merge pull request 'Report a Today blocker, defer it, and continue' (#1067) from timmy/1066-today-blocker-move-on into main
CI / browser-journey (push) Successful in 2m7s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m32s
2026-08-18 06:47:24 +00:00
timmy 4009a4a4c1 feat: report Today blockers and move on (Closes #1066)
CI / browser-journey (pull_request) Successful in 2m7s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 2m41s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 06:38:55 +00:00
rockachopa 339baab7ee Merge pull request 'Mention teammates from active Today progress updates' (#1065) from timmy/1064-today-progress-mentions into main
CI / browser-journey (push) Successful in 2m2s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m37s
2026-08-18 05:47:56 +00:00
timmy a6c769901d feat: mention teammates from Today updates (Closes #1064)
CI / browser-journey (pull_request) Successful in 2m0s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m39s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 05:38:20 +00:00
timmy a65aed5862 Merge pull request 'Correct or retract your own recent Today update in place' (#1063) from timmy/1062-correct-today-progress-update into main
CI / browser-journey (push) Successful in 1m48s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m35s
2026-08-18 04:55:04 +00:00
timmy 8b4bd480cc feat: correct recent Today updates in place (Closes #1062)
CI / browser-journey (pull_request) Successful in 1m56s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m43s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 04:46:53 +00:00
rockachopa 9c46455c35 Merge pull request 'Review recent activity before posting a Today update' (#1061) from timmy/1060-today-progress-recent-activity into main
CI / browser-journey (push) Successful in 1m59s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m32s
2026-08-18 04:06:40 +00:00
timmy 47626338ba feat: review activity before Today updates (Closes #1060)
CI / browser-journey (pull_request) Successful in 1m53s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m38s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 04:00:58 +00:00
timmy e290293bf1 Merge pull request 'Dictate and review active Today progress updates' (#1059) from timmy/1058-today-progress-voice-dictation into main
CI / browser-journey (push) Successful in 2m4s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m44s
2026-08-18 03:11:36 +00:00
timmy 688eadf17a feat: dictate active Today progress updates (Closes #1058)
CI / browser-journey (pull_request) Successful in 2m1s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m29s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 03:05:53 +00:00
rockachopa daf34d7cdd Merge pull request 'Keep Today progress photo retries bound to the intended evidence' (#1057) from timmy/1056-today-progress-evidence-retries into main
CI / browser-journey (push) Successful in 2m3s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 2m39s
2026-08-18 02:35:23 +00:00
timmy 1410a66a36 fix: bind Today progress retries to evidence (Closes #1056)
CI / browser-journey (pull_request) Successful in 2m2s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m45s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 02:29:13 +00:00
rockachopa 157d485685 Merge pull request 'Add photo evidence to active Today progress updates' (#1055) from timmy/1054-mobile-today-progress-photo-evidence into main
CI / browser-journey (push) Successful in 2m1s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m41s
2026-08-18 01:48:47 +00:00
timmy 4f07402936 fix: preserve photo drafts while reopening Today progress
CI / browser-journey (pull_request) Successful in 2m5s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m45s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 01:42:05 +00:00
timmy 9a4a1711fe feat: add photo evidence to Today progress updates (Closes #1054) 2026-08-18 01:33:56 +00:00
timmy 7502d6f44d Merge pull request 'Add mobile Today progress updates without advancing work' (#1053) from timmy/1052-mobile-today-progress-updates into main
CI / browser-journey (push) Successful in 1m58s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m40s
2026-08-18 00:52:44 +00:00
timmy bed9bb4270 fix: keep Today completion undo above progress HUD
CI / browser-journey (pull_request) Successful in 2m2s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m39s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 00:43:46 +00:00
timmy 406d75287d feat: add in-session Today progress updates (Closes #1052)
CI / browser-journey (pull_request) Failing after 2m5s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 2m46s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 00:34:37 +00:00
timmy 8c604e0b04 Merge pull request 'Show timed Today breaks on the mobile lock screen' (#1051) from timmy/1050-mobile-today-break-lock-screen into main
CI / browser-journey (push) Successful in 2m7s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 2m41s
2026-08-18 00:05:53 +00:00
timmy ed15392097 feat: show timed Today breaks on lock screen (Closes #1050)
CI / browser-journey (pull_request) Successful in 2m15s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m53s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 23:57:29 +00:00
timmy af86034339 Merge pull request 'Make deployed mobile dashboard reachable through authenticated readiness' (#1049) from timmy/1048-authenticated-deployment-readiness into main
CI / browser-journey (push) Successful in 1m59s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m37s
2026-08-17 23:38:24 +00:00
timmy e8817056f7 feat: verify authenticated deployment readiness (Closes #1048)
CI / browser-journey (pull_request) Successful in 2m2s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m40s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 23:31:53 +00:00
timmy a757bfe587 Merge pull request 'Make mobile issue capture a focus-contained modal journey' (#1047) from timmy/1046-mobile-issue-capture-modal into main
CI / browser-journey (push) Successful in 1m59s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m51s
Merge pull request 'Make mobile issue capture a focus-contained modal journey' (#1047)
2026-08-17 23:07:36 +00:00
timmy 44aedfd623 fix: preserve capture confirmation dialogs
CI / browser-journey (pull_request) Successful in 1m59s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 2m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 22:59:31 +00:00
timmy 58299311d0 fix: reset capture viewport after keyboard navigation 2026-08-17 22:58:22 +00:00
timmy 89c25369fc fix: close capture without leaving the dashboard 2026-08-17 22:56:25 +00:00
timmy be10dba4bd feat: contain mobile issue capture focus (Closes #1046) 2026-08-17 22:55:04 +00:00
rockachopa c655cf5861 Merge pull request 'Build release bundles from the declared Git commit' (#1045) from timmy/1044-declared-git-release-bundles into main
CI / browser-journey (push) Successful in 1m47s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m29s
2026-08-17 22:33:47 +00:00
timmy 2082fbd5eb ci: build releases from declared Git commits (Closes #1044)
CI / browser-journey (pull_request) Successful in 2m24s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m38s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 22:25:38 +00:00
timmy 55156f45c9 Merge pull request 'Recover valid Today edits from a corrupted offline queue' (#1043) from timmy/1042-recover-valid-today-edits into main
CI / browser-journey (push) Successful in 2m20s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m47s
2026-08-17 22:00:18 +00:00
timmy 5bf0f97df8 fix: preserve truthful queue recovery status
CI / browser-journey (pull_request) Successful in 1m53s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 2m35s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 21:55:00 +00:00
timmy ce2f04ab6d test: recover queue during active Today journey
CI / browser-journey (pull_request) Failing after 2m23s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 2m30s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 21:46:18 +00:00
timmy f7b2a96206 test: stabilize mobile Today recovery notice
CI / browser-journey (pull_request) Failing after 2m24s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 2m59s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 21:39:20 +00:00
timmy a6550f8b1f fix: recover valid Today edits from corrupt queue
CI / browser-journey (pull_request) Failing after 2m21s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 2m43s
CI / release-candidate (pull_request) Has been skipped
Closes #1042
2026-08-17 21:26:40 +00:00
rockachopa 66968d90c0 Merge pull request 'feat: CI browser journey is nondeterministic after Today cross-device merge' (#1041) from timmy/1040-ci-browser-journey-is-nondeterministic-after-tod into main
CI / browser-journey (push) Successful in 2m18s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m55s
2026-08-17 20:58:12 +00:00
timmy 442d0a157d fix: preserve Today sync enqueue order
CI / browser-journey (pull_request) Successful in 2m9s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 2m38s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 20:43:13 +00:00
rockachopa 4a85f02eae Merge pull request 'Continue a timed Today break across devices' (#1039) from timmy/1038-cross-device-today-break into main
CI / browser-journey (push) Failing after 2m10s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 2m44s
2026-08-17 20:23:06 +00:00
timmy dfd33aee1c fix: reject contradictory running break sessions
CI / browser-journey (pull_request) Successful in 2m5s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m44s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 20:17:43 +00:00
timmy c5b466f281 feat: continue timed breaks across devices (Closes #1038)
CI / browser-journey (pull_request) Failing after 2m8s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 2m53s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 20:08:05 +00:00
rockachopa 1d242bab75 Merge pull request 'Add a timed mobile break and return-to-Today flow' (#1037) from timmy/1036-timed-mobile-today-break into main
CI / browser-journey (push) Successful in 2m1s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m41s
2026-08-17 19:37:52 +00:00
timmy 40284a7e30 fix: preserve Today order when resuming a break
CI / browser-journey (pull_request) Successful in 2m4s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m41s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 19:32:10 +00:00
timmy ea08f566ff feat: add timed mobile Today breaks (Closes #1036)
CI / browser-journey (pull_request) Failing after 2m7s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 2m44s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 19:20:07 +00:00
rockachopa ca671f0d00 Merge pull request 'Undo accidental Today completion on mobile' (#1035) from timmy/1034-undo-today-completion into main
CI / browser-journey (push) Successful in 2m14s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m52s
2026-08-17 18:12:56 +00:00
timmy 6e677f8857 feat: undo accidental Today completion (Closes #1034)
CI / browser-journey (pull_request) Successful in 2m13s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m57s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 18:06:52 +00:00
rockachopa e47e6329cc Merge pull request 'Finish the current Today item from the mobile lock screen' (#1033) from timmy/1032-finish-today-lock-screen into main
CI / browser-journey (push) Successful in 2m13s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 12s
CI / lint (push) Successful in 3m5s
2026-08-17 17:09:29 +00:00
timmy 11395b655a feat: finish Today from the lock screen (Closes #1032)
CI / browser-journey (pull_request) Successful in 2m15s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 3m8s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 17:02:54 +00:00
rockachopa ca520d2eb6 Merge pull request 'Make cross-device Today session sync conflict-convergent' (#1031) from timmy/1030-today-session-conflict-convergence into main
CI / browser-journey (push) Successful in 2m35s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 11s
CI / lint (push) Successful in 3m3s
2026-08-17 16:09:21 +00:00
timmy 25dda21811 fix: converge Today session conflicts across devices (Closes #1030)
CI / browser-journey (pull_request) Successful in 2m17s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m58s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 16:00:59 +00:00
rockachopa 8a57ea73fb Merge pull request 'Make pull merges durably auditable before mutation' (#1029) from timmy/1028-durable-pull-merge-audit into main
CI / browser-journey (push) Successful in 2m9s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m51s
2026-08-17 15:33:56 +00:00
timmy 6729359ba2 fix: make pull merges durably auditable (Closes #1028)
CI / browser-journey (pull_request) Successful in 2m8s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m54s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 15:28:00 +00:00
timmy f687389046 Merge pull request 'Control an active Today session from the mobile lock screen' (#1027) from timmy/1026-today-lock-screen into main
CI / browser-journey (push) Successful in 2m2s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m46s
2026-08-17 14:53:43 +00:00
timmy a7f4f9714f feat: control Today from mobile lock screen (Closes #1026)
CI / browser-journey (pull_request) Successful in 2m21s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m55s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 14:47:07 +00:00
timmy 7ba238fb53 Merge pull request 'Make Prepare Today the primary truthful mobile work entry' (#1025) from timmy/1024-primary-prepare-today into main
CI / browser-journey (push) Successful in 2m35s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m4s
2026-08-17 13:41:24 +00:00
timmy b845f8ba95 feat: make Prepare Today the primary mobile entry (Closes #1024)
CI / browser-journey (pull_request) Successful in 2m27s
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 3m5s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 13:34:58 +00:00
timmy 996309f52f Merge pull request 'Continue an active Today session across devices' (#1023) from timmy/1022-today-session-handoff into main
CI / browser-journey (push) Successful in 2m24s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m58s
2026-08-17 12:51:50 +00:00
timmy 8d12a0384b feat: hand off active Today sessions across devices (Closes #1022)
CI / browser-journey (pull_request) Successful in 2m22s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 3m11s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 12:44:01 +00:00
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 / browser-journey (push) Successful in 2m34s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m11s
2026-08-17 12:04:30 +00:00
timmy 241f0f3a2a feat: make Security Center Back-safe on mobile (Closes #1020)
CI / browser-journey (pull_request) Successful in 2m35s
CI / build-release (pull_request) Successful in 8s
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 / browser-journey (push) Successful in 2m21s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m10s
2026-08-17 10:48:48 +00:00
timmy 3fde7cf3ad fix: foreground mobile delivery attention handoffs (Closes #1018)
CI / browser-journey (pull_request) Successful in 2m22s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 2m13s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m7s
2026-08-17 10:08:11 +00:00
timmy 437da72f3d fix: keep delivery recovery single-flight (Closes #1016)
CI / browser-journey (pull_request) Successful in 2m20s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 2m13s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m49s
2026-08-17 09:12:25 +00:00
timmy 1e4f101219 feat: guide mobile delivery recovery (Closes #1014)
CI / browser-journey (pull_request) Successful in 2m4s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 2m9s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m55s
2026-08-17 08:36:15 +00:00
timmy 246f995b56 feat: prioritize mobile delivery recovery (Closes #1012)
CI / browser-journey (pull_request) Successful in 2m5s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 1m51s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m40s
2026-08-17 08:07:58 +00:00
timmy 26aa12019e feat: guide blocked deliveries through Prepare Today (Closes #1010)
CI / browser-journey (pull_request) Successful in 1m53s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 2m6s
CI / build-release (push) Successful in 7s
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 / browser-journey (pull_request) Successful in 1m56s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 1m53s
CI / build-release (push) Successful in 6s
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 / browser-journey (pull_request) Successful in 2m1s
CI / build-release (pull_request) Successful in 5s
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 / browser-journey (push) Successful in 2m2s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m49s
Closes #1004
2026-08-17 06:32:24 +00:00
timmy 7571a4aa2d fix: resume private share bundles after sign-in (Closes #1004)
CI / browser-journey (pull_request) Successful in 2m30s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 1m55s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m54s
2026-08-17 05:49:33 +00:00
timmy 386acda1dc feat: add mobile pull-to-refresh for My Work (Closes #1002)
CI / browser-journey (pull_request) Successful in 2m1s
CI / build-release (pull_request) Successful in 7s
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 / browser-journey (push) Successful in 2m9s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m58s
Closes #1000
2026-08-17 04:45:00 +00:00
timmy a2c91e0296 fix: guard IndexedDB private work cleanup (Closes #1000)
CI / browser-journey (pull_request) Successful in 2m9s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 2m12s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m1s
2026-08-17 03:43:21 +00:00
timmy 70c8ea6584 feat: manage private device storage (Closes #998)
CI / browser-journey (pull_request) Successful in 2m5s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 1m59s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m44s
2026-08-17 02:52:39 +00:00
timmy cf76bc3636 test: model worker transport outage deterministically
CI / browser-journey (pull_request) Successful in 2m3s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (pull_request) Failing after 2m45s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (pull_request) Failing after 2m53s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (pull_request) Failing after 2m44s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (pull_request) Failing after 3m4s
CI / build-release (pull_request) Successful in 7s
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 / browser-journey (pull_request) Failing after 2m43s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 1m53s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m2s
Closes #994
2026-08-17 00:14:58 +00:00
timmy 0fcb7f5152 feat: make mobile Home task-first with Insights (Closes #994)
CI / browser-journey (pull_request) Successful in 2m8s
CI / build-release (pull_request) Successful in 7s
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 / browser-journey (push) Successful in 1m47s
CI / build-release (push) Successful in 6s
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 / browser-journey (pull_request) Successful in 1m50s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 1m39s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m53s
2026-08-16 22:12:24 +00:00
timmy 110fd860b3 feat: schedule unfinished work during Today wrap-up (Closes #990)
CI / browser-journey (pull_request) Successful in 1m36s
CI / build-release (pull_request) Successful in 7s
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 / browser-journey (push) Successful in 1m33s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m51s
2026-08-16 21:09:47 +00:00
timmy e395878f92 security: block vulnerable runtime dependencies (Closes #988)
CI / browser-journey (pull_request) Successful in 1m20s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 1m34s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m23s
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 / browser-journey (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 1m16s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m11s
2026-08-16 19:38:34 +00:00
timmy 966028e0a6 security: sanitize image evidence server-side (Closes #983)
CI / browser-journey (pull_request) Successful in 1m11s
CI / build-release (pull_request) Successful in 5s
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 / browser-journey (push) Successful in 1m12s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m10s
Closes #981
2026-08-16 19:02:46 +00:00
timmy e10bd2d261 test: gate packaged mobile Find Work journey (Closes #981)
CI / browser-journey (pull_request) Successful in 1m20s
CI / build-release (pull_request) Successful in 8s
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 / browser-journey (push) Successful in 1m6s
CI / build-release (push) Successful in 6s
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 / browser-journey (pull_request) Successful in 1m18s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 1m6s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m6s
2026-08-16 17:39:09 +00:00
timmy 85a000422d test: exercise migrated workflow offline
CI / browser-journey (pull_request) Successful in 1m1s
CI / build-release (pull_request) Successful in 5s
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 / browser-journey (pull_request) Failing after 1m43s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 1m43s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m58s
2026-08-16 16:40:44 +00:00
timmy fc33746d4c feat: add mobile Plan Today navigation (Closes #975)
CI / browser-journey (pull_request) Successful in 1m2s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 1m2s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m4s
2026-08-16 16:08:44 +00:00
timmy 8de2318f43 test: isolate Search Preview browser checks
CI / browser-journey (pull_request) Successful in 1m8s
CI / build-release (pull_request) Successful in 5s
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 / browser-journey (pull_request) Has been skipped
CI / build-release (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 / browser-journey (push) Successful in 59s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m1s
2026-08-16 15:16:04 +00:00
timmy fe95b4e32a fix: keep mobile filing navigation selected
CI / browser-journey (pull_request) Successful in 1m1s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (pull_request) Failing after 1m0s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 55s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m59s
2026-08-16 14:32:36 +00:00
timmy 31fb3fc8ae feat: add mobile requested-review navigation (Closes #969)
CI / browser-journey (pull_request) Successful in 55s
CI / build-release (pull_request) Successful in 7s
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 / browser-journey (push) Successful in 54s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m5s
2026-08-16 13:46:40 +00:00
timmy 6c7b6ebd4d feat: add mobile update workspace navigation (Closes #967)
CI / browser-journey (pull_request) Successful in 1m3s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 51s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m59s
2026-08-16 13:04:52 +00:00
timmy c48baac1ea test: keep rendered checks out of unit CI
CI / browser-journey (pull_request) Successful in 54s
CI / build-release (pull_request) Successful in 5s
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 / browser-journey (pull_request) Has been skipped
CI / build-release (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 / browser-journey (push) Successful in 56s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m3s
2026-08-16 12:38:07 +00:00
timmy 31f2c71575 feat: preserve My Work photo drafts (Closes #963)
CI / browser-journey (pull_request) Successful in 57s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 56s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m59s
2026-08-16 12:05:55 +00:00
timmy 6c200d7c97 feat: add mobile issue detail navigation (Closes #961)
CI / browser-journey (pull_request) Successful in 56s
CI / build-release (pull_request) Successful in 5s
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 / browser-journey (push) Successful in 54s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m2s
2026-08-16 11:08:30 +00:00
timmy c8a237aba4 test: tolerate container reconnect latency
CI / browser-journey (pull_request) Successful in 55s
CI / build-release (pull_request) Successful in 5s
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 / browser-journey (pull_request) Failing after 1m10s
CI / build-release (pull_request) Successful in 5s
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 / browser-journey (push) Successful in 1m0s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m1s
Closes #957
2026-08-16 10:12:08 +00:00
timmy 5af45cda57 feat: preserve Search photo reply drafts (Closes #957)
CI / browser-journey (pull_request) Successful in 57s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 51s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m58s
2026-08-16 09:34:05 +00:00
timmy d3a57f222e feat: attach photos from Search preview replies (Closes #955)
CI / browser-journey (pull_request) Successful in 51s
CI / build-release (pull_request) Successful in 5s
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 / browser-journey (push) Successful in 58s
CI / build-release (push) Successful in 6s
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 / browser-journey (pull_request) Successful in 51s
CI / build-release (pull_request) Successful in 5s
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 / browser-journey (push) Successful in 56s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m2s
2026-08-16 08:39:34 +00:00
timmy 0e13872311 feat: dictate mobile conversation replies (Closes #951)
CI / browser-journey (pull_request) Successful in 59s
CI / build-release (pull_request) Successful in 5s
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 / browser-journey (push) Successful in 1m2s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m2s
2026-08-16 07:58:41 +00:00
timmy a8e931f306 feat: strip metadata from photo evidence (Closes #949)
CI / browser-journey (pull_request) Successful in 57s
CI / build-release (pull_request) Successful in 5s
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 / browser-journey (push) Successful in 55s
CI / build-release (push) Successful in 5s
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 / browser-journey (pull_request) Successful in 57s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 1m0s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m8s
2026-08-16 07:02:09 +00:00
timmy 5da5d7cdc4 feat: review conversation photo bundles before sending (Closes #945)
CI / browser-journey (pull_request) Successful in 1m1s
CI / build-release (pull_request) Successful in 7s
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 / browser-journey (push) Successful in 1m0s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m7s
2026-08-16 06:13:37 +00:00
timmy c5d31c1972 feat: send mobile conversation photo bundles (Closes #943)
CI / browser-journey (pull_request) Successful in 57s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 1m0s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m3s
Closes #941
2026-08-16 05:37:38 +00:00
timmy 2338a07bab feat: capture photo evidence in conversation replies (Closes #941)
CI / browser-journey (pull_request) Successful in 1m2s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 59s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m2s
2026-08-16 05:06:46 +00:00
timmy caa0fbc2fd feat: save photo-only captures as drafts (Closes #939)
CI / browser-journey (pull_request) Successful in 1m2s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 59s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m7s
Merge pull request #938 from timmy/937-camera-first-photo-evidence
2026-08-16 04:33:45 +00:00
timmy b4662a175d feat: capture camera-first photo evidence (Closes #937)
CI / browser-journey (pull_request) Successful in 1m2s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 56s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m7s
2026-08-16 04:00:46 +00:00
timmy 2bb155a9e4 fix: deduplicate cumulative voice results (Closes #935)
CI / browser-journey (pull_request) Successful in 1m1s
CI / build-release (pull_request) Successful in 6s
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 / browser-journey (push) Successful in 58s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m10s
Merge PR #934: enable same-origin microphone access
2026-08-16 03:28:43 +00:00
timmy bd637d0141 fix: enable same-origin voice capture (Closes #933)
CI / browser-journey (pull_request) Successful in 1m1s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m12s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 03:24:10 +00:00
timmy 785fb990b4 Merge pull request #932 from timmy/931-recover-mobile-voice-transcripts
CI / browser-journey (push) Successful in 1m4s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 2m1s
Recover interrupted mobile voice transcripts privately

Closes #931
2026-08-16 02:42:02 +00:00
timmy f8c2c080f9 feat: recover interrupted mobile voice transcripts (Closes #931)
CI / browser-journey (pull_request) Successful in 56s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m5s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 02:37:54 +00:00
timmy 47220929bf Merge pull request 'Add voice-to-draft mobile issue capture' (#930) from timmy/929-voice-to-draft-mobile-capture into main
CI / browser-journey (push) Successful in 1m3s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m8s
2026-08-16 02:06:46 +00:00
timmy 3b4bb618dd feat: add voice-to-draft mobile capture (Closes #929)
CI / browser-journey (pull_request) Successful in 1m10s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 2m14s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 02:02:24 +00:00
rockachopa 9adfcd01b6 Merge pull request 'feat: CI regression: offline artifact browser journey does not flush after reconnect' (#928) from timmy/927-ci-regression-offline-artifact-browser-journey-d into main
CI / browser-journey (push) Successful in 58s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m6s
2026-08-16 01:39:48 +00:00
timmy 64e764b996 fix: flush durable outboxes after reconnect (Closes #927)
CI / browser-journey (pull_request) Successful in 56s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m59s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 01:32:11 +00:00
timmy 76eed47487 Merge pull request 'Close completed delegated parent from mobile Filed review' (#926) from timmy/925-close-completed-delegated-parent into main
CI / browser-journey (push) Failing after 1m17s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 2m5s
2026-08-16 01:02:34 +00:00
timmy 05b2f769e4 feat: close completed delegated parent from Filed review (Closes #925)
CI / browser-journey (pull_request) Successful in 1m4s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m5s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 00:58:39 +00:00
rockachopa 40b124bc2c Merge pull request #924 from timmy/923-complete-delegated-parent-checklist
CI / browser-journey (push) Successful in 1m3s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 2m2s
Complete delegated parent task from mobile Filed review
2026-08-16 00:35:39 +00:00
timmy 4ab0ffde58 feat: complete delegated parent task from Filed review (Closes #923)
CI / browser-journey (pull_request) Successful in 59s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m4s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 00:31:37 +00:00
timmy ab77beb3fe Merge pull request 'Promote checklist steps into related issues from mobile detail' (#922) from timmy/921-promote-checklist-related-issue into main
CI / browser-journey (push) Successful in 2m5s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m58s
2026-08-15 23:47:02 +00:00
timmy 170828b308 feat: promote checklist steps to related issues (Closes #921)
CI / browser-journey (pull_request) Successful in 58s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m58s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 23:40:17 +00:00
rockachopa 08139bc3ee Merge pull request 'Manage checklist steps inline from mobile issue detail' (#920) from timmy/919-manage-checklist-steps into main
CI / browser-journey (push) Successful in 57s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m55s
2026-08-15 22:42:45 +00:00
timmy 4589c46f16 feat: manage mobile checklist steps inline (Closes #919)
CI / browser-journey (pull_request) Successful in 56s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m59s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 22:38:52 +00:00
rockachopa 6b924a4757 Merge pull request #918 from timmy/917-ci-regression-offline-artifact-browser-journey-d
Fix queued issue delivery after offline reload and reconnect.
2026-08-15 21:41:45 +00:00
timmy 1602528e47 fix: resume queued delivery on reconnect (Closes #917) 2026-08-15 21:31:42 +00:00
rockachopa d28140bbd1 Merge pull request 'Add checklist steps inline from mobile issue detail' (#916) from timmy/915-mobile-add-checklist-step into main 2026-08-15 21:13:17 +00:00
timmy 3f559a50ea feat: add mobile checklist steps inline (Closes #915) 2026-08-15 21:09:25 +00:00
timmy e7a4eb3331 Merge pull request 'Resolve offline checklist conflicts from mobile Drafts' (#914)
Closes #913
2026-08-15 20:04:12 +00:00
timmy 7b27f85b39 feat: resolve mobile checklist conflicts (Closes #913)
CI / browser-journey (pull_request) Successful in 57s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m7s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 20:00:14 +00:00
timmy 7204a426af Merge pull request 'Finish a mobile checklist and continue Today' (#912) from timmy/911-checklist-close-next into main
CI / browser-journey (push) Successful in 1m1s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m54s
2026-08-15 19:32:07 +00:00
timmy 2aad1a9bef feat: finish mobile checklists and continue Today (Closes #911)
CI / browser-journey (pull_request) Successful in 58s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 19:27:54 +00:00
timmy dbadc73a57 Merge pull request 'Continue mobile checklist progress across offline changes' (#910) from timmy/909-offline-checklist-coalesce into main
CI / browser-journey (push) Successful in 59s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m57s
2026-08-15 19:05:22 +00:00
timmy 59bc13c5b0 test: stabilize transport admission deadline
CI / browser-journey (pull_request) Successful in 1m0s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 2m3s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 19:01:24 +00:00
timmy 5c370cbe33 feat: continue offline checklist progress (Closes #909)
CI / browser-journey (pull_request) Has been skipped
CI / build-release (pull_request) Has been skipped
CI / lint (pull_request) Failing after 1m57s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 18:26:34 +00:00
timmy 475739328c Merge pull request 'Queue offline issue checklist progress for conflict-safe delivery' (#908)
CI / browser-journey (push) Successful in 57s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m58s
2026-08-15 17:39:42 +00:00
timmy 411e40d117 feat: queue offline checklist progress (Closes #907)
CI / browser-journey (pull_request) Successful in 59s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m58s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 17:35:21 +00:00
timmy 88e5f3f33b Merge pull request 'Toggle issue checklist items from mobile detail' (#906) from timmy/905-mobile-checklist-toggle into main
CI / browser-journey (push) Successful in 1m1s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m56s
2026-08-15 16:54:47 +00:00
timmy 72dcdc6c52 feat: toggle mobile issue checklists (Closes #905)
CI / browser-journey (pull_request) Successful in 59s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m59s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 16:50:48 +00:00
timmy d8f2b803c7 Merge pull request 'Keep mobile share-target content out of navigation URLs' (#904) from timmy/903-private-share-bundle into main
CI / browser-journey (push) Successful in 56s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m58s
2026-08-15 16:06:28 +00:00
timmy fdd3cf03b4 security: keep mobile share content private (Closes #903)
CI / browser-journey (pull_request) Successful in 59s
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 1m56s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 16:02:35 +00:00
timmy 06184f1cb2 Merge pull request 'Continue Prepare Today after Attention clears' (#902) from timmy/901-continue-prepare-today-attention into main
CI / browser-journey (push) Successful in 58s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m59s
2026-08-15 15:29:33 +00:00
timmy c26f4bdeeb fix: continue Prepare Today after Attention clears (Closes #901)
CI / browser-journey (pull_request) Successful in 56s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m53s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 15:25:44 +00:00
timmy 04ae7480cf Merge pull request 'Resume guided Prepare Today across urgent queues' (#900) from timmy/899-resume-prepare-today into main
CI / browser-journey (push) Successful in 53s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m49s
2026-08-15 15:05:16 +00:00
timmy 21705a0859 feat: resume guided Prepare Today passes (Closes #899)
CI / browser-journey (pull_request) Successful in 54s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m51s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 15:01:26 +00:00
timmy a2076cf9a9 Merge pull request 'Guide mobile start-day review across urgent queues' (#898) from timmy/897-mobile-start-day into main
CI / browser-journey (push) Successful in 55s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m52s
2026-08-15 14:35:40 +00:00
timmy d4aafbf351 feat: guide mobile start-day review (Closes #897)
CI / browser-journey (pull_request) Successful in 57s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m54s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 14:31:37 +00:00
timmy 7351363982 Merge pull request 'Reassign open delegated issues from mobile Filed' (#896) from timmy/895-reassign-filed into main
CI / browser-journey (push) Successful in 52s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m51s
2026-08-15 13:50:01 +00:00
timmy c86a63c54b test: gate offline release journey deterministically
CI / browser-journey (pull_request) Successful in 56s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m54s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 13:46:28 +00:00
timmy 3c5c58fc38 feat: reassign delegated filings from mobile Filed (Closes #895)
CI / browser-journey (pull_request) Failing after 58s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m49s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 13:37:10 +00:00
timmy 498d7e772a Merge pull request 'Claim and start unowned work from mobile Filed' (#894) from timmy/893-claim-start-filed into main
CI / browser-journey (push) Successful in 53s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m43s
2026-08-15 13:03:51 +00:00
timmy c594cc20fc feat: claim unowned work from Filed (Closes #893)
CI / browser-journey (pull_request) Successful in 54s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m55s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 12:59:47 +00:00
timmy 5105fb40d3 Merge pull request 'Harden private SQLite state filesystem boundaries' (#892) from timmy/891-private-state-boundary into main
CI / browser-journey (push) Successful in 55s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m50s
2026-08-15 12:33:35 +00:00
timmy b13eeb9cc0 security: harden private SQLite state boundaries (Closes #891)
CI / browser-journey (pull_request) Successful in 54s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m57s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 12:29:44 +00:00
rockachopa 49ef3dad78 Merge pull request 'Purge every private offline store after background session revocation' (#890) from timmy/889-private-store-purge into main
CI / browser-journey (push) Successful in 52s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m44s
2026-08-15 11:52:38 +00:00
timmy a60f2cfac3 fix: purge every private offline store (Closes #889)
CI / browser-journey (pull_request) Successful in 54s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m47s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 11:48:54 +00:00
timmy 2eea42859e Merge pull request 'Revise open delegated issues from mobile Filed' (#888) from timmy/887-revise-filed-issue into main
CI / browser-journey (push) Successful in 53s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m48s
2026-08-15 11:04:12 +00:00
timmy 64f549848d feat: revise delegated filings from mobile Filed (Closes #887)
CI / browser-journey (pull_request) Successful in 55s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m50s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 11:00:18 +00:00
timmy 5201b18cae Merge pull request 'Stabilize the event-loop release gate' (#886) from timmy/884-stabilize-release-gate into main
CI / browser-journey (push) Successful in 56s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m55s
2026-08-15 10:16:39 +00:00
timmy 8f92286cce test: make event-loop release gate deterministic (Closes #884)
CI / browser-journey (pull_request) Successful in 59s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m53s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 10:13:05 +00:00
timmy 27be5d43a2 Merge pull request 'Withdraw open delegated issues from mobile Filed' (#885) from timmy/884-withdraw-filed-issue into main
CI / browser-journey (push) Has been skipped
CI / build-release (push) Has been skipped
CI / release-candidate (push) Has been skipped
CI / lint (push) Failing after 1m54s
2026-08-15 10:07:42 +00:00
timmy e64541463c test: cover Filed withdrawal authorization
CI / browser-journey (pull_request) Successful in 55s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m55s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 10:04:10 +00:00
timmy 6a03dfd7ac feat: withdraw delegated issues from Filed (Closes #884)
CI / browser-journey (pull_request) Has been skipped
CI / build-release (pull_request) Has been skipped
CI / lint (pull_request) Failing after 1m55s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 10:00:46 +00:00
timmy f01dfa5f93 Merge pull request 'Keep acknowledged outcomes in mobile Filed history' (#883) from timmy/882-reviewed-filed-history into main
CI / browser-journey (push) Successful in 54s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m46s
Closes #882
2026-08-15 09:13:43 +00:00
timmy a57aa879e2 feat: keep reviewed Filed history (Closes #882)
CI / browser-journey (pull_request) Successful in 52s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m44s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 09:10:05 +00:00
timmy 403190e888 Sync completed Filed acknowledgements across devices (#881)
CI / browser-journey (push) Successful in 56s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m52s
Closes #880
2026-08-15 08:31:05 +00:00
timmy 6acc8866ed feat: sync completed Filed reviews across devices (Closes #880)
CI / browser-journey (pull_request) Successful in 1m0s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m49s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 08:27:19 +00:00
timmy 8e7a21f316 Merge pull request 'Review completed delegated issues from mobile Filed' (#879) from timmy/878-completed-filed-review into main
CI / browser-journey (push) Successful in 55s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m53s
2026-08-15 07:44:21 +00:00
timmy 8411e907af feat: review completed mobile Filed outcomes (Closes #878)
CI / browser-journey (pull_request) Successful in 53s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m47s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 07:40:23 +00:00
timmy eb88355229 Merge pull request 'Reply to delegated issues from mobile Filed' (#877) from timmy/876-filed-followup-replies into main
CI / browser-journey (push) Successful in 58s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m44s
2026-08-15 07:02:51 +00:00
timmy 1b68223798 feat: reply from mobile Filed (Closes #876)
CI / browser-journey (pull_request) Successful in 52s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m59s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 06:58:49 +00:00
timmy 33d7f4b884 Merge pull request 'Complete the mobile Filed follow-up loop' (#875) from timmy/874-mobile-filed-followup into main
CI / browser-journey (push) Successful in 1m1s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m52s
2026-08-15 06:32:56 +00:00
timmy 05dd6c1e9c feat: complete mobile Filed follow-up (Closes #874)
CI / browser-journey (pull_request) Successful in 59s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m56s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 06:29:14 +00:00
timmy 42f2a077b2 Merge pull request 'Open delegated Filed issues in read-only detail' (#873) from timmy/872-filed-read-only-detail into main
CI / browser-journey (push) Successful in 54s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m47s
2026-08-15 05:48:39 +00:00
timmy 0bb76bdc8b feat: open delegated filings read-only (Closes #872)
CI / browser-journey (pull_request) Successful in 57s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m53s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 05:43:28 +00:00
timmy efafaaddd2 Merge pull request 'Keep delegated filings visible in a mobile Filed queue' (#871) from timmy/870-mobile-filed-queue into main
CI / browser-journey (push) Successful in 50s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m43s
2026-08-15 05:05:10 +00:00
timmy a9ffc2d879 feat: keep delegated filings in My Work (Closes #870)
CI / browser-journey (pull_request) Successful in 55s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m48s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 05:01:17 +00:00
timmy 1fdbba1ca1 Merge pull request 'File related issues from confirmed filing receipts' (#869) from timmy/868-file-related-issue into main
CI / browser-journey (push) Successful in 1m30s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m53s
2026-08-15 04:16:16 +00:00
timmy 4b88e1a533 feat: file related issues from confirmed receipts (Closes #868)
CI / browser-journey (pull_request) Successful in 59s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m47s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 04:12:27 +00:00
timmy 7a31ea5cba Merge pull request 'Complete delegated mobile filing with a shareable issue receipt' (#867) from timmy/866-issue-filing-receipt into main
CI / browser-journey (push) Successful in 50s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m48s
2026-08-15 03:10:27 +00:00
timmy 6633ba5244 feat: complete delegated mobile filing handoff (Closes #866)
CI / browser-journey (pull_request) Successful in 59s
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m44s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 03:06:32 +00:00
timmy 5b954a7a53 Merge pull request 'Isolate the release browser journey from the unit-test job' (#865) from timmy/863-release-e2e-opt-in into main
CI / browser-journey (push) Successful in 1m1s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m52s
2026-08-15 02:28:01 +00:00
timmy c30206bfb2 fix: isolate release browser journey
CI / browser-journey (pull_request) Successful in 59s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m53s
CI / release-candidate (pull_request) Has been skipped
Refs #863
2026-08-15 02:24:15 +00:00
timmy cf9a02ae38 Merge pull request 'Gate releases on a real mobile offline filing journey' (#864) from timmy/863-mobile-offline-release-gate into main
CI / browser-journey (push) Has been skipped
CI / build-release (push) Has been skipped
CI / release-candidate (push) Has been skipped
CI / lint (push) Failing after 1m52s
2026-08-15 02:17:33 +00:00
timmy 9f5aa33e95 test: gate releases on mobile offline filing
CI / browser-journey (pull_request) Successful in 1m4s
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m54s
CI / release-candidate (pull_request) Has been skipped
Closes #863
2026-08-15 02:13:15 +00:00
timmy ae3749bab9 Merge pull request 'Open ready-to-file mobile Drafts directly in final review' (#862) from timmy/861-review-ready-drafts into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m49s
2026-08-15 01:15:04 +00:00
timmy c1c4ff8e31 feat: review ready mobile drafts in one tap (Closes #861)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m46s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 01:12:10 +00:00
timmy ff41eb1d57 Resume fully planned issue drafts across devices (#860)
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m56s
Closes #859
2026-08-15 00:10:22 +00:00
timmy f23839f2b9 feat: resume fully planned drafts across devices (Closes #859)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m52s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 00:07:32 +00:00
timmy 7cab4e8749 Merge pull request 'Automatically reconcile concurrent cross-device Draft saves' (#858) from timmy/857-auto-reconcile-draft-saves into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m52s
2026-08-14 23:31:11 +00:00
timmy 8a3316a5c4 fix: reconcile concurrent Draft saves (Closes #857)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m52s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 23:28:12 +00:00
timmy 631eb2e584 Merge pull request 'File mobile issues without assigning an owner' (#856) from timmy/855-file-mobile-issues-without-owner into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m52s
2026-08-14 23:08:40 +00:00
timmy ed246ce6dd feat: file mobile issues without an owner (Closes #855)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m50s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 23:05:59 +00:00
timmy fc35b17d24 Merge pull request 'Preserve both versions when synchronized Drafts diverge' (#854) from timmy/853-divergent-unfiled-drafts into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m46s
2026-08-14 22:35:59 +00:00
timmy cee3d93864 fix: preserve divergent synchronized Drafts (Closes #853)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m53s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 22:29:37 +00:00
timmy fca99fee86 Merge pull request 'Continue unfiled issue drafts across signed-in devices' (#852) from timmy/851-cross-device-unfiled-drafts into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m49s
2026-08-14 21:42:29 +00:00
timmy 3dd0ba2024 feat: sync unfiled drafts across devices (Closes #851)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m52s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 21:39:22 +00:00
timmy 871e35c10b Require a Today estimate before Create & start
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 1m55s
2026-08-14 20:44:45 +00:00
timmy 5c9420ed9c feat: require estimate before create and start (Closes #849)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m48s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 20:41:57 +00:00
timmy 8edc83893f Merge pull request 'Load mobile issue-filing metadata in one resilient request' (#848) from timmy/847-issue-filing-metadata into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m49s
2026-08-14 20:02:51 +00:00
timmy a226addcc4 feat: load resilient issue filing metadata
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m55s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 19:59:45 +00:00
timmy 2402af9e82 Merge pull request 'Select repository issue templates while filing on mobile' (#846) from timmy/845-repository-issue-templates into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 1m44s
2026-08-14 19:18:02 +00:00
timmy 31f95589cb feat: apply repository templates during mobile issue filing
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m49s
CI / release-candidate (pull_request) Has been skipped
Closes #845
2026-08-14 19:15:19 +00:00
timmy 2ac6b1f56b Merge pull request 'feat: Choose the initial owner while filing a mobile issue' (#844) from timmy/843-choose-the-initial-owner-while-filing-a-mobile-i into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m46s
2026-08-14 18:32:28 +00:00
timmy 6449ead83d feat: choose initial owner during issue filing
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m48s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 18:26:33 +00:00
timmy 322f025794 Merge pull request 'Choose blockers while filing a mobile issue' (#842) from timmy/841-capture-blockers into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m43s
2026-08-14 17:36:42 +00:00
timmy 9ab22b97f8 feat: capture blockers while filing mobile issues (Closes #841)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m45s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 17:33:45 +00:00
timmy 35747cf9fb Merge pull request 'Load comment actions only when a conversation opens' (#840) from timmy/839-lazy-comment-actions into main
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m43s
2026-08-14 17:03:09 +00:00
timmy f094438dab Load comment actions on conversation demand
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m53s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 17:00:31 +00:00
timmy 04f06042b8 Merge pull request 'Bound decoded screenshot pixels before mobile evidence preview' (#838) from timmy/837-bound-decoded-screenshot-pixels into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m44s
2026-08-14 16:37:55 +00:00
timmy a476170fca feat: bound decoded screenshot pixels (Closes #837)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m46s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 16:35:21 +00:00
timmy 5a579362ff Merge pull request 'Visually verify mobile evidence in final filing review' (#836) from timmy/835-visual-mobile-evidence-review into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m47s
2026-08-14 16:01:02 +00:00
timmy d797179844 feat: visually review mobile issue evidence (Closes #835)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m44s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 15:56:08 +00:00
timmy a500915223 Merge pull request 'Review complete mobile issue payload before filing' (#834) from timmy/833-mobile-issue-filing-review into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m47s
2026-08-14 15:39:36 +00:00
timmy 59a2e4f616 feat: review mobile issue payload before filing (Closes #833)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m42s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 15:36:54 +00:00
timmy e125019f57 Merge pull request 'Annotate mobile evidence before filing' (#832) from timmy/831-annotate-mobile-evidence into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m55s
2026-08-14 14:59:50 +00:00
timmy 6ba823abd2 feat: annotate mobile evidence before filing (Closes #831)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m43s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 14:57:17 +00:00
timmy f44fa6e579 Crop and redact mobile evidence before filing (#830)
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m41s
Closes #829
2026-08-14 14:36:29 +00:00
timmy 87ff83bd2d feat: crop and redact mobile evidence (Closes #829)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m49s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 14:33:18 +00:00
timmy 7dd396154d Merge pull request 'Explain each mobile evidence screenshot before filing' (#828) from timmy/827-evidence-screenshot-notes into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m51s
2026-08-14 13:44:13 +00:00
timmy af5fe9b69d feat: explain mobile evidence screenshots (Closes #827)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m47s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 13:41:02 +00:00
timmy 807364c63b Merge pull request 'Review and reorder mobile evidence before filing' (#826) from timmy/825-review-reorder-mobile-evidence into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m46s
2026-08-14 12:47:07 +00:00
timmy 6315323a48 feat: review and reorder mobile evidence (Closes #825)
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 1m46s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 12:43:31 +00:00
timmy 6a9271cabb Merge pull request 'Capture mobile evidence bundles with one issue' (#824)
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m49s
Closes #823
2026-08-14 11:45:28 +00:00
timmy a1c56bd644 feat: capture mobile evidence bundles (Closes #823)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m42s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 11:43:01 +00:00
timmy ebc3b9ee4d Merge pull request 'Save and reopen synced mobile Search views' (#822)
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m48s
Closes #821
2026-08-14 10:49:01 +00:00
timmy 19772b5797 feat: sync saved mobile search views (Closes #821)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m46s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 10:46:13 +00:00
timmy 6314efb82b Merge pull request 'feat: Fix flaky live-snapshot freshness test blocking CI release' (#820) from timmy/819-fix-flaky-live-snapshot-freshness-test-blocking- into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m44s
2026-08-14 10:24:46 +00:00
timmy 5cda95eeb2 test: remove scheduler race from snapshot freshness check
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m41s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 10:14:09 +00:00
timmy 0c71dea00c Merge pull request 'Apply mobile Search release plans atomically' (#818) from timmy/817-atomic-search-release-plan into main
CI / build-release (push) Has been skipped
CI / release-candidate (push) Has been skipped
CI / lint (push) Failing after 1m54s
2026-08-14 10:00:02 +00:00
timmy 53539e0cdf feat: apply search release plans atomically (Closes #817)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m43s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 09:57:15 +00:00
timmy 9a24aff537 Merge pull request 'Plan selected mobile Search issues into a release milestone' (#816) from timmy/815-plan-search-release into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m38s
Plan selected mobile Search issues into a release milestone (#816)
2026-08-14 09:34:28 +00:00
timmy 43769460fc feat: plan search batches into releases (Closes #815)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m37s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 09:30:53 +00:00
timmy 2e62eb09f5 Merge pull request 'Batch-schedule selected mobile Search issues into Later' (#814) from timmy/813-batch-defer-search-results into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m36s
2026-08-14 09:08:14 +00:00
timmy 1ef3e291c2 feat: batch-defer mobile search results (Closes #813)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m36s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 09:05:30 +00:00
timmy 3694f2501b Merge pull request 'Fit mobile Search batches to Today time capacity' (#812) from timmy/811-search-batch-time-capacity into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m38s
2026-08-14 08:31:24 +00:00
timmy c3d58d7111 feat: fit search batches to Today capacity (Closes #811)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m43s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 08:28:49 +00:00
timmy da45cc36a3 Batch-plan mobile Search results into Today (#810)
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m39s
2026-08-14 08:08:45 +00:00
timmy c04cba29d9 feat: batch-plan mobile search results into Today (Closes #809)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m37s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 08:06:03 +00:00
timmy cacecac865 Merge pull request 'Make partial mobile Search pagination retryable and lossless' (#808) from timmy/807-lossless-partial-search into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m34s
2026-08-14 07:35:02 +00:00
timmy 10f7cc832a fix: make partial search pagination lossless (Closes #807)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 07:32:45 +00:00
timmy e5f37a866c Merge pull request 'Reply and continue from mobile Search' (#806) from timmy/805-reply-continue-mobile-search into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m39s
2026-08-14 06:44:29 +00:00
timmy 5a4fbd1564 feat: reply and continue from mobile search (Closes #805)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m33s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 06:41:34 +00:00
timmy 7bcab03b8a Merge pull request 'Read current conversations inside mobile Search preview' (#804)
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m37s
Closes #803
2026-08-14 06:07:12 +00:00
timmy 51fe9e5af2 feat: show conversations in mobile search preview (Closes #803)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m45s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 06:04:25 +00:00
timmy 2406d24cb8 Merge pull request 'Continue through mobile Search results while planning work' (#802) from timmy/801-sequential-mobile-search into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m33s
2026-08-14 05:13:09 +00:00
timmy 745d2529e5 feat: continue through mobile search results (Closes #801)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m39s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 05:08:56 +00:00
timmy af9699e362 Merge pull request 'Schedule mobile Search results into Later' (#800)
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m38s
Closes #799
2026-08-14 04:32:45 +00:00
timmy 71b38687c4 feat: schedule search results into Later (Closes #799)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m42s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 04:30:02 +00:00
timmy 0a8f33dc83 Merge pull request 'Queue mobile Search results into Today' (#798) from timmy/797-queue-mobile-search-results into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m45s
2026-08-14 04:08:20 +00:00
timmy 0f45b7aead feat: queue mobile search results into Today (Closes #797)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m41s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 04:05:49 +00:00
timmy a44bf46bef Merge pull request 'Scope mobile Search to an accessible repository' (#796) from timmy/795-repository-scoped-mobile-search into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m38s
2026-08-14 03:14:05 +00:00
timmy ec86bf55a8 Scope mobile Search by repository
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m37s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 03:11:40 +00:00
timmy 7636e30482 Merge pull request 'Scope mobile Search by type and status' (#794) from timmy/793-scope-mobile-search into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m42s
Merge pull request 'Scope mobile Search by type and status' (#794) from timmy/793-scope-mobile-search into main
2026-08-14 02:37:38 +00:00
timmy bd054998b2 feat: scope mobile search by type and status (Closes #793)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m45s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 02:34:44 +00:00
timmy 3c65bc1b31 Merge pull request 'Open requested pull reviews from mobile Search' (#792) from timmy/791-open-requested-reviews-from-search into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m36s
2026-08-14 01:47:39 +00:00
timmy bc082f2bd9 feat: open requested reviews from mobile search (Closes #791)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m45s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 01:44:51 +00:00
timmy 9ae0a89970 Merge pull request 'Share resumable mobile Search previews' (#790) from timmy/787-share-resumable-search-previews into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m37s
2026-08-14 01:07:05 +00:00
timmy 43163ed926 feat: share resumable mobile search previews (Closes #787)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m41s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 01:04:20 +00:00
timmy 66a2b78860 Merge pull request 'Open assigned pull requests from mobile Search' (#786) from timmy/785-open-assigned-pulls-from-search into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m31s
2026-08-14 00:27:40 +00:00
timmy 20d4f1ef7e feat: open assigned pulls from mobile search (Closes #785)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m31s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 00:25:07 +00:00
timmy 70281bb56a Merge pull request 'Load complete, balanced results in mobile Search' (#784) from timmy/783-paginated-balanced-mobile-search into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m32s
2026-08-14 00:06:24 +00:00
timmy 7b736bc4c6 feat: paginate balanced mobile search results (Closes #783)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 00:04:06 +00:00
timmy fa3e5e3bf0 Merge pull request 'Resume addressable mobile Search sessions' (#782) from timmy/781-resumable-mobile-search into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m32s
Merge pull request Resume addressable mobile Search sessions (#782)
2026-08-13 23:27:44 +00:00
timmy bc0d47e8e7 feat: resume addressable mobile search sessions (Closes #781)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m33s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 23:25:35 +00:00
timmy 741c27eacf Merge pull request 'Authorize and audit permanent comment deletion' (#780) from timmy/779-authorize-audit-comment-deletion into main
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m26s
2026-08-13 22:58:01 +00:00
timmy 3896f17a58 feat: authorize and audit comment deletion (Closes #779)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m28s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 22:55:47 +00:00
timmy 9d14fa5ffb Merge pull request 'Reset mobile work details when advancing to another item' (#778) from timmy/777-reset-mobile-work-detail-position into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m34s
2026-08-13 22:30:11 +00:00
timmy 83be8fcdbe fix: reset mobile work detail position (Closes #777)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m25s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 22:28:08 +00:00
timmy 35f3999787 Merge pull request 'Complete review requests without leaving mobile Updates triage' (#776) from timmy/775-update-review-handoff into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m35s
2026-08-13 22:01:54 +00:00
timmy 378291d3c8 feat: review pull requests from update triage (Closes #775)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m42s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 21:59:35 +00:00
timmy 584752ed24 Merge pull request 'Prioritize actionable work in mobile Updates triage' (#774) from timmy/773-actionable-update-triage into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m31s
2026-08-13 21:26:58 +00:00
timmy d54f58bf36 feat: prioritize actionable update triage (Closes #773)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m37s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 21:24:34 +00:00
timmy 84302d6f1e Merge pull request 'Make mobile Update decisions transaction-safe' (#772) from timmy/771-transaction-safe-update-decisions into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m39s
2026-08-13 21:01:00 +00:00
timmy 2e0d64907b fix: make update decisions transaction-safe (Closes #771)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m36s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 20:58:43 +00:00
timmy 80042ce561 Merge pull request 'Add one-handed swipe decisions to mobile Updates triage' (#770) from timmy/769-mobile-update-swipe-triage into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m34s
2026-08-13 20:32:44 +00:00
timmy 931f656f78 feat: add mobile update swipe triage (Closes #769)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m41s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 20:30:15 +00:00
timmy 18f56583ba Merge pull request 'Recover mobile sign-in from stalled requests' (#768) from timmy/767-stalled-login-recovery into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m35s
2026-08-13 19:54:59 +00:00
timmy 6e2f5d1120 fix: recover stalled mobile sign-in (Closes #767)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m32s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 19:52:44 +00:00
timmy 9bd755f521 Merge pull request 'Review kept-unread outcomes after mobile Updates triage' (#766) from timmy/765-update-outcome-review into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m33s
2026-08-13 19:23:52 +00:00
timmy fd2f801f3e feat: review mobile update triage outcomes (Closes #765)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m30s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 19:21:36 +00:00
timmy e21cc58cca Merge pull request 'Keep mobile Update decisions within thumb reach' (#764) from timmy/763-mobile-update-decision-bar into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m31s
2026-08-13 18:56:13 +00:00
timmy d6212b2812 feat: keep mobile update decisions within reach (Closes #763)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m33s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 18:53:50 +00:00
timmy 6528b2e73f Merge pull request 'Load mobile update conversations without blocking core detail' (#762) from timmy/761-progressive-update-conversation into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m36s
2026-08-13 18:27:41 +00:00
timmy a9d586e689 feat: load update conversations progressively (Closes #761)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m38s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 18:25:21 +00:00
timmy 7c86f28346 Merge pull request 'Open each mobile update at its newest unread activity' (#760) from timmy/759-newest-update-activity into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m33s
2026-08-13 17:58:03 +00:00
timmy 707ee4c1bc feat: open updates at newest activity (Closes #759)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m41s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 17:55:33 +00:00
timmy 82982e5741 Merge pull request 'Prefetch the next mobile update during triage' (#758) from timmy/757-prefetch-next-mobile-update into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m32s
2026-08-13 17:33:37 +00:00
timmy db08c7e75f feat: prefetch the next mobile update (Closes #757)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m33s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 17:31:20 +00:00
timmy 1a3df5d7a7 Merge pull request 'Make the mobile Work action launch the highest-priority queue' (#756) from timmy/755-priority-mobile-work into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m31s
2026-08-13 17:02:12 +00:00
timmy fdba0554df feat: prioritize the mobile Work action (Closes #755)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m39s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 16:59:36 +00:00
timmy d2a85cd5ea Merge pull request 'Bound mobile Updates discovery with one atomic snapshot' (#754) from timmy/753-atomic-updates-snapshot into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m40s
2026-08-13 16:27:32 +00:00
timmy 1f034a6f64 feat: make Updates discovery atomic (Closes #753)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m43s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 16:25:03 +00:00
timmy de6d6d0e61 Merge pull request 'Mute future updates during mobile triage' (#752) from timmy/751-mute-future-updates into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m32s
2026-08-13 15:57:37 +00:00
timmy 8dc3880167 feat: mute future updates during triage (Closes #751)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m33s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 15:55:02 +00:00
timmy b93e63ab70 Merge pull request 'Complete unread discovery before mobile Updates triage' (#750) from timmy/749-complete-updates-discovery into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m32s
2026-08-13 15:34:15 +00:00
timmy 8c82644515 feat: complete Updates discovery before triage (Closes #749)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m37s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 15:28:17 +00:00
timmy 6c6cd3c71b Merge pull request 'Resume interrupted mobile Updates triage' (#748) from timmy/747-resumable-updates-triage into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m40s
2026-08-13 15:00:45 +00:00
timmy 4f6cebae7c feat: resume mobile Updates triage (Closes #747)
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 1m37s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 14:58:19 +00:00
timmy 341a3283ea Merge pull request 'Make live data status truthful and actionable' (#746) from timmy/745-live-data-status into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m35s
Merge pull request 'Make live data status truthful and actionable' (#746) from timmy/745-live-data-status into main
2026-08-13 14:28:33 +00:00
timmy 9c276fa251 feat: make live data status actionable (Closes #745)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 14:26:09 +00:00
timmy 940f7c05d2 Merge pull request 'Make the mobile Queues summary truthful' (#744) from timmy/743-truthful-mobile-queue-summary into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m36s
2026-08-13 13:55:28 +00:00
timmy d34c082da2 fix: make mobile queue summary truthful (Closes #743)
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 1m37s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 13:53:04 +00:00
timmy 1c086be09b Merge pull request 'Preserve shared screenshots through operator sign-in' (#742) from timmy/741-preserve-shared-screenshot-signin into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m31s
2026-08-13 13:27:39 +00:00
timmy 4cfcb92eec fix: preserve shared screenshots through sign-in (Closes #741)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m33s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 13:25:26 +00:00
timmy b484bbef03 Merge pull request 'Share screenshots directly into mobile capture' (#740)
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m29s
Closes #739
2026-08-13 12:58:07 +00:00
timmy 623817eb7d feat: share screenshots into mobile capture (Closes #739)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m27s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 12:55:47 +00:00
timmy 11347e5fe0 Merge pull request 'Launch installed mobile work from task-specific app shortcuts' (#738) from timmy/737-pwa-task-shortcuts into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m35s
2026-08-13 12:39:20 +00:00
timmy bb1712279c feat: launch mobile work from PWA shortcuts (Closes #737)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m31s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 12:26:27 +00:00
timmy e7d5196987 Merge pull request 'Wait for complete Agenda discovery before starting a session' (#736)
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 1m26s
Closes #735
2026-08-13 11:58:30 +00:00
timmy 64ff5127d8 fix: wait for complete Agenda discovery (Closes #735)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 11:56:23 +00:00
timmy 4ac5a0f499 Merge pull request 'Make mobile Agenda sessions directly launchable and resumable' (#734) from timmy/733-resumable-mobile-agenda into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m32s
Merge pull request 'Make mobile Agenda sessions directly launchable and resumable' (#734)
2026-08-13 11:29:15 +00:00
timmy 715df380e0 feat: resume mobile Agenda sessions (Closes #733)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m25s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 11:27:13 +00:00
timmy 1fc70f6890 Merge pull request 'Run the mobile Agenda as a sequential work session' (#732)
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m24s
Closes #731
2026-08-13 10:57:28 +00:00
timmy 523968f5a3 feat: run Agenda as a work session (Closes #731)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m27s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 10:54:58 +00:00
timmy af27bb06e0 Merge pull request 'Bound assigned-issue reminder snapshots' (#730) from timmy/729-bounded-assigned-snapshot into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m29s
2026-08-13 10:26:37 +00:00
timmy 4410af09a6 Bound assigned-issue reminder snapshots
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m30s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 10:24:23 +00:00
timmy 880f60831a Merge pull request 'Configure the deadline reminder horizon per device' (#728) from timmy/727-configurable-deadline-horizon into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m26s
2026-08-13 09:57:25 +00:00
timmy e860d16d29 feat: configure deadline reminder horizon (Closes #727)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m33s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 09:55:09 +00:00
timmy abf8e23685 Merge pull request 'Bulk-authorize push devices before notification fan-out' (#726) from timmy/725-bulk-push-authorization into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m27s
2026-08-13 09:31:45 +00:00
timmy 02a472f841 perf: bulk-authorize push devices (Closes #725)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m35s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 09:29:18 +00:00
timmy c5e6e17fc1 Merge pull request 'Launch Protect Today directly from deadline reminders' (#724) from timmy/723-deadline-protect-today into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m35s
2026-08-13 08:59:34 +00:00
timmy 8a12524595 feat: launch Protect Today from deadline reminders (Closes #723)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m22s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 08:57:32 +00:00
timmy cbe4017bdc Merge pull request 'Protect Today by reconciling urgent Agenda deadlines' (#722) from timmy/721-protect-today into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m30s
2026-08-13 08:30:35 +00:00
timmy fcc9ead33e feat: protect Today from urgent deadlines (Closes #721)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 08:27:01 +00:00
timmy 0d29b29bef Merge pull request 'Preserve deadline calendar days across mobile time zones' (#720) from timmy/719-calendar-day-deadlines into main
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m21s
2026-08-13 07:58:32 +00:00
timmy dce9ae04c9 fix: preserve deadline calendar days (Closes #719)
CI / build-release (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 1m23s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 07:56:26 +00:00
timmy 9ec88179c3 Merge pull request 'Make deadline reminder polling eligibility-aware' (#718) from timmy/717-deadline-eligibility into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m36s
2026-08-13 07:29:20 +00:00
timmy 93890e72de test: synchronize deadline fanout assertion
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 07:27:22 +00:00
timmy 8f0df3d393 perf: gate deadline reminder polling (Closes #717)
CI / build-release (pull_request) Has been skipped
CI / lint (pull_request) Failing after 1m27s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 07:23:05 +00:00
timmy fc79e4523d Merge pull request 'Configure deadline reminder time during mobile device setup' (#716) from timmy/configurable-deadline-device-setup into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m39s
2026-08-13 06:59:43 +00:00
timmy 741d545199 feat: configure deadline reminder time in device setup (Closes #715)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 06:57:39 +00:00
timmy 9320016b50 Merge pull request 'Run push notification channels on independent fixed cadences' (#714) from timmy/713-independent-push-cadences into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m32s
2026-08-13 06:25:01 +00:00
timmy b069379df0 fix: isolate push channel schedules (Closes #713)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m27s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 06:22:58 +00:00
timmy 8387c57ceb Merge pull request 'Isolate and bound deadline reminder delivery' (#712) from timmy/711-isolate-deadline-reminders into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m29s
2026-08-13 05:59:02 +00:00
timmy 72842ecbcd fix: isolate deadline reminder dispatch (Closes #711)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m24s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 05:55:50 +00:00
timmy c503994be1 Merge pull request 'Send opt-in mobile deadline reminders into Agenda' (#710) from timmy/709-mobile-deadline-reminders into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m25s
2026-08-13 05:11:29 +00:00
timmy 5e6328a8d0 feat: send mobile deadline reminders (Closes #709)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 05:00:32 +00:00
timmy 4209834d6d Merge pull request 'Replan overdue Agenda deadlines in one mobile sweep' (#708) from timmy/707-mobile-agenda-replan into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m32s
2026-08-13 04:29:06 +00:00
timmy 46888d2876 feat: replan overdue Agenda deadlines (Closes #707)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m27s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 04:26:53 +00:00
timmy 7d7abefb0b Merge pull request 'Surface upcoming deadlines in the mobile task dock' (#706) from timmy/705-mobile-deadline-dock into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m23s
2026-08-13 03:52:58 +00:00
timmy 2796808759 feat: surface mobile deadline urgency (Closes #705)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m24s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 03:50:53 +00:00
timmy a2f83b0955 Merge pull request 'Make mobile Agenda discover every assigned deadline' (#704) from timmy/703-complete-mobile-agenda into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m25s
2026-08-13 03:27:33 +00:00
timmy 6b56188d30 feat: complete mobile Agenda discovery (Closes #703)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m27s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 03:25:25 +00:00
timmy 467c8c665d Merge pull request 'Launch fresh mobile work in the Agenda queue' (#702) from timmy/701-mobile-agenda-launch into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m28s
2026-08-13 02:53:37 +00:00
timmy 1b311c9cd4 feat: launch fresh mobile work in Agenda (Closes #701)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m27s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 02:51:25 +00:00
timmy 71d06347fd Merge pull request 'Keep Today and Later reads available during planning writes' (#700)
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m33s
2026-08-13 02:24:45 +00:00
timmy 1de6d73e75 perf: keep planning reads available during writes (Closes #699)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m28s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 02:22:29 +00:00
timmy 46c75f0b22 Merge pull request 'Continue cleared mobile queues into Agenda deadlines' (#698) from timmy/697-agenda-queue-continuation into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m25s
2026-08-13 01:55:57 +00:00
timmy 5cdb3f7b3e feat: continue cleared queues into Agenda (Closes #697)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m28s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 01:53:36 +00:00
timmy 15d197327a Merge pull request 'Keep cold Find Work startup reliable across workers' (#696) from timmy/695-cold-find-work-workers into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m36s
2026-08-13 01:25:01 +00:00
timmy df8385b325 fix: keep cold Find Work reliable across workers (Closes #695)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 01:22:42 +00:00
timmy 47c6412a12 Merge pull request 'Make the mobile Agenda addressable and Back-safe' (#694) from timmy/693-mobile-agenda-route into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m27s
2026-08-13 00:55:20 +00:00
timmy 6cac778acd feat: make mobile Agenda addressable (Closes #693)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 00:53:09 +00:00
timmy a8a93b2f3b Merge pull request 'Start work from an empty mobile queue' (#692) from timmy/691-empty-mobile-find-start into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m30s
2026-08-13 00:26:37 +00:00
timmy ef37a66fd7 feat: start work from an empty mobile queue (Closes #691)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m33s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 00:24:04 +00:00
timmy 52e2c26304 Merge pull request 'Stabilize recap bounded-concurrency verification' (#690) from timmy/689-stabilize-recap-concurrency-test into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m30s
2026-08-12 23:55:24 +00:00
timmy 3817d2c67b test: stabilize recap concurrency verification (Closes #689)
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 1m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 23:52:56 +00:00
timmy ad6c1edf80 Merge pull request 'Isolate readiness probes from live Gitea traffic' (#688) from timmy/687-isolate-readiness into main
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 1m56s
2026-08-12 23:26:57 +00:00
timmy d2b2c7bada feat: isolate readiness probes from Gitea traffic (Closes #687)
CI / build-release (pull_request) Successful in 9s
CI / lint (pull_request) Successful in 2m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 23:10:29 +00:00
timmy 9ba03613d4 Merge pull request 'Plan upcoming deadlines from a mobile Agenda' (#686) from timmy/685-mobile-agenda into main
CI / build-release (push) Has been skipped
CI / release-candidate (push) Has been skipped
CI / lint (push) Failing after 1m40s
2026-08-12 22:30:56 +00:00
timmy 1132075318 feat: add mobile deadline Agenda (Closes #685)
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 1m39s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 22:28:36 +00:00
timmy 16b1d8670f Merge pull request 'Continue mobile work across queue boundaries' (#684) from timmy/683-mobile-queue-continuation into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m38s
2026-08-12 21:59:42 +00:00
timmy e5c5c8ec31 feat: continue mobile work across queues (Closes #683)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m25s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 21:57:34 +00:00
timmy 87e0d74414 Merge pull request 'Filter Find Work by repository and label' (#682) from timmy/681-find-work-facets into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m25s
2026-08-12 21:35:47 +00:00
timmy f031a37e8d feat: filter Find Work by repository and label (Closes #681)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 21:33:27 +00:00
timmy 057bec7dc2 Merge pull request 'Resume interrupted Find Work batches without duplicate claims' (#680) from timmy/679-resume-find-work-batches into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m28s
2026-08-12 21:04:39 +00:00
timmy 654ae8714e feat: resume interrupted Find Work batches (Closes #679)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m25s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 21:02:25 +00:00
timmy a0dd044995 Merge pull request 'Fill remaining Today slots from Find Work' (#678) from timmy/677-fill-today-from-find-work into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m27s
2026-08-12 20:27:50 +00:00
timmy c93a682dfb feat: fill Today from ranked Find Work results (Closes #677)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m31s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 20:25:37 +00:00
timmy 5675ca5d41 Merge pull request 'Make Find Work batch claiming respect Today time capacity' (#676) from timmy/675-find-work-time-capacity into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m26s
2026-08-12 19:56:09 +00:00
timmy 0767ccaa9d feat: budget Find Work batches by time (Closes #675)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m28s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 19:53:57 +00:00
timmy c6eabb5fc7 Merge pull request 'Search the full Find Work catalog' (#674) from timmy/673-search-find-work into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m25s
2026-08-12 19:27:10 +00:00
timmy 77fab85a66 feat: search the full Find Work catalog (Closes #673)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m30s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 19:24:55 +00:00
timmy dcf26c896c Merge pull request 'Batch-claim available work into Today from Find Work' (#672) from timmy/671-batch-claim-find-work into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m29s
2026-08-12 19:02:54 +00:00
timmy fba1f4238d feat: batch-claim available work into Today (Closes #671)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 18:58:26 +00:00
timmy 9e301924b4 Merge pull request 'Select all matching work for mobile batch planning' (#670) from timmy/669-select-all-matching-work into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m31s
2026-08-12 18:25:41 +00:00
timmy 8edd542379 feat: select matching mobile work in one step (Closes #669)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m27s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 18:23:34 +00:00
timmy 0fbe047e03 Merge pull request 'Find work within the active mobile queue' (#668) from timmy/667-find-active-queue into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m27s
2026-08-12 17:58:48 +00:00
275 changed files with 48722 additions and 1883 deletions

No files matched your search

+25 -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:
@@ -36,9 +38,30 @@ jobs:
name: release-bundle
path: dist/
browser-journey:
runs-on: ubuntu-latest
needs: build-release
env:
STACKCHAIN_RUN_RELEASE_E2E: "1"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.11" }
- name: Download assembled release bundle
uses: actions/download-artifact@v3
with:
name: release-bundle
path: dist
- name: Install browser test dependencies
run: |
pip install -r requirements-e2e.txt
python3 -m playwright install --with-deps chromium
- 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_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q
release-candidate:
runs-on: ubuntu-latest
needs: [lint, build-release]
needs: [lint, build-release, browser-journey]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: write
@@ -59,7 +82,7 @@ jobs:
RELEASE_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
(cd dist && sha256sum -c ./*.sha256)
python3 scripts/verify_release.py --input-dir dist --commit "$TARGET"
python3 scripts/verify_release.py --input-dir dist --commit "$TARGET" --repository .
printf '{"tag_name":"%s","target_commitish":"%s","name":"Release Candidate %s","body":"CI-tested release candidate for commit %s. Verify downloads with the attached SHA-256 checksum.","draft":true,"prerelease":true}\n' \
"$TAG" "$TARGET" "$TAG" "$TARGET" > /tmp/release.json
+197 -32
View File
@@ -19,29 +19,31 @@ threads, create and self-assign issues, discover, claim, and release issue assig
list repository labels and open milestones, set or clear due dates on assigned issues, create issue comments, close assigned issues,
inspect/comment on assigned pull
requests, merge assigned pull requests, and submit pull-request reviews.
Assigned-issue and assigned-pull-request comments can include one PNG, JPEG, or WebP screenshot; each mobile composer automatically optimizes oversized screenshots on-device to fit the 2 MB upload boundary while leaving already-valid files unchanged.
For online delivery, the screenshot uploads before the comment is posted—to the exact assigned issue or pull request—and produces one Markdown comment; validation or upload
failures keep both the typed comment and removable preview available for retry. Offline screenshot comments
admit their text and image bytes to IndexedDB before confirmation, keep only bounded metadata in
localStorage, and use checkpointed upload/comment identities so reconnect retries cannot duplicate
either stage. The mobile **New issue** capture-first stage accepts the same image formats before a
repository is chosen. **Save to Drafts** durably writes the optimized Blob to IndexedDB before
confirmation, keeps only account-bound attachment metadata in localStorage, and restores the exact
preview when the operator later chooses a repository. The source Draft remains available until its
screenshot has safely transferred to the issue outbox; discard and bounded pruning remove the Blob.
Repository-aware durable admission likewise stores the screenshot with its account-bound outbox
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
bundle when the operator later chooses a repository. A scrollable thumbnail tray lets the operator
review every restored image before filing; **Move earlier**, **Move later**, and **Remove selected**
change the durable evidence order without changing the issue title or note. The active screenshot's
optional **Evidence note** stays paired with that image through reorder, Draft restore, offline delivery,
and retry, then appears as a Markdown-safe caption immediately before its uploaded image. The selected screenshot can be cropped, privacy-redacted, highlighted, and marked with touch-drawn arrows before a flattened derivative replaces it; undo, reset, and cancel keep editing reversible without changing its note or bundle position. The source Draft remains
available until its evidence has safely transferred to the issue outbox; discard and bounded pruning remove every Blob.
Repository-aware durable admission likewise stores the evidence bundle with its account-bound outbox
capture, avoiding base64 quota pressure and synchronous multi-megabyte writes. Online and background
delivery send the original bytes as multipart form data, avoiding the roughly 33% base64 wire
expansion. Existing queued base64 screenshot payloads remain readable and are converted only at
delivery time.
Delivery creates the issue exactly once, then uploads and comments with
the image; after a partial failure, retry resumes with the confirmed issue instead of
creating a duplicate.
Delivery creates the issue exactly once, then uploads each image under a checkpointed per-image
identity and posts one ordered Markdown evidence comment. After a partial failure, retry resumes with the confirmed issue and from
the first unconfirmed image instead of duplicating the issue or earlier uploads. The installed PWA
Share Target accepts the same bounded multi-image bundle through sign-in continuation.
Pull-request replies and mobile My Work issue and PR comments use Gitea's
issue-comment API. In issue, pull-request, and unread-update conversations, typing
at least two characters after `@` offers repository-scoped teammate suggestions;
touch or keyboard selection inserts the login without leaving the draft. Mention
lookup failure never blocks literal text or comment delivery. Mobile issue capture requires issue
issue-comment API. In issue, pull-request, unread-update, and active Today progress
conversations, typing at least two characters after `@` offers repository-scoped
teammate suggestions; touch or keyboard selection inserts the login without leaving
the draft. Mention lookup failure never blocks literal text or comment delivery. Mobile issue capture requires issue
creation and assignment permission. A fresh capture never silently targets the first repository:
the operator must explicitly choose one, either from the paginated browser or through the bounded
authenticated repository search. Search results include only visible repository identities, stale
@@ -56,23 +58,80 @@ repository milestone and due date; the dashboard validates both and sends them w
self-assignment in the single create request, so planned work appears in its release
lane immediately. On a cold offline launch, **Save for filing** stores up to 20
account-bound title/description captures without selecting a repository or entering the
mutation outbox. Drafts marks them **Needs filing**; after a fresh reconnect confirms
the same Gitea login, **Choose repository** restores the capture to the normal planning
and durable delivery flow. A different or unconfirmed account can only copy or discard
mutation outbox. Drafts marks them **Needs filing** and saves locally first. After a fresh
reconnect confirms the same Gitea login, a bounded, revisioned collection synchronizes the title,
description, blockers, and ordered screenshot evidence; another signed-in device can then use
**Choose repository** to continue the normal planning and durable delivery flow. Draft cards report
whether they are synced or still pending locally. Concurrent changes surface a conflict, and
successful discard or outbox admission propagates deletion so a stale device cannot resurrect the
draft. Synchronization is limited to 20 drafts and 12 MiB of decoded evidence per account; failure
never blocks local capture. A different or unconfirmed account can only copy or discard
the private content. Issue capture and authored mobile actions (issue
comments, pull-request comments, notification replies, and reviews) persist per-draft
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
another worker replays a confirmed result instead of posting duplicate content. The ordered,
another worker replays a confirmed result instead of posting duplicate content. Mobile Search previews
let operators assign an eligible issue directly into Later at an exact local return time without filling
Today or interrupting active work. Cancel and browser Back preserve the Search preview without assigning;
confirmation claims only when needed, syncs the Later plan across devices, and returns to the preserved
query, filters, results, and scroll position. If assignment succeeds but Later storage fails, the issue
remains recoverable in My Work and the dashboard reports the partial outcome instead of claiming success.
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
overwriting newer views. Rename and delete affect only the saved view, never Gitea work; an unavailable
sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to override the default
`.stackchain-state/saved-searches.sqlite3` path.
Completed delegated issues remain in the mobile **Filed** queue until their latest outcome is acknowledged.
The mobile queue sheet begins with **Prepare Today**, a live briefing that totals Agenda, Attention, Updates, and
Filed work and opens the highest-priority non-empty review queue. Starting it saves a confirmed-account, local-day
checkpoint: finishing Agenda, Updates, or the final Filed review returns to a focused handoff using fresh queue
counts, while reopening the queue sheet resumes the next live phase. **Finish for now** removes only that local
checkpoint. Once urgent review is clear the pass continues the existing Today plan, or opens Find Work when Today
is empty; the briefing and checkpoint never change Gitea state.
Filed separates actionable **Needs review** from a browsable **Reviewed** history, so acknowledgement clears the
queue without erasing the delegated-work record. Reviewed cards reopen the existing read-only issue detail and
conversation, while the Filed badge continues to count actionable outcomes only. A later Gitea update moves that
issue back to Needs review automatically. Acknowledgements synchronize the exact Gitea `updated_at` revision in
bounded batches to the confirmed account and preserve the same Reviewed state on other signed-in devices. Offline
or failed synchronization keeps the local acknowledgement and retries on the next healthy dashboard refresh
without blocking **Acknowledge & next**. Set
`STACKCHAIN_COMPLETED_FILED_REVIEW_DB` to override the default
`.stackchain-state/completed-filed-reviews.sqlite3` path.
Search previews also
let operators assign an eligible issue and add it to Today without starting or replacing active work.
The queue action keeps the Search query, filters, results, and scroll position available for continued
planning, reports an existing Today item without duplicating it, and uses the same capacity, sync, and
offline-warm path as other Today admission flows. The ordered,
five-item Today plan syncs across the operator's devices. **Plan Today** also stores available minutes
and a per-item estimate with the account-scoped plan, continuously showing planned/free or over-capacity
time. An over-capacity plan requires a second explicit save, legacy plans migrate with unestimated work,
and an active Today session shows the current estimate plus estimated remaining runway. Adding an issue through **Plan Today**
first previews its Gitea dependencies: unresolved blockers are listed with links and require the
explicit **Add blocked item anyway** override, while an unavailable dependency lookup is reported
as unknown rather than unblocked. Starting a Today work session also
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap.
as unknown rather than unblocked. Completing any Today item now exposes a 10-second, touch-safe
**Undo** receipt. Undo restores the item's original order and estimate, queues the inverse cross-device
plan changes, and leaves the already-advanced work session on its current item; expiry, capacity, or a
concurrently changed plan is reported without overwriting newer work. Starting a Today work session also
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. An opt-in, privacy-safe lock-screen notification mirrors the current pause/resume control and adds **Finish current**: its opaque one-shot action is bound to the exact active item, reuses **Done & next** or recap, and never changes the underlying Gitea issue or pull request. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap.
After wrap-up, **Share day summary** opens a private review of the exact worked-on and tomorrow selections. Every row is opt-in adjustable, actual time is excluded by default, and an optional bounded note is previewed before the native share sheet or clipboard fallback. Canceling or closing keeps the account-scoped device draft; successful sharing or **Discard draft** removes it.
A running issue or pull request also exposes **Add update** without advancing Today. The operator can type or dictate a progress note, review and explicitly append, replace, or discard the transcript, add up to five photo-evidence items, then save privately or admit the exact comment to durable delivery. When work is blocked, **Post blocker & move on** requires a future return time, admits the comment before any planning change, defers the item to Later, and continues the existing Today session. An admitted planning transition remains checkpointed for retry, so a storage or Today-removal failure cannot post the blocker twice. Final transcripts—not audio—are bounded to 2,000 characters and isolated by confirmed account and Today item; closing the sheet aborts listening while leaving text and photos usable.
After a reload or installed-app
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed);
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed). In an open
assigned issue, the mobile detail sheet renders Markdown checklist items as touch-safe controls and keeps
**Add step** directly beside the plan. A new step is normalized, checked for duplicates, and appended without
exposing or replacing the full issue description. Offline additions enter the same account-bound durable
issue-content queue as checklist toggles; reconnect conflict review applies unique local additions to the
latest remote body while preserving remote prose and task-state changes.
**Comment & next** on that current issue or pull request posts the handoff online or admits it
to durable account-bound delivery, then removes the item only from Today and opens the next
one without closing or merging it. **Reply & next** provides the same one-action continuation
@@ -81,7 +140,11 @@ an open, unassigned issue update also offers **Take ownership & start**, which c
before assignment, preserves the unread update, adds and syncs the owned issue to Today, checkpoints
the session, and opens the issue. The adjacent **Take ownership** action remains available for
claim-only triage, and a local start failure opens the now-owned issue with truthful recovery guidance.
**Mark read & next** remains the explicit acknowledgement path. Delivery or local-admission
**Mark read & next** remains the explicit acknowledgement path. During an online Updates pass,
Stackchain preloads at most the next surviving conversation from the fixed snapshot while the
current one is being read. Advancing consumes that account-bound result without another detail
request; failures fall back to the normal foreground retry path, and offline triage never speculates.
Delivery or local-admission
failure preserves both the reply draft and checkpoint. Finishing or choosing **End session** clears only the checkpoint and leaves the Today plan
unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
@@ -125,7 +188,28 @@ and runs up to three deliveries concurrently, but claims a record only when a de
is ready. Claims have unique fencing tokens and are renewed before every network stage;
completion, release, failure, and delivery checkpoints are token-fenced so an expired worker
cannot alter a newer crash-recovery claim. Device purge cancels an active drain before closing
private outbox storage. Results are coordinated through a bounded SQLite ledger. Set `STACKCHAIN_STATE_DIR` to a
private outbox storage. Results are coordinated through a bounded SQLite ledger. All private SQLite stores enforce a
filesystem boundary independently of the service umask: the database directory is repaired to
owner-only `0700`, database and SQLite sidecar files are owner-only `0600`, and symlinked database
paths are rejected before access. Worker-shared live and Find Work snapshots add AES-256-GCM
envelopes authenticated to their store identity (and live generation), so copied databases do not
expose issue bodies, titles, notification metadata, or repository context. The authored-action
idempotency ledger encrypts both request fingerprints and confirmed upstream responses, authenticating
each envelope to its operation key and field purpose so rows and fields cannot be substituted.
Existing plaintext snapshot and ledger rows migrate atomically on their first read without changing
freshness, revisions, ordering, replay, or conflict semantics. Synchronized unfiled Draft collections
use a separate AES-256-GCM key and authenticate the account and revision; existing plaintext rows
likewise migrate on first read. Synchronized Saved Search collections use the private-state key and
authenticate each envelope to its normalized account, preventing rows from being substituted between
operators. Existing plaintext Saved Searches migrate atomically on first read without advancing their
revision; missing, wrong, or modified key material returns no saved-view content. Other private stores
are not encrypted at the application layer. Web
Push subscriptions use a third, independent
AES-256-GCM key authenticated to the device session, with keyed endpoint indexes preserving
single-device enrollment without retaining capability URLs. Existing plaintext subscriptions
migrate atomically at startup without resetting delivery checkpoints or reminder schedules. Secure
host access, encrypted volumes, and private backups are still required.
Set `STACKCHAIN_STATE_DIR` to a
persistent, writable service directory (or set `STACKCHAIN_IDEMPOTENCY_DB` to an explicit
SQLite path); the local default is `.stackchain-state/idempotency.sqlite3`. Ledger reads and
writes run outside the request event loop, and lock admission is bounded to 100 ms by
@@ -190,6 +274,33 @@ export STACKCHAIN_PASSKEY_MAX_CHALLENGES_PER_SOURCE=10
export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000
# Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3.
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
# Required for worker-shared live/Find Work snapshots, synchronized Today/Later
# planning and Saved Search state, and the Security activity journal. Keep this key independent
# from the Draft key and inject the
# base64 encoding of exactly 32 random bytes from a secret manager. Never commit
# it. Missing, malformed, wrong-key, or modified state fails closed without
# returning content. Legacy Today/Later, Saved Search, and Security activity rows
# migrate atomically on first use without changing logical revisions or journal IDs.
export STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY='<base64-encoded-32-byte-key>'
# Required for cross-device unfiled Draft sync. The single-key setting remains
# supported for the first deployment of keyring-capable code and writes v1 envelopes.
# Inject the base64 encoding of exactly 32 random bytes from a secret manager.
export STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEY='<base64-encoded-32-byte-key>'
# After every worker runs keyring-capable code, replace the single-key setting with
# a bounded JSON object (at most eight keys) and name one active write key. During
# the first rotation, preserve the original single key under the reserved `legacy`
# ID so existing v1 envelopes remain readable. Key IDs use 1-32 letters, digits,
# underscores, or hyphens. Never commit either setting.
export STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEYS='{"legacy":"<old-base64-key>","2026-08":"<new-base64-key>"}'
export STACKCHAIN_UNFILED_DRAFT_ACTIVE_KEY_ID='2026-08'
# New writes use authenticated v2 envelopes carrying the active key ID. Reads
# atomically rewrap plaintext, v1, and inactive-key rows without advancing their
# logical revision. Run the content-free, restart-safe migration until it exits 0:
python3 scripts/rotate_unfiled_drafts.py
# A result such as {"current":42,"failed":0,"migrated":0,"total":42} proves the
# old key has no remaining row dependencies. Only then remove `legacy`/old keys and
# restart. A nonzero exit reports unreadable row counts but never account or Draft
# content. Roll back only to a keyring-capable build and retain every configured key.
# Trust forwarding headers only from these immediate reverse-proxy networks.
export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
# Optional Web Push. Generate a VAPID key pair outside the repo and inject it.
@@ -205,11 +316,20 @@ export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
export STACKCHAIN_VAPID_PUBLIC_KEY='<url-safe-public-key>'
export STACKCHAIN_VAPID_PRIVATE_KEY='<private-key-from-secret-manager>'
export STACKCHAIN_VAPID_SUBJECT='mailto:ops@example.com'
# Required whenever all three VAPID settings enable Web Push. Keep this key
# independent from the snapshot and Draft keys. Startup authenticates every retained
# subscription and fails closed for a missing, malformed, wrong, or modified key.
# Back up the key separately: losing it makes existing device enrollments unrecoverable.
export STACKCHAIN_PUSH_STATE_ENCRYPTION_KEY='<base64-encoded-32-byte-key>'
# Optional; defaults to a 30-second poll, 10-second endpoint deadline,
# 8 concurrently dispatched devices, 60-second renewable cross-worker lease,
# and STACKCHAIN_STATE_DIR/push-subscriptions.sqlite3. Threads stay ordered
# within each device; one slow device does not delay healthy devices behind it.
export STACKCHAIN_PUSH_POLL_SECONDS=30
# Unread updates and deadline reminders run on independent, fixed-cadence
# workers, so a slow channel cannot delay the other or add drift to its ticks.
# On each phone, Device Setup can enable deadline reminders and choose any local
# reminder hour from 00:00 through 23:00; the server-confirmed choice is restored.
export STACKCHAIN_PUSH_SEND_TIMEOUT_SECONDS=10
export STACKCHAIN_PUSH_MAX_CONCURRENCY=8
# Maximum individual alerts per device and poll before one digest covers the rest.
@@ -219,6 +339,29 @@ export STACKCHAIN_PUSH_DB='/var/lib/stackchain-dashboard/push-subscriptions.sqli
uvicorn src.main:app --host 127.0.0.1 --port 8000
```
`GITEA_URL` is also the trust boundary for every Gitea resource link returned
to the browser. Configure the externally reachable scheme, host, and subpath
(for example `https://forge.example.com/git`); cross-origin, downgraded, and
same-host links outside that subpath are discarded.
For a systemd deployment, keep those values in a root-readable environment file
(`chmod 600`), reference it with `EnvironmentFile=`, and keep secrets out of the unit
command line and repository. Liveness alone does not prove that operators can use the
service: `/healthz` intentionally remains healthy when authentication is missing. After
each restart or proxy change, run the complete public-subpath smoke journey with the
operator secret supplied only through the process environment:
```bash
STACKCHAIN_DASHBOARD_ACCESS_TOKEN='<operator-sign-in-secret>' \
python3 scripts/verify_deployment.py \
https://forge.example.com/dashboard/
```
The verifier requires readiness, rendered sign-in, a manifest whose `scope` and
`start_url` remain inside the supplied subpath, and an authenticated mobile Home with
its New-issue entry. It emits only a small JSON result and never includes the operator
secret in success or failure output.
Token and passkey sign-in failures are scoped to a hashed canonical client address and
persisted across workers and restarts. Public passkey option issuance has a separate
fixed-window admission budget in the same ledger, and live challenges are bounded per
@@ -251,8 +394,13 @@ place, their live sessions remain valid, and their idle clock starts at migratio
The same sheet includes **Security activity**, a reverse-chronological journal of
successful token/passkey sign-ins, passkey enrollments, sign-outs, remote device
revocations, issue closures, and pull-request merges. The separate SQLite journal
retains at most 10,000 events for 90 days and stores only bounded device labels and
action targets. It never stores access tokens, cookies, session/CSRF values,
retains at most 10,000 events for 90 days. Its bounded event kind, authentication
method, device label, and action target are sealed at rest with the private-state
encryption key and authenticated against the immutable event ID. Legacy plaintext
journals migrate atomically without changing IDs, timestamps, order, or pending
operations. Missing or wrong key material and modified ciphertext fail closed with
no partial activity response. The journal never stores access tokens, cookies,
session/CSRF values,
credential IDs, public keys, challenges, attestation data, raw network addresses,
request bodies, or comment content. Keep its database on the same class of persistent,
writable storage as the session registry. Before passkey credential creation, session
@@ -281,8 +429,9 @@ remain compatible. The bounded **Passkey counter anomaly** Security activity ent
only the device label and attempted action; repeated alerts should prompt removal and
re-enrollment of that passkey.
High-impact actions—merging a pull request, closing an assigned issue, revoking a
remote device, or signing out every device—require a passkey assertion or the
High-impact actions—merging a pull request, closing an assigned issue, permanently
deleting an authored comment, revoking a remote device, or signing out every device—
require a passkey assertion or the
operator access token again.
The server issues a random 90-second grant bound to the active session, exact action,
and exact target. Only its digest is stored, and the grant is consumed atomically on
@@ -319,6 +468,11 @@ is assembled in source order into one content-addressed JavaScript response. Das
HTML and the offline worker reference that exact fingerprint, while the runtime receives
immutable caching and HTML/worker responses remain revalidated. The stylesheet and
fingerprinted runtime are same-origin assets included atomically in the offline PWA shell.
Device Setup also checks whether the browser has granted persistent storage without prompting.
Choose **Protect offline work** to request protection from automatic storage-pressure eviction;
Stackchain reports **Protected**, **Best effort**, **Denied**, or an unavailable/retryable state
truthfully. Offline work continues when protection is unavailable or denied, but the browser may
remove best-effort data, so persistence does not replace backups or device security.
Use **Sign out & clear this device** on shared devices; it clears Stackchain's
offline snapshots, drafts, outboxes, background IndexedDB, and PWA caches without
removing unrelated forge preferences. Rotate either dashboard secret by replacing
@@ -373,7 +527,10 @@ account and never contain the Gitea token. Find Work uses the same worker-shared
`STACKCHAIN_AVAILABLE_ISSUE_SNAPSHOT_DB` overrides
`STACKCHAIN_STATE_DIR/available-issue-snapshot.sqlite3`. One expiring lease bounds each
catalog scan across the deployment, shared retry metadata prevents worker-by-worker retry
bursts, and confirmed claims are removed from every worker's retained catalog. Releasing
bursts, and confirmed claims are removed from every worker's retained catalog. On a cold
catalog, non-owner workers wait within the bounded foreground deadline for the lease owner
to publish; if that owner releases or abandons the lease, a waiter can take over the scan
within its remaining request budget. Releasing
an assignment invalidates the shared catalog so the newly available issue can be discovered
by the next authoritative scan. Each bounded opaque revision token includes the
store generation, so a token from a different deployment or before replacement of the store
@@ -396,6 +553,14 @@ filter, preserves the selected release lane, shows the current draft count, resp
the device safe area, and moves out of the way while a full-screen task is open.
Desktop layout is unchanged.
Mobile **Search** provides touch-sized **Repository**, **Type**, and **Status** controls.
Operators can find and select a repository visible to their Gitea account, then scope results
to that exact repository, issues, pull requests, open work, closed work, or all accessible work;
the server applies every scope before pagination. Clearing Repository returns to organization-wide
Search. The selected scope is bounded and addressable, survives preview/back, reload, sharing,
and sign-in continuation, and a scope change cancels obsolete requests before restarting at the
first page. Repository lookup failure leaves organization-wide Search usable.
My Work also has an account-synced **Later** queue. **Later today** defers an item for four
hours, while **Tomorrow** returns it at 09:00 in the device's local timezone. **Choose date & time**
accepts a valid future local date and time and returns the item at that exact instant; the picker
+180
View File
@@ -0,0 +1,180 @@
(function (root) {
'use strict';
function escapeText(value) {
return String(value || '')
.replace(/\\/g, '\\\\')
.replace(/\r?\n/g, '\\n')
.replace(/,/g, '\\,')
.replace(/;/g, '\\;');
}
function calendarDay(value) {
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})/);
return match ? match.slice(1).join('') : '';
}
function nextDay(day) {
const date = new Date(Date.UTC(
Number(day.slice(0, 4)), Number(day.slice(4, 6)) - 1, Number(day.slice(6, 8)) + 1
));
return date.toISOString().slice(0, 10).replace(/-/g, '');
}
function uid(item) {
const repository = String(item.repository || '').replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').toLowerCase();
return `issue-${Number(item.number)}@${repository}`;
}
function foldLine(line) {
const encoder = new TextEncoder();
const chunks = [];
let chunk = '';
let bytes = 0;
let limit = 75;
for (const character of String(line)) {
const width = encoder.encode(character).length;
if (chunk && bytes + width > limit) {
chunks.push(chunk);
chunk = character;
bytes = width;
limit = 74;
} else {
chunk += character;
bytes += width;
}
}
chunks.push(chunk);
return chunks.join('\r\n ');
}
function serializeAgendaCalendar(items, { generatedOn } = {}) {
const stampDay = String(generatedOn || new Date().toISOString().slice(0, 10).replace(/-/g, ''));
const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Stackchain//Agenda Snapshot//EN',
'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', 'X-WR-CALNAME:Stackchain Agenda'];
(items || []).forEach(item => {
const day = calendarDay(item.due_date);
if (!day || !Number.isInteger(Number(item.number)) || !item.repository) return;
lines.push(
'BEGIN:VEVENT',
`UID:${uid(item)}`,
`DTSTAMP:${stampDay}T000000Z`,
`DTSTART;VALUE=DATE:${day}`,
`DTEND;VALUE=DATE:${nextDay(day)}`,
`SUMMARY:${escapeText(item.title)}`,
`DESCRIPTION:${escapeText(`${item.repository}#${item.number} · Stackchain Agenda snapshot`)}`,
`URL:${String(item.url || '')}`,
'TRANSP:TRANSPARENT',
'END:VEVENT',
);
});
lines.push('END:VCALENDAR');
return lines.map(foldLine).join('\r\n') + '\r\n';
}
async function deliverCalendarSnapshot({
text,
filename,
navigator,
document,
urlApi,
FileCtor,
}) {
const file = new FileCtor([text], filename, { type: 'text/calendar;charset=utf-8' });
const sharePayload = { files: [file], title: 'Stackchain Agenda', text: 'Agenda calendar snapshot' };
if (typeof navigator?.share === 'function' && typeof navigator?.canShare === 'function' &&
navigator.canShare(sharePayload)) {
await navigator.share(sharePayload);
return 'shared';
}
const href = urlApi.createObjectURL(file);
try {
const anchor = document.createElement('a');
anchor.href = href;
anchor.download = filename;
anchor.click();
} finally {
urlApi.revokeObjectURL(href);
}
return 'downloaded';
}
function mountAgendaCalendarExport({
qs,
getItems,
escapeHtml,
onDone,
windowObject = root,
navigatorObject = root.navigator,
documentObject = root.document,
urlApi = root.URL,
FileCtor = root.File,
}) {
const sheet = qs('#agenda-export-sheet');
let items = [];
let scrollY = 0;
let trigger = null;
const selectedItems = () => Array.from(qs('#agenda-export-items').querySelectorAll('input[type="checkbox"]'))
.filter(checkbox => checkbox.checked)
.map(checkbox => items[Number(checkbox.value)])
.filter(Boolean);
const updateSelection = () => {
const selected = selectedItems();
qs('#share-agenda-export').disabled = selected.length === 0;
qs('#agenda-export-status').textContent = selected.length + ' of ' + items.length +
(items.length === 1 ? ' deadline selected.' : ' deadlines selected.');
};
const close = () => {
sheet.close();
windowObject.scrollTo({ top:scrollY, behavior:'instant' });
trigger?.focus();
};
qs('#open-agenda-export').addEventListener('click', event => {
items = getItems();
scrollY = windowObject.scrollY;
trigger = event.currentTarget;
qs('#agenda-export-items').innerHTML = items.map((item, index) =>
'<label class="agenda-export-item"><input type="checkbox" value="' + index + '" checked> ' +
'<span><strong>' + escapeHtml(item.title) + '</strong><small>' +
escapeHtml(item.repository + '#' + item.number + ' · ' + item.due_date.slice(0, 10)) +
'</small></span></label>'
).join('');
qs('#agenda-export-items').querySelectorAll('input[type="checkbox"]').forEach(checkbox =>
checkbox.addEventListener('change', updateSelection)
);
updateSelection();
sheet.showModal();
qs('#cancel-agenda-export').focus();
});
qs('#cancel-agenda-export').addEventListener('click', close);
sheet.addEventListener('cancel', event => {
event.preventDefault();
close();
});
qs('#share-agenda-export').addEventListener('click', async () => {
const selected = selectedItems();
if (!selected.length) return;
const button = qs('#share-agenda-export');
button.disabled = true;
qs('#agenda-export-status').textContent = 'Preparing calendar snapshot…';
const day = new Date().toISOString().slice(0, 10);
try {
const text = serializeAgendaCalendar(selected, { generatedOn:day.replace(/-/g, '') });
const result = await deliverCalendarSnapshot({
text, filename:'stackchain-agenda-' + day + '.ics',
navigator:navigatorObject, document:documentObject, urlApi, FileCtor,
});
onDone(result);
close();
} catch (error) {
qs('#agenda-export-status').textContent = error?.name === 'AbortError' ?
'Share cancelled. Nothing was exported.' : 'Calendar export failed. Retry without leaving Agenda.';
button.disabled = false;
}
});
}
const api = { calendarDay, deliverCalendarSnapshot, escapeText, foldLine, mountAgendaCalendarExport, serializeAgendaCalendar };
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else root.StackchainAgendaCalendar = api;
})(typeof window !== 'undefined' ? window : globalThis);
+83
View File
@@ -0,0 +1,83 @@
function createAgendaReplan({ now = () => new Date(), update }) {
let items = [];
let index = 0;
let pending = false;
let active = false;
const dayKey = date => [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0'),
].join('-');
const dueDay = value => {
const match = String(value || '').match(/^(\d{4}-\d{2}-\d{2})(?:$|T)/);
return match ? match[1] : '';
};
const current = () => active ? items[index] || null : null;
const snapshot = () => ({
active,
index,
total: items.length,
current: current()?.key || null,
pending,
});
const advance = () => {
index += 1;
if (index >= items.length) active = false;
return { ok:true, done:!active };
};
const change = async dueDate => {
const item = current();
if (!item) return { ok:false, error:'No overdue deadline selected.' };
if (pending) return { ok:false, error:'Deadline update already in progress.' };
pending = true;
try {
await update(item, dueDate);
return advance();
} catch (error) {
return { ok:false, error:error?.message || 'Deadline could not be updated.' };
} finally {
pending = false;
}
};
return {
start(overdue) {
items = (overdue || []).slice().sort((left, right) =>
dueDay(left.due_date).localeCompare(dueDay(right.due_date)) ||
String(left.repository || '').localeCompare(String(right.repository || '')) ||
Number(left.number || 0) - Number(right.number || 0)
);
index = 0;
pending = false;
active = items.length > 0;
return snapshot();
},
snapshot,
current,
keep() {
if (pending) return Promise.resolve({ ok:false, error:'Deadline update already in progress.' });
if (!current()) return Promise.resolve({ ok:false, error:'No overdue deadline selected.' });
return Promise.resolve(advance());
},
tomorrow() {
const date = new Date(now());
date.setDate(date.getDate() + 1);
return change(dayKey(date) + 'T23:59:59Z');
},
choose(value) {
const chosen = String(value || '');
if (!/^\d{4}-\d{2}-\d{2}$/.test(chosen) || chosen <= dayKey(now())) {
return Promise.resolve({ ok:false, error:'Choose a future date.' });
}
return change(chosen + 'T23:59:59Z');
},
cancel() {
active = false;
pending = false;
return snapshot();
},
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createAgendaReplan;
+33
View File
@@ -0,0 +1,33 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createAgendaSessionLauncher = factory;
})(typeof self !== 'undefined' ? self : this, function createAgendaSessionLauncher(options) {
let pending = null;
function open() {
if (pending) return pending;
options.selectAgenda();
pending = Promise.resolve()
.then(() => options.discover())
.then(complete => {
if (!options.isAgendaSelected()) return 'cancelled';
if (complete === false || options.hasMore()) {
options.announce('Agenda check paused. Retry to check older assigned deadlines.');
return 'incomplete';
}
const opened = options.hasCheckpoint() ? options.resume() : options.start();
if (!opened) options.announce('No deadlines are ready in Agenda.');
return opened ? 'opened' : 'empty';
})
.catch(() => {
if (options.isAgendaSelected()) {
options.announce('Agenda check paused. Retry to check older assigned deadlines.');
}
return 'incomplete';
})
.finally(() => { pending = null; });
return pending;
}
return {open};
});
+163 -21
View File
@@ -1,10 +1,50 @@
function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 50 }) {
function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, getOwnerLogin = () => '', createOperationId, mergeChecklistConflict, now = () => Date.now(), maxItems = 50 }) {
const storageKey = 'stackchain.authored-outbox.v1';
const makeId = createOperationId || (() =>
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']);
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 || '');
if (!['rename', 'remove', 'move-earlier', 'move-later'].includes(action)) return null;
return {
action,
index:Number(value.index),
...(action === 'rename' ? { label:String(value.label || '') } : {}),
};
}
function reviewFingerprint(message) {
return JSON.stringify({
@@ -15,6 +55,20 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
});
}
function operationFingerprint(message) {
return JSON.stringify({
kind:String(message.kind || ''), repository:String(message.repository || ''),
number:Number(message.number || 0), notificationId:Number(message.notificationId || 0),
body:String(message.body || ''), targetKind:String(message.targetKind || ''),
decision:String(message.decision || 'comment'), expectedHeadSha:String(message.expectedHeadSha || ''),
comments:Array.isArray(message.comments) ? message.comments : [],
blockerRepository:String(message.blockerRepository || ''), blockerNumber:Number(message.blockerNumber || 0),
present:message.present === true, title:String(message.title || ''),
expectedUpdatedAt:String(message.expectedUpdatedAt || ''),
attachments:messageAttachments(message).map(attachmentMetadata),
});
}
function clearConfirmedReviewState(item) {
if (item.kind !== 'pull-review') return;
try {
@@ -45,12 +99,20 @@ 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();
const requestedOperationId = String(message.operationId || '').slice(0, 128);
const existing = requestedOperationId && items.find(item => item.operationId === requestedOperationId);
if (existing) return { ...existing };
if (existing) {
if (operationFingerprint(existing) !== operationFingerprint(message)) {
throw new Error('This operation ID is already bound to a different queued action.');
}
return { ...existing };
}
if (message.kind === 'pull-review') {
const queuedReview = items.find(item => item.kind === 'pull-review' &&
item.repository === String(message.repository || '') &&
@@ -61,8 +123,26 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
throw new Error('A review for this saved head is already queued. Open Drafts to inspect or discard it first.');
}
}
if (message.kind === 'issue-content') {
const queuedContent = items.find(item => item.kind === 'issue-content' && item.status === 'queued' &&
item.ownerLogin === ownerLogin && item.repository === String(message.repository || '') &&
item.number === Number(message.number || 0));
if (queuedContent) {
const operation = checklistOperation(message.checklistOperation);
const replacement = {
...queuedContent,
operationId: String(requestedOperationId || makeId()).slice(0, 128),
title: String(message.title || ''),
body: String(message.body || ''),
...(operation ? { checklistOperations:[...(queuedContent.checklistOperations || []), operation] } : {}),
};
write(items.map(item => item.id === queuedContent.id ? replacement : item), mirror);
return replacement;
}
}
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,
@@ -74,14 +154,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 || ''),
@@ -96,6 +173,14 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
blockerNumber: Number(message.blockerNumber || 0),
present: message.present === true,
} : {}),
...(message.kind === 'issue-content' ? {
title: String(message.title || ''),
baseBody: String(message.baseBody ?? message.body ?? ''),
expectedUpdatedAt: String(message.expectedUpdatedAt || ''),
...(checklistOperation(message.checklistOperation) ? {
checklistOperations:[checklistOperation(message.checklistOperation)],
} : {}),
} : {}),
};
items.push(item);
write(items, mirror);
@@ -103,8 +188,10 @@ 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.');
@@ -112,19 +199,15 @@ 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');
} catch (error) {
write(read().filter(candidate => candidate.id !== item.id), false);
write(previousItems, false);
throw error;
}
try {
@@ -171,9 +254,16 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
if (item.kind === 'issue-blocker') {
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers';
}
if (item.kind === 'issue-content') {
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/content';
}
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';
}
@@ -246,9 +336,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
decision: item.decision,
expected_head_sha: item.expectedHeadSha,
comments: item.comments,
} : item.kind === 'issue-content' ? {
title:item.title, body:item.body, expected_updated_at:item.expectedUpdatedAt,
} : { body: item.body };
result = await fetchJson(endpoint(item), {
method: 'POST',
method: item.kind === 'issue-content' ? 'PATCH' : 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
@@ -256,6 +348,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
},
body: JSON.stringify(body),
});
if (item.kind === 'issue-content' &&
(result?.number !== item.number || result?.title !== item.title || result?.body !== item.body)) {
throw new Error('Checklist update was not confirmed.');
}
}
}
if (!result) return { blocked: true };
@@ -334,6 +430,51 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
return retryItem(id, currentLogin);
}
async function resolveIssueContentConflict(id, latest, currentLogin) {
const previousItems = read();
const item = previousItems.find(candidate => candidate.id === id);
currentLogin = String(currentLogin || '').trim();
if (!item || item.kind !== 'issue-content' || item.status !== 'attention') {
throw new Error('This checklist conflict is no longer available.');
}
if (!currentLogin || item.ownerLogin !== currentLogin) {
throw new Error('Confirm the account that queued this checklist update.');
}
if (typeof mergeChecklistConflict !== 'function') {
throw new Error('Checklist conflict review is unavailable.');
}
const merged = mergeChecklistConflict({
baseBody: String(item.baseBody ?? item.body ?? ''),
localBody: String(item.body || ''),
remoteBody: String(latest?.body || ''),
});
if (merged.conflicts?.length) return merged;
if (!String(latest?.updated_at || '')) throw new Error('Latest issue revision is unavailable.');
const rebased = {
...item,
operationId: String(makeId()).slice(0, 128),
title: String(latest?.title || ''),
baseBody: String(latest?.body || ''),
body: merged.body,
expectedUpdatedAt: String(latest.updated_at),
status: 'queued',
};
delete rebased.error;
delete rebased.deliveryState;
const nextItems = previousItems.map(candidate => candidate.id === id ? rebased : candidate);
write(nextItems, false);
if (backgroundSync?.reconcile) {
try { await backgroundSync.reconcile(nextItems, 'authored'); }
catch (error) {
write(previousItems, false);
throw error;
}
try { await backgroundSync.requestSync?.(); }
catch (_error) { /* Foreground retry remains available. */ }
}
return { ...merged, item: rebased };
}
function reconcileBackground(records) {
const statuses = new Map((records || []).map(item => [item.id, item]));
const items = read().flatMap(item => {
@@ -359,7 +500,8 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
return items;
}
return { enqueue, enqueueDurably, update, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
return { enqueue, enqueueDurably, update, discard, flush, retry, resolveIssueContentConflict,
reconcileBackground, list: () => read().map(item => ({ ...item })) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createAuthoredOutbox;
+190 -69
View File
@@ -91,13 +91,25 @@ function createIssueSyncStore({
}
continue;
}
const preservedBundle = current.operationId === replacement.operationId &&
current.attachments?.every(value => value?.data || value?.blob) &&
replacement.attachments?.every(value => value?.stored && !value.data && !value.blob)
? current.attachments : null;
if (current.operationId === replacement.operationId &&
(current.attachment?.data || current.attachment?.blob) &&
replacement.attachment?.stored &&
!replacement.attachment.data && !replacement.attachment.blob) {
replacement = { ...replacement, attachment: current.attachment };
!replacement.attachment.data && !replacement.attachment.blob || preservedBundle) {
replacement = {
...replacement,
...(preservedBundle ? {attachments:preservedBundle} : {attachment:current.attachment}),
};
incoming.set(current.id, replacement);
}
if (current.kind === 'issue-content' && replacement.kind === 'issue-content' &&
current.operationId !== replacement.operationId && current.status === 'sending' &&
Number(current.claimUntil) > Number(now())) {
throw new Error('Checklist sync is already delivering. Retry this change.');
}
if ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
(['attention', 'authorization'].includes(current.status) &&
replacement.status === current.status) ||
@@ -174,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;
@@ -353,6 +370,9 @@ function createBackgroundIssueSync({
const resource = item.kind === 'pull-comment' ? 'pull' : 'issue';
return { id: item.id, status, kind: 'message', route: '#/my-work/' + resource + '/' + repository + '/' + encodeURIComponent(item.number) };
}
if (typeof delivered.url === 'string' && /^https?:\/\//.test(delivered.url)) {
return { id: item.id, status, kind: 'issue', url: delivered.url };
}
return { id: item.id, status, kind: 'issue', route: '#/my-work/issue/' + repository + '/' + encodeURIComponent(delivered.number) };
}
@@ -400,6 +420,24 @@ function createBackgroundIssueSync({
},
};
}
if (item.kind === 'issue-content') {
return {
url: base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/content',
options: {
method: 'PATCH',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({
title: item.title,
body: item.body,
expected_updated_at: item.expectedUpdatedAt,
}),
},
};
}
if (item.kind === 'pull-review') {
return {
url: base + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review',
@@ -426,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: {
@@ -439,6 +484,8 @@ function createBackgroundIssueSync({
title: item.title,
body: item.body,
label_ids: item.labelIds,
...(item.unassigned ? { unassigned: true } : {}),
...(item.assignee ? { assignee: item.assignee } : {}),
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
}),
@@ -466,31 +513,75 @@ function createBackgroundIssueSync({
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
}
function evidenceMarkdown(attachment, markdown, index) {
const note = String(attachment?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
if (!note) return markdown;
const escaped = note.replace(/([\\`*_[\]{}()<>#+\-.!|])/g, '\\$1');
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',
@@ -525,8 +616,31 @@ function createBackgroundIssueSync({
deliveredIssue = await requestStage(item, request.url, request.options);
await checkpointClaim(item, current => ({ ...current, deliveredIssue }));
}
let attachmentMarkdown = item.attachmentMarkdown;
if (!attachmentMarkdown) {
const blockers = Array.isArray(item.blockers) ? item.blockers : [];
let deliveredBlockers = Math.min(Number(item.deliveredBlockers) || 0, blockers.length);
for (let index = deliveredBlockers; index < blockers.length; index += 1) {
const blocker = blockers[index];
await requestStage(
item,
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/blockers',
{
method:'PATCH',
headers:{
Accept:'application/json', 'Content-Type':'application/json',
'Idempotency-Key':stageOperationId(item.operationId, 'blocker-' + index),
},
body:JSON.stringify({repository:blocker.repository, number:blocker.number, present:true}),
},
);
deliveredBlockers = index + 1;
await checkpointClaim(item, current => ({ ...current, deliveredIssue, deliveredBlockers }));
}
const attachments = (Array.isArray(item.attachments) ? item.attachments : [item.attachment]).filter(Boolean);
if (!attachments.length) return deliveredIssue;
const attachmentMarkdowns = Array.isArray(item.attachmentMarkdowns)
? item.attachmentMarkdowns.slice(0, attachments.length)
: (item.attachmentMarkdown ? [item.attachmentMarkdown] : []);
for (let index = attachmentMarkdowns.length; index < attachments.length; index += 1) {
const uploaded = await requestStage(
item,
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/attachments',
@@ -534,19 +648,27 @@ function createBackgroundIssueSync({
method: 'POST',
headers: {
Accept: 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
'Idempotency-Key': stageOperationId(
item.operationId, attachments.length === 1 ? 'attachment' : 'attachment-' + index,
),
},
body: attachmentMultipart(item.attachment),
body: attachmentMultipart(attachments[index]),
},
);
attachmentMarkdown = String(uploaded?.markdown || '');
if (!attachmentMarkdown) {
const markdown = String(uploaded?.markdown || '');
if (!markdown) {
const error = new Error('The server did not confirm the screenshot upload.');
error.status = 422;
throw error;
}
await checkpointClaim(item, current => ({ ...current, deliveredIssue, attachmentMarkdown }));
attachmentMarkdowns.push(markdown);
await checkpointClaim(item, current => ({
...current, deliveredIssue, attachmentMarkdowns:attachmentMarkdowns.slice(),
...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}),
}));
}
const attachmentMarkdown = attachmentMarkdowns.map((markdown, index) =>
evidenceMarkdown(attachments[index], markdown, index)).join('\n\n');
await requestStage(
item,
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/comments',
@@ -565,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,
@@ -602,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',
@@ -635,10 +747,13 @@ 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) : await requestStage(item, request.url, request.options);
await deliverIssueCapture(item) : item.attachments?.length && !item.kind ?
await deliverIssueCapture(item) : item.blockers?.length && !item.kind ?
await deliverIssueCapture(item) : await requestStage(item, request.url, request.options);
if (item.kind === 'issue-close' && delivered?.state !== 'closed') {
const error = new Error('Issue closure was not confirmed.');
error.status = 422;
@@ -654,6 +769,12 @@ function createBackgroundIssueSync({
throw error;
}
}
if (item.kind === 'issue-content' &&
(delivered?.number !== item.number || delivered?.title !== item.title || delivered?.body !== item.body)) {
const error = new Error('Checklist update was not confirmed.');
error.status = 422;
throw error;
}
await completeClaim(item, delivered);
const receipt = receiptFor(item, 'confirmed', delivered);
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
+162
View File
@@ -0,0 +1,162 @@
function createBatchFindWork({
capacity,
claim,
queue,
timeBudget = () => ({ capacity_minutes: null, planned_minutes: 0 }),
persistEstimate = () => {},
onProgress = () => {},
storage = typeof localStorage === 'undefined' ? null : localStorage,
owner = () => '',
journalName = 'find-work-batch',
queueFailureReason = 'assigned but Today sync is unavailable',
autoMount = true,
}) {
let request = null;
const journalKey = () => 'stackchain.' + journalName + '.v1.' +
encodeURIComponent(String(owner() || ''));
function readJournal() {
if (!storage || !String(owner() || '')) return null;
try {
const value = JSON.parse(storage.getItem(journalKey()) || 'null');
return value?.owner === String(owner()) && Array.isArray(value.items) ? value : null;
} catch (_error) { return null; }
}
function writeJournal(value) {
if (!storage || !String(owner() || '')) return;
storage.setItem(journalKey(), JSON.stringify(value));
}
function clearJournal() {
if (storage && String(owner() || '')) storage.removeItem(journalKey());
}
function result(status, selected, available, queued = [], failed = []) {
return { status, selected, available, queued, failed };
}
function key(item) {
return String(item.repository || '') + '#' + String(item.number || '');
}
async function processJournal(journal) {
const selected = journal.items;
const queued = [];
const failed = [];
for (let index = 0; index < selected.length; index += 1) {
const entry = selected[index];
const item = entry.item;
if (entry.state === 'queued') {
queued.push(key(item));
onProgress({ status: 'running', processed: index + 1, selected: selected.length });
continue;
}
try {
const confirmed = entry.confirmed || await claim(item);
if (!entry.confirmed) {
entry.confirmed = confirmed;
entry.state = 'assigned';
writeJournal(journal);
}
const queueResult = await queue(confirmed, journal.context);
if (queueResult === 'queued' || queueResult === 'exists') {
queued.push(key(item));
entry.state = 'queued';
if (Number.isInteger(entry.estimate) && entry.estimate > 0) {
persistEstimate(confirmed, entry.estimate);
}
writeJournal(journal);
} else {
failed.push({ key: key(item), reason: queueFailureReason, assigned: true });
}
} catch (error) {
failed.push({ key: key(item), reason: error?.message || 'assignment failed' });
}
onProgress({ status: 'running', processed: index + 1, selected: selected.length });
}
const outcome = result('complete', selected.length, journal.available, queued, failed);
if (!failed.length) clearJournal();
else writeJournal(journal);
onProgress({ ...outcome, processed: selected.length });
return outcome;
}
function run(items, estimates = {}, context = null) {
if (request) return request;
const selected = Array.isArray(items) ? items.slice() : [];
const available = Math.max(0, Number(capacity()) || 0);
if (selected.length > available) {
const outcome = result('full', selected.length, available);
onProgress(outcome);
return Promise.resolve(outcome);
}
const budget = timeBudget() || {};
if (Number.isInteger(budget.capacity_minutes) && budget.capacity_minutes > 0) {
const remaining = Math.max(0, budget.capacity_minutes - (Number(budget.planned_minutes) || 0));
const invalid = selected.map(key).filter(id =>
!Number.isInteger(estimates[id]) || estimates[id] <= 0
);
const requested = selected.reduce((sum, item) => sum +
(Number.isInteger(estimates[key(item)]) && estimates[key(item)] > 0 ? estimates[key(item)] : 0), 0);
if (invalid.length) {
const outcome = { ...result('estimates-required', selected.length, available),
remaining_minutes: remaining, requested_minutes: requested, invalid };
onProgress(outcome);
return Promise.resolve(outcome);
}
if (requested > remaining) {
const outcome = { ...result('over-budget', selected.length, available),
remaining_minutes: remaining, requested_minutes: requested, over_minutes: requested - remaining };
onProgress(outcome);
return Promise.resolve(outcome);
}
}
const journal = {
owner: String(owner() || ''), available, context,
items: selected.map(item => ({ item, estimate: estimates[key(item)] || null, state: 'pending' })),
};
writeJournal(journal);
request = processJournal(journal).finally(() => { request = null; });
return request;
}
function resume() {
if (request) return request;
const journal = readJournal();
if (!journal) return Promise.resolve(null);
request = processJournal(journal).finally(() => { request = null; });
return request;
}
function pending() {
const journal = readJournal();
return journal ? journal.items.filter(item => item.state !== 'queued').length : 0;
}
function mountRecovery(button, opener) {
const show = () => {
const journal = readJournal();
button.hidden = !journal;
if (journal) button.textContent = 'Resume ' + journal.items.filter(item => item.state !== 'queued').length + ' interrupted';
};
button.addEventListener('click', async () => {
button.disabled = true;
const outcome = await resume();
button.disabled = false;
show();
if (outcome) {
button.previousElementSibling.textContent = outcome.failed.length ?
outcome.failed.length + ' still need recovery.' : outcome.queued.length + ' queued · batch recovered.';
}
});
opener.addEventListener('click', show);
}
if (autoMount && typeof document !== 'undefined') mountRecovery(
document.getElementById('resume-find-work-batch'), document.getElementById('find-work')
);
return { run, resume, pending, mountRecovery };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createBatchFindWork;
+141
View File
@@ -0,0 +1,141 @@
function mergeChecklistConflict({ baseBody, localBody, remoteBody, operations = [] }) {
const taskPattern = /^(\s*[-*+]\s+\[)([ xX])(\]\s+)(.*)$/;
function tasks(body) {
const entries = [];
String(body || '').split('\n').forEach((line, lineIndex) => {
const match = line.match(taskPattern);
if (!match) return;
const label = match[4].trim();
const key = label.replace(/\s+/g, ' ').toLocaleLowerCase();
if (!key) return;
entries.push({ key, label, checked: match[2].toLowerCase() === 'x', lineIndex, match });
});
return entries;
}
function grouped(entries) {
const result = new Map();
entries.forEach(entry => result.set(entry.key, [...(result.get(entry.key) || []), entry]));
return result;
}
function replayOperations(body, requested, reportChanges) {
let lines = String(body || '').split('\n');
const visibleTasks = value => tasks(value).filter(entry => /^[-*+]/.test(entry.match[1]));
const logical = visibleTasks(baseBody).map(entry => ({ key:entry.key, label:entry.label }));
const replayed = [];
for (const operation of requested) {
const index = Number(operation?.index);
const target = logical[index];
if (!target) return { body:null, changes:replayed, conflict:{ label:'Checklist step', reason:'missing' } };
let entries = visibleTasks(lines.join('\n'));
const matches = entries.filter(entry => entry.key === target.key);
if (matches.length !== 1) {
return { body:null, changes:replayed, conflict:{
label:target.label, reason:matches.length ? 'ambiguous' : 'missing',
} };
}
const match = matches[0];
if (operation.action === 'rename') {
const label = String(operation.label || '').trim().replace(/\s+/g, ' ');
const key = label.toLocaleLowerCase();
if (!label || entries.some(entry => entry.key === key && entry.lineIndex !== match.lineIndex)) {
return { body:null, changes:replayed, conflict:{ label:target.label, reason:'ambiguous' } };
}
lines[match.lineIndex] = match.match[1] + match.match[2] + match.match[3] + label;
if (reportChanges) replayed.push({ label:target.label, renamed:label });
target.label = label;
target.key = key;
} else if (operation.action === 'remove') {
lines.splice(match.lineIndex, 1);
logical.splice(index, 1);
if (reportChanges) replayed.push({ label:target.label, removed:true });
} else if (operation.action === 'move-earlier' || operation.action === 'move-later') {
const neighborIndex = operation.action === 'move-earlier' ? index - 1 : index + 1;
const neighbor = logical[neighborIndex];
const neighborMatches = neighbor ? entries.filter(entry => entry.key === neighbor.key) : [];
if (neighborMatches.length !== 1 || Math.abs(neighborMatches[0].lineIndex - match.lineIndex) !== 1) {
return { body:null, changes:replayed, conflict:{ label:target.label, reason:'order-changed' } };
}
const neighborLine = neighborMatches[0].lineIndex;
[lines[match.lineIndex], lines[neighborLine]] = [lines[neighborLine], lines[match.lineIndex]];
[logical[index], logical[neighborIndex]] = [logical[neighborIndex], logical[index]];
if (reportChanges) replayed.push({
label:target.label, moved:operation.action === 'move-earlier' ? 'earlier' : 'later',
});
}
}
return { body:lines.join('\n'), changes:replayed, conflict:null };
}
if (Array.isArray(operations) && operations.length) {
const baseReplay = replayOperations(baseBody, operations, false);
const remoteReplay = replayOperations(remoteBody, operations, true);
const conflict = baseReplay.conflict || remoteReplay.conflict;
if (conflict) return { body:null, changes:remoteReplay.changes || [], conflicts:[conflict] };
const residual = mergeChecklistConflict({
baseBody:baseReplay.body, localBody, remoteBody:remoteReplay.body,
});
return {
body:residual.body,
changes:[...remoteReplay.changes, ...residual.changes],
conflicts:residual.conflicts,
};
}
const base = grouped(tasks(baseBody));
const local = grouped(tasks(localBody));
const remoteEntries = tasks(remoteBody);
const remote = grouped(remoteEntries);
const changes = [];
const conflicts = [];
for (const [key, baseMatches] of base) {
const localMatches = local.get(key) || [];
if (baseMatches.length !== 1 || localMatches.length !== 1) continue;
if (baseMatches[0].checked === localMatches[0].checked) continue;
const remoteMatches = remote.get(key) || [];
if (remoteMatches.length !== 1) {
conflicts.push({
label: baseMatches[0].label,
reason: remoteMatches.length ? 'ambiguous' : 'missing',
});
continue;
}
changes.push({ label: remoteMatches[0].label, checked: localMatches[0].checked });
}
for (const [key, localMatches] of local) {
if (base.has(key)) continue;
if (localMatches.length !== 1) {
conflicts.push({ label:localMatches[0].label, reason:'ambiguous' });
continue;
}
const remoteMatches = remote.get(key) || [];
if (remoteMatches.length > 1) {
conflicts.push({ label: localMatches[0].label, reason: 'ambiguous' });
continue;
}
if (remoteMatches.length === 0) {
changes.push({ label: localMatches[0].label, checked:localMatches[0].checked, added:true });
}
}
if (conflicts.length) return { body: null, changes, conflicts };
const desired = new Map(changes.map(change => [
change.label.replace(/\s+/g, ' ').toLocaleLowerCase(), change.checked,
]));
const lines = String(remoteBody || '').split('\n');
remoteEntries.forEach(entry => {
if (!desired.has(entry.key)) return;
const marker = desired.get(entry.key) ? 'x' : ' ';
lines[entry.lineIndex] = entry.match[1] + marker + entry.match[3] + entry.match[4];
});
changes.filter(change => change.added).forEach(change => {
lines.push('- [' + (change.checked ? 'x' : ' ') + '] ' + change.label);
});
return { body: lines.join('\n'), changes, conflicts: [] };
}
if (typeof module !== 'undefined') module.exports = mergeChecklistConflict;
+73 -17
View File
@@ -15,6 +15,15 @@
return current;
};
filterCommands.searchUrl = function searchUrl(query, page, scope, continuation) {
const streamPages = continuation ?
(continuation.issues ? '&issues_page=' + encodeURIComponent(continuation.issues) : '') +
(continuation.pulls ? '&pulls_page=' + encodeURIComponent(continuation.pulls) : '') : '';
return 'api/v1/search?q=' + encodeURIComponent(query) + '&limit=10&page=' + page +
'&kind=' + encodeURIComponent(scope.kind) + '&state=' + encodeURIComponent(scope.state) +
(scope.repository ? '&repository=' + encodeURIComponent(scope.repository) : '') + streamPages;
};
filterCommands.createGlobalSearchController = function createGlobalSearchController(options) {
const search = options.search;
const onState = options.onState;
@@ -22,6 +31,46 @@
let timer = null;
let generation = 0;
let activeController = null;
let scope = { kind:'all', state:'all' };
let state = { status: 'idle', query: '', items: [], more: false, next: 1, scope };
function publish(next) {
state = next;
onState(next);
return state;
}
async function requestPage(query, page, current, append, continuation) {
const requestController = new AbortController();
activeController = requestController;
try {
const requestScope = { ...scope };
const result = await search(
query, requestController.signal, page, requestScope, continuation
);
const partial = result.partial === true;
const incoming = result.items || result;
const combined = append ? state.items.concat(incoming) : incoming;
const items = [...new Map((combined || []).map(item =>
[`${item.kind}:${item.repository}:${item.number}`, item]
)).values()];
if (current === generation) return publish({
status: 'ready', query, items, partial,
more: !!result.has_more,
next: result.next_page,
continuation:result.continuation,
failedStreams:result.failed_streams || [],
scope:requestScope,
});
} catch (error) {
if (error && error.name === 'AbortError') return;
if (current === generation) return publish(append
? { ...state, status: 'ready' }
: { status: 'error', query, items: [], error, scope:{ ...scope } });
} finally {
if (activeController === requestController) activeController = null;
}
}
return {
setQuery(value) {
@@ -32,25 +81,32 @@
if (activeController !== null) activeController.abort();
activeController = null;
if (query.length < 2) {
onState({ status: 'idle', query, items: [] });
publish({ status: 'idle', query, items: [], more: false, next: 1, scope:{ ...scope } });
return;
}
onState({ status: 'loading', query, items: [] });
timer = setTimeout(async () => {
const requestController = new AbortController();
activeController = requestController;
try {
const result = await search(query, requestController.signal);
const items = Array.isArray(result) ? result : result.items;
const partial = !Array.isArray(result) && result.partial === true;
if (current === generation) onState({ status: 'ready', query, items, partial });
} catch (error) {
if (error && error.name === 'AbortError') return;
if (current === generation) onState({ status: 'error', query, items: [], error });
} finally {
if (activeController === requestController) activeController = null;
}
}, delay);
publish({ status: 'loading', query, items: [], more: false, next: 1, scope:{ ...scope } });
timer = setTimeout(() => requestPage(query, 1, current, false), delay);
},
setScope(value) {
const repository = typeof value?.repository === 'string' &&
/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value.repository.trim())
? value.repository.trim() : '';
const next = {
kind:['all', 'issue', 'pull'].includes(value?.kind) ? value.kind : 'all',
state:['all', 'open', 'closed'].includes(value?.state) ? value.state : 'all',
...(repository ? { repository } : {}),
};
if (next.kind === scope.kind && next.state === scope.state &&
(next.repository || '') === (scope.repository || '')) return;
const query = state.query;
scope = next;
this.setQuery(query);
},
loadMore() {
if (state.status !== 'ready' || !state.more || activeController) return Promise.resolve(state);
const current = generation;
const page = state.next;
return requestPage(state.query, page, current, true, state.continuation);
},
};
};
+13
View File
@@ -37,6 +37,8 @@ function createContextPoller({
let retainedSnapshot = null;
let nextDelayMs = intervalMs;
let failureStreak = 0;
let nextRetryAt = null;
let lastSuccessAt = null;
function snapshotDelay(snapshot) {
const freshness = snapshot && snapshot.freshness;
@@ -69,8 +71,10 @@ function createContextPoller({
function schedule(delayMs = intervalMs) {
cancelTimer();
if (stopped || isHidden()) return;
nextRetryAt = Date.now() + delayMs;
timer = setTimer(() => {
timer = null;
nextRetryAt = null;
refresh();
}, delayMs);
}
@@ -125,6 +129,7 @@ function createContextPoller({
.then((snapshot) => {
if (activeRequest !== requestState) return retainedSnapshot;
failureStreak = 0;
lastSuccessAt = Date.now();
nextDelayMs = snapshotDelay(snapshot);
const changedSections = ['context', 'events', 'notifications'].filter(
(section) => Object.prototype.hasOwnProperty.call(snapshot, section)
@@ -165,6 +170,14 @@ function createContextPoller({
start: refresh,
refresh,
setVisible,
getState() {
return {
refreshing: Boolean(activeRequest),
nextRetryAt,
lastSuccessAt,
failureStreak,
};
},
stop() {
stopped = true;
cancelTimer();
+44
View File
@@ -0,0 +1,44 @@
function createConversationActionHydrator({ load, activate }) {
let actions = null;
let pending = null;
const wired = new WeakSet();
async function ensure() {
if (actions) return actions;
if (!pending) pending = load().then(() => actions = activate()).catch(error => {
pending = null;
throw error;
});
return pending;
}
function show({ root, state, paint, wire, retry }) {
if (actions) {
retry.hidden = true;
if (!wired.has(root)) {
wire(actions);
wired.add(root);
}
paint(state, actions);
return Promise.resolve(true);
}
paint(state, null);
retry.hidden = true;
return ensure().then(controller => {
if (!wired.has(root)) {
wire(controller);
wired.add(root);
}
paint(state, controller);
return true;
}).catch(() => {
retry.hidden = false;
return false;
});
}
return {show,get:ensure};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createConversationActionHydrator;
+88
View File
@@ -0,0 +1,88 @@
(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.restoring = true;
try {
current.controller.clear();
if (attachments?.length) 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.restoring = true;
try { current.controller.clear(); }
finally {
current.restoring = false;
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.restoring = true;
try { current.controller.clear(); }
finally {
current.restoring = false;
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 };
};
});
+127
View File
@@ -0,0 +1,127 @@
(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 = () => '', scope = 'conversation',
} = {}) {
const transact = transaction || createTransaction(indexedDB);
const draftScope = String(scope || 'conversation').trim().slice(0, 64) || 'conversation';
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;
const identityParts = draftScope === 'conversation' ? [ownerLogin, normalized.kind, targetKey] :
[ownerLogin, draftScope, normalized.kind, targetKey];
return {
ownerLogin, scope:draftScope, target:normalized,
id:identityParts.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, scope:recordScope, 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, scope:recordScope, ...normalized, attachments:list });
return list;
}
async function load(target) {
if (!transact) return null;
const { id, ownerLogin, scope:recordScope, target:normalized } = identity(target);
const record = await transact('get', id);
const scopeMatches = recordScope === 'conversation' ? (!record?.scope || record.scope === recordScope) :
record?.scope === recordScope;
const same = record?.version === 1 && record.ownerLogin === ownerLogin && scopeMatches &&
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 };
};
});
+50 -2
View File
@@ -5,7 +5,40 @@ function createCreateAndStart({ todayWork, todaySync, refresh, warm, start, anno
return todayWork.read().length < todayWork.limit;
}
function complete(issue) {
function capacity(value) {
if (String(value ?? '').trim() === '') return { valid: false, reason: 'required' };
const minutes = Number(value);
if (!Number.isInteger(minutes) || minutes < 5 || minutes > 1440) {
return { valid: false, reason: 'range' };
}
const plan = todayWork.planning();
const capacityMinutes = plan.capacity_minutes;
if (!Number.isInteger(capacityMinutes)) {
return { valid: true, minutes, capacityMinutes: null, plannedMinutes: null,
remainingMinutes: null, projectedMinutes: null, fits: true };
}
const plannedMinutes = todayWork.read().reduce(
(total, identity) => total + (Number.isInteger(plan.estimates[identity]) ? plan.estimates[identity] : 0), 0
);
const remainingMinutes = capacityMinutes - plannedMinutes;
const projectedMinutes = remainingMinutes - minutes;
return { valid: true, minutes, capacityMinutes, plannedMinutes, remainingMinutes,
projectedMinutes, fits: projectedMinutes >= 0 };
}
function capacityMessage(result) {
if (!result.valid) return result.reason === 'required'
? 'Required only for Create & start.'
: 'Use a whole number from 5 to 1440 minutes.';
if (result.capacityMinutes === null) {
return result.minutes + ' min · Set a Today budget to see remaining time.';
}
return result.fits
? result.minutes + ' min · ' + result.projectedMinutes + ' min will remain in Today.'
: result.minutes + ' min · ' + Math.abs(result.projectedMinutes) + ' min over Today capacity.';
}
function complete(issue, estimateMinutes) {
const identity = todayWork.identity(issue);
if (!identity || completed.has(identity)) return 'exists';
const added = todayWork.add(issue);
@@ -17,10 +50,25 @@ function createCreateAndStart({ todayWork, todaySync, refresh, warm, start, anno
announce('Created, but Today could not be saved on this device.');
return 'unavailable';
}
let estimatedPlan = null;
if (Number.isInteger(estimateMinutes)) {
estimatedPlan = todayWork.planning();
estimatedPlan.estimates[identity] = estimateMinutes;
if (!todayWork.replacePlanning(estimatedPlan)) {
announce('Created and added to Today, but its estimate could not be saved on this device.');
return 'estimate-unavailable';
}
}
if (!todaySync.enqueue('add', identity)) {
announce('Created and saved to Today on this device, but account sync is unavailable.');
return 'sync-unavailable';
}
if (estimatedPlan) {
if (!todaySync.enqueueConfiguration(estimatedPlan.capacity_minutes, estimatedPlan.estimates)) {
announce('Created and estimated on this device, but Today plan sync is unavailable.');
return 'sync-unavailable';
}
}
completed.add(identity);
refresh();
todaySync.flush();
@@ -30,7 +78,7 @@ function createCreateAndStart({ todayWork, todaySync, refresh, warm, start, anno
return 'started';
}
return { available, complete };
return { available, capacity, capacityMessage, complete };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createCreateAndStart;
+602 -4
View File
@@ -8,6 +8,59 @@ function newIssueOperationId() {
return String(Date.now()) + '-' + Math.random().toString(16).slice(2);
}
function createIssueModalLifecycle({ root, background = [], document, requestClose }) {
let launcher = null;
let active = false;
let backgroundState = [];
const focusableSelector = [
'a[href]', 'button:not([disabled])', 'input:not([disabled])',
'select:not([disabled])', 'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
const focusable = () => Array.from(root.querySelectorAll(focusableSelector)).filter(element =>
!element.hidden && !element.disabled && element.getClientRects().length > 0
);
function keydown(event) {
if (!active) return;
if (event.key === 'Escape') {
event.preventDefault();
requestClose();
return;
}
if (event.key !== 'Tab') return;
const controls = focusable();
if (!controls.length) return;
const first = controls[0];
const last = controls[controls.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
root.addEventListener('keydown', keydown);
return {
open(trigger = null) {
launcher = trigger?.isConnected ? trigger : document.activeElement;
backgroundState = background.map(element => [element, element.inert]);
backgroundState.forEach(([element]) => { element.inert = true; });
active = true;
(root.querySelector('#cancel-new-issue') || focusable()[0])?.focus();
},
close({ restore = true } = {}) {
active = false;
backgroundState.forEach(([element, inert]) => { element.inert = inert; });
backgroundState = [];
const panel = root.querySelector('.create-issue-panel');
if (panel) panel.scrollTop = 0;
if (restore && launcher?.isConnected) launcher.focus();
launcher = null;
},
};
}
function normalizeSharedContent(value = {}) {
const clean = input => String(input || '').replace(/\s+/g, ' ').trim();
const text = String(value.text || '').trim().slice(0, 9500);
@@ -19,22 +72,244 @@ function normalizeSharedContent(value = {}) {
return { title, body };
}
function buildRelatedDraft(value = {}, template = null) {
const repository = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(String(value.repository || ''))
? String(value.repository) : '';
const labelIds = Array.from(new Set((Array.isArray(value.labelIds) ? value.labelIds : [])
.filter(id => Number.isInteger(id) && id > 0))).slice(0, 20);
const draft = {repository, title:'', body:'', labelIds};
const milestoneId = Number(value.milestoneId);
if (Number.isInteger(milestoneId) && milestoneId > 0) draft.milestoneId = milestoneId;
if (/^\d{4}-\d{2}-\d{2}$/.test(String(value.dueDate || ''))) draft.dueDate = String(value.dueDate);
if (value.unassigned === true) draft.unassigned = true;
else if (/^[A-Za-z0-9_.-]+$/.test(String(value.assignee || ''))) {
draft.assignee = String(value.assignee);
draft.assigneeName = String(value.assigneeName || value.assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
}
const templateId = String(value.templateId || '').slice(0, 80);
if (templateId && template && String(template.id || '') === templateId) {
draft.templateId = templateId;
draft.templateName = String(value.templateName || template.name || 'Issue template').trim().slice(0, 80);
draft.capturedBody = '';
draft.body = String(template.body || '').trim().slice(0, 9000);
}
return draft;
}
function relatedChecklistDraft(item, label) {
const title = String(label || '').trim().replace(/\s+/g, ' ').slice(0, 240);
if (!title) throw new Error('Choose a checklist step to file.');
const repository = String(item?.repository || '');
const number = Number(item?.number);
const url = String(item?.url || '');
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) ||
!Number.isInteger(number) || number < 1 || !/^https:\/\//.test(url)) {
throw new Error('The parent issue link is unavailable.');
}
return {repository, title,
body:'Related to [' + repository + '#' + number + '](' + url + ').', labelIds:[]};
}
function linkChecklistTask(raw, targetIndex, child) {
const url = String(child?.url || '');
if (!/^https:\/\//.test(url)) throw new Error('The related issue link is unavailable.');
const parts = String(raw || '').split(/(\r\n|\n|\r)/);
let fenced = false;
let taskIndex = 0;
for (let index = 0; index < parts.length; index += 2) {
const line = parts[index];
if (/^\s*```/.test(line)) { fenced = !fenced; continue; }
if (fenced) continue;
const task = line.match(/^(\s*[-*+]\s+\[[ xX]\]\s+)(.*)$/);
if (!task) continue;
if (taskIndex === Number(targetIndex)) {
if (/^\s*\[[^\]]+\]\([^)]+\)\s*$/.test(task[2])) throw new Error('That checklist step is already linked.');
const label = task[2].trim().replace(/\s+/g, ' ');
parts[index] = task[1] + '[' + label + '](' + url + ')';
return parts.join('');
}
taskIndex += 1;
}
throw new Error('The checklist step is no longer available.');
}
function createChecklistPromotion({ issueController, issueCapture, clearAttachments, onLinked, onStatus }) {
let pending = null;
const persistedPromotion = value => ({
item:{repository:value.repository, number:value.number, url:value.url},
detail:{title:value.title, body:value.body, updated_at:value.updatedAt}, taskIndex:value.taskIndex,
});
return {
pending:() => Boolean(pending),
start(context) {
pending = {item:{...context.item}, detail:{...context.detail}, taskIndex:Number(context.taskIndex)};
const draft = issueController.relatedTaskDraft(context.item, context.detail, context.taskIndex, context.label);
issueCapture.saveDraft(draft);
clearAttachments();
return draft;
},
cancel() { if (!pending) return false; pending = null; issueCapture.clearDraft(); return true; },
deliveryContext() {
if (!pending) return null;
return {repository:pending.item.repository, number:pending.item.number, url:pending.item.url,
title:pending.detail.title, body:pending.detail.body, updatedAt:pending.detail.updated_at,
taskIndex:pending.taskIndex};
},
async finish(child, persisted = null) {
const promotion = pending || (persisted ? persistedPromotion(persisted) : null);
if (!promotion || !child) return false;
pending = null;
try {
const confirmed = await issueController.linkRelatedTask(
promotion.item, promotion.detail, promotion.taskIndex, child
);
onLinked(promotion, confirmed);
onStatus('Related issue created and linked from its parent checklist.');
return true;
} catch (error) {
onStatus('Related issue created, but the parent link needs attention.' +
(child.url ? ' ' + child.url : '') + ' ' + error.message);
return false;
}
},
};
}
function createIssueOwnerPicker(issueCapture, documentRef, onChange) {
const NO_OWNER = '__unassigned__';
const select = documentRef.querySelector('#create-issue-assignee');
const status = documentRef.querySelector('#create-issue-assignee-status');
const getRepository = () => documentRef.querySelector('#create-issue-repository').value;
const loadOwners = repository => issueCapture.loadOwners(repository);
let ownerRequest = 0;
function reset(repository, selected = {}) {
select.replaceChildren();
const me = documentRef.createElement('option');
me.value = '';
me.textContent = 'Me';
select.appendChild(me);
const noOwner = documentRef.createElement('option');
noOwner.value = NO_OWNER;
noOwner.textContent = 'No owner';
select.appendChild(noOwner);
if (selected.unassigned === true) select.value = NO_OWNER;
if (selected.assignee) {
const option = documentRef.createElement('option');
option.value = selected.assignee;
option.textContent = (selected.assigneeName || selected.assignee) + ' (@' + selected.assignee + ')';
option.dataset.name = selected.assigneeName || selected.assignee;
select.appendChild(option);
select.value = selected.assignee;
}
select.dataset.repository = '';
status.textContent = repository ? 'Open the owner picker to load eligible teammates.' : 'Choose a repository first.';
}
async function load(repository) {
if (!repository || select.dataset.repository === repository) return;
const request = ++ownerRequest;
const selected = select.value;
const selectedName = select.selectedOptions?.[0]?.dataset.name || '';
status.textContent = 'Loading eligible teammates…';
try {
const owners = await loadOwners(repository);
if (request !== ownerRequest || getRepository() !== repository) return;
const selectedStillEligible = owners.some(owner => owner?.login === selected);
reset(repository, selected === NO_OWNER ? {unassigned:true} :
(selectedStillEligible ? {assignee:selected, assigneeName:selectedName} : {}));
owners.forEach(owner => {
if (!owner?.login || owner.login === selected) return;
const option = documentRef.createElement('option');
option.value = owner.login;
option.textContent = (owner.name || owner.login) + ' (@' + owner.login + ')';
option.dataset.name = owner.name || owner.login;
select.appendChild(option);
});
select.dataset.repository = repository;
status.textContent = owners.length ? 'Choose yourself, no owner, or an eligible teammate.' :
'Choose yourself or no owner; no eligible teammates are available.';
} catch (_error) {
if (request !== ownerRequest || getRepository() !== repository) return;
status.textContent = 'Teammates could not be loaded. The issue will stay assigned to you.';
}
}
function updateActions(hasRepository, hasBlockers, canStart) {
const submit = documentRef.querySelector('#submit-new-issue');
const start = documentRef.querySelector('#create-and-start-issue');
const hasNoOwner = select.value === NO_OWNER;
const hasTeammateOwner = Boolean(select.value) && !hasNoOwner;
submit.disabled = !hasRepository;
submit.textContent = hasNoOwner ? 'Create unassigned' :
(hasTeammateOwner ? 'Create & assign' : 'Create & assign to me');
start.disabled = !hasRepository || hasBlockers || hasTeammateOwner || hasNoOwner || !canStart;
start.title = hasBlockers ? 'Blocked work cannot start until its blockers are complete.' :
(hasNoOwner ? 'No owner work cannot be added to your Today queue.' :
(hasTeammateOwner ? 'Work assigned to a teammate cannot be added to your Today queue.' : ''));
}
function fields() {
return {
assignee: select.value === NO_OWNER ? '' : select.value,
assigneeName: select.value === NO_OWNER ? '' : (select.selectedOptions?.[0]?.dataset.name || ''),
unassigned: select.value === NO_OWNER,
};
}
function draft(labelIds, blockers, trim = false) {
const value = id => documentRef.querySelector(id).value;
const clean = input => trim ? input.trim() : input;
return {
repository:value('#create-issue-repository'),
title:clean(value('#create-issue-title')), body:clean(value('#create-issue-body')),
labelIds, milestoneId:Number(value('#create-issue-milestone')) || null,
dueDate:value('#create-issue-due-date'), ...fields(), blockers,
};
}
select.addEventListener('focus', () => {
const repository = getRepository();
if (repository) load(repository);
});
select.addEventListener('change', onChange);
return { reset, load, updateActions, fields, draft };
}
function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOperationId }) {
const storageKey = 'stackchain.issue-capture.v1';
const sharedStorageKey = 'stackchain.issue-share.v1';
const followUpStorageKey = 'stackchain.issue-follow-up.v1';
let pending = null;
let issueTemplates = [];
let activeTemplate = null;
let templateRequest = 0;
let duplicateRequest = 0;
let repositorySearchRequest = 0;
let filingMetadataRequest = 0;
let blockerSearchRequest = 0;
let duplicateState = {status: 'idle', key: '', candidates: []};
let acknowledgedDuplicateKey = '';
const repositoryPageRequests = new Map();
const safeLabelIds = value => Array.from(new Set(
(Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0)
)).slice(0, 20);
const safeBlockers = value => {
const seen = new Set();
return (Array.isArray(value) ? value : []).reduce((items, blocker) => {
const repository = String(blocker?.repository || '').trim();
const number = Number(blocker?.number);
const key = repository + '#' + number;
if (items.length >= 5 || seen.has(key) ||
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) ||
!Number.isInteger(number) || number < 1) return items;
seen.add(key);
items.push({repository, number,
title:String(blocker?.title || '').replace(/\s+/g, ' ').trim().slice(0, 255)});
return items;
}, []);
};
const emptyDraft = () => ({ repository: '', title: '', body: '', labelIds: [] });
const safeMilestoneId = value => Number.isInteger(Number(value)) && Number(value) > 0 ? Number(value) : null;
const safeDueDate = value => /^\d{4}-\d{2}-\d{2}$/.test(String(value || '')) ? String(value) : '';
const safeAssignee = value => /^[A-Za-z0-9_.-]+$/.test(String(value || '')) ? String(value) : '';
const safeEstimate = value => Number.isInteger(Number(value)) && Number(value) >= 5 && Number(value) <= 1440 ?
Number(value) : null;
function loadStored() {
try {
@@ -47,10 +322,28 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
labelIds: safeLabelIds(parsed.labelIds),
operationId: String(parsed.operationId || '').slice(0, 128),
};
if (parsed.unassigned === true) draft.unassigned = true;
if (typeof parsed.templateName === 'string' && parsed.templateName.trim()) {
draft.templateName = parsed.templateName.trim().slice(0, 80);
draft.templateId = String(parsed.templateId || '').slice(0, 80);
draft.capturedBody = String(parsed.capturedBody || '').slice(0, 10000);
}
const assignee = safeAssignee(parsed.assignee);
if (assignee) {
draft.assignee = assignee;
draft.assigneeName = String(parsed.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
}
const milestoneId = safeMilestoneId(parsed.milestoneId);
const dueDate = safeDueDate(parsed.dueDate);
if (milestoneId !== null) draft.milestoneId = milestoneId;
if (dueDate) draft.dueDate = dueDate;
const estimateMinutes = safeEstimate(parsed.estimateMinutes);
if (estimateMinutes !== null) draft.estimateMinutes = estimateMinutes;
if (['create', 'create-and-start'].includes(parsed.completionIntent)) {
draft.completionIntent = parsed.completionIntent;
}
const blockers = safeBlockers(parsed.blockers);
if (blockers.length) draft.blockers = blockers;
return draft;
} catch (_error) {
return {...emptyDraft(), operationId: ''};
@@ -70,13 +363,33 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
body: String(draft?.body || ''),
labelIds: safeLabelIds(draft?.labelIds),
};
if (draft?.unassigned === true) safe.unassigned = true;
if (typeof draft?.templateName === 'string' && draft.templateName.trim()) {
safe.templateName = draft.templateName.trim().slice(0, 80);
safe.templateId = String(draft.templateId || '').slice(0, 80);
safe.capturedBody = String(draft.capturedBody || '').slice(0, 10000);
}
const assignee = safe.unassigned ? '' : safeAssignee(draft?.assignee);
if (assignee) {
safe.assignee = assignee;
safe.assigneeName = String(draft?.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
}
const milestoneId = safeMilestoneId(draft?.milestoneId);
const dueDate = safeDueDate(draft?.dueDate);
if (milestoneId !== null) safe.milestoneId = milestoneId;
if (dueDate) safe.dueDate = dueDate;
const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate']
const estimateMinutes = safeEstimate(draft?.estimateMinutes);
if (estimateMinutes !== null) safe.estimateMinutes = estimateMinutes;
if (['create', 'create-and-start'].includes(draft?.completionIntent)) {
safe.completionIntent = draft.completionIntent;
}
const blockers = safeBlockers(draft?.blockers);
if (blockers.length) safe.blockers = blockers;
const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate', 'assignee', 'assigneeName', 'unassigned',
'templateName', 'templateId', 'capturedBody', 'estimateMinutes', 'completionIntent']
.every(key => (previous[key] || '') === (safe[key] || '')) &&
JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds);
JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds) &&
JSON.stringify(previous.blockers || []) === JSON.stringify(safe.blockers || []);
writeStored({...safe, operationId: unchanged ? previous.operationId : ''});
return safe;
}
@@ -182,6 +495,266 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
);
}
function loadOwners(repository) {
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
return fetchJson('api/v1/repos/' + encoded + '/issue-assignees').then(owners =>
Array.isArray(owners) ? owners : []
);
}
function loadTemplates(repository) {
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
return fetchJson('api/v1/repos/' + encoded + '/issue-templates').then(templates =>
Array.isArray(templates) ? templates.slice(0, 20) : []
);
}
async function loadFilingMetadata(repository) {
const selectedRepository = String(repository || '');
const encoded = selectedRepository.split('/').map(encodeURIComponent).join('/');
const request = ++filingMetadataRequest;
const payload = await fetchJson(
'api/v1/repos/' + encoded + '/issue-filing-metadata'
);
if (request !== filingMetadataRequest) {
return {status:'stale', repository:selectedRepository};
}
const section = name => {
const value = payload?.[name];
const available = value?.available === true;
return {
available,
items:available && Array.isArray(value?.items) ? value.items : [],
...(!available && typeof value?.error === 'string' ? {error:value.error} : {}),
};
};
return {
status:'ready', repository:selectedRepository,
labels:section('labels'), milestones:section('milestones'),
templates:section('templates'),
};
}
function applyTemplate(draft, template, availableLabels = []) {
const source = {...(draft || {})};
const capturedBody = source.templateName ? String(source.capturedBody || '') : String(source.body || '');
if (!template) {
delete source.templateName;
delete source.templateId;
delete source.capturedBody;
return {...source, body: capturedBody};
}
const scaffold = String(template.body || '').trim().slice(0, 9000);
const body = [capturedBody.trim(), scaffold].filter(Boolean).join('\n\n---\n\n').slice(0, 10000);
const validByName = new Map((Array.isArray(availableLabels) ? availableLabels : [])
.filter(label => Number.isInteger(label?.id) && typeof label?.name === 'string')
.map(label => [label.name.toLowerCase(), label.id]));
const templateLabelIds = (Array.isArray(template.labels) ? template.labels : [])
.map(name => validByName.get(String(name).toLowerCase())).filter(Boolean);
return {
...source,
title: source.title || String(template.title || '').trim().slice(0, 255),
body,
labelIds: safeLabelIds([...(source.labelIds || []), ...templateLabelIds]),
templateId: String(template.id || '').slice(0, 80),
templateName: String(template.name || 'Issue template').trim().slice(0, 80),
capturedBody,
};
}
function setTemplateState(draft) {
activeTemplate = draft?.templateName ? {
templateName:draft.templateName, templateId:draft.templateId,
capturedBody:draft.capturedBody || '',
} : null;
}
function templateFields(draft) {
return activeTemplate ? {...draft, ...activeTemplate} : draft;
}
function restoreTemplate(draft, availableLabels) {
const restored = applyTemplate(templateFields(draft), null, availableLabels);
activeTemplate = null;
return restored;
}
async function loadTemplateOptions(repository, elements, selectedId = '') {
const request = ++templateRequest;
issueTemplates = [];
elements.select.innerHTML = '<option value="">Blank issue</option>';
elements.field.hidden = true;
if (!repository) return;
elements.status.textContent = 'Loading issue types…';
try {
const templates = await loadTemplates(repository);
if (request !== templateRequest || elements.getRepository() !== repository) return;
issueTemplates = templates;
templates.forEach(template => {
const option = elements.document.createElement('option');
option.value = template.id;
option.textContent = template.name;
elements.select.appendChild(option);
});
elements.field.hidden = templates.length === 0;
elements.select.value = templates.some(template => template.id === selectedId) ? selectedId : '';
elements.status.textContent = templates.length ?
'Choose a repository guide or keep a blank issue.' : 'Blank issue selected.';
} catch (_error) {
if (request !== templateRequest || elements.getRepository() !== repository) return;
elements.status.textContent = 'Issue types could not be loaded. Blank issue filing is still available.';
}
}
function selectTemplate(identifier, draft, availableLabels) {
const template = issueTemplates.find(item => item.id === identifier) || null;
const applied = applyTemplate(templateFields(draft), template, availableLabels);
activeTemplate = template ? {
templateId:applied.templateId, templateName:applied.templateName,
capturedBody:applied.capturedBody,
} : null;
return {draft: applied, template};
}
function relatedDraft(draft) {
const template = issueTemplates.find(item => String(item?.id || '') === String(draft?.templateId || '')) || null;
return buildRelatedDraft(draft, template);
}
function bindTemplatePicker(elements, callbacks) {
let labels = [];
elements.select.addEventListener('change', event => {
const {draft, template} = selectTemplate(event.target.value, callbacks.getDraft(), labels);
callbacks.setDraft(draft);
const selected = new Set(draft.labelIds || []);
elements.labelInputs().forEach(input => { input.checked = selected.has(Number(input.value)); });
elements.status.textContent = template ?
template.name + (template.about ? ' — ' + template.about : '') : 'Blank issue selected.';
callbacks.changed();
});
return {
fields:templateFields,
reset:setTemplateState,
setLabels(value) { labels = Array.isArray(value) ? value : []; },
load(repository, selectedId) {
return loadTemplateOptions(repository, elements, selectedId);
},
render(repository, section, selectedId = '') {
++templateRequest;
issueTemplates = [];
elements.select.innerHTML = '<option value="">Blank issue</option>';
elements.field.hidden = true;
if (!repository || elements.getRepository() !== repository) return;
if (section?.available !== true) {
elements.status.textContent = 'Issue types could not be loaded. Blank issue filing is still available.';
return;
}
issueTemplates = Array.isArray(section.items) ? section.items.slice(0, 20) : [];
issueTemplates.forEach(template => {
const option = elements.document.createElement('option');
option.value = template.id;
option.textContent = template.name;
elements.select.appendChild(option);
});
elements.field.hidden = issueTemplates.length === 0;
elements.select.value = issueTemplates.some(template => template.id === selectedId) ? selectedId : '';
elements.status.textContent = issueTemplates.length ?
'Choose a repository guide or keep a blank issue.' : 'Blank issue selected.';
},
changeRepository(draft) {
const restored = restoreTemplate(draft, labels);
callbacks.setDraft(restored);
return restored;
},
};
}
function bindFilingMetadata(elements, templatePicker) {
function renderLabels(repository, section, selectedIds = []) {
elements.labelList.replaceChildren();
if (!repository) {
elements.labelStatus.textContent = 'Choose a repository to load labels.';
return;
}
if (section?.available !== true) {
templatePicker.setLabels([]);
elements.labelStatus.textContent = 'Labels could not be loaded. You can still create the issue without labels.';
return;
}
const priorities = new Set(['p0', 'priority-high', 'critical']);
const labels = (Array.isArray(section.items) ? section.items : []).slice().sort((left, right) =>
Number(priorities.has(String(right?.name || '').toLowerCase())) -
Number(priorities.has(String(left?.name || '').toLowerCase()))
);
templatePicker.setLabels(labels);
const selected = new Set(selectedIds.map(Number));
labels.forEach(label => {
const option = elements.document.createElement('label');
option.className = 'create-issue-label-option';
const input = elements.document.createElement('input');
input.type = 'checkbox';
input.name = 'create-issue-label';
input.value = String(Number(label.id));
input.checked = selected.has(Number(label.id));
const name = elements.document.createElement('span');
name.textContent = label.name;
option.append(input, name);
elements.labelList.appendChild(option);
});
elements.labelStatus.textContent = labels.length ?
'Select labels to triage this issue.' : 'This repository has no labels.';
}
function renderMilestones(repository, section, selectedId = null) {
elements.milestoneSelect.replaceChildren();
const blank = elements.document.createElement('option');
blank.value = '';
blank.textContent = 'No milestone';
elements.milestoneSelect.appendChild(blank);
if (!repository) {
elements.milestoneStatus.textContent = 'Choose a repository to load milestones.';
return;
}
if (section?.available !== true) {
elements.milestoneStatus.textContent = 'Milestones could not be loaded. You can still create an unplanned issue.';
return;
}
const milestones = Array.isArray(section.items) ? section.items : [];
milestones.forEach(milestone => {
const option = elements.document.createElement('option');
option.value = String(Number(milestone.id));
option.textContent = milestone.title;
elements.milestoneSelect.appendChild(option);
});
if (selectedId) elements.milestoneSelect.value = String(selectedId);
elements.milestoneStatus.textContent = milestones.length ?
'Choose the release lane for this issue.' : 'This repository has no open milestones.';
}
async function load(repository, selected = {}) {
renderLabels(repository, null, selected.labelIds || []);
renderMilestones(repository, null, selected.milestoneId);
templatePicker.render(repository, null, selected.templateId);
if (!repository) return;
elements.labelStatus.textContent = 'Loading labels…';
elements.milestoneStatus.textContent = 'Loading milestones…';
elements.templateStatus.textContent = 'Loading issue types…';
try {
const metadata = await loadFilingMetadata(repository);
if (metadata.status !== 'ready' || elements.getRepository() !== repository) return;
renderLabels(repository, metadata.labels, selected.labelIds || []);
renderMilestones(repository, metadata.milestones, selected.milestoneId);
templatePicker.render(repository, metadata.templates, selected.templateId);
} catch (_error) {
if (elements.getRepository() !== repository) return;
renderLabels(repository, {available:false}, selected.labelIds || []);
renderMilestones(repository, {available:false}, selected.milestoneId);
templatePicker.render(repository, {available:false}, selected.templateId);
}
}
return {load};
}
function loadRepositoryPage(page) {
const safePage = Math.max(1, Math.floor(Number(page) || 1));
if (repositoryPageRequests.has(safePage)) return repositoryPageRequests.get(safePage);
@@ -216,6 +789,21 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
}
}
async function searchBlockers(value) {
const query = String(value || '').trim().slice(0, 80);
const request = ++blockerSearchRequest;
if (query.length < 2) return {status:'idle', items:[]};
try {
const payload = await fetchJson('api/v1/search?q=' + encodeURIComponent(query) + '&limit=20');
if (request !== blockerSearchRequest) return {status:'stale', items:[]};
return {status:'ready', items:(Array.isArray(payload?.items) ? payload.items : [])
.filter(item => item?.kind === 'issue' && item?.state === 'open').slice(0, 20)};
} catch (error) {
if (request !== blockerSearchRequest) return {status:'stale', items:[]};
return {status:'failed', items:[], error};
}
}
function duplicateKey(draft) {
const repository = String(draft?.repository || '').trim();
const title = String(draft?.title || '').replace(/\s+/g, ' ').trim();
@@ -276,6 +864,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
},
body: JSON.stringify({
title: saved.title, body: saved.body, label_ids: saved.labelIds,
...(saved.unassigned ? {unassigned:true} : {}),
...(saved.assignee ? {assignee: saved.assignee} : {}),
...(saved.milestoneId ? {milestone_id: saved.milestoneId} : {}),
...(saved.dueDate ? {due_date: saved.dueDate + 'T23:59:59Z'} : {}),
}),
@@ -287,8 +877,10 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
}
return {
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage,
searchRepositories, findDuplicates,
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadOwners, loadTemplates,
loadFilingMetadata, applyTemplate, setTemplateState, templateFields, restoreTemplate, loadTemplateOptions,
selectTemplate, buildRelatedDraft:relatedDraft, bindTemplatePicker, bindFilingMetadata, loadRepositoryPage,
searchRepositories, searchBlockers, findDuplicates,
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
stageFollowUp, pendingFollowUp, acceptFollowUp, discardFollowUp,
@@ -296,5 +888,11 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
}
createIssueCapture.normalizeSharedContent = normalizeSharedContent;
createIssueCapture.createOwnerPicker = createIssueOwnerPicker;
createIssueCapture.buildRelatedDraft = buildRelatedDraft;
createIssueCapture.relatedChecklistDraft = relatedChecklistDraft;
createIssueCapture.linkChecklistTask = linkChecklistTask;
createIssueCapture.createChecklistPromotion = createChecklistPromotion;
createIssueCapture.createModalLifecycle = createIssueModalLifecycle;
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
+646 -19
View File
@@ -5,14 +5,56 @@ html, body { height: 100%; margin: 0; background: var(--bg); color: var(--text);
header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex; gap:16px; align-items:center; justify-content:space-between; background: linear-gradient(180deg, rgba(11,21,38,.95), rgba(11,21,38,.55), transparent); backdrop-filter: blur(4px); border-bottom: 1px solid #1b2d45; }
.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
.app-brand { display:flex; align-items:center; gap:6px; white-space:nowrap; }
.app-live-status { display:inline-flex; gap:6px; align-items:center; }
.app-live-status { display:inline-flex; gap:6px; align-items:center; min-height:44px; padding:6px 10px; background:transparent; border-color:transparent; }
.sign-out-review-sheet { position:fixed; inset:0; z-index:112; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.sign-out-review-sheet[hidden] { display:none; }
.sign-out-review-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; overflow-wrap:anywhere; }
.sign-out-review-panel h2 { margin:.25rem 0; }
.sign-out-review-warning { color:#fbbf24; }
.sign-out-review-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
.sign-out-review-actions button { min-height:44px; width:100%; }
#confirm-sign-out { border-color:#b45309; }
@media (max-width:359px) { .sign-out-review-actions { grid-template-columns:1fr; } }
.live-data-status-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.live-data-status-sheet[hidden] { display:none; }
.live-data-status-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
.live-data-status-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.live-data-status-header h2, .live-data-status-header p { margin-top:0; }
.live-data-status-header button, .live-data-status-actions button { min-height:44px; }
.live-data-status-today-paused { padding:10px 12px; border:1px solid #4ade80; border-radius:10px; color:#bbf7d0; background:#10291e; }
.issue-filing-receipt { position:fixed; inset:0; z-index:108; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.issue-filing-receipt[hidden] { display:none; }
.release-receipt-sheet { position:fixed; inset:0; width:100%; max-width:none; height:100%; max-height:none; margin:0; padding:0; border:0; background:rgba(5,12,21,.82); color:#e5e7eb; }
.release-receipt-sheet::backdrop { background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.release-receipt-panel { position:absolute; left:0; right:0; bottom:0; box-sizing:border-box; width:min(560px,100%); max-height:100dvh; margin:auto; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #4ade80; border-radius:18px 18px 0 0; background:#0b1526; overflow-wrap:anywhere; }
.release-receipt-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.release-receipt-launcher { width:100%; min-height:44px; border-color:#4ade80; }
.release-receipt-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
.release-receipt-panel button, .release-receipt-panel .button-link { box-sizing:border-box; display:flex; align-items:center; justify-content:center; min-width:0; min-height:44px; width:100%; text-align:center; }
.release-watchlist { display:grid; gap:8px; margin-top:14px; }
.release-watchlist-item { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:10px; padding:10px; border:1px solid #334155; border-radius:10px; }
.release-watchlist-item > div { display:grid; min-width:0; gap:3px; overflow-wrap:anywhere; }
.release-watchlist-item > button { width:auto; min-width:88px; }
@media (max-width:359px) { .release-receipt-actions { grid-template-columns:1fr; } }
.issue-filing-receipt-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #4ade80; border-radius:18px 18px 0 0; background:#0b1526; overflow-wrap:anywhere; }
.issue-filing-receipt-panel h2, .issue-filing-receipt-panel h3 { margin:.25rem 0; }
.issue-filing-receipt-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
.issue-filing-receipt-actions button, .issue-filing-receipt-actions .button-link { box-sizing:border-box; display:flex; align-items:center; justify-content:center; min-width:0; min-height:44px; width:100%; text-align:center; }
@media (max-width:359px) { .issue-filing-receipt-actions { grid-template-columns:1fr; } }
.live-data-status-feeds { display:grid; gap:8px; margin:14px 0; }
.live-data-status-feed { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
.live-data-status-feed strong, .live-data-status-feed span { overflow-wrap:anywhere; }
.live-data-status-actions { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }
.app-menu { margin-left:auto; }
.app-menu > summary { display:none; }
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
#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; }
.notification-undo button { min-height:44px; min-width:64px; flex:none; }
.notification-undo { position:fixed; }
.notification-undo button { min-height:44px; }
.notification-undo, .today-completion-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], .today-completion-undo[hidden] { display:none; }
.notification-undo button, .today-completion-undo button { min-height:44px; min-width:64px; flex:none; }
.draft-capacity-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.draft-capacity-sheet[hidden] { display:none; }
.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
@@ -28,6 +70,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; }
@@ -38,7 +85,14 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
.device-setup-list { display:grid; gap:10px; margin:14px 0; padding:0; list-style:none; }
.device-setup-step { min-width:0; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:12px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#10233a; overflow-wrap:anywhere; }
.device-setup-step p { margin:5px 0 0; }
.device-setup-deadline-controls { display:flex; align-items:end; gap:8px; flex-wrap:wrap; }
.device-setup-deadline-controls label { font-size:12px; color:#bfdbfe; }
.device-setup-deadline-controls select, .device-setup-deadline-controls button, #push-deadline-hour, #push-deadline-days { min-height:44px; }
.device-setup-ready { margin:0; padding:12px; border-radius:10px; background:#0f2237; color:#bfdbfe; font-weight:700; }
.device-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; }
@@ -56,6 +110,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; }
@@ -71,12 +126,22 @@ 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; }
h2 { font-size: 13px; margin: 8px 0; text-transform: uppercase; letter-spacing: .08em; color: #94a3b8; }
a { color: #60a5fa; text-decoration: none; }
.muted { color: #94a3b8; }
.empty-work-start { display:grid; gap:12px; margin:12px 0; padding:16px; border:1px solid #31577f; border-radius:14px; background:#0b1b2f; }
.empty-work-start[hidden] { display:none; }
.empty-work-start p { margin:6px 0 0; }
.empty-work-start-actions { display:grid; grid-template-columns:minmax(0, 1fr) minmax(0, 1fr); gap:8px; }
.empty-work-start-actions button { min-width:0; min-height:44px; white-space:normal; }
@media (max-width:420px) {
.empty-work-start-actions { grid-template-columns:1fr; }
}
.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 10px; }
.kv .label { color: #94a3b8; }
.kv .value { color: #e5e7eb; }
@@ -93,11 +158,55 @@ textarea { resize: vertical; min-height: 120px; }
#cmd-palette { position: fixed; left: 50%; top: 10%; transform: translateX(-50%); width: min(900px, 94vw); background: rgba(11,21,38,.96); border: 1px solid #2a496e; border-radius: 12px; box-shadow: 0 20px 70px rgba(0,0,0,.55); padding: 10px; z-index: 30; display: none; backdrop-filter: blur(12px); }
#cmd-palette.open { display: block; }
.cmd-palette-header { display:none; align-items:center; justify-content:space-between; gap:10px; }
.cmd-palette-header-actions { display:flex; gap:8px; }
.search-today-interruption { display:flex; align-items:center; justify-content:space-between; gap:10px; margin:8px 0; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
.search-today-interruption[hidden] { display:none; }
.search-today-interruption button { min-height:44px; flex:0 0 auto; }
.cmd-search-scope { display:grid; grid-template-columns:auto minmax(120px,1fr) auto minmax(120px,1fr); gap:6px 10px; align-items:center; margin:8px 0 0; padding:8px; border:1px solid #1f3a5f; border-radius:8px; }
.cmd-search-scope legend { padding:0 4px; color:#93a4b8; font-size:12px; }
.cmd-search-scope label { font-size:12px; color:#cbd5e1; }
.cmd-search-scope select { min-width:0; padding:7px; border-radius:8px; border:1px solid #31577f; background:#0b1526; color:#e5e7eb; }
.cmd-search-scope input { min-width:0; padding:7px; border-radius:8px; border:1px solid #31577f; background:#0b1526; color:#e5e7eb; }
.cmd-repository-status { grid-column:2 / -1; min-height:16px; color:#93a4b8; font-size:12px; }
.saved-searches { margin-top:8px; padding:8px; border:1px solid #1f3a5f; border-radius:8px; }
.saved-search-header, .saved-search-create, .saved-search-row, .saved-search-row-actions { display:flex; align-items:center; gap:8px; }
.saved-search-header { justify-content:space-between; }
.saved-search-create { margin-top:6px; }
.saved-search-create input { flex:1; min-width:0; }
.saved-search-list { display:grid; gap:4px; margin-top:6px; }
.saved-search-row { justify-content:space-between; border-top:1px solid #1f3a5f; padding-top:4px; }
.saved-search-open { flex:1; min-width:0; text-align:left; }
.saved-search-action { min-height:44px; }
#cmd-results { margin-top:8px; max-height:min(65vh,520px); overflow-y:auto; }
.cmd-item { padding: 10px; min-height:44px; cursor: pointer; border-radius: 10px; color:#e5e7eb; display:flex; gap:10px; align-items:center; justify-content:space-between; }
.cmd-item:hover, .cmd-item.selected { background: #10233a; outline:1px solid #31577f; }
.cmd-meta { color:#93a4b8; font-size:12px; text-align:right; }
.cmd-status { padding:10px; color:#93a4b8; font-size:13px; }
.cmd-select-result { min-height:44px; width:100%; display:grid; grid-template-columns:28px 1fr auto; gap:10px; align-items:center; text-align:left; }
.cmd-select-result input { width:22px; height:22px; }
.cmd-select-result[disabled] { cursor:not-allowed; opacity:.62; }
.search-batch-actions { position:sticky; bottom:0; z-index:3; display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; padding:10px 0 calc(10px + env(safe-area-inset-bottom)); background:#0b1526; border-top:1px solid #2a496e; }
.search-batch-actions[hidden] { display:none; }
.search-batch-actions span { grid-column:1 / -1; }
.search-batch-actions button, .search-batch-recovery { min-height:44px; }
.search-batch-recovery { width:100%; font-weight:700; }
.search-batch-estimate-review { display:grid; gap:12px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; }
.search-batch-estimate-review[hidden] { display:none; }
.search-batch-estimate-review > div:first-child, #search-batch-estimate-list { display:grid; gap:8px; }
.search-batch-estimate-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(92px,auto); gap:10px; align-items:center; overflow-wrap:anywhere; }
.search-batch-estimate-row label { display:flex; align-items:center; gap:6px; }
.search-batch-estimate-review input { min-height:44px; width:76px; }
.search-batch-estimate-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
.search-batch-estimate-actions button { min-height:44px; }
.search-release-review { display:grid; gap:12px; max-height:calc(100dvh - 48px); overflow:auto; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
.search-release-review[hidden] { display:none; }
.search-release-review > div:first-child, #search-release-list, .search-release-row { display:grid; gap:6px; }
.search-release-review label { display:grid; gap:6px; }
.search-release-review select, .search-release-review input { min-height:44px; width:100%; }
.search-release-row { padding:8px 0; border-bottom:1px solid #2a496e; }
.search-release-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
.search-release-actions button { min-height:44px; }
.cmd-group { padding:8px 10px 3px; color:#60a5fa; font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; }
#whiteboard-modal, #markdown-modal { position: fixed; inset: 0; background: rgba(5,12,21,.55); display: none; align-items: center; justify-content: center; z-index: 40; backdrop-filter: blur(6px); }
#whiteboard-modal.open, #markdown-modal.open { display: flex; }
@@ -132,6 +241,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; }
@@ -165,6 +279,59 @@ textarea { resize: vertical; min-height: 120px; }
.today-interruption-panel h2 { margin:.25rem 0; overflow-wrap:anywhere; }
.today-interruption-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
.today-interruption-actions button { min-height:44px; width:100%; }
.today-break-sheet { position:fixed; inset:0; width:100%; max-width:none; height:100%; max-height:none; margin:0; padding:0; border:0; background:rgba(5,12,21,.82); color:#e5e7eb; }
.today-break-sheet::backdrop { background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-break-panel { position:absolute; left:0; right:0; bottom:0; box-sizing:border-box; width:min(620px,100%); max-height:100dvh; margin:auto; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
.today-break-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.today-break-panel h2, .today-break-panel p { margin-top:0; }
.today-break-panel header button, .today-break-actions button, .today-break-custom button { min-height:44px; }
.today-break-actions { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin:16px 0; }
.today-break-actions button { min-width:0; width:100%; }
.today-break-custom { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:end; }
.today-break-custom label, .today-break-custom p { grid-column:1 / -1; }
.today-break-custom input { box-sizing:border-box; width:100%; min-height:44px; }
.today-break-status { position:fixed; z-index:46; left:12px; right:12px; bottom:calc(224px + env(safe-area-inset-bottom)); box-sizing:border-box; display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:8px; padding:10px 12px; border:1px solid #60a5fa; border-radius:12px; background:#102641; }
.today-break-status:has(> span[hidden]) { display:none; }
.today-break-status button { min-height:44px; min-width:112px; }
@media(max-width:359px) { .today-break-actions { grid-template-columns:1fr; } }
.today-progress-sheet { position:fixed; inset:0; width:100%; max-width:none; height:100%; max-height:none; margin:0; padding:0; border:0; background:rgba(5,12,21,.82); color:#e5e7eb; }
.today-progress-sheet::backdrop { background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-progress-panel { position:absolute; left:0; right:0; bottom:0; box-sizing:border-box; width:min(620px,100%); max-height:100dvh; margin:auto; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
.today-progress-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.today-progress-panel h2, .today-progress-panel p { margin-top:0; }
.today-progress-panel header button, .today-progress-actions button { min-height:44px; }
.today-progress-panel textarea { box-sizing:border-box; width:100%; min-height:120px; resize:vertical; }
.today-progress-activity { display:grid; gap:8px; min-width:0; margin:0 0 14px; padding:12px; border:1px solid #294767; border-radius:12px; background:#0d1b2d; }
.today-progress-activity-heading { display:flex; justify-content:space-between; gap:8px; }
.today-progress-activity-list { display:grid; gap:8px; max-height:32dvh; overflow:auto; overflow-x:hidden; overflow-wrap:anywhere; }
.today-progress-activity-list:empty::before { content:'No recent messages yet.'; color:#9ca3af; font-size:.875rem; }
.today-progress-activity-item { min-width:0; padding:8px; border-radius:8px; background:#101f34; }
.today-progress-activity-item .markdown-content { overflow-wrap:anywhere; }
.today-progress-activity-actions { display:flex; gap:8px; }
.today-progress-activity-actions button { min-height:44px; }
.today-progress-activity-status { margin:0; }
.today-progress-evidence { display:grid; gap:8px; min-width:0; margin-top:12px; }
.today-progress-evidence .issue-attachment-preview { width:100%; box-sizing:border-box; }
.today-progress-evidence .issue-evidence-note textarea { min-height:72px; }
.today-progress-blocker { display:grid; gap:8px; margin-top:14px; padding:12px; border:1px solid #875f2a; border-radius:12px; background:#21180d; }
.today-progress-blocker input, .today-progress-blocker button { box-sizing:border-box; width:100%; min-height:44px; }
.today-blocker-presets { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; }
.today-blocker-presets button { min-width:0; padding-inline:6px; }
.today-progress-blocker p { margin:0; }
.today-progress-blocker .today-progress-blocker-recovery { padding:8px; border-radius:8px; background:#3a270d; color:#fde68a; font-weight:700; }
.today-progress-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:12px; }
.today-progress-actions button { width:100%; }
.today-progress-panel .voice-conversation { margin-top:10px; }
.today-progress-panel .voice-conversation-controls button,
.today-progress-panel .voice-conversation-review-actions button { min-height:44px; }
.today-progress-panel.blocker-mode .today-progress-activity,
.today-progress-panel.blocker-mode .voice-conversation,
.today-progress-panel.blocker-mode .today-progress-evidence,
.today-progress-panel.blocker-mode .today-progress-update-help,
.today-progress-panel.blocker-mode .today-progress-actions { display:none; }
.today-progress-panel.blocker-mode textarea { min-height:112px; }
@media(max-width:359px) { .today-blocker-presets { grid-template-columns:1fr; } }
@media(max-width:359px) { .today-progress-actions { grid-template-columns:1fr; } }
.today-recap-sheet { position:fixed; inset:0; z-index:88; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-recap-sheet[hidden] { display:none; }
.today-recap-panel { box-sizing:border-box; width:min(620px,100%); max-height:100%; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
@@ -184,12 +351,60 @@ 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-summary-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-summary-sheet[hidden] { display:none; }
.today-summary-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-summary-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.today-summary-header h2 { margin:.2rem 0; }
.today-summary-header button { min-height:44px; min-width:44px; }
.today-summary-items { display:grid; gap:8px; }
.today-summary-item, .today-summary-option { display:flex; align-items:center; gap:10px; min-height:44px; padding:10px; border:1px solid #2a496e; border-radius:10px; background:#10233d; overflow-wrap:anywhere; }
.today-summary-item input, .today-summary-option input { width:24px; min-height:24px; flex:0 0 auto; }
.today-summary-item span { display:block; min-width:0; }
.today-summary-note { display:grid; gap:6px; margin:14px 0; font-weight:700; }
.today-summary-note textarea { box-sizing:border-box; width:100%; min-height:88px; resize:vertical; }
.today-summary-preview { min-height:72px; white-space:pre-wrap; overflow-wrap:anywhere; padding:12px; border:1px solid #31577f; border-radius:10px; background:#07101e; color:#e8f1ff; }
.today-summary-destination { margin-top:14px; overflow-wrap:anywhere; padding:12px; border:1px solid #31577f; border-radius:10px; background:#0d1c31; }
.today-summary-destination h3 { margin-top:0; }
.today-summary-destination-controls { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }
.today-summary-destination-controls input, .today-summary-destination-controls button { box-sizing:border-box; min-width:0; min-height:44px; }
.today-summary-target { min-height:1.4em; margin-top:8px; }
.today-summary-actions { position:sticky; bottom:0; display:grid; grid-template-columns:repeat(3,minmax(0,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-summary-actions button { min-height:44px; width:100%; }
@media (max-width:420px) { .today-summary-destination-controls, .today-summary-actions { grid-template-columns:1fr; } }
.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; }
@@ -216,11 +431,15 @@ textarea { resize: vertical; min-height: 120px; }
.queue-finder button { padding-inline:14px; }
#search-older-work { width:100%; }
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
.filed-history-tabs { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin:10px 0; }
.filed-history-tabs[hidden] { display:none; }
.filed-history-tabs button { min-height:44px; min-width:0; }
.filed-history-tabs button[aria-pressed="true"] { border-color:#60a5fa; background:#17365d; }
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; scroll-margin-bottom:calc(76px + env(safe-area-inset-bottom)); }
.draft-card:focus-visible { outline:3px solid #60a5fa; outline-offset:3px; border-color:#93c5fd; }
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
.draft-actions button { min-height:44px; width:100%; }
.draft-actions button, .draft-filing-session-actions button, .agenda-replan-actions button { min-height:44px; width:100%; }
.delivery-center { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; grid-column:1/-1; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
.delivery-center h3, .delivery-center p { margin:0; }
.delivery-center button { min-height:44px; flex:0 0 auto; }
@@ -228,19 +447,53 @@ textarea { resize: vertical; min-height: 120px; }
.draft-section { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; grid-column:1/-1; }
.draft-section > h3 { grid-column:1/-1; margin:8px 0 0; }
.create-issue-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); }
.create-issue-actions button { min-height:44px; max-width:100%; width:100%; }
.create-issue-actions button { min-height:44px; width:100%; }
.create-issue-actions #create-issue-status { grid-column:1/-1; }
.create-issue-estimate { display:grid; gap:6px; padding:10px; border:1px solid #31577f; border-radius:10px; background:#101d31; }
.create-issue-estimate > span:first-of-type { display:flex; align-items:center; gap:8px; }
.create-issue-estimate input { min-height:44px; width:min(9rem,60vw); }
.draft-filing-session { position:sticky; top:0; z-index:2; display:grid; gap:6px; margin-bottom:12px; padding:10px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:12px; background:rgba(11,21,38,.98); }
.draft-filing-session[hidden] { display:none; }
.draft-filing-session-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.draft-filing-session-actions button { min-height:44px; width:100%; }
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
.deadline-snooze { box-sizing:border-box; min-width:0; margin:10px 0; padding:12px; border:1px solid #a16207; border-radius:12px; background:#2a1d08; display:flex; align-items:center; justify-content:space-between; gap:12px; }
.deadline-snooze p { margin:4px 0 0; overflow-wrap:anywhere; }
.deadline-snooze button { min-height:44px; flex:0 0 auto; }
.agenda-replan { margin:10px 0; padding:12px; border:1px solid #7c4a1d; border-radius:12px; background:#24170d; }
.agenda-replan-launch { display:flex; align-items:center; justify-content:space-between; gap:12px; }
.agenda-replan-launch p { margin:4px 0 0; }
.agenda-replan-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:10px; }
.agenda-replan-actions label { grid-column:1/-1; }
.agenda-replan-actions input { box-sizing:border-box; min-height:44px; width:100%; max-width:100%; }
.agenda-export { margin:10px 0; padding:12px; border:1px solid #2f6f9f; border-radius:12px; background:#0d2136; display:flex; align-items:center; justify-content:space-between; gap:12px; }
.agenda-export p { margin:4px 0 0; }
.agenda-export button { min-height:44px; flex:0 0 auto; }
.agenda-export-sheet { box-sizing:border-box; width:min(560px,100%); max-width:none; max-height:none; height:100dvh; margin:0 0 0 auto; padding:0; border:0; color:var(--text); background:#0b1526; }
.agenda-export-sheet::backdrop { background:rgba(5,12,21,.78); backdrop-filter:blur(4px); }
.agenda-export-panel { box-sizing:border-box; min-height:100%; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); overflow:auto; overflow-x:hidden; }
.agenda-export-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.agenda-export-panel h2, .agenda-export-panel header p { margin:0; }
#agenda-export-items { min-width:0; display:grid; gap:8px; margin:0; padding:0; border:0; }
.agenda-export-item { min-width:0; min-height:44px; display:flex; align-items:center; gap:10px; padding:10px; border:1px solid #31577f; border-radius:10px; background:#10233a; }
.agenda-export-item input { width:22px; height:22px; flex:0 0 auto; }
.agenda-export-item span, .agenda-export-item strong, .agenda-export-item small { min-width:0; display:block; overflow-wrap:anywhere; }
#cancel-agenda-export, #share-agenda-export { min-height:44px; }
#share-agenda-export { position:sticky; bottom:0; width:100%; margin-top:auto; }
@media(max-width:430px) { .agenda-export { align-items:stretch; flex-direction:column; } .agenda-export button { width:100%; } .agenda-export-panel { padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); } }
.protect-today { margin:10px 0; padding:12px; border:1px solid #2f6f9f; border-radius:12px; background:#0d2136; display:flex; align-items:center; justify-content:space-between; gap:12px; }
.protect-today p { margin:4px 0; }
.protect-today button { min-height:44px; flex:0 0 auto; }
@media(max-width:360px) { .agenda-replan-launch { align-items:stretch; flex-direction:column; } .agenda-replan-actions { grid-template-columns:1fr; } .agenda-replan-actions label { grid-column:auto; } }
@media(max-width:430px) { .protect-today { align-items:stretch; flex-direction:column; } .protect-today button { width:100%; } }
@media(max-width:430px) { .deadline-snooze { align-items:stretch; flex-direction:column; } .deadline-snooze button { width:100%; } }
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
.my-work-card:hover { border-color:var(--accent); }
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
.update-selection-controls { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
.update-selection-controls button { min-height:44px; }
.selection-scope-actions { display:flex; align-items:center; gap:8px; flex-wrap:wrap; max-width:100%; }
.selection-scope-actions button { min-height:44px; max-width:100%; }
.update-selector { min-height:44px; }
.work-selector { min-height:44px; }
.update-selector, .work-selector { display:flex; align-items:center; gap:10px; padding:6px; border:1px solid #31577f; border-radius:8px; cursor:pointer; }
@@ -348,7 +601,11 @@ textarea { resize: vertical; min-height: 120px; }
.update-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
.update-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.update-sheet-header button { min-height:44px; }
.update-triage-progress { margin:10px 0; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
.update-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
.update-new-activity { scroll-margin-block:18px 96px; border-color:#60a5fa; box-shadow:0 0 0 2px rgba(96,165,250,.25); }
.update-new-activity::before { content:'New activity'; display:inline-block; margin-bottom:6px; padding:2px 7px; border-radius:999px; background:#1d4ed8; color:#fff; font-size:12px; font-weight:700; }
.update-new-activity-jump { position:sticky; bottom:92px; z-index:4; min-height:44px; width:100%; margin:10px 0; border-color:#60a5fa; background:#17345a; }
.update-reply { display:grid; gap:8px; margin-top:16px; }
.update-reply textarea { width:100%; min-height:112px; resize:vertical; }
.update-reply button { min-height:44px; width:100%; }
@@ -365,14 +622,69 @@ textarea { resize: vertical; min-height: 120px; }
.update-ownership-actions button { min-width:0; width:100%; }
.update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
.update-sheet-actions #acknowledge-update-next { min-height:44px; width:100%; }
.update-sheet-actions #mute-update-next { min-height:44px; width:100%; }
.update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.update-more-actions { margin-top:14px; }
.update-more-actions > summary { min-height:44px; display:flex; align-items:center; justify-content:center; cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.update-decision-bar { display:grid; grid-template-columns:repeat(auto-fit,minmax(76px,1fr)); gap:6px; margin-top:14px; padding:8px; background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.update-decision-bar button, .update-decision-bar summary { min-height:44px; min-width:0; padding-inline:6px; }
.update-retry { min-height:44px; width:100%; margin-top:10px; }
.issue-sheet { position:fixed; inset:0; z-index:56; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.issue-sheet.open { display:flex; }
.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; }
.completed-filed-buttons { display:flex; flex-wrap:wrap; gap:8px; }
.completed-filed-buttons button { flex:1 1 180px; }
#issue-sheet:has(.completed-filed-actions:not([hidden])) .issue-sheet-panel { padding-bottom:calc(170px + env(safe-area-inset-bottom)); }
.issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
.checklist-step-editor { display:grid; gap:8px; margin:10px 0 16px; padding:12px; border:1px solid #31577f; border-radius:12px; background:#0b1526; }
.checklist-step-editor[hidden] { display:none; }
.checklist-step-editor input { width:100%; min-width:0; box-sizing:border-box; }
.checklist-step-editor-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.checklist-step-editor button, .checklist-step-editor input { min-height:44px; }
#remove-checklist-step { border-color:#b45309; }
#issue-sheet.read-only .checklist-step-editor { display:none; }
.checklist-add { display:grid; gap:8px; margin:10px 0 16px; }
.checklist-add form { display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:8px; }
.checklist-add form[hidden] { display:none; }
.checklist-add input { min-width:0; width:100%; box-sizing:border-box; }
.checklist-add button, .checklist-add input { min-height:44px; }
#issue-sheet.read-only .checklist-add { display:none; }
@media(max-width:340px) { .checklist-add form { grid-template-columns:1fr 1fr; } .checklist-add input { grid-column:1 / -1; } }
.checklist-completion { position:fixed; right:0; bottom:0; z-index:58; box-sizing:border-box; width:min(560px,100%); display:grid; grid-template-columns:minmax(0,1fr) repeat(2,minmax(0,auto)); align-items:center; gap:8px; padding:10px 12px calc(10px + env(safe-area-inset-bottom)); border-top:1px solid #4ade80; background:rgba(11,21,38,.98); overflow-wrap:anywhere; }
.checklist-completion[hidden] { display:none; }
.checklist-completion button { min-height:44px; min-width:0; }
#issue-sheet:has(.checklist-completion:not([hidden])) .issue-sheet-panel { padding-bottom:calc(118px + env(safe-area-inset-bottom)); }
#issue-sheet:has(.checklist-completion:not([hidden])) .work-session-nav { display:none; }
@media(max-width:420px) { .checklist-completion { grid-template-columns:1fr 1fr; } .checklist-completion span { grid-column:1 / -1; } }
.issue-blockers { max-width:100%; overflow-x:hidden; margin:16px 0; padding:12px; border:1px solid #b45309; border-radius:12px; background:#291b0c; }
.issue-blockers h2 { margin-top:0; }
.issue-blocker-list { display:grid; gap:8px; }
@@ -387,6 +699,7 @@ textarea { resize: vertical; min-height: 120px; }
.issue-blocker-manager [role="option"] { text-align:left; overflow-wrap:anywhere; }
#manage-issue-blockers, #start-unblocked-issue { min-height:44px; width:100%; margin-top:10px; }
.today-readiness-blocker { width:100%; min-height:44px; text-align:left; background:#291b0c; }
.issue-content-editor { max-width:100%; overflow-x:hidden; margin-top:16px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
.issue-planning { max-width:100%; margin-top:16px; border:1px solid #2a496e; border-radius:12px; padding:0 12px 12px; overflow-x:hidden; }
.issue-planning > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; font-weight:700; }
.issue-planning-retry { min-height:44px; width:100%; }
@@ -399,10 +712,13 @@ textarea { resize: vertical; min-height: 120px; }
.issue-edit-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; }
.issue-comment-composer { display:grid; gap:8px; margin-top:16px; }
.issue-comment-composer textarea { min-height:96px; }
.issue-comment-composer button { min-height:44px; width:100%; }
.visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
.issue-attachment-controls { display:flex; max-width:100%; }
.issue-attachment-trigger { min-height:44px; display:inline-flex; align-items:center; justify-content:center; padding:8px 12px; border:1px solid #60a5fa; border-radius:10px; color:#dbeafe; font-weight:700; cursor:pointer; }
.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; }
@@ -428,6 +744,18 @@ textarea { resize: vertical; min-height: 120px; }
.issue-milestone-editor { display:grid; gap:8px; max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
.issue-milestone-editor select { width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
.work-milestone-filter, .issue-milestone-editor select, .issue-milestone-editor button { min-height:44px; }
#issue-sheet.read-only .issue-attachment-controls,
#issue-sheet.read-only .issue-attachment-preview,
#issue-sheet.read-only #issue-planning,
#issue-sheet.read-only #issue-handoff,
#issue-sheet.read-only #release-issue,
#issue-sheet.read-only .detail-defer,
#issue-sheet.read-only #issue-blockers,
#issue-sheet.read-only #retry-issue-comment-actions { display:none; }
#issue-sheet.read-only.filed-reassignable #issue-handoff { display:block; }
.filed-claim-actions { position:sticky; bottom:0; z-index:4; box-sizing:border-box; width:min(560px,100%); display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:14px; padding:10px 4px calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #4ade80; }
.filed-claim-actions[hidden] { display:none; }
.filed-claim-actions button { min-width:0; min-height:44px; }
.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
@@ -449,11 +777,52 @@ 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; }
.find-work-search input, .find-work-search button { min-height:44px; }
.find-work-filters { border:1px solid #2a496e; border-radius:10px; padding:10px; }
.find-work-filters summary { min-height:44px; display:flex; align-items:center; justify-content:space-between; cursor:pointer; }
.find-work-filter-panel, .find-work-filter-options { display:grid; gap:8px; }
.find-work-filter-panel fieldset { min-width:0; border:0; padding:6px 0; margin:0; }
.find-work-filter-options { grid-template-columns:repeat(auto-fit,minmax(130px,1fr)); }
.find-work-filter-option { min-height:44px; display:flex; align-items:center; gap:8px; padding:6px; overflow-wrap:anywhere; }
.find-work-filter-option input { width:20px; height:20px; flex:0 0 auto; }
#clear-find-work-filters { min-height:44px; width:100%; }
.find-work-list { display:grid; gap:10px; }
.find-work-card { display:grid; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
.find-work-card.selected { border-color:#60a5fa; box-shadow:0 0 0 2px rgba(96,165,250,.25); }
.find-work-select { display:flex; align-items:center; gap:10px; min-height:44px; }
.find-work-select input { width:22px; height:22px; }
.fill-find-work-today { min-height:44px; width:100%; font-weight:700; }
.find-work-batch-actions { position:sticky; bottom:0; z-index:2; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:12px 0 calc(12px + env(safe-area-inset-bottom)); background:#0b1526; border-top:1px solid #2a496e; }
.find-work-batch-actions[hidden] { display:none; }
.find-work-batch-actions span { grid-column:1 / -1; }
.find-work-batch-actions button { min-height:44px; }
.find-work-batch-recovery { min-height:44px; width:100%; font-weight:700; }
.find-work-estimate-review { display:grid; gap:12px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; }
.find-work-estimate-review[hidden] { display:none; }
.find-work-estimate-review > div:first-child, #find-work-estimate-list { display:grid; gap:8px; }
.find-work-estimate-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(92px,auto); gap:10px; align-items:center; overflow-wrap:anywhere; }
.find-work-estimate-row label { display:flex; align-items:center; gap:6px; }
.find-work-estimate-review input { min-height:44px; width:76px; }
.find-work-estimate-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
.find-work-estimate-actions button { min-height:44px; }
.find-work-card button { width:100%; font-weight:700; }
.find-work-claim-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.find-work-claim-actions [data-claim-start-index] { grid-column:1 / -1; }
@@ -462,12 +831,59 @@ 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; }
.search-preview-conversation h2 { margin:0; font-size:1rem; }
.search-preview-comment { min-width:0; padding:10px 0; border-bottom:1px solid #1b2d45; overflow-wrap:anywhere; }
.search-preview-conversation button { min-height:44px; width:100%; }
.search-preview-reply { display:grid; gap:8px; padding-top:8px; border-top:1px solid #2a496e; }
.search-preview-reply[hidden] { display:none; }
.search-preview-reply h2 { margin:0; font-size:1rem; }
.search-preview-reply textarea { box-sizing:border-box; width:100%; min-height:110px; resize:vertical; scroll-margin-bottom:calc(190px + env(safe-area-inset-bottom)); }
.search-preview-reply-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.search-preview-reply-actions button { min-height:44px; }
.search-preview-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; }
.search-preview-primary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
@media (max-width:420px) {
.search-preview-primary-actions { grid-template-columns:1fr; }
}
@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; }
@@ -480,19 +896,92 @@ textarea { resize: vertical; min-height: 120px; }
.markdown-content .task-list { padding-left:0; list-style:none; }
.markdown-content .task-list-item { display:flex; gap:8px; align-items:flex-start; }
.markdown-content .task-list-item input { flex:0 0 auto; margin-top:3px; }
.markdown-content .task-list-toggle { min-width:44px; min-height:44px; margin:-9px 0 -9px -9px; cursor:pointer; accent-color:#60a5fa; }
.markdown-content .task-list-manage { min-width:44px; min-height:44px; margin:-9px -9px -9px auto; padding:6px 8px; flex:0 0 auto; }
.markdown-content.checklist-pending .task-list-toggle { opacity:.65; cursor:wait; }
.markdown-content.checklist-pending .task-list-manage { opacity:.65; cursor:wait; }
.markdown-content a { min-height:44px; display:inline-flex; align-items:center; max-width:100%; overflow-wrap:anywhere; }
@media(max-width:320px) { .find-work-panel { padding:12px; overflow-x:hidden; } .find-work-card { min-width:0; } .my-work-actions { width:100%; } .my-work-actions button { flex:1 1 100%; } }
.create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.create-issue-sheet.open { display:flex; }
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; 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; }
.today-capture-interruption { display:grid; gap:2px; padding:10px 12px; border:1px solid #3f6f9f; border-radius:10px; background:#10243b; }
.today-capture-interruption[hidden] { display:none; }
.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; }
.issue-evidence-tray[hidden] { display:none; }
.issue-evidence-thumbnail { position:relative; flex:0 0 64px; min-width:44px; min-height:64px; padding:3px; border:2px solid transparent; border-radius:10px; background:#10213a; }
.issue-evidence-thumbnail[aria-pressed="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px rgba(96,165,250,.25); }
.issue-evidence-thumbnail img { display:block; width:56px; height:56px; border-radius:6px; object-fit:cover; }
.issue-evidence-thumbnail span { position:absolute; right:3px; bottom:3px; min-width:20px; padding:1px 4px; border-radius:999px; background:#07101d; color:#fff; font-size:12px; text-align:center; }
.issue-evidence-note { display:grid; gap:6px; min-width:0; }
.issue-evidence-note textarea { box-sizing:border-box; width:100%; min-height:72px; padding:10px; resize:vertical; border:1px solid #2a496e; border-radius:8px; background:#07101d; color:#e5e7eb; font:inherit; }
.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; }
.issue-evidence-editor-panel header { display:flex; align-items:start; justify-content:space-between; gap:12px; }
.issue-evidence-editor-panel h3 { margin:0 0 4px; }
.issue-evidence-editor canvas { display:block; max-width:100%; width:100%; max-height:60dvh; object-fit:contain; touch-action:none; border:1px solid #365b86; border-radius:8px; background:#020617; }
.issue-evidence-editor-tools { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; }
.issue-evidence-editor button { min-width:44px; min-height:44px; }
.issue-evidence-editor button[aria-pressed="true"] { border-color:#60a5fa; background:#173a64; color:#fff; }
.issue-evidence-editor-apply { width:100%; }
@media(max-width:320px) { .issue-evidence-editor-panel { padding:12px; } .issue-evidence-editor-tools { grid-template-columns:repeat(2,minmax(0,1fr)); } }
.issue-filing-review { position:fixed; inset:0; z-index:72; display:grid; place-items:end center; overflow-x:hidden; background:rgba(2,6,15,.92); }
.issue-filing-review[hidden] { display:none; }
.issue-filing-review-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; display:grid; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
.issue-filing-review-panel h2, .issue-filing-review-panel h3, .issue-filing-review-panel p { margin:.2rem 0; }
.issue-filing-review-summary { display:grid; gap:10px; margin:0; min-width:0; }
.issue-filing-review-summary > div { display:grid; gap:4px; padding:10px; border:1px solid #2a496e; border-radius:10px; min-width:0; }
.issue-filing-review-summary dt { color:#94a3b8; font-size:12px; font-weight:700; text-transform:uppercase; }
.issue-filing-review-summary dd { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
.issue-filing-review ol { display:grid; grid-template-columns:repeat(auto-fit,minmax(72px,1fr)); gap:8px; margin:0; padding:0; list-style:none; overflow-wrap:anywhere; }
.issue-filing-evidence-selector { min-height:44px; width:100%; padding:4px; border:2px solid #31577f; border-radius:10px; background:#08111f; }
.issue-filing-evidence-selector[aria-pressed="true"] { border-color:#93c5fd; box-shadow:0 0 0 2px rgba(96,165,250,.35); }
.issue-filing-evidence-selector img { display:block; width:100%; height:64px; object-fit:cover; border-radius:6px; }
.issue-filing-evidence-preview { display:grid; gap:8px; margin:10px 0 0; padding:10px; border:1px solid #31577f; border-radius:12px; background:#020617; }
.issue-filing-evidence-preview[hidden] { display:none; }
.issue-filing-evidence-preview > img { display:block; width:100%; max-height:42dvh; object-fit:contain; border-radius:8px; background:#000; }
.issue-filing-evidence-preview figcaption { display:grid; gap:3px; min-width:0; overflow-wrap:anywhere; }
.issue-filing-review-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0 calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
.issue-filing-review-actions button { min-height:44px; width:100%; }
@media(max-width:320px) { .issue-filing-review-panel { padding:12px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); } }
.create-issue-repository-more { min-height:44px; width:100%; }
.create-issue-repository-picker { min-width:0; display:grid; gap:8px; }
.create-issue-repository-picker input { min-width:0; min-height:44px; width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
@@ -500,8 +989,18 @@ textarea { resize: vertical; min-height: 120px; }
#create-issue-repository-results[hidden] { display:none; }
.create-issue-repository-result { min-height:44px; min-width:0; padding:10px 12px; overflow-wrap:anywhere; text-align:left; border:0; border-bottom:1px solid #1f3a5f; border-radius:0; background:#10213a; color:#e5e7eb; }
.create-issue-repository-result:last-child { border-bottom:0; }
.create-issue-blockers { display:grid; gap:8px; min-width:0; }
#create-issue-blocker-search { min-width:0; min-height:44px; width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
#create-issue-blocker-results { display:grid; max-height:min(36dvh,280px); overflow:auto; border:1px solid #2a496e; border-radius:10px; }
#create-issue-blocker-results[hidden] { display:none; }
.create-issue-blocker-result { min-height:44px; min-width:0; padding:10px 12px; overflow-wrap:anywhere; text-align:left; border:0; border-bottom:1px solid #1f3a5f; border-radius:0; background:#10213a; color:#e5e7eb; }
#create-issue-blocker-selected { display:grid; gap:8px; margin:0; padding:0; list-style:none; min-width:0; }
.create-issue-blocker-selected { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; min-width:0; overflow-wrap:anywhere; }
.create-issue-blocker-selected button { min-height:44px; }
.create-issue-form label { display:grid; gap:6px; }
.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
.create-issue-owner { min-width:0; }
.create-issue-owner select { min-height:44px; width:100%; }
.create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; }
.create-issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; }
.create-issue-label-option { min-height:44px; display:flex !important; grid-template-columns:auto 1fr !important; align-items:center; gap:8px; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
@@ -524,6 +1023,7 @@ textarea { resize: vertical; min-height: 120px; }
.pull-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
.pull-file, .pull-comment-card { margin:8px 0; padding:10px; border:1px solid #203a5c; border-radius:10px; }
.conversation-more { min-height:44px; width:100%; margin:8px 0; }
.conversation-actions-retry { min-height:44px; max-width:100%; margin:8px 0; }
.pull-file-toggle, .pull-review-file { min-height:44px; width:100%; }
.pull-file-toggle { display:flex; justify-content:space-between; align-items:center; gap:8px; text-align:left; }
.pull-review-file { margin-top:8px; }
@@ -545,17 +1045,33 @@ textarea { resize: vertical; min-height: 120px; }
.pull-retry { min-height:44px; width:100%; margin-top:10px; }
.mobile-task-dock { display:none; }
.mobile-task-dock[hidden] { display:none; }
.mobile-today-hud { display:none; }
.mobile-today-hud[hidden], .mobile-today-hud[data-overlay-hidden="true"] { display:none; }
.mobile-today-actions { box-sizing:border-box; width:min(520px,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-today-actions::backdrop { background:rgba(3,9,18,.76); }
.mobile-today-actions-panel { box-sizing:border-box; display:grid; gap:12px; padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
.mobile-today-actions-panel header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
.mobile-today-actions-panel h2 { margin:0; }
.mobile-today-navigation, .mobile-today-secondary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.mobile-today-actions-panel button { min-width:0; min-height:44px; }
.mobile-today-secondary-actions [data-mobile-today-end] { grid-column:1 / -1; border-color:#b45309; }
.today-session-handoff { position:fixed; left:12px; right:12px; bottom:calc(68px + env(safe-area-inset-bottom)); z-index:45; display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:10px; padding:12px; border:1px solid #60a5fa; border-radius:12px; background:rgba(16,38,65,.98); box-shadow:0 8px 28px rgba(0,0,0,.35); }
.today-session-handoff[hidden] { display:none; }
.today-session-handoff button { min-height:44px; }
@media(max-width:420px) { .today-session-handoff { grid-template-columns:1fr; } .today-session-handoff button { width:100%; } }
.mobile-task-action { min-width:0; min-height:44px; padding:6px 2px; border:0; border-radius:8px; background:transparent; display:grid; place-items:center; gap:2px; font-size:12px; }
.mobile-task-action[hidden] { display:none; }
.mobile-task-action[aria-current="page"] { color:#bfdbfe; background:#17365a; outline:1px solid #31577f; }
.mobile-task-count { min-width:18px; min-height:18px; padding:1px 5px; border-radius:999px; background:#31577f; font-size:11px; line-height:16px; }
.mobile-task-count { min-width:18px; min-height:18px; max-width:100%; padding:1px 5px; border-radius:999px; background:#31577f; font-size:10px; line-height:16px; white-space:nowrap; }
.mobile-task-deadline { padding:1px 5px; border-radius:999px; color:#fef3c7; background:#92400e; font-size:10px; line-height:14px; }
.attention-interruption:not([hidden]) { max-width:100%; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:0 0 12px; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#102641; }
.attention-interruption button { min-height:44px; flex:0 0 auto; }
.today-detour-interruption:not([hidden]) { max-width:100%; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:8px 0 12px; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#102641; }
.today-detour-interruption button { min-height:44px; flex:0 0 auto; }
@media (max-width: 600px) {
body { padding-bottom:calc(66px + env(safe-area-inset-bottom)); }
body.mobile-today-active { padding-bottom:calc(166px + env(safe-area-inset-bottom)); }
body.mobile-today-active { padding-bottom:calc(var(--mobile-today-clearance, 166px) + env(safe-area-inset-bottom)); }
header { min-height:56px; max-height:64px; padding:6px 10px; align-items:center; gap:8px; background:rgba(11,21,38,.98); }
.app-brand .muted, #clock { display:none; }
.app-live-status { margin-left:auto; }
@@ -565,6 +1081,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%; }
@@ -585,6 +1107,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%; }
@@ -593,6 +1117,12 @@ textarea { resize: vertical; min-height: 120px; }
.work-filter { flex:1 1 calc(50% - 8px); }
.review-sheet-panel { width:100%; border-left:0; padding:14px; }
.update-sheet-panel { width:100%; border-left:0; padding:14px; overflow-x:hidden; }
.update-sheet-panel { padding-bottom:calc(82px + env(safe-area-inset-bottom)); }
.update-gesture-status { display:block; padding:6px 10px; border-radius:999px; text-align:center; background:#102641; }
.update-sheet-panel[data-triage-gesture="keep"] { box-shadow:inset 8px 0 #60a5fa; }
.update-sheet-panel[data-triage-gesture="read"] { box-shadow:inset -8px 0 #34d399; }
.update-decision-bar { position:fixed; inset:auto 0 0; z-index:57; margin:0; padding-bottom:calc(8px + env(safe-area-inset-bottom)); }
.update-more-actions > summary { display:none; }
.issue-sheet-panel { width:100%; border-left:0; padding:14px; }
.issue-blocker-row { grid-template-columns:1fr; }
.issue-blocker-remove { width:100%; }
@@ -600,10 +1130,30 @@ textarea { resize: vertical; min-height: 120px; }
#cmd-palette { left:0; top:var(--search-viewport-top,0px); transform:none; width:100%; height:var(--search-viewport-height,100dvh); border:0; border-radius:0; padding:12px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); z-index:46; }
#cmd-palette.open { display:flex; flex-direction:column; }
.cmd-palette-header { display:flex; flex:0 0 auto; min-height:44px; }
#close-command-palette { min-height:44px; }
#close-command-palette, #cmd-load-more { min-height:44px; }
#cmd-input { flex:0 0 auto; min-height:44px; }
.cmd-search-scope { grid-template-columns:auto minmax(0,1fr); }
#cmd-search-kind, #cmd-search-state { min-height:44px; width:100%; }
#cmd-search-repository { min-height:44px; width:100%; }
.saved-search-create { align-items:stretch; }
.saved-search-create input, .saved-search-open, .saved-search-row-actions button { min-height:44px; }
.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,
@@ -618,23 +1168,100 @@ textarea { resize: vertical; min-height: 120px; }
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
.mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
.mobile-first-task { 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-first-task::backdrop { background:rgba(3,9,18,.78); }
.mobile-first-task-panel { display:grid; gap:14px; padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
.mobile-first-task-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.mobile-first-task-panel h2, .mobile-first-task-panel p { margin:0; }
.mobile-first-task-actions { display:grid; gap:10px; }
.mobile-first-task-actions button { min-height:48px; width:100%; }
.mobile-first-task-actions button:disabled { opacity:.55; }
.mobile-queue-sheet::backdrop { background:rgba(3,9,18,.7); }
.mobile-queue-panel { padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
.mobile-queue-panel header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
.mobile-queue-panel h2 { margin:0; }
.mobile-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; }
.mobile-start-day-action { width:100%; min-height:48px; text-align:center; }
.mobile-start-day-finish { min-height:44px; width:100%; background:transparent; }
.mobile-queue-list { display:grid; gap:8px; margin-top:12px; }
.mobile-queue-list button { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:56px; width:100%; padding:10px 14px; text-align:left; }
.mobile-queue-list button > span:first-child { display:grid; gap:2px; }
.mobile-queue-list small { color:var(--muted); }
.mobile-queue-list [data-mobile-queue-count] { min-width:28px; padding:3px 8px; border-radius:999px; text-align:center; background:#1d426d; }
.mobile-today-hud { position:fixed; left:8px; right:8px; bottom:calc(56px + env(safe-area-inset-bottom)); z-index:44; display:grid; grid-template-columns:minmax(0,1fr) minmax(112px,auto); grid-template-areas:"summary complete" "progress toggle"; gap:4px 8px; max-width:100%; padding:8px; border:1px solid #31577f; border-radius:12px 12px 0 0; background:rgba(16,38,65,.98); box-shadow:0 -8px 24px rgba(0,0,0,.28); }
.mobile-today-summary { grid-area:summary; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; font-weight:700; }
.mobile-today-hud [data-work-session-progress] { grid-area:progress; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.mobile-today-hud [data-mobile-today-complete] { grid-area:complete; }
.mobile-today-hud [data-mobile-today-toggle] { grid-area:toggle; }
.mobile-today-hud button { min-height:44px; max-width:100%; }
.mobile-today-hud [data-work-session-adjust-plan] { grid-column:1 / -1; }
.mobile-queue-list [data-mobile-queue="agenda"][data-deadlines="true"] { border-color:#f59e0b; background:#30240f; box-shadow:inset 3px 0 #f59e0b; }
.mobile-queue-list [data-recommended="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px #60a5fa; }
.mobile-update-outcome { margin-top:12px; padding:12px; border:1px solid #31577f; border-radius:12px; background:#0b1b30; }
.mobile-update-outcome p { margin:0 0 10px; }
.mobile-update-outcome button { width:100%; min-height:44px; }
.mobile-today-hud { position:fixed; left:8px; right:8px; bottom:calc(56px + env(safe-area-inset-bottom)); z-index:44; display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:4px; max-width:100%; padding:8px; border:1px solid #31577f; border-radius:12px 12px 0 0; background:rgba(16,38,65,.98); box-shadow:0 -8px 24px rgba(0,0,0,.28); }
.mobile-today-summary { grid-column:1 / 4; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; font-weight:700; }
.mobile-today-hud [data-work-session-progress] { grid-column:4 / 7; align-self:center; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.mobile-today-hud [data-mobile-today-complete] { grid-column:1 / 3; }
.mobile-today-hud [data-mobile-today-toggle] { grid-column:3 / 5; }
.mobile-today-hud [data-mobile-today-more] { grid-column:5 / 7; }
.mobile-today-hud button { min-width:0; min-height:44px; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding-inline:6px; }
.today-completion-undo { bottom:calc(var(--mobile-today-clearance, 166px) + 8px + env(safe-area-inset-bottom)); }
}
@media (min-width:701px) { .update-gesture-status { display:none; } }
@media (prefers-reduced-motion: reduce) {
.update-sheet-panel[data-triage-gesture] { transition:none; }
}
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
/* 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;
}
}
+2599 -388
View File
File diff suppressed because it is too large. Load diff
+142
View File
@@ -0,0 +1,142 @@
(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;
let persistence = { state:'unavailable', detail:'Storage protection is unavailable in this browser.', label:'Unavailable' };
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 inspectPersistence() {
if (typeof options.storageManager?.persisted !== 'function' || typeof options.storageManager?.persist !== 'function') {
persistence = {state:'unavailable', detail:'Storage protection is unavailable in this browser.', label:'Unavailable'};
return persistence;
}
try {
const protectedStorage = await options.storageManager.persisted();
persistence = protectedStorage
? {state:'complete', detail:'Offline work is protected from automatic browser storage cleanup.', label:'Protected'}
: {state:'incomplete', detail:'Offline work uses best-effort browser storage and may be removed under storage pressure.', label:'Best effort'};
} catch (_error) {
persistence = {state:'incomplete', detail:'Storage protection could not be checked. Retry to protect offline work.', label:'Check failed'};
}
return persistence;
}
function persistenceReadiness() {
return {state:persistence.state, detail:persistence.detail};
}
async function requestPersistence() {
if (typeof options.storageManager?.persist !== 'function') return persistenceReadiness();
let granted = false;
try { granted = await options.storageManager.persist(); }
catch (_error) {
persistence = {state:'incomplete', detail:'Storage protection request failed. Offline work still uses best-effort storage; retry when ready.', label:'Request failed'};
options.detail.textContent = options.detail.textContent.replace(/Storage protection: [^·]+/, 'Storage protection: Request failed ');
return persistenceReadiness();
}
await refresh();
if (!granted && persistence.state !== 'complete') {
persistence = {state:'incomplete', detail:'The browser did not grant storage protection. Offline work still works but may be removed under storage pressure.', label:'Denied'};
options.detail.textContent = options.detail.textContent.replace(/Storage protection: [^·]+/, 'Storage protection: Denied ');
}
return persistenceReadiness();
}
async function refresh() {
let estimate = null;
try { estimate = await options.storageManager?.estimate?.(); }
catch (_error) { /* Storage estimates are optional. */ }
await inspectPersistence();
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 = `Storage protection: ${persistence.label} · ${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 = `Storage protection: ${persistence.label} · 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, persistenceReadiness, requestPersistence };
});
+9 -6
View File
@@ -1,4 +1,4 @@
function showDraftCapacityDialog(captures) {
function showDraftCapacityDialog(captures, qs) {
const state = captures.capacity();
if (!state.full) return false;
const oldest = state.oldest;
@@ -11,7 +11,10 @@ function showDraftCapacityDialog(captures) {
return true;
}
function bindDraftCapacityDialog() {
function bindDraftCapacityDialog({
qs, saveIssueCaptureDraft, closeCreateIssueSheet, mobileTaskDock, refreshMyWorkView,
unfiledCaptures, currentIssueCaptureDraft, createIssueAttachmentController, issueCapture,
}) {
qs('#keep-editing-draft').addEventListener('click', () => {
qs('#draft-capacity-sheet').hidden = true;
qs('#save-unfiled-issue').focus();
@@ -40,10 +43,10 @@ function bindDraftCapacityDialog() {
const button = qs('#replace-oldest-draft');
button.disabled = true;
try {
const draft = {
title:qs('#create-issue-title').value.trim(), body:qs('#create-issue-body').value.trim(),
attachment:await createIssueAttachmentController.serialize(),
};
const draft = currentIssueCaptureDraft();
const evidence = await createIssueAttachmentController.serialize();
Object.assign(draft,
Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence});
const saved = await unfiledCaptures.replaceOldest(draft, oldest.id);
qs('#draft-capacity-sheet').hidden = true;
issueCapture.clearDraft();
+30 -4
View File
@@ -1,6 +1,7 @@
function createDraftFilingSession({list}) {
let orderedIds = [];
let currentId = '';
let reviewTrigger = null;
function visibleIds() {
const available = new Set(list().map(item => item.id));
@@ -49,17 +50,42 @@ function createDraftFilingSession({list}) {
const session = {start, current:state, next:()=>move(1), previous:()=>move(-1), removeCurrentAndNext};
session.attach = (qs, dependencies) => {
const openCapture = async captureId => {
const openCapture = async (captureId, trigger = null) => {
const resumed = await dependencies.captures.resume(captureId, dependencies.getLogin());
dependencies.issueCapture.saveDraft(resumed);
const issueCapture = dependencies.capture?.() || dependencies.issueCapture;
issueCapture.saveDraft(resumed);
dependencies.setResumedId(captureId);
await dependencies.openSheet();
dependencies.attachment[resumed.attachment ? 'restore' : 'clear'](resumed.attachment);
await dependencies.openSheet(false);
const evidence = resumed.attachments || resumed.attachment;
dependencies.attachment[evidence ? 'restore' : 'clear'](evidence);
dependencies.setFilingMode(true);
session.render();
if (trigger) {
await dependencies.review(resumed);
reviewTrigger = trigger;
const submitter = resumed.completionIntent === 'create-and-start' ?
qs('#create-and-start-issue') : qs('#submit-new-issue');
const disabled = submitter.disabled;
submitter.disabled = false;
qs('#create-issue-form').requestSubmit(submitter);
submitter.disabled = disabled;
}
};
return Object.assign(session, {
nextCapture() { const state = session.current(); return state ? openCapture(state.id) : null; },
reviewCapture(captureId, trigger) {
session.start(captureId);
return openCapture(captureId, trigger);
},
open(captureId, trigger = null) {
session.start(captureId);
return openCapture(captureId, trigger);
},
takeTrigger() {
const trigger = reviewTrigger;
reviewTrigger = null;
return trigger;
},
render() {
const state = session.current();
qs('#draft-filing-session').hidden = !state;
+23 -4
View File
@@ -155,6 +155,8 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
.filter(Boolean).join('\n\n');
const ownerLogin = String(item.ownerLogin || '').trim();
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
const checklistConflict = !quarantined && item.kind === 'issue-content' &&
item.status === 'attention' && typeof item.baseBody === 'string';
return {
id: 'stackchain.authored-outbox.v1:' + item.id,
outbox_id: item.id,
@@ -162,7 +164,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
kind: 'authored-outbox',
status: item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' :
(item.status === 'authorization' || isClosure ? 'authorization' : 'queued')),
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
label: checklistConflict ? 'Checklist conflict' : item.deliveryState === 'uncertain' ? 'Verify delivery' :
(item.status === 'attention' ? (isClosure ? 'Issue closure needs attention' : 'Needs attention') :
(isReview ? (item.status === 'authorization' ? 'Review awaiting authorization' : 'Queued review') :
(isClosure ? 'Awaiting authorization' : 'Queued message'))),
@@ -172,6 +174,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
title: target,
preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')),
copy_text: copyText,
checklist_conflict: checklistConflict,
...(isReview ? { head_sha: item.expectedHeadSha } : {}),
quarantined,
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
@@ -237,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' :
@@ -248,10 +258,19 @@ 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 };
}
return { list, discard, partition };
function deliveryLabel(item) {
if (item.quarantined) return 'Identity protected';
if (item.status === 'completion') return 'Created · ready to start';
if (item.status === 'attention') return 'Needs attention';
if (item.status === 'sending') return 'Sending';
if (item.status === 'authorization') return 'Awaiting authorization';
return 'Queued for sync';
}
return { list, discard, partition, deliveryLabel };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createDraftInbox;
+9
View File
@@ -0,0 +1,9 @@
function filedClaimEligible(item, detail) {
return Boolean(
item?.is_filed && !item?.is_assigned && !item?.is_completed &&
item?.state === 'open' && detail?.state === 'open' &&
Array.isArray(detail?.assignees) && detail.assignees.length === 0
);
}
if (typeof module !== 'undefined' && module.exports) module.exports = filedClaimEligible;
+1093 -83
View File
File diff suppressed because it is too large. Load diff
+277 -118
View File
@@ -1,55 +1,28 @@
(function(root, factory) {
const api = factory();
const review = typeof module === 'object' && module.exports ? require('./issue-evidence-review.js') : root.issueEvidenceReview;
const editor = typeof module === 'object' && module.exports ? require('./issue-evidence-editor.js') : root.issueEvidenceEditor;
const api = factory(review, editor);
if (typeof module === 'object' && module.exports) module.exports = api;
else root.issueAttachment = api;
})(typeof self !== 'undefined' ? self : this, function() {
})(typeof self !== 'undefined' ? self : this, function(issueEvidenceReview, issueEvidenceEditor) {
'use strict';
const MAX_BYTES = 2 * 1024 * 1024;
const MAX_PIXELS = issueEvidenceReview.MAX_PIXELS;
const MAX_FILES = 5;
const MAX_FILES_MESSAGE = 'Up to 5 screenshots. Remove one before adding another.';
const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
function namedBlob(blob, name) {
if (typeof File === 'function') {
return new File([blob], name, { type: blob.type });
}
Object.defineProperty(blob, 'name', { value: name, configurable: true });
return blob;
function normalizeNote(value) {
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 240);
}
async function optimizeImage(file, environment = {}) {
if (file.size <= MAX_BYTES) return file;
const decode = environment.createImageBitmap || globalThis.createImageBitmap;
const makeCanvas = environment.createCanvas || (() => document.createElement('canvas'));
if (typeof decode !== 'function') {
throw new Error('This browser cannot optimize the screenshot. Try cropping it and choose it again.');
}
let bitmap;
try {
bitmap = await decode(file);
const canvas = makeCanvas();
const context = canvas && canvas.getContext && canvas.getContext('2d');
if (!context || !bitmap.width || !bitmap.height) throw new Error('decode');
let scale = Math.min(1, Math.sqrt(MAX_BYTES / file.size) * 0.92);
for (let attempt = 0; attempt < 10; attempt += 1) {
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
const blob = await new Promise(resolve => {
canvas.toBlob(resolve, file.type, file.type === 'image/png' ? undefined : 0.86);
});
if (!blob) throw new Error('encode');
if (blob.size > 0 && blob.size <= MAX_BYTES) return namedBlob(blob, file.name);
scale *= 0.8;
}
} catch (_error) {
throw new Error('The screenshot could not be optimized. Try cropping it and choose it again.');
} finally {
if (bitmap && typeof bitmap.close === 'function') bitmap.close();
}
throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.');
function escapeMarkdown(value) {
return value.replace(/([\\`*_[\]{}()<>#+\-.!|])/g, '\\$1');
}
const optimizeImage = issueEvidenceReview.optimizeImage;
function multipart(attachment) {
let blob = attachment?.blob;
if (!blob && attachment?.data) {
@@ -65,32 +38,44 @@
function create(options) {
const upload = options.upload;
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();
}
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
});
let selected = null;
let confirmed = null;
let serialized = null;
let operationId = null;
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) ||
file.size <= 0 || file.size > MAX_BYTES) {
throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.');
}
selected = file;
confirmed = null;
serialized = null;
operationId = createOperationId();
if (selected.length >= maxFiles) {
if (maxFiles === 1) selected = [];
else throw new Error(MAX_FILES_MESSAGE);
}
selected.push({file, note:'', confirmed:null, serialized:null, operationId:createOperationId()});
changed();
return state();
}
function select(file) {
if (selected.length >= maxFiles && maxFiles > 1) {
throw new Error(MAX_FILES_MESSAGE);
}
const generation = ++selectionGeneration;
if (!file || !IMAGE_TYPES.has(file.type)) {
throw new Error('Choose a PNG, JPEG, or WebP screenshot.');
@@ -98,7 +83,7 @@
if (!Number.isFinite(file.size) || file.size <= 0) {
throw new Error('Choose a screenshot that is 2 MB or smaller.');
}
if (file.size > MAX_BYTES) {
if (file.size > MAX_BYTES || inspectPixels) {
return Promise.resolve(optimizeSelectedImage(file)).then(optimized => {
if (generation !== selectionGeneration) return state();
return commitSelection(optimized);
@@ -109,92 +94,193 @@
function clear() {
selectionGeneration += 1;
selected = null;
confirmed = null;
serialized = null;
operationId = null;
selected = [];
}
function remove(index) {
const position = Number(index);
if (!Number.isInteger(position) || position < 0 || position >= selected.length) return state();
selectionGeneration += 1;
selected.splice(position, 1);
changed();
return state();
}
function move(fromIndex, toIndex) {
const from = Number(fromIndex);
const to = Number(toIndex);
if (!Number.isInteger(from) || !Number.isInteger(to) || from < 0 ||
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();
}
function replace(index, file) {
const position = Number(index);
if (!Number.isInteger(position) || position < 0 || position >= selected.length) return state();
if (!file || !IMAGE_TYPES.has(file.type) || !Number.isFinite(file.size) ||
file.size <= 0 || file.size > MAX_BYTES) {
throw new Error('The edited screenshot must be a PNG, JPEG, or WebP image no larger than 2 MB.');
}
selectionGeneration += 1;
selected[position] = {
...selected[position], file, confirmed:null, serialized:null, operationId:createOperationId(),
};
changed();
return state();
}
function setNote(index, value) {
const position = Number(index);
if (!Number.isInteger(position) || position < 0 || position >= selected.length) return state();
selected[position].note = normalizeNote(value);
selected[position].serialized = null;
changed();
return state();
}
function note(index) {
const position = Number(index);
return Number.isInteger(position) && selected[position] ? selected[position].note : '';
}
function restore(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();
restoreOne(value);
return state();
} finally { restoring = false; }
}
function restoreOne(value) {
const contentType = String(value?.contentType || '');
const filename = String(value?.filename || '');
const blob = value?.blob;
const data = String(value?.data || '');
if (!blob && !data) {
clear();
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
}
const padding = (data.match(/=*$/) || [''])[0].length;
const size = blob ? Number(blob.size) : Math.max(1, Math.floor(data.length * 3 / 4) - padding);
select({ name: filename, type: contentType, size, ...(blob ? { blob } : {}) });
serialized = blob ? { filename, contentType, blob } : { filename, contentType, data };
return state();
commitSelection({ name: filename, type: contentType, size, ...(blob ? { blob } : {}) });
const item = selected[selected.length - 1];
item.note = normalizeNote(value?.note);
item.serialized = {
...(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() {
return selected ? {
name: selected.name,
size: selected.size,
uploaded: Boolean(confirmed),
} : null;
const values = selected.map(item => ({
name: item.file.name,
size: item.file.size,
uploaded: Boolean(item.confirmed),
}));
return values.length > 1 ? values : (values[0] || null);
}
function serializeItem(item) {
if (!item.serialized) item.serialized = {
filename:item.file.name, contentType:item.file.type, blob:item.file.blob || item.file,
...(item.note ? {note:item.note} : {}),
};
return {
...item.serialized,
operationId:item.operationId,
...(item.confirmed?.markdown ? { confirmed:{ markdown:item.confirmed.markdown } } : {}),
};
}
async function serialize() {
if (!selected) return null;
if (!serialized) {
serialized = {
filename: selected.name,
contentType: selected.type,
blob: selected.blob || selected,
};
}
return { ...serialized };
if (!selected.length) return null;
const values = selected.map(serializeItem);
return values.length > 1 ? values : values[0];
}
async function prepareComment(item, body) {
async function prepareComment(target, body) {
const text = String(body || '').trim();
if (!selected) return text;
if (!confirmed) {
const attachment = await serialize();
confirmed = await upload({
repository: item.repository,
number: item.number,
filename: attachment.filename,
content_type: attachment.contentType,
...(attachment.blob ? { blob: attachment.blob } : { data: attachment.data }),
operation_id: operationId,
});
if (!confirmed || typeof confirmed.markdown !== 'string' || !confirmed.markdown) {
confirmed = null;
throw new Error('The server did not confirm the screenshot upload.');
if (!selected.length) return text;
const markdown = [];
for (const evidence of selected) {
if (!evidence.confirmed) {
const attachment = serializeItem(evidence);
evidence.confirmed = await upload({
repository: target.repository,
number: target.number,
filename: attachment.filename,
content_type: attachment.contentType,
...(attachment.blob ? { blob: attachment.blob } : { data: attachment.data }),
operation_id: evidence.operationId,
});
if (!evidence.confirmed || typeof evidence.confirmed.markdown !== 'string' || !evidence.confirmed.markdown) {
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' +
evidence.confirmed.markdown);
} else markdown.push(evidence.confirmed.markdown);
}
return text ? text + '\n\n' + confirmed.markdown : confirmed.markdown;
const evidence = markdown.join('\n\n');
return text ? text + '\n\n' + evidence : evidence;
}
return { select, restore, clear, state, serialize, prepareComment };
return { select, restore, remove, move, replace, setNote, note, clear, state, serialize, prepareComment };
}
function mount(options) {
const controller = create(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),
});
const clearSelection = controller.clear;
const restoreSelection = controller.restore;
const reviewEnabled = Boolean(options.tray && options.earlier && options.later);
let previewUrl = '';
let selectionSequence = 0;
const review = reviewEnabled ? issueEvidenceReview.create({
...options, edit:options.editor?.edit, controller,
}) : null;
function setBusy(busy) {
options.input.disabled = Boolean(busy);
options.remove.disabled = Boolean(busy);
function setBusy(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);
}
function clearPreview() {
selectionSequence += 1;
if (previewUrl) options.revokeObjectURL(previewUrl);
previewUrl = '';
review?.clear();
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) {
@@ -203,57 +289,130 @@
options.image.src = previewUrl;
options.meta.textContent = file.name + ' · ' + Math.ceil(file.size / 1024) + ' KB';
options.preview.hidden = false;
if (options.editor?.edit) options.editor.edit.disabled = false;
options.status.textContent = optimized ? 'Screenshot optimized and ready to upload.' :
(options.readyMessage || 'Screenshot ready to upload with this comment.');
}
options.input.addEventListener('change', event => {
const file = event.target.files && event.target.files[0];
function selectFromInput(event) {
const sourceInput = event.target;
const files = Array.from(sourceInput.files || []);
const sequence = ++selectionSequence;
let result;
try {
result = controller.select(file);
} catch (error) {
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 (!result || typeof result.then !== 'function') {
showPreview(file, false);
if (!reviewEnabled && files.length === 1 && (!first || typeof first.then !== 'function')) {
showPreview(files[0], false);
return;
}
options.status.textContent = 'Optimizing screenshot…';
options.input.disabled = true;
return result.then(() => controller.serialize()).then(value => {
if (sequence === selectionSequence) showPreview(value.blob, true);
let optimized = Boolean(first && typeof first.then === 'function');
if (optimized) {
options.status.textContent = 'Optimizing screenshot…';
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…';
inputs.forEach(input => { input.disabled = true; });
await result;
}
}), Promise.resolve(first));
return selectAll.then(() => controller.serialize()).then(value => {
if (sequence !== selectionSequence) return;
const values = Array.isArray(value) ? value : [value];
const latest = values[values.length - 1];
if (reviewEnabled) {
review.render(values, values.length - 1, optimized);
} else {
showPreview(latest.blob, optimized);
if (values.length > 1) {
options.meta.textContent = values.length + ' screenshots ready · latest: ' + latest.filename;
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', () => {
clearPreview();
options.status.textContent = options.removedMessage || 'Screenshot removed. Your comment is unchanged.';
const current = controller.state();
const count = Array.isArray(current) ? current.length : (current ? 1 : 0);
if (count <= 1) {
clearPreview();
options.status.textContent = options.removedMessage || 'Screenshot removed. Your comment is unchanged.';
return Promise.resolve();
}
const removedIndex = reviewEnabled ? review.activeIndex() : count - 1;
controller.remove(removedIndex);
selectionSequence += 1;
review?.afterRemoval(removedIndex, count);
return controller.serialize().then(value => {
const values = Array.isArray(value) ? value : [value];
if (reviewEnabled) {
review.render(values, review.activeIndex());
} else {
const latest = values[values.length - 1];
showPreview(latest.blob, false);
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());
});
});
function restorePreview(value) {
clearPreview();
const restored = controller.restore(value);
previewUrl = value.blob ? options.createObjectURL(value.blob) :
'data:' + value.contentType + ';base64,' + value.data;
const restored = restoreSelection(value);
const values = Array.isArray(value) ? value : [value];
if (reviewEnabled) {
review.render(values, values.length - 1);
return restored;
}
const latest = values[values.length - 1];
previewUrl = latest.blob ? options.createObjectURL(latest.blob) :
'data:' + latest.contentType + ';base64,' + latest.data;
options.image.src = previewUrl;
options.meta.textContent = restored.name + ' · ' + Math.ceil(restored.size / 1024) + ' KB';
options.meta.textContent = values.length > 1 ?
values.length + ' screenshots ready · latest: ' + latest.filename :
restored.name + ' · ' + Math.ceil(restored.size / 1024) + ' KB';
options.preview.hidden = false;
options.status.textContent = options.readyMessage || 'Screenshot ready to upload with this comment.';
options.status.textContent = values.length > 1 ?
values.length + ' screenshots ready to file in this order.' :
(options.readyMessage || 'Screenshot ready to upload with this comment.');
return restored;
}
if (review && options.editor && issueEvidenceEditor) {
issueEvidenceEditor.mount({
...options.editor,
controller,
getActiveIndex:review.activeIndex,
optimizeImage,
onApplied: async index => {
const value = await controller.serialize();
review.render(value, index);
options.status.textContent = options.editor.appliedMessage ||
'Edited screenshot flattened and ready to file.';
},
});
options.editor.edit.disabled = !controller.state();
}
return Object.assign(controller, { clear: clearPreview, restore: restorePreview, setBusy });
}
return { create, mount, multipart, optimizeImage, MAX_BYTES };
return { create, mount, multipart, optimizeImage, MAX_BYTES, MAX_PIXELS };
});
+306
View File
@@ -0,0 +1,306 @@
(function(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.issueEvidenceEditor = api;
})(typeof self !== 'undefined' ? self : this, function() {
'use strict';
function boundedRect(value, bounds) {
const x = Math.max(bounds.x, Math.min(bounds.x + bounds.width - 1, Math.round(Number(value.x) || 0)));
const y = Math.max(bounds.y, Math.min(bounds.y + bounds.height - 1, Math.round(Number(value.y) || 0)));
const width = Math.max(1, Math.min(bounds.x + bounds.width - x, Math.round(Number(value.width) || 0)));
const height = Math.max(1, Math.min(bounds.y + bounds.height - y, Math.round(Number(value.height) || 0)));
return { x, y, width, height };
}
function boundedPoint(value, bounds) {
return {
x: Math.max(bounds.x, Math.min(bounds.x + bounds.width, Math.round(Number(value.x) || 0))),
y: Math.max(bounds.y, Math.min(bounds.y + bounds.height, Math.round(Number(value.y) || 0))),
};
}
function createModel(size) {
const full = { x:0, y:0, width:Math.max(1, Math.round(size.width)), height:Math.max(1, Math.round(size.height)) };
let crop = { ...full };
let redactions = [];
let annotations = [];
let history = [];
function remember() {
history.push({
crop:{...crop}, redactions:redactions.map(value => ({...value})),
annotations:annotations.map(value => ({...value})),
});
}
function intersects(annotation, bounds) {
const left = annotation.type === 'arrow' ? Math.min(annotation.startX, annotation.endX) : annotation.x;
const top = annotation.type === 'arrow' ? Math.min(annotation.startY, annotation.endY) : annotation.y;
const right = annotation.type === 'arrow' ? Math.max(annotation.startX, annotation.endX) : annotation.x + annotation.width;
const bottom = annotation.type === 'arrow' ? Math.max(annotation.startY, annotation.endY) : annotation.y + annotation.height;
return left < bounds.x + bounds.width && top < bounds.y + bounds.height && right > bounds.x && bottom > bounds.y;
}
function clampAnnotation(annotation) {
if (annotation.type === 'highlight') {
const x = Math.max(crop.x, annotation.x);
const y = Math.max(crop.y, annotation.y);
const right = Math.min(crop.x + crop.width, annotation.x + annotation.width);
const bottom = Math.min(crop.y + crop.height, annotation.y + annotation.height);
return { type:'highlight', x, y, width:Math.max(1, right - x), height:Math.max(1, bottom - y) };
}
const start = boundedPoint({x:annotation.startX, y:annotation.startY}, crop);
const end = boundedPoint({x:annotation.endX, y:annotation.endY}, crop);
return { type:'arrow', startX:start.x, startY:start.y, endX:end.x, endY:end.y };
}
function setCrop(value) {
remember();
crop = boundedRect(value, crop);
redactions = redactions.filter(rectangle =>
rectangle.x < crop.x + crop.width && rectangle.y < crop.y + crop.height &&
rectangle.x + rectangle.width > crop.x && rectangle.y + rectangle.height > crop.y
).map(rectangle => boundedRect(rectangle, crop));
annotations = annotations.filter(annotation => intersects(annotation, crop)).map(clampAnnotation);
return snapshot();
}
function addRedaction(value) {
remember();
redactions.push(boundedRect(value, crop));
return snapshot();
}
function addHighlight(value) {
remember();
annotations.push({ type:'highlight', ...boundedRect(value, crop) });
return snapshot();
}
function addArrow(value) {
remember();
const start = boundedPoint({x:value.startX, y:value.startY}, crop);
const end = boundedPoint({x:value.endX, y:value.endY}, crop);
annotations.push({ type:'arrow', startX:start.x, startY:start.y, endX:end.x, endY:end.y });
return snapshot();
}
function undo() {
const previous = history.pop();
if (previous) {
crop = previous.crop;
redactions = previous.redactions;
annotations = previous.annotations;
}
return snapshot();
}
function reset() {
remember();
crop = { ...full };
redactions = [];
annotations = [];
return snapshot();
}
function snapshot() {
return {
crop:{...crop}, redactions:redactions.map(value => ({...value})),
annotations:annotations.map(value => ({...value})),
};
}
return { addArrow, addHighlight, addRedaction, reset, setCrop, snapshot, undo };
}
function namedBlob(blob, name) {
if (typeof File === 'function') return new File([blob], name, {type:blob.type});
Object.defineProperty(blob, 'name', {value:name, configurable:true});
return blob;
}
function paint(options) {
const state = options.model.snapshot();
const maxDimension = Number.isFinite(options.maxDimension) ? options.maxDimension : Infinity;
const scale = Math.min(1, maxDimension / Math.max(state.crop.width, state.crop.height));
const width = Math.max(1, Math.round(state.crop.width * scale));
const height = Math.max(1, Math.round(state.crop.height * scale));
options.canvas.width = width;
options.canvas.height = height;
const context = options.canvas.getContext('2d');
if (!context) throw new Error('Screenshot editing is unavailable in this browser.');
context.drawImage(options.source, state.crop.x, state.crop.y, state.crop.width, state.crop.height, 0, 0, width, height);
context.fillStyle = '#000000';
state.redactions.forEach(rectangle => context.fillRect(
Math.round((rectangle.x - state.crop.x) * scale),
Math.round((rectangle.y - state.crop.y) * scale),
Math.round(rectangle.width * scale),
Math.round(rectangle.height * scale),
));
state.annotations.forEach(annotation => {
if (annotation.type === 'highlight') {
context.fillStyle = 'rgba(250, 204, 21, 0.38)';
context.fillRect(
Math.round((annotation.x - state.crop.x) * scale),
Math.round((annotation.y - state.crop.y) * scale),
Math.round(annotation.width * scale),
Math.round(annotation.height * scale),
);
return;
}
const startX = (annotation.startX - state.crop.x) * scale;
const startY = (annotation.startY - state.crop.y) * scale;
const endX = (annotation.endX - state.crop.x) * scale;
const endY = (annotation.endY - state.crop.y) * scale;
const angle = Math.atan2(endY - startY, endX - startX);
const head = Math.max(10, Math.min(24, 16 * scale));
context.save();
context.strokeStyle = '#facc15';
context.lineWidth = Math.max(4, 6 * scale);
context.lineCap = 'round';
context.lineJoin = 'round';
context.beginPath();
context.moveTo(Math.round(startX), Math.round(startY));
context.lineTo(Math.round(endX), Math.round(endY));
context.lineTo(endX - head * Math.cos(angle - Math.PI / 6), endY - head * Math.sin(angle - Math.PI / 6));
context.moveTo(Math.round(endX), Math.round(endY));
context.lineTo(endX - head * Math.cos(angle + Math.PI / 6), endY - head * Math.sin(angle + Math.PI / 6));
context.stroke();
context.restore();
});
return state;
}
async function flatten(options) {
paint(options);
const type = ['image/png', 'image/jpeg', 'image/webp'].includes(options.type) ? options.type : 'image/png';
const blob = await new Promise(resolve => options.canvas.toBlob(resolve, type, type === 'image/png' ? undefined : 0.9));
if (!blob || !blob.size) throw new Error('The edited screenshot could not be saved. Try again.');
return namedBlob(blob, options.name || 'edited-screenshot.png');
}
function attachmentBlob(value) {
if (value.blob) return value.blob;
const binary = atob(String(value.data || ''));
return new Blob([Uint8Array.from(binary, character => character.charCodeAt(0))], {type:value.contentType});
}
function mount(options) {
let source = null;
let model = null;
let mode = 'redact';
let start = null;
let selectedIndex = -1;
let previousFocus = null;
function status(message) { options.status.textContent = message; }
function setMode(value) {
mode = value;
options.crop.setAttribute('aria-pressed', value === 'crop' ? 'true' : 'false');
options.redact.setAttribute('aria-pressed', value === 'redact' ? 'true' : 'false');
options.highlight.setAttribute('aria-pressed', value === 'highlight' ? 'true' : 'false');
options.arrow.setAttribute('aria-pressed', value === 'arrow' ? 'true' : 'false');
status({
crop:'Drag around the part to keep.', redact:'Drag over private information to hide it.',
highlight:'Drag around the detail to highlight.', arrow:'Drag toward the detail you want to point out.',
}[value]);
}
function render() {
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;
model = null;
start = null;
(previousFocus || options.edit).focus();
}
async function open() {
const value = await options.controller.serialize();
const values = (Array.isArray(value) ? value : [value]).filter(Boolean);
selectedIndex = options.getActiveIndex();
const selected = values[selectedIndex];
if (!selected) return;
previousFocus = options.document.activeElement;
status('Opening screenshot editor…');
try {
const decode = options.decode || globalThis.createImageBitmap;
if (typeof decode !== 'function') throw new Error('decode');
source = await decode(attachmentBlob(selected));
model = createModel({width:source.width, height:source.height});
options.dialog.hidden = false;
render();
setMode('redact');
options.redact.focus();
} catch (_error) {
source = null;
status('This browser cannot edit the screenshot. Your original is unchanged.');
}
}
function point(event) {
const box = options.canvas.getBoundingClientRect();
const state = model.snapshot();
return {
x:state.crop.x + Math.round((event.clientX - box.left) * state.crop.width / box.width),
y:state.crop.y + Math.round((event.clientY - box.top) * state.crop.height / box.height),
};
}
options.canvas.addEventListener('pointerdown', event => {
if (!model) return;
start = point(event);
options.canvas.setPointerCapture?.(event.pointerId);
});
options.canvas.addEventListener('pointerup', event => {
if (!model || !start) return;
const end = point(event);
const dragStart = start;
const rectangle = {
x:Math.min(dragStart.x, end.x), y:Math.min(dragStart.y, end.y),
width:Math.abs(end.x - dragStart.x), height:Math.abs(end.y - dragStart.y),
};
start = null;
const tooSmall = mode === 'arrow' ? Math.hypot(end.x - dragStart.x, end.y - dragStart.y) < 8 :
rectangle.width < 3 || rectangle.height < 3;
if (tooSmall) {
status('Drag a larger area on the screenshot.');
return;
}
if (mode === 'crop') model.setCrop(rectangle);
else if (mode === 'redact') model.addRedaction(rectangle);
else if (mode === 'highlight') model.addHighlight(rectangle);
else model.addArrow({startX:dragStart.x, startY:dragStart.y, endX:end.x, endY:end.y});
render();
status({
crop:'Crop applied. Undo or reset if needed.', redact:'Private area hidden with an opaque redaction.',
highlight:'Highlight added.', arrow:'Arrow added.',
}[mode]);
});
options.edit.addEventListener('click', open);
options.crop.addEventListener('click', () => setMode('crop'));
options.redact.addEventListener('click', () => setMode('redact'));
options.highlight.addEventListener('click', () => setMode('highlight'));
options.arrow.addEventListener('click', () => setMode('arrow'));
options.undo.addEventListener('click', () => { if (model) { model.undo(); render(); status('Last edit undone.'); } });
options.reset.addEventListener('click', () => { if (model) { model.reset(); render(); status('Crop, redactions, and annotations reset.'); } });
options.cancel.addEventListener('click', close);
options.dialog.addEventListener('keydown', event => { if (event.key === 'Escape') { event.preventDefault(); close(); } });
options.apply.addEventListener('click', async () => {
if (!model || !source) return;
options.apply.disabled = true;
status('Applying flattened edit…');
try {
const current = await options.controller.serialize();
const values = Array.isArray(current) ? current : [current];
const selected = values[selectedIndex];
let derivative = await flatten({
source, model, canvas:options.exportCanvas, maxDimension:2048,
name:selected.filename, type:selected.contentType,
});
if (options.optimizeImage) derivative = await options.optimizeImage(derivative);
options.controller.replace(selectedIndex, derivative);
await options.onApplied(selectedIndex);
close();
} catch (error) {
status(error.message || 'The edited screenshot could not be applied. Your original is unchanged.');
} finally {
options.apply.disabled = false;
}
});
return { close, open };
}
return { createModel, flatten, mount, paint };
});
+185
View File
@@ -0,0 +1,185 @@
(function(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.issueEvidenceReview = api;
})(typeof self !== 'undefined' ? self : this, function() {
'use strict';
const MAX_BYTES = 2 * 1024 * 1024;
const MAX_PIXELS = 12 * 1024 * 1024;
const MAX_DIMENSION = 8192;
function namedBlob(blob, name) {
if (typeof File === 'function') return new File([blob], name, { type: blob.type });
Object.defineProperty(blob, 'name', { value: name, configurable: true });
return blob;
}
async function optimizeImage(file, environment = {}) {
const decode = environment.createImageBitmap || globalThis.createImageBitmap;
const makeCanvas = environment.createCanvas || (() => document.createElement('canvas'));
if (typeof decode !== 'function') throw new Error('This browser cannot safely inspect the screenshot. Try cropping it and choose it again.');
let bitmap;
try {
bitmap = await decode(file);
const width = Number(bitmap.width);
const height = Number(bitmap.height);
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) {
throw new Error('decode');
}
const pixelScale = Math.min(1, Math.sqrt(MAX_PIXELS / (width * height)));
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);
const canvas = makeCanvas();
const context = canvas && canvas.getContext && canvas.getContext('2d');
if (!context) throw new Error('decode');
for (let attempt = 0; attempt < 10; attempt += 1) {
canvas.width = Math.max(1, Math.floor(width * scale));
canvas.height = Math.max(1, Math.floor(height * scale));
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
const blob = await new Promise(resolve => canvas.toBlob(resolve, file.type, file.type === 'image/png' ? undefined : 0.86));
if (!blob) throw new Error('encode');
if (blob.size > 0 && blob.size <= MAX_BYTES) return namedBlob(blob, file.name);
scale *= 0.8;
}
} catch (_error) {
throw new Error('The screenshot could not be optimized. Try cropping it and choose it again.');
} finally {
if (bitmap && typeof bitmap.close === 'function') bitmap.close();
}
throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.');
}
function create(options) {
const controller = options.controller;
const documentRef = options.document || document;
let activeIndex = 0;
let busy = false;
let previewUrl = '';
let thumbnailUrls = [];
function revoke(url) {
if (url && !url.startsWith('data:')) options.revokeObjectURL(url);
}
function attachmentUrl(attachment) {
return attachment?.blob ? options.createObjectURL(attachment.blob) :
'data:' + attachment.contentType + ';base64,' + attachment.data;
}
function count() {
const value = controller.state();
return Array.isArray(value) ? value.length : (value ? 1 : 0);
}
function setBusy(value) {
busy = Boolean(value);
options.earlier.disabled = busy || activeIndex <= 0;
options.later.disabled = busy || activeIndex >= count() - 1;
if (options.note) options.note.disabled = busy;
if (options.edit) options.edit.disabled = busy || !count();
Array.from(options.tray.children || []).forEach(button => { button.disabled = busy; });
}
function updateNote(values) {
if (!options.note) return;
options.note.value = controller.note(activeIndex);
options.note.disabled = busy;
if (options.noteLabel) {
options.noteLabel.textContent = 'Evidence note for screenshot ' + (activeIndex + 1) + ' of ' +
values.length + ' (optional)';
}
}
function update(values, optimized = false) {
if (!values.length) return;
activeIndex = Math.max(0, Math.min(activeIndex, values.length - 1));
revoke(previewUrl);
previewUrl = attachmentUrl(values[activeIndex]);
options.image.src = previewUrl;
const current = values[activeIndex];
const size = current.blob ? current.blob.size : Math.max(1, Math.floor(String(current.data || '').length * 3 / 4));
options.meta.textContent = 'Screenshot ' + (activeIndex + 1) + ' of ' + values.length + ' · ' +
current.filename + ' · ' + Math.ceil(size / 1024) + ' KB';
options.preview.hidden = false;
if (options.edit) options.edit.disabled = busy;
Array.from(options.tray.children || []).forEach((button, index) =>
button.setAttribute('aria-pressed', index === activeIndex ? 'true' : 'false'));
options.earlier.disabled = busy || activeIndex === 0;
options.later.disabled = busy || activeIndex === values.length - 1;
updateNote(values);
options.status.textContent = optimized ? 'Screenshots optimized and ready to file in this order.' :
values.length + ' screenshots ready to file in this order.';
}
function render(value, index = activeIndex, optimized = false) {
const values = (Array.isArray(value) ? value : [value]).filter(Boolean);
activeIndex = Math.max(0, Math.min(index, values.length - 1));
thumbnailUrls.forEach(revoke);
thumbnailUrls = [];
options.tray.replaceChildren(...values.map((item, itemIndex) => {
const button = documentRef.createElement('button');
button.type = 'button';
button.className = 'issue-evidence-thumbnail';
button.setAttribute('aria-label', 'Review screenshot ' + (itemIndex + 1) + ' of ' + values.length + ': ' + item.filename);
const thumbnail = documentRef.createElement('img');
thumbnail.alt = '';
thumbnail.src = attachmentUrl(item);
thumbnailUrls.push(thumbnail.src);
button.appendChild(thumbnail);
const position = documentRef.createElement('span');
position.textContent = String(itemIndex + 1);
button.appendChild(position);
button.addEventListener('click', () => { activeIndex = itemIndex; update(values); });
return button;
}));
options.tray.hidden = !values.length;
if (values.length) update(values, optimized);
}
function clear() {
revoke(previewUrl);
thumbnailUrls.forEach(revoke);
previewUrl = '';
thumbnailUrls = [];
activeIndex = 0;
options.tray.replaceChildren();
options.tray.hidden = true;
options.earlier.disabled = true;
options.later.disabled = true;
if (options.note) {
options.note.value = '';
options.note.disabled = true;
}
if (options.edit) options.edit.disabled = true;
if (options.noteLabel) options.noteLabel.textContent = 'Evidence note (optional)';
}
function afterRemoval(removedIndex, previousCount) {
activeIndex = Math.min(removedIndex, previousCount - 2);
}
function move(offset) {
const total = count();
const destination = activeIndex + offset;
if (destination < 0 || destination >= total) return Promise.resolve();
controller.move(activeIndex, destination);
activeIndex = destination;
return controller.serialize().then(value => {
render(value, activeIndex);
options.status.textContent = 'Screenshot moved to position ' + (activeIndex + 1) + ' of ' + total + '.';
});
}
options.earlier.addEventListener('click', () => move(-1));
options.later.addEventListener('click', () => move(1));
options.note?.addEventListener('input', event => {
controller.setNote(activeIndex, event.target.value);
});
clear();
return { activeIndex: () => activeIndex, afterRemoval, clear, render, setBusy };
}
return { create, optimizeImage, MAX_PIXELS };
});
+95
View File
@@ -0,0 +1,95 @@
(function (root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.createIssueFilingReceipt = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
function createIssueFilingReceipt({
root, heading, key, title, ownership, openLink, shareButton, filedButton,
relatedButton, fileAnotherButton, doneButton, status, navigator = {}, clipboard = navigator.clipboard,
onFileRelated = function () {},
onFileAnother = function () {},
onViewFiled = function () {},
}) {
let active = null;
let relatedPlan = null;
let restoreFocus = null;
function close() {
root.hidden = true;
active = null;
relatedPlan = null;
const target = restoreFocus;
restoreFocus = null;
target?.focus?.();
}
async function share() {
if (!active) return;
const payload = {
title: active.repository + '#' + active.number + ' · ' + active.title,
text: active.title,
url: active.url,
};
if (typeof navigator.share === 'function') {
try {
await navigator.share(payload);
return;
} catch (error) {
if (error?.name === 'AbortError') return;
}
}
if (typeof clipboard?.writeText !== 'function') {
status.textContent = 'Issue link is ready in Open in Gitea.';
return;
}
await clipboard.writeText(active.url);
status.textContent = 'Issue link copied.';
}
function show(issue, returnTarget, reusablePlan = null) {
active = issue;
relatedPlan = reusablePlan;
restoreFocus = returnTarget || null;
key.textContent = issue.repository + '#' + issue.number;
title.textContent = issue.title;
ownership.textContent = issue.assignees?.length
? 'Assigned to @' + issue.assignees.join(', @') + '.'
: 'Created with no owner.';
openLink.href = issue.url;
status.textContent = '';
if (relatedButton) relatedButton.hidden = !relatedPlan;
root.hidden = false;
heading.focus();
}
shareButton.addEventListener('click', share);
filedButton?.addEventListener('click', () => {
if (!active) return;
const issue = active;
close();
onViewFiled(issue);
});
root.addEventListener('keydown', event => {
if (event.key !== 'Escape' || root.hidden) return;
event.preventDefault();
close();
});
doneButton.addEventListener('click', close);
relatedButton?.addEventListener('click', () => {
if (!relatedPlan) return;
const plan = relatedPlan;
close();
onFileRelated(plan);
});
fileAnotherButton.addEventListener('click', () => {
close();
onFileAnother();
});
return { show, close, share };
}
return createIssueFilingReceipt;
});
+181
View File
@@ -0,0 +1,181 @@
(function(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.createIssueFilingReview = api;
})(typeof self !== 'undefined' ? self : this, function() {
'use strict';
const INTENT_LABELS = {
'create-and-assign': 'Create & assign to me',
'create-and-start': 'Create & start',
'follow-up-and-next': 'Create follow-up & next',
};
function clone(value) {
if (typeof structuredClone === 'function') return structuredClone(value);
return JSON.parse(JSON.stringify(value));
}
function create(options) {
let reviewed = null;
let trigger = null;
let busy = false;
let evidenceItems = [];
const generatedUrls = new Set();
function releaseEvidenceUrls() {
generatedUrls.forEach(url => options.revokeObjectURL?.(url));
generatedUrls.clear();
evidenceItems = [];
}
function evidenceUrl(attachment) {
if (attachment?.blob) {
const url = options.createObjectURL(attachment.blob);
generatedUrls.add(url);
return url;
}
if (attachment?.data) {
return 'data:' + String(attachment.contentType || 'application/octet-stream') +
';base64,' + String(attachment.data);
}
return '';
}
function selectEvidence(index) {
const selected = evidenceItems[index];
if (!selected) return;
evidenceItems.forEach((item, position) =>
item.button.setAttribute('aria-pressed', position === index ? 'true' : 'false'));
const filename = selected.attachment.filename || 'Screenshot';
options.evidencePreview.hidden = false;
options.evidenceImage.src = selected.url;
options.evidenceImage.alt = 'Evidence ' + (index + 1) + ' of ' + evidenceItems.length + ': ' + filename;
options.evidencePosition.textContent = 'Evidence ' + (index + 1) + ' of ' + evidenceItems.length;
options.evidenceFilename.textContent = filename;
options.evidenceNote.textContent = selected.attachment.note || 'No evidence note.';
}
function renderVisualEvidence(attachments) {
releaseEvidenceUrls();
options.evidenceEmpty.hidden = attachments.length > 0;
options.evidencePreview.hidden = true;
options.evidenceImage.src = '';
evidenceItems = attachments.map((attachment, index) => {
const url = evidenceUrl(attachment);
const item = options.document.createElement('li');
const button = options.document.createElement('button');
const image = options.document.createElement('img');
const filename = attachment.filename || 'Screenshot';
button.setAttribute('type', 'button');
button.className = 'issue-filing-evidence-selector';
button.setAttribute('aria-label', 'Preview evidence ' + (index + 1) + ' of ' + attachments.length + ': ' + filename);
button.setAttribute('aria-pressed', 'false');
image.src = url;
image.alt = '';
button.appendChild(image);
item.appendChild(button);
return { attachment, button, item, url };
});
evidenceItems.forEach((entry, index) =>
entry.button.addEventListener('click', () => selectEvidence(index)));
options.evidenceList.replaceChildren(...evidenceItems.map(entry => entry.item));
if (evidenceItems.length) selectEvidence(0);
}
function close() {
options.sheet.hidden = true;
const previousTrigger = trigger;
trigger = null;
reviewed = null;
busy = false;
releaseEvidenceUrls();
options.confirmButton.disabled = false;
previousTrigger?.focus();
}
function render(payload) {
const draft = payload.draft;
options.repository.textContent = draft.repository || 'No repository';
options.intent.textContent = draft.unassigned === true ? 'Create unassigned' :
(INTENT_LABELS[payload.intent] || payload.intent);
if (options.issueType) options.issueType.textContent = draft.templateName || 'Blank issue';
options.title.textContent = draft.title;
options.body.textContent = draft.body || 'No note provided.';
const labels = (draft.labels || []).map(label => typeof label === 'string' ? label : label.name);
const milestone = draft.milestone?.title || draft.milestoneTitle || 'No milestone';
const dueDate = draft.dueDate || draft.due_date || 'No due date';
const owner = draft.unassigned === true ? 'Owner: No owner' : (draft.assignee
? 'Owner: ' + (draft.assigneeName || draft.assignee) + ' (@' + draft.assignee + ')'
: 'Assigned to you');
const planning = [];
if (Number.isInteger(draft.estimateMinutes)) {
planning.push('Estimate: ' + draft.estimateMinutes + ' min');
const projected = draft.todayCapacity?.projectedMinutes;
if (Number.isInteger(projected)) planning.push(projected >= 0
? 'Today after start: ' + projected + ' min free'
: 'Today after start: ' + Math.abs(projected) + ' min over capacity');
}
options.metadata.textContent = [
labels.length ? 'Labels: ' + labels.join(', ') : 'No labels',
'Milestone: ' + milestone,
'Due: ' + dueDate,
owner,
...planning,
].join(' · ');
if (options.blockerList) {
options.blockerList.replaceChildren(...(draft.blockers || []).map(blocker => {
const item = options.document.createElement('li');
item.textContent = blocker.repository + ' #' + blocker.number + ' — ' + blocker.title;
return item;
}));
}
const attachments = (draft.attachments || (draft.attachment ? [draft.attachment] : [])).filter(Boolean);
if (options.evidencePreview) {
renderVisualEvidence(attachments);
return;
}
options.evidenceList.replaceChildren(...attachments.map((attachment, index) => {
const item = options.document.createElement('li');
item.textContent = (index + 1) + '. ' + (attachment.filename || 'Screenshot') +
(attachment.note ? ' — ' + attachment.note : '');
return item;
}));
}
function open(payload, opener) {
reviewed = clone(payload);
trigger = opener || null;
busy = false;
options.confirmButton.disabled = false;
render(reviewed);
options.sheet.hidden = false;
options.backButton.focus();
}
options.backButton.addEventListener('click', close);
options.confirmButton.addEventListener('click', async () => {
if (!reviewed || busy) return;
busy = true;
options.confirmButton.disabled = true;
try {
await options.onConfirm(reviewed);
close();
} catch (error) {
busy = false;
options.confirmButton.disabled = false;
if (options.status) options.status.textContent = error.message;
options.confirmButton.focus();
}
});
options.document.addEventListener('keydown', event => {
if (!options.sheet.hidden && event.key === 'Escape' && !busy) {
event.preventDefault();
close();
}
});
return { close, open };
}
return create;
});
+224 -40
View File
@@ -11,10 +11,80 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
const blob = value?.blob;
const data = String(value?.data || '');
if (!filename || !['image/png', 'image/jpeg', 'image/webp'].includes(contentType)) return undefined;
if (blob) return { filename, contentType, blob };
if (!data && value?.stored === true) return { filename, contentType, stored: true };
const note = String(value?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
const noteValue = note ? { note } : {};
if (blob) return { filename, contentType, blob, ...noteValue };
if (!data && value?.stored === true) return { filename, contentType, stored: true, ...noteValue };
if (!data) return undefined;
return { filename, contentType, data };
return { filename, contentType, data, ...noteValue };
}
function captureAttachments(values) {
if (!Array.isArray(values)) return undefined;
if (values.length > 5) throw new Error('You can attach up to 5 screenshots.');
const attachments = values.map(captureAttachment);
if (attachments.some(value => !value)) {
throw new Error('Some screenshots are unavailable. Choose them again before queueing.');
}
return attachments.length ? attachments : undefined;
}
function captureBlockers(values) {
if (!Array.isArray(values)) return undefined;
const seen = new Set();
const blockers = [];
for (const value of values) {
const repository = String(value?.repository || '').trim();
const number = Number(value?.number);
const key = repository + '#' + number;
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) ||
!Number.isInteger(number) || number < 1 || seen.has(key)) continue;
seen.add(key);
blockers.push({
repository, number,
title: String(value?.title || '').replace(/\s+/g, ' ').trim().slice(0, 255),
});
if (blockers.length === 5) break;
}
return blockers.length ? blockers : undefined;
}
function captureEstimate(value) {
const estimate = Number(value);
return Number.isInteger(estimate) && estimate >= 5 && estimate <= 1440 ? estimate : undefined;
}
function captureRelatedDraft(value) {
const repository = String(value?.repository || '');
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) return undefined;
const plan = {
repository, title:'', body:String(value?.body || '').slice(0, 9000),
labelIds:Array.from(new Set((Array.isArray(value?.labelIds) ? value.labelIds : [])
.filter(id => Number.isInteger(id) && id > 0))).slice(0, 20),
};
const milestoneId = Number(value?.milestoneId);
if (Number.isInteger(milestoneId) && milestoneId > 0) plan.milestoneId = milestoneId;
if (/^\d{4}-\d{2}-\d{2}$/.test(String(value?.dueDate || ''))) plan.dueDate = String(value.dueDate);
if (value?.unassigned === true) plan.unassigned = true;
else if (/^[A-Za-z0-9_.-]+$/.test(String(value?.assignee || ''))) {
plan.assignee = String(value.assignee);
plan.assigneeName = String(value.assigneeName || value.assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
}
const templateId = String(value?.templateId || '').slice(0, 80);
if (templateId) {
plan.templateId = templateId;
plan.templateName = String(value?.templateName || 'Issue template').trim().slice(0, 80);
plan.capturedBody = '';
}
const promotion = value?.checklistPromotion;
if (promotion?.repository && promotion?.number && promotion?.url && promotion?.updatedAt) {
plan.checklistPromotion = {
repository:String(promotion.repository), number:Number(promotion.number), url:String(promotion.url),
title:String(promotion.title || ''), body:String(promotion.body || '').slice(0, 10000),
updatedAt:String(promotion.updatedAt), taskIndex:Number(promotion.taskIndex),
};
}
return plan;
}
function read() {
@@ -51,11 +121,28 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
status: 'queued',
queuedAt: Number(now()),
};
const assignee = /^[A-Za-z0-9_.-]+$/.test(String(draft?.assignee || ''))
? String(draft.assignee) : '';
if (assignee) {
item.assignee = assignee;
item.assigneeName = String(draft?.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
}
if (draft?.unassigned === true) item.unassigned = true;
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
if (sourceCaptureId) item.sourceCaptureId = sourceCaptureId;
if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start';
if (draft?.completionIntent === 'create-and-start' && !assignee && !item.unassigned) {
item.completionIntent = 'create-and-start';
item.estimateMinutes = captureEstimate(draft?.estimateMinutes);
}
const attachment = captureAttachment(draft?.attachment);
if (attachment) item.attachment = attachment;
const attachments = captureAttachments(draft?.attachments);
if (attachments) item.attachments = attachments;
const blockers = captureBlockers(draft?.blockers);
if (blockers) item.blockers = blockers;
const relatedDraft = captureRelatedDraft(draft?.relatedDraft);
if (relatedDraft) item.relatedDraft = relatedDraft;
item.operationId = item.id;
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
item.milestoneId = Number(draft.milestoneId);
@@ -65,14 +152,21 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
}
function localIndexItem(item) {
if (!item?.attachment?.data && !item?.attachment?.blob) return item;
const hasAttachmentBytes = item?.attachment?.data || item?.attachment?.blob;
const hasBundleBytes = Array.isArray(item?.attachments) &&
item.attachments.some(value => value?.data || value?.blob);
if (!hasAttachmentBytes && !hasBundleBytes) return item;
return {
...item,
attachment: {
...(hasAttachmentBytes ? {attachment: {
filename: item.attachment.filename,
contentType: item.attachment.contentType,
stored: true,
},
}} : {}),
...(Array.isArray(item.attachments) ? {attachments:item.attachments.map(value => ({
filename:value.filename, contentType:value.contentType, stored:true,
...(value.note ? {note:value.note} : {}),
}))} : {}),
};
}
@@ -120,50 +214,90 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
async function hydrateForEdit(id) {
const item = read().find(candidate => candidate.id === id);
if (!item) return null;
if (!item.attachment?.stored || item.attachment.data || item.attachment.blob) return { ...item };
const storedBundle = Array.isArray(item.attachments) && item.attachments.some(value => value?.stored);
const storedAttachment = item.attachment?.stored && !item.attachment.data && !item.attachment.blob;
if (!storedAttachment && !storedBundle) return { ...item };
if (!backgroundSync?.get) {
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
}
const durable = await backgroundSync.get(id);
const attachment = captureAttachment(durable?.attachment);
if ((!attachment?.data && !attachment?.blob) || durable?.operationId !== item.operationId) {
const attachments = captureAttachments(durable?.attachments);
if (durable?.operationId !== item.operationId ||
(storedAttachment && (!attachment?.data && !attachment?.blob)) ||
(storedBundle && (!attachments || attachments.length !== item.attachments.length))) {
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
}
return { ...item, attachment };
return {
...item,
...(attachment ? {attachment} : {}),
...(attachments ? {attachments} : {}),
};
}
function prepareUpdate(id, draft) {
let updated = null;
const items = read().map(item => {
if (item.id !== id) return item;
const nextRepository = String(draft?.repository || '');
const nextTitle = String(draft?.title || '');
const nextBody = String(draft?.body || '');
const nextLabelIds = Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [];
const nextMilestoneId = Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0
const repository = String(draft?.repository || '');
const title = String(draft?.title || '');
const body = String(draft?.body || '');
const labelIds = Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [];
const unassigned = draft?.unassigned === true;
const assignee = !unassigned && /^[A-Za-z0-9_.-]+$/.test(String(draft?.assignee || ''))
? String(draft.assignee) : undefined;
const assigneeName = assignee
? String(draft?.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255)
: undefined;
const milestoneId = Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0
? Number(draft.milestoneId) : undefined;
const nextDueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))
const dueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))
? String(draft.dueDate) : undefined;
const nextAttachment = captureAttachment(draft?.attachment);
const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(nextAttachment || null);
const changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate
|| attachmentChanged;
const attachment = captureAttachment(draft?.attachment);
const attachments = captureAttachments(draft?.attachments);
const blockers = captureBlockers(draft?.blockers);
const relatedDraft = captureRelatedDraft(draft?.relatedDraft);
const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(attachment || null) ||
JSON.stringify(item.attachments || null) !== JSON.stringify(attachments || null);
const changed = item.repository !== repository || item.title !== title || item.body !== body
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(labelIds)
|| Boolean(item.unassigned) !== unassigned
|| item.assignee !== assignee || item.assigneeName !== assigneeName
|| item.milestoneId !== milestoneId || item.dueDate !== dueDate
|| item.estimateMinutes !== captureEstimate(draft?.estimateMinutes)
|| attachmentChanged || JSON.stringify(item.blockers || null) !== JSON.stringify(blockers || null)
|| JSON.stringify(item.relatedDraft || null) !== JSON.stringify(relatedDraft || null);
updated = {
...item,
repository: nextRepository, title: nextTitle,
body: nextBody, labelIds: nextLabelIds,
milestoneId: nextMilestoneId, dueDate: nextDueDate, attachment: nextAttachment,
repository, title, body, labelIds,
unassigned: unassigned || undefined,
assignee, assigneeName, milestoneId, dueDate,
attachment, attachments, blockers, relatedDraft,
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
status: 'queued',
};
if (draft?.completionIntent === 'create-and-start') updated.completionIntent = 'create-and-start';
else delete updated.completionIntent;
if (nextMilestoneId === undefined) delete updated.milestoneId;
if (nextDueDate === undefined) delete updated.dueDate;
if (nextAttachment === undefined) delete updated.attachment;
if (attachmentChanged) delete updated.attachmentMarkdown;
if (draft?.completionIntent === 'create-and-start' && !assignee && !unassigned) {
updated.completionIntent = 'create-and-start';
updated.estimateMinutes = captureEstimate(draft?.estimateMinutes);
} else {
delete updated.completionIntent;
delete updated.estimateMinutes;
}
if (assignee === undefined) {
delete updated.assignee;
delete updated.assigneeName;
}
if (!unassigned) delete updated.unassigned;
if (milestoneId === undefined) delete updated.milestoneId;
if (dueDate === undefined) delete updated.dueDate;
if (attachment === undefined) delete updated.attachment;
if (attachments === undefined) delete updated.attachments;
if (blockers === undefined) delete updated.blockers;
if (relatedDraft === undefined) delete updated.relatedDraft;
if (attachmentChanged) {
delete updated.attachmentMarkdown;
delete updated.attachmentMarkdowns;
}
delete updated.error;
delete updated.deliveryState;
return updated;
@@ -213,6 +347,13 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
}
function evidenceMarkdown(attachment, markdown, index) {
const note = String(attachment?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
if (!note) return markdown;
const escaped = note.replace(/([\\`*_[\]{}()<>#+\-.!|])/g, '\\$1');
return '**Screenshot ' + (index + 1) + ' — ' + escaped + '**\n\n' + markdown;
}
function attachmentMultipart(attachment) {
let blob = attachment?.blob;
if (!blob && attachment?.data) {
@@ -237,34 +378,68 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
},
body: JSON.stringify({
title: item.title, body: item.body, label_ids: item.labelIds,
...(item.unassigned ? { unassigned: true } : {}),
...(item.assignee ? { assignee: item.assignee } : {}),
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
}),
});
if (item.attachment) persistDeliveryStage(item.id, { deliveredIssue: issue });
if (item.attachment || item.attachments || item.blockers?.length) {
persistDeliveryStage(item.id, { deliveredIssue: issue });
}
}
if (!item.attachment) return issue;
let markdown = item.attachmentMarkdown;
if (!markdown) {
const blockers = Array.isArray(item.blockers) ? item.blockers : [];
let deliveredBlockers = Math.min(Number(item.deliveredBlockers) || 0, blockers.length);
for (let index = deliveredBlockers; index < blockers.length; index += 1) {
const blocker = blockers[index];
await fetchJson(
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/blockers',
{
method: 'PATCH',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'blocker-' + index),
},
body: JSON.stringify({repository:blocker.repository, number:blocker.number, present:true}),
},
);
deliveredBlockers = index + 1;
persistDeliveryStage(item.id, { deliveredIssue:issue, deliveredBlockers });
}
const attachments = Array.isArray(item.attachments) ? item.attachments :
(item.attachment ? [item.attachment] : []);
if (!attachments.length) return issue;
const markdowns = Array.isArray(item.attachmentMarkdowns)
? item.attachmentMarkdowns.slice(0, attachments.length)
: (item.attachmentMarkdown ? [item.attachmentMarkdown] : []);
for (let index = markdowns.length; index < attachments.length; index += 1) {
const uploaded = await fetchJson(
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/attachments',
{
method: 'POST',
headers: {
Accept: 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
'Idempotency-Key': stageOperationId(
item.operationId, attachments.length === 1 ? 'attachment' : 'attachment-' + index,
),
},
body: attachmentMultipart(item.attachment),
body: attachmentMultipart(attachments[index]),
},
);
markdown = String(uploaded?.markdown || '');
const markdown = String(uploaded?.markdown || '');
if (!markdown) {
const error = new Error('The server did not confirm the screenshot upload.');
error.status = 422;
throw error;
}
persistDeliveryStage(item.id, { deliveredIssue: issue, attachmentMarkdown: markdown });
markdowns.push(markdown);
persistDeliveryStage(item.id, {
deliveredIssue: issue, attachmentMarkdowns:markdowns.slice(),
...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}),
});
}
const markdown = markdowns.map((value, index) =>
evidenceMarkdown(attachments[index], value, index)).join('\n\n');
await fetchJson(
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/comments',
{
@@ -329,6 +504,8 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
async function flushQueue(currentLogin) {
const confirmed = [];
const completions = [];
const filings = [];
let blocked = 0;
currentLogin = String(currentLogin || '').trim();
for (const item of read()) {
@@ -337,17 +514,20 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
const result = await sendItem(item, currentLogin);
if (result.issue) {
confirmed.push(result.issue);
if (result.item?.relatedDraft) filings.push({issue:result.issue, relatedDraft:result.item.relatedDraft});
if (result.item?.completionIntent) completions.push({
id: result.item.id,
intent: result.item.completionIntent,
ownerLogin: result.item.ownerLogin,
operationId: result.item.operationId,
estimateMinutes: result.item.estimateMinutes,
issue: result.issue,
});
}
if (result.transient) break;
}
return { confirmed, completions, remaining: read(), blocked };
return { confirmed, completions, filings, remaining: read(), blocked };
}
async function flush(currentLogin) {
@@ -376,11 +556,14 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
const result = await sendItem(queued, currentLogin);
return {
confirmed: result.issue ? [result.issue] : [],
filings: result.issue && result.item?.relatedDraft ? [{issue:result.issue, relatedDraft:result.item.relatedDraft}] : [],
completions: result.issue && result.item?.completionIntent ? [{
id: result.item.id,
intent: result.item.completionIntent,
ownerLogin: result.item.ownerLogin,
operationId: result.item.operationId,
estimateMinutes: result.item.estimateMinutes,
issue: result.issue,
}] : [],
remaining: read(), blocked: 0,
@@ -425,6 +608,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
intent: item.completionIntent,
ownerLogin: item.ownerLogin,
operationId: item.operationId,
estimateMinutes: item.estimateMinutes,
issue: item.deliveredIssue,
}));
}
+396 -9
View File
@@ -18,11 +18,146 @@ function createPlanningLoader({ loadLabels, loadMilestones }) {
};
}
function createIssueSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
function escapeOptionHtml(value) {
return String(value || '').replace(/[&<>"']/g, character => ({
'&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;',
})[character]);
}
function appendChecklistTask(body, label) {
const normalized = String(label || '').trim().replace(/\s+/g, ' ');
if (!normalized) throw new Error('Enter a checklist step.');
const key = normalized.toLocaleLowerCase();
const duplicate = String(body || '').split('\n').some(line => {
const match = line.match(/^\s*[-*+]\s+\[[ xX]\]\s+(.*)$/);
return match && match[1].trim().replace(/\s+/g, ' ').toLocaleLowerCase() === key;
});
if (duplicate) throw new Error('That checklist step already exists.');
const prefix = body ? String(body).replace(/\s+$/, '') + '\n' : '';
return prefix + '- [ ] ' + normalized;
}
function manageChecklistTask(raw, targetIndex, operation = {}) {
const parts = String(raw || '').split(/(\r\n|\n|\r)/);
let fenced = false;
let taskIndex = 0;
for (let index = 0; index < parts.length; index += 2) {
const line = parts[index];
if (/^\s*```/.test(line)) { fenced = !fenced; continue; }
if (fenced) continue;
const task = line.match(/^([-*+]\s+\[[ xX]\]\s+)(.*)$/);
if (!task) continue;
if (taskIndex === Number(targetIndex)) {
if (operation.action === 'rename') {
const label = String(operation.label || '').trim().replace(/\s+/g, ' ');
if (!label) throw new Error('Enter a checklist step.');
const key = label.toLocaleLowerCase();
let inFence = false;
const duplicate = parts.some((candidate, candidateIndex) => {
if (candidateIndex % 2) return false;
if (/^\s*```/.test(candidate)) { inFence = !inFence; return false; }
if (inFence || candidateIndex === index) return false;
const match = candidate.match(/^[-*+]\s+\[[ xX]\]\s+(.*)$/);
return match && match[1].trim().replace(/\s+/g, ' ').toLocaleLowerCase() === key;
});
if (duplicate) throw new Error('That checklist step already exists.');
parts[index] = task[1] + label;
} else if (operation.action === 'remove') {
if (index + 1 < parts.length) parts.splice(index, 2);
else if (index > 0) parts.splice(index - 1, 2);
} else if (operation.action === 'move-earlier' && index >= 2 && /^(\s*[-*+]\s+\[[ xX]\]\s+)/.test(parts[index - 2])) {
[parts[index - 2], parts[index]] = [parts[index], parts[index - 2]];
} else if (operation.action === 'move-later' && index + 2 < parts.length && /^(\s*[-*+]\s+\[[ xX]\]\s+)/.test(parts[index + 2])) {
[parts[index], parts[index + 2]] = [parts[index + 2], parts[index]];
}
return parts.join('');
}
taskIndex += 1;
}
return parts.join('');
}
function checklistIsComplete(raw) {
const lines = String(raw || '').split(/(?:\r\n|\n|\r)/);
let fenced = false;
let found = false;
for (const line of lines) {
if (/^\s*```/.test(line)) { fenced = !fenced; continue; }
if (fenced) continue;
const task = line.match(/^\s*[-*+]\s+\[([ xX])\]\s+/);
if (!task) continue;
found = true;
if (task[1].toLocaleLowerCase() !== 'x') return false;
}
return found;
}
function completeDelegatedParentTask(raw, childUrl) {
const url = String(childUrl || '');
if (!/^https:\/\/[^\s)]+$/.test(url)) throw new Error('The delegated issue link is unavailable.');
const parts = String(raw || '').split(/(\r\n|\n|\r)/);
let fenced = false;
let taskIndex = 0;
for (let index = 0; index < parts.length; index += 2) {
const line = parts[index];
if (/^\s*```/.test(line)) { fenced = !fenced; continue; }
if (fenced) continue;
const task = line.match(/^(\s*[-*+]\s+\[)([ xX])(\]\s+.*)$/);
if (!task) continue;
const linkedTask = task[3].match(/^\]\s+\[[^\]]+\]\((https:\/\/[^\s)]+)\)\s*$/);
if (linkedTask?.[1] === url) {
const alreadyCompleted = task[2].toLocaleLowerCase() === 'x';
if (!alreadyCompleted) parts[index] = task[1] + 'x' + task[3];
const body = parts.join('');
return { body, taskIndex, alreadyCompleted, checklistComplete:checklistIsComplete(body) };
}
taskIndex += 1;
}
throw new Error('The parent checklist no longer links to this delegated issue.');
}
async function finishDelegatedParentReview({ parentItem, parentDetail, childUrl, updateContent, closeParent }) {
const completion = completeDelegatedParentTask(parentDetail?.body, childUrl);
if (!completion.alreadyCompleted) {
await updateContent(parentItem, {
title:parentDetail.title,
body:completion.body,
expectedUpdatedAt:parentDetail.updated_at,
});
}
if (completion.checklistComplete) await closeParent(parentItem);
return { updated:!completion.alreadyCompleted, closed:completion.checklistComplete };
}
function delegatedParentReference(childDetail) {
const body = String(childDetail?.body || '');
const match = body.match(/(?:^|\n)Related to \[([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#([1-9]\d*)\]\((https:\/\/[^\s)]+)\)\.(?:\r?$|\s)/m);
if (!match) return null;
return { repository:match[1], number:Number(match[2]), url:match[3] };
}
function resolveDelegatedParent(child, childDetail, parentDetail) {
const parent = delegatedParentReference(childDetail);
if (!parent) return null;
try {
const completion = completeDelegatedParentTask(parentDetail?.body, child?.url);
return {
parent,
taskIndex:completion.taskIndex,
alreadyCompleted:completion.alreadyCompleted,
checklistComplete:completion.checklistComplete,
};
} catch (_error) {
return null;
}
}
function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.renderMarkdown, toggleTask = renderMarkdown?.toggleTask, manageTask: manageTaskTransform = manageChecklistTask, relatedTaskDraft: relatedTaskDraftTransform = (...args) => globalThis.createIssueCapture?.relatedChecklistDraft(...args), linkRelatedTask: linkRelatedTaskTransform = (...args) => globalThis.createIssueCapture?.linkChecklistTask(...args), enqueueDurably, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
let commentRequest = null;
let closeRequest = null;
let releaseRequest = null;
let handoffRequest = null;
let reassignRequest = null;
let labelRequest = null;
let editRequest = null;
let dueDateRequest = null;
@@ -38,16 +173,25 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
const milestoneDraftKey = item => 'stackchain.issue-milestone.v1:' + item.repository + '#' + item.number;
return {
delegatedParentReference,
resolveDelegatedParent,
completeDelegatedParentTask,
readOnly(item) {
return Boolean(item?.is_completed || (item?.is_filed && !item?.is_assigned));
},
load(item) {
return fetchJson(issuePath(item) + '/detail', {
const access = this.readOnly(item) ? '?access=filed' : '';
return fetchJson(issuePath(item) + '/detail' + access, {
headers: { Accept: 'application/json' },
});
},
conversation(item, initialPage) {
const access = this.readOnly(item) ? '&access=filed' : '';
const pager = createConversationPager({
loadPage: page => fetchJson(issuePath(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20', {
headers: { Accept: 'application/json' },
}),
loadPage: page => fetchJson(
issuePath(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20' + access,
{ headers: { Accept: 'application/json' } },
),
});
pager.reset(initialPage || { comments: [], page: 1, older_page: null, total: 0 });
return pager;
@@ -64,7 +208,8 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
});
},
loadHandoffCandidates(item) {
return fetchJson(issuePath(item) + '/handoff-candidates', {
const access = item?.is_filed ? '?access=filed' : '';
return fetchJson(issuePath(item) + '/handoff-candidates' + access, {
headers: { Accept: 'application/json' },
});
},
@@ -111,7 +256,8 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
updateContent(item, draft) {
if (editRequest) return editRequest;
this.saveEditDraft(item, draft);
editRequest = fetchJson(issuePath(item) + '/content', {
const access = this.readOnly(item) ? '?access=filed' : '';
editRequest = fetchJson(issuePath(item) + '/content' + access, {
method: 'PATCH',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -129,6 +275,218 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
}).finally(() => { editRequest = null; });
return editRequest;
},
toggleTask(item, detail, taskIndex, checked) {
if (typeof toggleTask !== 'function') return Promise.reject(new Error('Checklist updates are unavailable.'));
return this.updateContent(item, {
title: detail.title,
body: toggleTask(detail.body, taskIndex, checked),
expectedUpdatedAt: detail.updated_at,
});
},
manageTask(item, detail, taskIndex, operation) {
if (typeof manageTaskTransform !== 'function') return Promise.reject(new Error('Checklist management is unavailable.'));
return this.updateContent(item, {
title: detail.title,
body: manageTaskTransform(detail.body, taskIndex, operation),
expectedUpdatedAt: detail.updated_at,
});
},
relatedTaskDraft(item, _detail, _taskIndex, label) {
if (typeof relatedTaskDraftTransform !== 'function') throw new Error('Related issue capture is unavailable.');
return relatedTaskDraftTransform(item, label);
},
linkRelatedTask(item, detail, taskIndex, child) {
if (typeof linkRelatedTaskTransform !== 'function') return Promise.reject(new Error('Related issue linking is unavailable.'));
return this.updateContent(item, {
title:detail.title,
body:linkRelatedTaskTransform(detail.body, taskIndex, child),
expectedUpdatedAt:detail.updated_at,
});
},
async addTask(item, detail, label) {
const body = appendChecklistTask(detail.body, label);
return this.updateContent(item, {
title: detail.title,
body,
expectedUpdatedAt: detail.updated_at,
});
},
async queueAddedTask(item, detail, label) {
if (typeof enqueueDurably !== 'function') {
throw new Error('Offline checklist updates are unavailable.');
}
const body = appendChecklistTask(detail.body, label);
await enqueueDurably({ kind:'issue-content', repository:item.repository, number:item.number,
title:detail.title, baseBody:detail.body, body, expectedUpdatedAt:detail.updated_at });
return { queued:true, detail:{ ...detail, body, checklist_pending:true } };
},
async queueManagedTask(item, detail, taskIndex, operation) {
if (typeof manageTaskTransform !== 'function' || typeof enqueueDurably !== 'function') {
throw new Error('Offline checklist management is unavailable.');
}
const body = manageTaskTransform(detail.body, taskIndex, operation);
const checklistOperation = {
action:String(operation?.action || ''), index:Number(taskIndex),
...(operation?.label ? { label:String(operation.label) } : {}),
};
await enqueueDurably({ kind:'issue-content', repository:item.repository, number:item.number,
title:detail.title, baseBody:detail.body, body, expectedUpdatedAt:detail.updated_at, checklistOperation });
return { queued:true, detail:{ ...detail, body, checklist_pending:true } };
},
async queueTask(item, detail, taskIndex, checked) {
if (typeof toggleTask !== 'function' || typeof enqueueDurably !== 'function') {
throw new Error('Offline checklist updates are unavailable.');
}
const body = toggleTask(detail.body, taskIndex, checked);
await enqueueDurably({ kind:'issue-content', repository:item.repository, number:item.number,
title:detail.title, baseBody:detail.body, body, expectedUpdatedAt:detail.updated_at });
return { queued:true, detail:{ ...detail, body, checklist_pending:true } };
},
pendingTask(item, detail, items, ownerLogin) {
const pending = [...(items || [])].reverse().find(candidate => candidate?.kind === 'issue-content' &&
candidate.repository === item?.repository && Number(candidate.number) === Number(item?.number) &&
candidate.ownerLogin === ownerLogin && ['queued', 'sending', 'attention'].includes(candidate.status));
if (!pending) return { ...detail };
return { ...detail, title:pending.title, body:pending.body, checklist_pending:true };
},
bindTaskToggles({ container, status, retry, current, confirmed, restore }) {
container.addEventListener('change', async event => {
const control = event.target.closest('input.task-list-toggle');
const state = current();
if (!control || !state?.item || !state?.detail?.updated_at) return;
container.classList.add('checklist-pending');
container.querySelectorAll('input.task-list-toggle').forEach(input => { input.disabled = true; });
status.textContent = 'Updating checklist…';
try {
const result = await (state.offline ? this.queueTask : this.toggleTask).call(
this, state.item, state.detail, Number(control.dataset.taskIndex), control.checked
);
const latest = current();
if (latest?.item?.repository === state.item.repository && latest.item.number === state.item.number) {
confirmed(state.item, state.detail, state.offline ? result.detail : result);
if (state.offline) status.textContent = 'Checklist queued. Pending sync.';
else status.textContent = 'Checklist updated.';
}
} catch (error) {
const latest = current();
if (latest?.item?.repository === state.item.repository && latest.item.number === state.item.number) {
restore(state.detail);
status.textContent = error.message + ' Checklist was not changed; reload latest or use Edit issue.';
retry.hidden = false;
}
}
});
},
bindTaskManagement({ container, editor, label, earlier, later, fileRelated, onFileRelated, remove, cancel, status, sheetStatus, retry, current, confirmed }) {
let taskIndex = null;
let trigger = null;
const reset = (returnFocus = false) => {
editor.hidden = true;
status.textContent = '';
if (returnFocus && trigger?.isConnected) trigger.focus();
taskIndex = null;
trigger = null;
};
container.addEventListener('click', event => {
const control = event.target.closest('button.task-list-manage');
if (!control || !current()?.detail?.updated_at) return;
taskIndex = Number(control.dataset.taskIndex);
trigger = control;
label.value = control.dataset.taskLabel || '';
earlier.disabled = control.dataset.taskFirst === 'true';
later.disabled = control.dataset.taskLast === 'true';
fileRelated.disabled = /^\s*\[[^\]]+\]\([^)]+\)\s*$/.test(label.value);
editor.hidden = false;
status.textContent = fileRelated.disabled ? 'This step already links to related work.' :
'Rename, reorder, remove, or file this step as related work.';
label.focus();
});
const run = async operation => {
const state = current();
const selectedIndex = taskIndex;
if (!state?.item || !state.detail?.updated_at || selectedIndex === null) return;
if (operation.action === 'remove' && !globalThis.confirm('Remove this checklist step?')) return;
const controls = editor.querySelectorAll('button,input');
controls.forEach(control => { control.disabled = true; });
status.textContent = state.offline ? 'Queueing checklist change…' : 'Updating checklist…';
try {
const result = await (state.offline ? this.queueManagedTask : this.manageTask).call(
this, state.item, state.detail, selectedIndex, operation
);
if (current()?.item !== state.item) return;
confirmed(state.item, state.detail, state.offline ? result.detail : result);
reset();
sheetStatus.textContent = state.offline ? 'Checklist change queued. Pending sync.' : 'Checklist step updated.';
const focusIndex = operation.action === 'remove' ? Math.max(0, selectedIndex - 1) : selectedIndex;
container.querySelector('button.task-list-manage[data-task-index="' + focusIndex + '"]')?.focus();
} catch (error) {
if (current()?.item === state.item) {
status.textContent = error.message + ' Your change is still here; retry.';
retry.hidden = false;
label.focus();
}
} finally {
if (!editor.hidden) controls.forEach(control => { control.disabled = false; });
}
};
editor.addEventListener('submit', event => { event.preventDefault(); run({ action:'rename', label:label.value }); });
earlier.addEventListener('click', () => run({ action:'move-earlier' }));
later.addEventListener('click', () => run({ action:'move-later' }));
fileRelated.addEventListener('click', async () => {
const state = current();
const selectedIndex = taskIndex;
if (!state?.item || !state.detail?.updated_at || selectedIndex === null || fileRelated.disabled) return;
try {
await onFileRelated({ item:state.item, detail:state.detail, taskIndex:selectedIndex,
label:label.value, trigger });
reset();
} catch (error) {
status.textContent = error.message + ' The checklist step was not changed.';
label.focus();
}
});
remove.addEventListener('click', () => run({ action:'remove' }));
cancel.addEventListener('click', () => reset(true));
return { reset };
},
renderTasks(container, detail, interactive) {
container.classList.toggle('checklist-pending', detail.checklist_pending === true);
container.innerHTML = renderMarkdown(
detail.body || 'No description provided.', {
interactiveTasks: Boolean(interactive),
manageTasks: Boolean(interactive),
}
);
},
checklistCompletion(container, { interactive, today, offline, dismissed = false }) {
const tasks = Array.from(container.querySelectorAll('input.task-list-toggle'));
const visible = Boolean(interactive && !dismissed && tasks.length && tasks.every(task => task.checked));
return {
visible,
status: offline ? 'Checklist complete · pending sync' : 'Checklist complete',
label: today ? (offline ? 'Queue close & next' : 'Close & next') :
(offline ? 'Queue issue closure' : 'Close issue'),
};
},
mergeContent(snapshot, item, detail, confirmed, replace) {
return {
snapshot: snapshot ? replace(snapshot, item.repository, item.number, confirmed) : snapshot,
item: { ...item, ...confirmed, key:item.key },
detail: { ...detail, ...confirmed },
};
},
renderMilestoneEditor(item, confirmed, milestones, document = globalThis.document) {
const select = document.querySelector('#issue-milestone');
const status = document.querySelector('#issue-milestone-status');
select.innerHTML = '<option value="">No milestone</option>' + milestones.map(milestone =>
'<option value="' + Number(milestone.id) + '">' + escapeOptionHtml(milestone.title) + '</option>'
).join('');
select.value = String(this.loadMilestoneDraft(item) ?? confirmed?.id ?? '');
select.disabled = false;
document.querySelector('#save-issue-milestone').disabled = false;
status.textContent = confirmed ? 'Planned for ' + confirmed.title + '.' : 'No milestone set.';
},
loadDueDateDraft(item) {
try {
const raw = storage?.getItem(dueDateDraftKey(item));
@@ -208,7 +566,8 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
},
close(item) {
if (closeRequest) return closeRequest;
closeRequest = fetchJson(issuePath(item) + '/close', {
const access = item?.is_filed && !item?.is_assigned ? '?access=filed' : '';
closeRequest = fetchJson(issuePath(item) + '/close' + access, {
method: 'PATCH',
headers: { Accept: 'application/json' },
}).then(result => {
@@ -254,6 +613,28 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
}).finally(() => { handoffRequest = null; });
return handoffRequest;
},
reassign(item, recipient) {
if (reassignRequest) return reassignRequest;
const expectedAssignees = Array.isArray(item?.assignees) ? item.assignees.slice() : [];
reassignRequest = fetchJson(issuePath(item) + '/reassign', {
method: 'PATCH',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ recipient, expected_assignees: expectedAssignees }),
}).then(result => {
if (
result?.number !== item.number ||
result?.recipient !== recipient ||
!Array.isArray(result.assignees) ||
result.assignees.length !== 1 ||
result.assignees[0] !== recipient ||
JSON.stringify(result.previous_assignees) !== JSON.stringify(expectedAssignees)
) {
throw new Error('Issue reassignment was not confirmed.');
}
return result;
}).finally(() => { reassignRequest = null; });
return reassignRequest;
},
comment(item, body) {
if (commentRequest) return commentRequest;
this.saveDraft(item, body);
@@ -262,7 +643,8 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
operationId = storage?.getItem(operationKey(item)) || String(createOperationId()).slice(0, 128);
storage?.setItem(operationKey(item), operationId);
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
commentRequest = fetchJson(issuePath(item) + '/comments', {
const access = this.readOnly(item) ? '?access=filed' : '';
commentRequest = fetchJson(issuePath(item) + '/comments' + access, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': operationId },
body: JSON.stringify({ body }),
@@ -280,4 +662,9 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
createIssueSheet.createPlanningLoader = createPlanningLoader;
createIssueSheet.manageChecklistTask = manageChecklistTask;
createIssueSheet.completeDelegatedParentTask = completeDelegatedParentTask;
createIssueSheet.finishDelegatedParentReview = finishDelegatedParentReview;
createIssueSheet.delegatedParentReference = delegatedParentReference;
createIssueSheet.resolveDelegatedParent = resolveDelegatedParent;
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueSheet;
+2 -1
View File
@@ -90,7 +90,8 @@ function createLaterPicker({
onState({ open:true, message:result.message, value:String(input || '') });
return false;
}
const confirmed = onConfirm(item, result.value, context);
const confirmed = typeof item?.confirm === 'function' ? item.confirm(result.value) :
onConfirm(item, result.value, context);
if (confirmed !== true && typeof confirmed !== 'function') return false;
committed = true;
afterClose = typeof confirmed === 'function' ? confirmed : null;
+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 };
+185
View File
@@ -0,0 +1,185 @@
(function (root, factory) {
const api = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else root.liveDataStatus = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
const feeds = [
['context', 'Work'],
['notifications', 'Updates'],
['events', 'Activity'],
];
function boundedSeconds(value) {
const seconds = Number(value);
return Number.isFinite(seconds) && seconds >= 0 ? Math.min(Math.round(seconds), 86400) : null;
}
function describe(freshness = {}) {
const sections = freshness.sections || {};
const hasSectionData = feeds.some(([key]) => Object.prototype.hasOwnProperty.call(sections, key));
const described = feeds.map(([key, label]) => {
const section = sections[key] || {};
const state = section.revalidating ? 'refreshing' :
(section.stale || section.degraded ? 'delayed' : 'live');
return { key, label, state, ageSeconds: boundedSeconds(section.age_seconds) };
});
const delayed = described.filter(feed => feed.state === 'delayed');
const refreshing = described.filter(feed => feed.state === 'refreshing');
let summary = hasSectionData ? 'Live' : 'Live data unavailable';
if (delayed.length === 1) summary = delayed[0].label + ' delayed';
else if (delayed.length > 1) summary = delayed.length + ' data feeds delayed';
else if (refreshing.length === 1) summary = refreshing[0].label + ' refreshing';
else if (refreshing.length > 1) summary = 'Refreshing live data';
const retryValues = described.map(feed => {
const section = sections[feed.key] || {};
return boundedSeconds(section.retry_in_seconds);
}).filter(value => value !== null && value > 0);
const aggregateRetry = boundedSeconds(freshness.retry_in_seconds);
if (aggregateRetry !== null && aggregateRetry > 0) retryValues.push(aggregateRetry);
return {
summary,
feeds: described,
nextRetrySeconds: retryValues.length ? Math.min(...retryValues) : null,
};
}
function createRefreshController({ button, output, refresh, onState = () => {} }) {
let pending = null;
function run() {
if (pending) return pending;
button.disabled = true;
output.textContent = 'Refreshing live data…';
onState('refreshing');
let request;
try {
request = refresh();
} catch (error) {
request = Promise.reject(error);
}
pending = Promise.resolve(request).then(result => {
if (!result) throw new Error('Live data remains unavailable.');
output.textContent = 'Live data refreshed.';
onState('success');
return result;
}).catch(error => {
output.textContent = error && error.message ? error.message : 'Live data refresh failed.';
onState('error');
return null;
}).finally(() => {
button.disabled = false;
pending = null;
});
return pending;
}
return { run };
}
function createSheetController(options) {
let ownsDetour = false;
let backgroundState = null;
function focusableControls() {
return [...options.sheet.querySelectorAll('button:not([disabled]), [tabindex]:not([tabindex="-1"])')]
.filter(control => !control.hidden);
}
function containBackground() {
if (backgroundState) return;
backgroundState = new Map((options.backgroundElements || []).map(element => [element, element.inert]));
backgroundState.forEach((_wasInert, element) => { element.inert = true; });
}
function releaseBackground() {
if (!backgroundState) return;
backgroundState.forEach((wasInert, element) => { element.inert = wasInert; });
backgroundState = null;
}
function finishClose() {
if (options.sheet.hidden) return;
options.sheet.hidden = true;
options.trigger.setAttribute('aria-expanded', 'false');
if (options.pausedStatus) options.pausedStatus.hidden = true;
releaseBackground();
if (ownsDetour) options.timerView?.finishDetour?.();
ownsDetour = false;
options.trigger.focus?.();
}
function close() {
if (options.history?.state?.liveDataStatus) {
options.history.back();
return;
}
finishClose();
}
function open() {
if (!options.sheet.hidden) return;
ownsDetour = options.timerView?.beginDetour?.('live-data-status')?.reason === 'live-data-status';
if (options.pausedStatus) options.pausedStatus.hidden = !ownsDetour;
if (options.returnButton) options.returnButton.hidden = !ownsDetour;
containBackground();
options.sheet.hidden = false;
options.trigger.setAttribute('aria-expanded', 'true');
if (options.history && !options.history.state?.liveDataStatus) {
options.history.pushState({ ...options.history.state, liveDataStatus:true }, '');
}
options.closeButton.focus();
}
function start() {
options.trigger.addEventListener('click', open);
options.closeButton.addEventListener('click', close);
options.returnButton?.addEventListener('click', close);
options.sheet.addEventListener('click', event => {
if (event.target === options.sheet) close();
});
options.escapeTarget?.addEventListener('keydown', event => {
if (options.sheet.hidden) return;
if (event.key === 'Escape') {
event.preventDefault?.();
close();
return;
}
if (event.key !== 'Tab') return;
const controls = focusableControls();
if (!controls.length) return;
const first = controls[0];
const last = controls[controls.length - 1];
if (event.shiftKey && event.target === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && event.target === last) {
event.preventDefault();
first.focus();
}
});
options.historyTarget?.addEventListener('popstate', event => {
if (!options.sheet.hidden && !event.state?.liveDataStatus) finishClose();
});
}
return { start, open, close };
}
function mount(options) {
const document = options.document;
const qs = selector => document.querySelector(selector);
const trigger = qs('#open-live-data-status');
trigger.addEventListener('click', options.onOpen);
return createSheetController({
sheet:qs('#live-data-status-sheet'), trigger,
closeButton:qs('#close-live-data-status'),
returnButton:qs('#return-from-live-data-status'),
pausedStatus:qs('#live-data-status-today-paused'),
timerView:options.timerView,
history:options.window.history,
historyTarget:options.window,
escapeTarget:document,
backgroundElements:[qs('header'), qs('main'), qs('#mobile-task-dock')].filter(Boolean),
});
}
return { describe, createRefreshController, createSheetController, mount };
});
+75 -10
View File
@@ -4,7 +4,8 @@
}(typeof self !== 'undefined' ? self : this, function createLoginController(options) {
function validShareContinuation(value) {
if (typeof value !== 'string' || !value.startsWith('./?') || value.includes('#')) return './';
const limits = { title: 200, text: 8000, url: 2048 };
const limits = { title: 200, text: 8000, url: 2048, launch: 8, shared: 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());
if (!entries.length) return './';
@@ -14,6 +15,29 @@
|| content.length > limits[name]
));
if (invalid) return './';
if (Object.keys(limits).some(name => params.getAll(name).length > 1)) return './';
const search = params.get('search');
const preview = params.get('preview');
if (search !== null || preview !== null) {
if (entries.some(([name]) => ![
'search', 'preview', 'search_kind', 'search_state', 'search_repository'
].includes(name))) return './';
if (search === null || preview === null) return './';
if (!/^(issue|pull):[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+:[1-9]\d*$/.test(preview)) return './';
const kind = params.get('search_kind');
const state = params.get('search_state');
const repository = params.get('search_repository');
if (kind !== null && !['all', 'issue', 'pull'].includes(kind)) return './';
if (state !== null && !['all', 'open', 'closed'].includes(state)) return './';
if (repository !== null && !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) return './';
return value;
}
const launch = params.get('launch');
const shared = params.get('shared');
if (launch && !['continue', 'new', 'agenda'].includes(launch)) return './';
if (shared === 'bundle') {
if (launch !== 'new' || entries.some(([name]) => !['launch', 'shared'].includes(name))) return './';
} else if (shared !== null && (shared !== 'image' || launch !== 'new')) return './';
return value;
}
@@ -28,7 +52,31 @@
const continuation = validShareContinuation(options.continuation);
const setIntervalImpl = options.setIntervalImpl || setInterval;
const clearIntervalImpl = options.clearIntervalImpl || clearInterval;
const setTimeoutImpl = options.setTimeoutImpl || setTimeout;
const clearTimeoutImpl = options.clearTimeoutImpl || clearTimeout;
const requestTimeoutMs = options.requestTimeoutMs || 15000;
let timer = null;
let activeAttempt = false;
function setAttemptActive(active) {
activeAttempt = active;
button.disabled = active;
if (passkeyButton) passkeyButton.disabled = active || !credentials?.get;
}
async function requestWithDeadline(url, init) {
const controller = new AbortController();
const timeout = setTimeoutImpl(() => {
const error = new Error('Sign-in request timed out');
error.name = 'TimeoutError';
controller.abort(error);
}, requestTimeoutMs);
try {
return await fetchImpl(url, { ...init, signal: controller.signal });
} finally {
clearTimeoutImpl(timeout);
}
}
function decodeBase64Url(value) {
const padded = String(value).replaceAll('-', '+').replaceAll('_', '/')
@@ -58,7 +106,12 @@
};
}
if (continuation !== './') status.textContent = 'Sign in to continue your shared capture.';
if (continuation !== './') {
const continuationParams = new URLSearchParams(continuation.slice(3));
status.textContent = continuationParams.has('preview')
? 'Sign in to open the shared Search result.'
: 'Sign in to continue your shared capture.';
}
async function showReason(reason) {
if (reason === 'session-expired') {
@@ -100,18 +153,24 @@
}
async function submit(accessToken, deviceLabel = 'This device') {
if (activeAttempt) return false;
setAttemptActive(true);
status.textContent = 'Signing in…';
let response;
try {
response = await fetchImpl('api/v1/session', {
response = await requestWithDeadline('api/v1/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ access_token: accessToken, device_label: deviceLabel }),
});
} catch (_error) {
} catch (error) {
form.reset();
status.textContent = 'Sign-in failed. Check your connection and try again.';
return;
status.textContent = error?.name === 'TimeoutError'
? 'Sign-in timed out. Check your connection and try again.'
: 'Sign-in failed. Check your connection and try again.';
return false;
} finally {
setAttemptActive(false);
}
form.reset();
if (response.ok) {
@@ -130,9 +189,11 @@
status.textContent = 'Passkeys are not supported in this browser. Use the access token.';
return false;
}
if (activeAttempt) return false;
setAttemptActive(true);
status.textContent = 'Waiting for your passkey…';
try {
const optionsResponse = await fetchImpl('api/v1/passkeys/authentication/options', {
const optionsResponse = await requestWithDeadline('api/v1/passkeys/authentication/options', {
method: 'POST', headers: { Accept: 'application/json' },
});
if (!optionsResponse.ok) throw new Error('No enrolled passkey');
@@ -143,7 +204,7 @@
...item, id: decodeBase64Url(item.id),
}));
const credential = await credentials.get({ publicKey });
const response = await fetchImpl('api/v1/passkeys/authentication/verify', {
const response = await requestWithDeadline('api/v1/passkeys/authentication/verify', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -157,9 +218,13 @@
if (!response.ok) throw new Error('Passkey verification failed');
location.replace(continuation);
return true;
} catch (_error) {
status.textContent = 'Passkey sign-in was not completed. Try again or use the access token.';
} catch (error) {
status.textContent = error?.name === 'TimeoutError'
? 'Passkey sign-in timed out. Check your connection and try again.'
: 'Passkey sign-in was not completed. Try again or use the access token.';
return false;
} finally {
setAttemptActive(false);
}
}
+32 -4
View File
@@ -12,10 +12,38 @@
{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"},
{"src": "static/icons/stackchain-512.png", "sizes": "512x512", "type": "image/png"}
],
"shortcuts": [
{
"name": "Continue work",
"short_name": "Continue",
"description": "Resume the highest-priority mobile work flow.",
"url": "./?launch=continue",
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}]
},
{
"name": "New issue",
"short_name": "New",
"description": "Capture work now and file it online or offline.",
"url": "./?launch=new",
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}]
},
{
"name": "Open Agenda",
"short_name": "Agenda",
"description": "Check every assigned deadline and continue the Agenda session.",
"url": "./?launch=agenda",
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}]
}
],
"share_target": {
"action": "./",
"method": "GET",
"enctype": "application/x-www-form-urlencoded",
"params": {"title": "title", "text": "text", "url": "url"}
"action": "./share-target",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [{"name": "image", "accept": ["image/png", "image/jpeg", "image/webp"]}]
}
}
}
+43 -6
View File
@@ -32,23 +32,33 @@
return rendered.replace(/\u0000CODE(\d+)\u0000/g, (_match, index) => code[Number(index)]);
}
function renderList(lines) {
function renderList(lines, options, firstTaskIndex) {
const taskList = lines.every(line => /^[-*+]\s+\[[ xX]\]\s+/.test(line));
const items = lines.map(line => {
const items = lines.map((line, offset) => {
let body = line.replace(/^[-*+]\s+/, '');
if (!taskList) return '<li>' + renderInline(body) + '</li>';
const checked = /^\[[xX]\]\s+/.test(body);
body = body.replace(/^\[[ xX]\]\s+/, '');
return '<li class="task-list-item"><input type="checkbox" disabled' +
(checked ? ' checked' : '') + '> ' + renderInline(body) + '</li>';
const control = options.interactiveTasks ?
' class="task-list-toggle" data-task-index="' + (firstTaskIndex + offset) +
'" aria-label="Mark ' + escapeHtml(body) + (checked ? ' incomplete"' : ' complete"') :
' disabled';
const manage = options.interactiveTasks && options.manageTasks ?
'<button type="button" class="task-list-manage" data-task-index="' + (firstTaskIndex + offset) +
'" data-task-label="' + escapeHtml(body) + '" aria-label="Manage step: ' + escapeHtml(body) + '"' +
(offset === 0 ? ' data-task-first="true"' : '') +
(offset === lines.length - 1 ? ' data-task-last="true"' : '') + '>Manage</button>' : '';
return '<li class="task-list-item"><input type="checkbox"' + control +
(checked ? ' checked' : '') + '> ' + renderInline(body) + manage + '</li>';
}).join('');
return '<ul' + (taskList ? ' class="task-list"' : '') + '>' + items + '</ul>';
}
return function renderMarkdown(raw) {
function renderMarkdown(raw, options = {}) {
const lines = String(raw || '').replace(/\r\n?/g, '\n').split('\n');
const output = [];
let index = 0;
let taskIndex = 0;
while (index < lines.length) {
const line = lines[index];
if (!line.trim()) {
@@ -90,7 +100,10 @@
list.push(lines[index]);
index += 1;
}
output.push(renderList(list));
output.push(renderList(list, options, taskIndex));
if (list.every(candidate => /^[-*+]\s+\[[ xX]\]\s+/.test(candidate))) {
taskIndex += list.length;
}
continue;
}
const paragraph = [];
@@ -103,5 +116,29 @@
output.push('<p>' + renderInline(paragraph.join('\n')).replace(/\n/g, '<br>') + '</p>');
}
return output.join('');
}
renderMarkdown.toggleTask = function toggleTask(raw, targetIndex, checked) {
const parts = String(raw || '').split(/(\r\n|\n|\r)/);
let fenced = false;
let taskIndex = 0;
for (let index = 0; index < parts.length; index += 2) {
const line = parts[index];
if (/^\s*```/.test(line)) {
fenced = !fenced;
continue;
}
if (fenced) continue;
const task = line.match(/^([-*+]\s+\[)([ xX])(\])/);
if (!task) continue;
if (taskIndex === Number(targetIndex)) {
parts[index] = task[1] + (checked ? 'x' : ' ') + task[3] + line.slice(task[0].length);
return parts.join('');
}
taskIndex += 1;
}
return parts.join('');
};
return renderMarkdown;
});
+31
View File
@@ -0,0 +1,31 @@
(function (root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.mobileAppShortcuts = api;
})(typeof self !== 'undefined' ? self : this, function () {
const ACTIONS = new Set(['continue', 'new', 'agenda']);
function parse(search) {
const action = new URLSearchParams(search || '').get('launch');
return ACTIONS.has(action) ? action : null;
}
function createController(options) {
const launchAction = parse(options.search);
let handled = false;
function action() { return launchAction; }
function run() {
if (!launchAction || handled) return null;
handled = true;
if (launchAction === 'continue') return options.continueWork();
if (launchAction === 'new') return options.newIssue();
return options.agenda();
}
return {action, run};
}
return {parse, createController};
});
+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};
});
+90 -12
View File
@@ -5,11 +5,34 @@
const promptDismissKey = 'stackchain.device-setup-prompt-dismissed-until';
const promptDismissMs = 7 * 24 * 60 * 60 * 1000;
const steps = [
['install', options.installButton, options.installStatus, options.install],
['offline', options.offlineButton, options.offlineStatus, options.enableOffline],
['push', options.pushButton, options.pushStatus, options.enablePush],
['install', options.installButton, options.installStatus, options.install, 'Install'],
['offline', options.offlineButton, options.offlineStatus, options.enableOffline, 'Enable'],
['protection', options.protectionButton, options.protectionStatus, options.protectStorage, 'Protect'],
['push', options.pushButton, options.pushStatus, options.enablePush, 'Enable'],
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline, 'Enable'],
];
let trigger = options.launcher;
let ownsDetour = false;
let backgroundInert = null;
let pendingResume = true;
function focusableControls() {
return [...options.sheet.querySelectorAll(
'button:not([disabled]), select:not([disabled]), input:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
)].filter(control => !control.hidden);
}
function containBackground() {
if (backgroundInert) return;
backgroundInert = new Map((options.backgroundElements || []).map(element => [element, element.inert]));
backgroundInert.forEach((_wasInert, element) => { element.inert = true; });
}
function releaseBackground() {
if (!backgroundInert) return;
backgroundInert.forEach((wasInert, element) => { element.inert = wasInert; });
backgroundInert = null;
}
function readinessCounts(readiness) {
const available = steps.filter(([name]) => readiness[name].state !== 'unavailable').length;
@@ -32,9 +55,10 @@
const readiness = await options.getReadiness();
let available = 0;
let complete = 0;
steps.forEach(([name, button, status]) => {
steps.forEach(([name, button, status, _action, defaultActionLabel]) => {
const step = readiness[name];
status.textContent = step.detail;
button.textContent = step.actionLabel || defaultActionLabel;
button.hidden = step.state === 'complete' || step.state === 'unavailable';
button.disabled = step.state === 'pending';
if (step.state !== 'unavailable') available += 1;
@@ -49,16 +73,35 @@
async function open(event) {
trigger = event?.currentTarget || options.launcher;
if (options.isMobile?.()) {
ownsDetour = options.timerView?.beginDetour?.('device-setup')?.reason === 'device-setup';
}
await render();
containBackground();
options.sheet.hidden = false;
if (options.history && !options.history.state?.deviceSetup) {
options.history.pushState({...options.history.state, deviceSetup:true}, '');
}
options.closeButton.focus();
}
function close() {
function finishClose(resume = true) {
options.sheet.hidden = true;
releaseBackground();
if (resume && ownsDetour) options.timerView?.finishDetour?.();
ownsDetour = false;
trigger?.focus?.();
}
function close(resume = true) {
if (options.history?.state?.deviceSetup) {
pendingResume = resume;
options.history.back();
return;
}
finishClose(resume);
}
async function start() {
options.launcher.addEventListener('click', open);
options.promptLauncher?.addEventListener('click', open);
@@ -69,11 +112,33 @@
options.promptCard.hidden = true;
});
options.closeButton.addEventListener('click', close);
options.returnButton?.addEventListener('click', () => close(false));
options.sheet.addEventListener('click', event => {
if (event.target === options.sheet) close();
});
options.escapeTarget.addEventListener('keydown', event => {
if (event.key === 'Escape' && !options.sheet.hidden) close();
if (event.key === 'Escape' && !options.sheet.hidden) {
event.preventDefault?.();
close();
}
if (event.key !== 'Tab' || options.sheet.hidden) return;
const controls = focusableControls();
if (!controls.length) return;
const first = controls[0];
const last = controls[controls.length - 1];
if (event.shiftKey && event.target === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && event.target === last) {
event.preventDefault();
first.focus();
}
});
options.historyTarget?.addEventListener('popstate', event => {
if (!options.sheet.hidden && !event.state?.deviceSetup) {
finishClose(pendingResume);
pendingResume = true;
}
});
steps.forEach(([_name, button, _status, action]) => {
button.addEventListener('click', async () => {
@@ -96,16 +161,29 @@ if (typeof module === 'object' && module.exports) {
function mountMobileDeviceSetup(options) {
const qs = selector => options.document.querySelector(selector);
const window = options.window || options.document.defaultView;
return createMobileDeviceSetup({
launcher:qs('#open-device-setup'), closeButton:qs('#close-device-setup'),
sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'),
offlineButton:qs('#device-setup-offline'), pushButton:qs('#device-setup-push'),
protectionButton:qs('#device-setup-protection'),
deadlineButton:qs('#device-setup-deadline'),
installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'),
pushStatus:qs('#device-setup-push-status'), readyStatus:qs('#device-setup-ready-status'),
protectionStatus:qs('#device-setup-protection-status'),
pushStatus:qs('#device-setup-push-status'), deadlineStatus:qs('#device-setup-deadline-status'),
readyStatus:qs('#device-setup-ready-status'),
promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'),
promptLauncher:qs('#finish-device-setup'), promptDismiss:qs('#dismiss-device-readiness'),
promptStorage:options.promptStorage,
returnButton:qs('#return-from-device-setup'),
timerView:options.timerView,
isMobile:options.isMobile || (() => innerWidth <= 600),
escapeTarget:options.document,
history:window?.history,
historyTarget:window,
backgroundElements:[
qs('header'), qs('main'), qs('#mobile-task-dock'),
].filter(Boolean),
getReadiness:() => ({
install:options.installApp.state(),
offline:!options.offlineAvailable()
@@ -113,14 +191,14 @@ function mountMobileDeviceSetup(options) {
: options.offlineEnabled()
? {state:'complete', detail:qs('#offline-work-status').textContent || 'Offline work is saved.'}
: {state:'incomplete', detail:'Private My Work and Today data are not saved offline.'},
push:qs('#push-updates').disabled
? {state:'unavailable', detail:qs('#push-update-status').textContent || 'Update notifications are unavailable.'}
: qs('#push-updates').checked
? {state:'complete', detail:'New update notifications are enabled.'}
: {state:'incomplete', detail:qs('#push-update-status').textContent || 'New update notifications are off.'},
protection:options.storageProtectionReadiness(),
push:options.notificationReadiness(),
deadline:options.deadlineReadiness(),
}),
install:() => options.installApp.install(),
enableOffline:options.enableOffline,
protectStorage:options.protectStorage,
enablePush:options.enablePush,
enableDeadline:options.enableDeadline,
});
}
+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,
};
};
});
+103
View File
@@ -0,0 +1,103 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileFirstTask = factory;
})(typeof self !== 'undefined' ? self : this, function createMobileFirstTask(options) {
const doc = typeof document !== 'undefined' ? document : null;
const win = typeof window !== 'undefined' ? window : null;
options = Object.assign({
storage: typeof localStorage !== 'undefined' ? localStorage : null,
isOnline: () => typeof navigator === 'undefined' || navigator.onLine,
mediaQuery: win?.matchMedia('(max-width: 600px)') || {matches:false},
eventTarget: win,
sheet: doc?.querySelector('#mobile-first-task'),
title: doc?.querySelector('#mobile-first-task-title'),
findButton: doc?.querySelector('#mobile-first-task-find'),
createButton: doc?.querySelector('#mobile-first-task-create'),
setupButton: doc?.querySelector('#mobile-first-task-setup'),
closeButton: doc?.querySelector('#close-mobile-first-task'),
status: doc?.querySelector('#mobile-first-task-status'),
isTodayActive: () => false,
onFind: () => doc?.querySelector('#find-work')?.click(),
onCreate: () => doc?.querySelector('#new-issue')?.click(),
onSetup: () => doc?.querySelector('#open-device-setup')?.click(),
}, options);
const prefix = 'stackchain.first-task.v1:';
function account() {
return String(options.getLogin?.() || '').trim().toLowerCase();
}
function key() {
const login = account();
return login ? prefix + login : '';
}
function completed() {
const currentKey = key();
if (!currentKey) return false;
try { return options.storage.getItem(currentKey) === 'complete'; }
catch (_error) { return false; }
}
function required() {
return Boolean(options.mediaQuery.matches && account() && !options.isTodayActive() && !completed());
}
function markComplete() {
const currentKey = key();
if (!currentKey) return;
try { options.storage.setItem(currentKey, 'complete'); }
catch (_error) {}
}
function render() {
const online = options.isOnline();
const awaitingStart = options.hasWork();
options.findButton.disabled = !online;
if (options.title) options.title.textContent = awaitingStart ? 'Finish starting your first task' : 'Start your first task';
options.findButton.textContent = 'Find & start';
options.createButton.textContent = 'Create & start';
options.status.textContent = awaitingStart ?
'Your task is ready. Start it from Find or create and start another task.' : online ?
'Choose a task to claim and start, or create and start one of your own.' :
'You are offline. Create a task now and it will stay in Drafts until you reconnect.';
}
function open() {
if (!required()) return false;
render();
if (!options.sheet.open) options.sheet.showModal();
(options.findButton.disabled ? options.createButton : options.findButton).focus();
return true;
}
function refresh() {
if (account() && options.isTodayActive() && !completed()) {
markComplete();
if (options.sheet.open) options.sheet.close();
return 'completed';
}
if (options.sheet.open) render();
if (required() && options.hasWork()) return 'awaiting-start';
return required() ? 'required' : 'inactive';
}
function handoff(callback, requiresOnline = false) {
if (requiresOnline && !options.isOnline()) return;
if (options.sheet.open) options.sheet.close();
callback();
}
function start() {
options.findButton.addEventListener('click', () => handoff(options.onFind, true));
options.createButton.addEventListener('click', () => handoff(options.onCreate));
options.setupButton.addEventListener('click', () => handoff(options.onSetup));
options.closeButton.addEventListener('click', () => {
if (options.sheet.open) options.sheet.close();
});
options.eventTarget?.addEventListener('online', render);
options.eventTarget?.addEventListener('offline', render);
}
return {required, open, refresh, render, start};
});
+96
View File
@@ -0,0 +1,96 @@
(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';
const events = o.eventTarget || self;
const location = o.location || self.location;
let active = false;
let previous = '#/my-work';
let retries = 0;
function attr(element, name, value) {
if (value === null) element.removeAttribute(name);
else element.setAttribute(name, value);
}
function render(open, focus = false) {
const wasActive = active;
active = Boolean(open && o.mediaQuery.matches);
const detour = active ? o.detour?.() : null;
const started = detour?.beginDetour('insights');
if (detour && !started && retries++ < 40) setTimeout(sync, 250);
else if (started && retries) { retries = 0; setTimeout(sync, 500); }
else retries = 0;
if (wasActive && !active) o.detour?.()?.finishDetour();
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 (location.pathname || '') + (location.search || '') + hash;
}
function open() {
if (!o.mediaQuery.matches || active) return false;
previous = location.hash && location.hash !== route ? location.hash : '#/my-work';
const menu = o.menu || o.launcher.closest?.('details');
if (menu) menu.open = false;
o.history.pushState({ ...(o.history.state || {}), mobileInsights:true, previousHash:previous }, '', url(route));
render(true);
return true;
}
function sync() {
render(location.hash === route, active && 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 returnToToday() {
if (!active) return false;
const state = { ...(o.history.state || {}) };
delete state.mobileInsights;
delete state.previousHash;
o.history.replaceState(state, '', url(previous));
render(false);
return true;
}
function start() {
o.launcher.addEventListener('click', open);
o.closeButton.addEventListener('click', close);
(o.returnButton || o.root.querySelector?.('[data-return-from-detour]'))?.addEventListener('click', returnToToday);
events.addEventListener('popstate', sync);
events.addEventListener('hashchange', sync);
events.addEventListener('keydown', event => {
if (active && event.key === 'Escape') {
event.preventDefault?.();
close();
}
});
o.mediaQuery.addEventListener?.('change', sync);
sync();
}
return { start, open, close, sync, 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;
+3 -2
View File
@@ -4,13 +4,14 @@
else root.mobileLaunch = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
const allowedFilters = new Set([
'all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft',
'all', 'today', 'attention', 'agenda', 'issue', 'pull', 'review', 'update', 'later', 'draft',
]);
function chooseFilter({ saved, today = 0, attention = 0 } = {}) {
function chooseFilter({ saved, today = 0, attention = 0, agenda = 0 } = {}) {
if (allowedFilters.has(saved)) return saved;
if (today > 0) return 'today';
if (attention > 0) return 'attention';
if (agenda > 0) return 'agenda';
return 'all';
}
+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();
},
};
});
+33 -1
View File
@@ -8,9 +8,32 @@
later: 'No deferred work is ready to open.',
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'],
];
function recommend() {
const counts = options.getCounts ? options.getCounts() : {};
const match = continuation.find(([name]) => Number(counts[name]) > 0);
if (!match) return {name: 'find', count: 0, label: 'Find Work'};
const [name, label] = match;
const count = Math.max(0, Number(counts[name]) || 0);
return {name, count, label: label + ' (' + count + ')'};
}
function open(name) {
if (name === '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();
if (name === 'filed' && options.openFiled) return options.openFiled();
options.selectFilter(name);
const action = options.firstAction(name);
if (!action) {
@@ -21,5 +44,14 @@
return 'opened';
}
return { open };
function continueWork() {
const next = recommend();
if (next.name === 'find') {
options.openFindWork();
return 'find';
}
return open(next.name);
}
return { open, recommend, continueWork };
});
+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});
+180
View File
@@ -0,0 +1,180 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileStartDay = factory;
})(typeof self !== 'undefined' ? self : this, function createMobileStartDay(options) {
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'],
['filed', 'Filed'],
];
const anonymousItems = new WeakMap();
let anonymousItemSequence = 0;
function count(value) {
return Math.max(0, Number(value) || 0);
}
function itemIdentity(item) {
if (item === null || item === undefined) return '';
if (typeof item !== 'object') return String(item);
const explicit = item.outbox_id || item.id || item.key || item.url;
if (explicit !== null && explicit !== undefined && String(explicit)) {
return String(item.kind || 'item') + ':' + String(explicit);
}
if (!anonymousItems.has(item)) anonymousItems.set(item, 'anonymous:' + (++anonymousItemSequence));
return anonymousItems.get(item);
}
function reviewPhases(counts) {
if (!options.getPhaseItems) {
return reviewOrder
.map(([name, label]) => ({name, label, count: count(counts[name])}))
.filter(phase => phase.count > 0);
}
const items = options.getPhaseItems() || {};
const seen = new Set();
return reviewOrder.flatMap(([name, label]) => {
let phaseCount = 0;
(items[name] || []).forEach(item => {
const identity = itemIdentity(item);
if (!identity || seen.has(identity)) return;
seen.add(identity);
phaseCount += 1;
});
return phaseCount ? [{name, label, count:phaseCount}] : [];
});
}
function localDay() {
const now = new Date();
const pad = value => String(value).padStart(2, '0');
return now.getFullYear() + '-' + pad(now.getMonth() + 1) + '-' + pad(now.getDate());
}
function identity() {
try {
return {
login: String(options.getLogin ? options.getLogin() : '').trim(),
day: String(options.getDay ? options.getDay() : localDay()),
};
} catch (_) {
return {login:'', day:''};
}
}
function checkpoint() {
if (!storage) return null;
const current = identity();
if (!current.login || !current.day) return null;
try {
const saved = JSON.parse(storage.getItem(checkpointKey) || 'null');
return saved?.login === current.login && saved?.day === current.day ? saved : null;
} catch (_) {
return null;
}
}
function saveCheckpoint(phase = null) {
if (!storage) return false;
const current = identity();
if (!current.login || !current.day) return false;
try {
storage.setItem(checkpointKey, JSON.stringify({...current, phase}));
return true;
} catch (_) {
return false;
}
}
function clearCheckpoint() {
if (!storage || !checkpoint()) return false;
try {
storage.removeItem(checkpointKey);
return true;
} catch (_) {
return false;
}
}
function briefing() {
const counts = options.getCounts ? options.getCounts() : {};
const phases = reviewPhases(counts);
const total = phases.reduce((sum, phase) => sum + phase.count, 0);
const today = count(counts.today);
const delivery = phases.find(phase => phase.name === 'delivery')?.count || 0;
const other = total - delivery;
const next = phases.length ? phases[0].name : (today ? 'today' : 'find');
const nextLabel = next === 'delivery' ? 'Review Delivery' :
(phases.length ? 'Review ' + phases[0].label : (today ? 'Continue Today' : 'Find Work'));
return {
total,
next,
label: nextLabel,
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,
};
}
function startNext() {
const next = briefing().next;
if (reviewOrder.some(([name]) => name === next)) saveCheckpoint(next);
else clearCheckpoint();
options.openQueue(next);
return next;
}
function state() {
const current = briefing();
return {active:Boolean(checkpoint()), next:current.next, label:current.label};
}
function completePhase(phase) {
const saved = checkpoint();
if (!saved || (saved.phase && saved.phase !== phase)) return false;
const current = render();
saveCheckpoint();
if (options.onHandoff) options.onHandoff(current);
return true;
}
function reconcile({authoritative = false, authoritativePhases = []} = {}) {
const saved = checkpoint();
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);
}
function finish() {
const cleared = clearCheckpoint();
render();
return cleared;
}
function render() {
const current = briefing();
if (!options.elements) return current;
options.elements.summary.textContent = current.summary;
options.elements.phases.textContent = current.phases.length ?
current.phases.map(phase => phase.label + ' ' + phase.count).join(' · ') :
'All urgent queues reviewed';
options.elements.action.textContent = checkpoint() ? 'Resume preparation · ' + current.label : current.label;
if (options.elements.finish) options.elements.finish.hidden = !checkpoint();
return current;
}
function start() {
render();
if (options.elements) options.elements.action.addEventListener('click', startNext);
}
return {briefing, completePhase, finish, reconcile, render, start, startNext, state};
});
+44 -21
View File
@@ -33,8 +33,9 @@
options.queueSheet.showModal();
}
function closeQueues() {
function closeQueues(returnToToday = false) {
options.queueSheet?.open && options.queueSheet.close();
if (returnToToday) options.detour?.()?.finishDetour();
}
function start() {
@@ -42,14 +43,16 @@
button.addEventListener('click', event => {
launcher = event.currentTarget;
select(name);
name === 'queues' && options.queueSheet ? openQueues() : options.actions[name]();
if (name === 'queues') options.detour?.()?.beginDetour(name);
options.actions?.[name]?.();
if (name === 'queues' && options.queueSheet) openQueues();
});
});
if (options.queueSheet) {
options.queueClose?.addEventListener('click', closeQueues);
options.queueClose?.addEventListener('click', () => closeQueues(true));
options.queueSheet.addEventListener('cancel', event => {
event.preventDefault();
closeQueues();
closeQueues(true);
});
options.queueSheet.addEventListener('close', () => buttons.queues?.focus());
Object.entries(options.queueRows || {}).forEach(([name, row]) => {
@@ -64,18 +67,18 @@
}
function updateWork(mode) {
const labels = {
continue: ['Continue', 'Continue Today'],
resume: ['Resume', 'Resume Today'],
start: ['Start', 'Start Today'],
plan: ['Plan', 'Plan Today'],
work: ['Work', 'Work'],
};
const workMode = labels[mode] ? mode : 'work';
if (options.workLabel) options.workLabel.textContent = labels[workMode][0];
if (buttons.work) {
buttons.work.setAttribute('aria-label', labels[workMode][1]);
}
const text = {
continue:'Continue', resume:'Resume', start:'Start', plan:'Plan', find:'Find',
prepare:'Prepare', 'prepare-resume':'Resume prep',
delivery:'Delivery', attention:'Attention', update:'Updates', agenda:'Agenda', filed:'Filed', later:'Later', draft:'Drafts',
}[mode] || 'Work';
const queue = ['Delivery', 'Attention', 'Updates', 'Agenda', 'Filed', 'Later', 'Drafts'].includes(text);
if (options.workLabel) options.workLabel.textContent = text;
const actionLabel = mode === 'prepare' ? 'Prepare Today' :
mode === 'prepare-resume' ? 'Resume preparation' :
queue ? (mode === 'update' ? 'Resume ' : 'Open ') + text :
mode === 'find' ? 'Find work' : text + (mode === 'work' ? '' : ' Today');
if (buttons.work) buttons.work.setAttribute('aria-label', actionLabel);
}
function updateAttention(count) {
@@ -94,9 +97,10 @@
}
function updateQueues(counts) {
const names = 'today attention update 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 total = ['today', 'attention', 'later', 'draft'].reduce((sum, name) => sum + normalized[name], 0);
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);
});
@@ -104,10 +108,29 @@
'aria-label', 'Updates, ' + normalized.update + ' unread conversations'
);
if (options.queueBadge) {
options.queueBadge.textContent = String(total);
options.queueBadge.hidden = total === 0;
options.queueBadge.textContent = active + ' active';
options.queueBadge.hidden = active === 0;
}
buttons.queues?.setAttribute('aria-label', 'Queues, ' + total + ' items');
if (options.deadlineBadge) {
options.deadlineBadge.textContent = normalized.agenda + ' due';
options.deadlineBadge.hidden = normalized.agenda === 0;
}
if (options.queueRows?.agenda) {
if (normalized.agenda > 0) options.queueRows.agenda.setAttribute('data-deadlines', 'true');
else options.queueRows.agenda.removeAttribute('data-deadlines');
}
const label = active === 0 && normalized.agenda === 0
? 'Queues: no active queues; no upcoming deadlines'
: 'Queues: Today ' + normalized.today
+ ', Agenda ' + normalized.agenda + ' due'
+ ', Delivery ' + normalized.delivery
+ ', Attention ' + normalized.attention
+ ', Updates ' + normalized.update
+ ', Filed ' + normalized.filed
+ ', Later ' + normalized.later
+ ', Drafts ' + normalized.draft
+ '; ' + active + ' active ' + (active === 1 ? 'queue' : 'queues');
buttons.queues?.setAttribute('aria-label', label);
}
return {start, select, refreshVisibility, updateAttention, updateQueues, updateWork};
+56
View File
@@ -0,0 +1,56 @@
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
else root.createMobileTodayCommandBar = factory;
})(typeof window !== 'undefined' ? window : this, function createMobileTodayCommandBar({
more,
sheet,
close,
firstAction,
endAction,
existingEndControl,
actionButtons = [],
hud,
dock,
style,
ResizeObserverImpl = typeof ResizeObserver === 'undefined' ? null : ResizeObserver,
}) {
if (!more || !sheet) return null;
const dismiss = () => {
if (sheet.open) sheet.close();
};
const open = () => {
if (sheet.open) return;
more.setAttribute('aria-expanded', 'true');
sheet.showModal();
firstAction?.focus();
};
more.setAttribute('aria-expanded', 'false');
more.addEventListener('click', open);
close?.addEventListener('click', dismiss);
actionButtons.forEach(button => button?.addEventListener('click', dismiss));
endAction?.addEventListener('click', () => {
dismiss();
existingEndControl?.click();
});
sheet.addEventListener('cancel', event => {
event.preventDefault();
dismiss();
});
sheet.addEventListener('close', () => {
more.setAttribute('aria-expanded', 'false');
more.focus();
});
const measure = () => {
if (!style || !hud || !dock) return;
style.setProperty('--mobile-today-clearance', `${hud.offsetHeight + dock.offsetHeight + 16}px`);
};
const observer = ResizeObserverImpl && hud && dock ? new ResizeObserverImpl(measure) : null;
observer?.observe(hud);
observer?.observe(dock);
measure();
return { open, close:dismiss, measure, destroy:() => observer?.disconnect() };
});
+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;
+11 -2
View File
@@ -4,19 +4,28 @@
})(typeof self !== 'undefined' ? self : this, function createMobileWorkEntry(options) {
function mode() {
if (options.isTodayActive()) return 'continue';
const preparation = options.getPreparationState?.();
if (preparation?.active) return 'prepare-resume';
if (preparation?.next && !['today', 'find'].includes(preparation.next)) return 'prepare';
const recommended = options.queueLauncher && options.queueLauncher.recommend();
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';
return 'work';
if (options.shouldActivate?.()) return 'activate';
return 'find';
}
function open() {
const current = mode();
if (current === 'continue') options.continueToday();
else if (['prepare', 'prepare-resume'].includes(current)) options.prepareToday();
else if (current === 'resume') options.resumeToday();
else if (current === 'start') options.startToday();
else if (current === 'plan') options.planToday();
else options.openFallback();
else if (current === 'activate') options.openActivation();
else if (current === 'find') options.findWork();
else options.queueLauncher.open(current);
return current;
}
+285 -29
View File
@@ -1,15 +1,26 @@
function issueDueState(dueDate, now) {
if (!dueDate) return null;
const due = new Date(dueDate);
if (Number.isNaN(due.getTime())) return null;
const day = value => value.getFullYear() + '-' + String(value.getMonth() + 1).padStart(2, '0') + '-' +
function calendarDay(value) {
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})(?:$|T)/);
if (!match) return '';
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
return date.getFullYear() === Number(match[1]) && date.getMonth() === Number(match[2]) - 1 &&
date.getDate() === Number(match[3]) ? match.slice(1, 4).join('-') : '';
}
function localDay(value) {
return value.getFullYear() + '-' + String(value.getMonth() + 1).padStart(2, '0') + '-' +
String(value.getDate()).padStart(2, '0');
const dueDay = day(due);
const today = day(now);
}
function issueDueState(dueDate, now) {
const dueDay = calendarDay(dueDate);
if (!dueDay) return null;
const today = localDay(now);
if (dueDay < today) return { label: 'Overdue', priority: 2 };
if (dueDay === today) return { label: 'Due today', priority: 2.5 };
return {
label: 'Due ' + due.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
label: 'Due ' + new Date(
Number(dueDay.slice(0, 4)), Number(dueDay.slice(5, 7)) - 1, Number(dueDay.slice(8, 10))
).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
priority: 4,
};
}
@@ -33,6 +44,19 @@ function needsAttention(item) {
return Boolean(item && (item.has_update || item.is_review || urgencyReason(item)));
}
function updateActionability(item) {
const priorityLabels = ['p0', 'priority-high', 'critical'];
if ((item?.labels || []).some(label => priorityLabels.includes(String(label).toLowerCase()))) {
return { priority: 0, reason: 'Critical' };
}
if (item?.is_review) return { priority: 1, reason: 'Review requested' };
if (item?.is_assigned && ['Overdue', 'Due today'].includes(item?.due_label)) {
return { priority: 2, reason: item.due_label };
}
if (item?.is_assigned) return { priority: 3, reason: 'Assigned to you' };
return { priority: 4, reason: '' };
}
function buildMyWork(data, now = new Date()) {
const login = data.user?.login || '';
const issues = (data.issues || []).map((item) => ({ ...item, kind: 'issue' }));
@@ -46,19 +70,24 @@ function buildMyWork(data, now = new Date()) {
);
const assigned = (item.assignees || []).includes(login);
const isReview = (item.work_reasons || []).includes('review_requested');
const isFiled = (item.work_reasons || []).includes('created_by_me');
const isCompleted = isFiled && item.state === 'closed';
const due = item.kind === 'issue' ? issueDueState(item.due_date, now) : null;
const normalized = {
...item,
key: (item.repository || 'unknown') + '#' + item.number,
is_review: isReview,
is_filed: isFiled,
is_completed: isCompleted,
is_assigned: assigned,
has_update: false,
...(due ? { due_label: due.label } : {}),
reason: priorityLabel ? priorityLabel + ' priority' :
(due && due.priority < 4 ? due.label :
(isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : 'Open work'))),
(isReview ? 'Needs your review' : (isCompleted ? 'Completed · review outcome' :
(assigned ? 'Assigned to you' : (isFiled ? 'Filed by you' : 'Open work'))))),
_priority: priorityLabel ? 0 :
(due && due.priority < 4 ? due.priority : (isReview ? 3 : (assigned ? 4 : 5))),
(due && due.priority < 4 ? due.priority : (isReview ? 3 : (isCompleted ? 3.5 : (assigned ? 4 : 5)))),
};
normalized.needs_attention = needsAttention(normalized);
normalized.attention_reason = attentionReason(normalized);
@@ -71,13 +100,15 @@ function buildMyWork(data, now = new Date()) {
const subjectKind = String(update.subject_type || '').toLowerCase().includes('pull') ? 'pull' : 'issue';
const existing = byKey.get(subjectKind + ':' + key);
if (existing) {
const actionability = updateActionability(existing);
existing.has_update = true;
existing.needs_attention = true;
existing.attention_reason = 'Unread update';
existing.notification_id = update.id;
existing.url = update.url || existing.url;
existing.updated_at = update.updated_at || existing.updated_at;
existing._priority = Math.min(existing._priority, 1);
existing.update_reason = actionability.reason;
existing._priority = actionability.priority;
return;
}
if (!update.url) return;
@@ -92,7 +123,8 @@ function buildMyWork(data, now = new Date()) {
attention_reason: 'Unread update',
notification_id: update.id,
reason: 'Unread update',
_priority: 1,
update_reason: '',
_priority: 4,
};
work.push(item);
byKey.set(subjectKind + ':' + key, item);
@@ -237,7 +269,8 @@ function createNotificationSelection({ limit = 50, onChange = () => {} } = {}) {
function createNotificationPager({ load, onNotifications, onPagination, onStatus }) {
let pagination = { page: 1, total: 0, has_more: false };
let pending = false;
return {
let completing = null;
const pager = {
reset(next) {
pagination = { ...pagination, ...(next || {}) };
onPagination(pagination);
@@ -271,18 +304,31 @@ function createNotificationPager({ load, onNotifications, onPagination, onStatus
pending = false;
}
},
loadAll(getExisting) {
if (completing) return completing;
completing = (async () => {
while (pagination.has_more) {
const loaded = await pager.loadMore(getExisting());
if (!loaded) return false;
}
return true;
})().finally(() => { completing = null; });
return completing;
},
};
return pager;
}
function createWorkPager({ load, onItems, onPagination, onStatus }) {
let pagination = {};
const pending = new Set();
const completing = new Map();
const labels = {
issue: 'issues',
pull: 'pull requests',
review: 'review requests',
};
return {
const pager = {
reset(next) {
Object.entries(next || {}).forEach(([stream, value]) => {
const current = pagination[stream];
@@ -331,7 +377,20 @@ function createWorkPager({ load, onItems, onPagination, onStatus }) {
pending.delete(stream);
}
},
loadAll(stream, getExisting) {
if (completing.has(stream)) return completing.get(stream);
const completion = (async () => {
while (pagination[stream]?.has_more) {
const loaded = await pager.loadMore(stream, getExisting());
if (!loaded) return false;
}
return true;
})().finally(() => completing.delete(stream));
completing.set(stream, completion);
return completion;
},
};
return pager;
}
function createNotificationReader({
@@ -341,13 +400,52 @@ function createNotificationReader({
loadSaved = () => null,
loadConversation = null,
onConversation = () => {},
onConversationStatus = () => {},
createPager = typeof createConversationPager === 'function' ? createConversationPager : null,
getScope = () => '',
}) {
let selected = null;
let loadVersion = 0;
let marking = false;
let conversationPager = null;
let offlineHydrated = false;
let prefetched = null;
let prefetchKey = '';
async function hydrateConversation(item, version) {
if (!loadConversation || !createPager || !item || offlineHydrated) return false;
onConversationStatus('Loading conversation…');
try {
const initial = await loadConversation(item.notification_id, null);
if (selected !== item || version !== loadVersion) return false;
conversationPager = createPager({
loadPage: page => loadConversation(item.notification_id, page),
});
const state = conversationPager.reset(initial);
onConversation(state);
onConversationStatus(state.comments.length + ' of ' +
Math.max(state.total || 0, state.comments.length) + ' messages loaded.');
return true;
} catch (_error) {
if (selected === item && version === loadVersion) {
conversationPager = null;
onConversationStatus('Conversation temporarily unavailable. Retry.');
}
return false;
}
}
function prefetch(item) {
if (!item) { prefetched = null; prefetchKey = ''; return false; }
const notificationId = item?.notification_id;
if (!Number.isInteger(notificationId) || offlineHydrated) return false;
const key = getScope() + notificationId;
if (prefetchKey === key) return prefetched;
if (prefetched) return false;
prefetchKey = key;
prefetched = load(notificationId);
return prefetched;
}
async function advanceAfterRead(items, current, queueing = false) {
const updated = acknowledgeNotification(items, current.notification_id);
@@ -369,10 +467,19 @@ function createNotificationReader({
selected = item;
offlineHydrated = Boolean(savedDetail && !Array.isArray(savedDetail));
const version = ++loadVersion;
const preload = !offlineHydrated && prefetchKey === getScope() + item.notification_id ? prefetched : null;
prefetched = null;
prefetchKey = '';
onOpen(item);
onStatus('Loading update…');
if (!preload) onStatus('Loading update…');
try {
const detail = offlineHydrated ? savedDetail : await load(item.notification_id);
let detail;
if (offlineHydrated) detail = savedDetail;
else if (preload) detail = await preload.catch(() => {
onStatus('Loading update…');
return load(item.notification_id);
});
else detail = await load(item.notification_id);
if (selected !== item || version !== loadVersion) return false;
onDetail(detail);
if (createPager && loadConversation && detail.conversation) {
@@ -384,6 +491,9 @@ function createNotificationReader({
conversationPager = null;
}
onStatus('Update ready.');
if (!offlineHydrated && detail.conversation_available) {
void hydrateConversation(item, version);
}
return true;
} catch (_error) {
if (selected === item && version === loadVersion) {
@@ -395,6 +505,11 @@ function createNotificationReader({
return {
open,
prefetch,
retryConversation() {
if (!selected || offlineHydrated) return Promise.resolve(false);
return hydrateConversation(selected, loadVersion);
},
commentPager() {
return conversationPager;
},
@@ -478,7 +593,7 @@ function createNotificationReplier({
if ((storage.getItem(keyFor(item)) || '') !== body) storage.removeItem(operationKeyFor(item));
storage.setItem(keyFor(item), body);
}
catch (_error) { /* Keep the editable textarea as the fallback. */ }
catch (_error) {}
},
async submit(item, body, attachment = null) {
if (pending) return false;
@@ -504,15 +619,15 @@ function createNotificationReplier({
return { queued:true };
}
try { storage.removeItem(keyFor(item)); storage.removeItem(operationKeyFor(item)); }
catch (_error) { /* Confirmed delivery is authoritative. */ }
catch (_error) {}
onStatus('Reply posted. You can mark this update read when ready.');
return delivery.confirmed[0];
}
const result = await post(item.notification_id, body, operationId);
try { storage.removeItem(keyFor(item)); }
catch (_error) { /* The posted reply is still authoritative. */ }
catch (_error) {}
try { storage.removeItem(operationKeyFor(item)); }
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
catch (_error) {}
onStatus('Reply posted. You can mark this update read when ready.');
return result;
} catch (error) {
@@ -541,6 +656,7 @@ function createNotificationReplier({
function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
let filtered = items;
if (selectedFilter === 'attention') filtered = items.filter(needsAttention);
else if (selectedFilter === 'filed') filtered = items.filter((item) => item.is_filed);
else if (selectedFilter === 'review') filtered = items.filter((item) => item.is_review);
else if (selectedFilter === 'update') filtered = items.filter((item) => item.has_update);
else if (selectedFilter !== 'all') filtered = items.filter((item) => item.kind === selectedFilter);
@@ -553,6 +669,134 @@ function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
);
}
function createCompletedFiledReview({
storage,
getLogin,
key = 'stackchain.completed-filed-review.v1',
}) {
const login = () => String(getLogin?.() || '').trim();
const ownerKey = () => key + ':' + login();
const identity = item => String(item?.repository || '') + '#' + String(item?.number || '');
const read = () => {
if (!login()) return { items:{}, pending:{} };
try {
const value = JSON.parse(storage?.getItem(ownerKey()) || '{}');
return value && value.version === 1 && value.items && typeof value.items === 'object' ? {
items:value.items,
pending:value.pending && typeof value.pending === 'object' ? value.pending : {},
} : { items:{}, pending:{} };
} catch (_error) { return { items:{}, pending:{} }; }
};
const receipt = (item, stamp = String(item?.updated_at || '')) => ({
repository:String(item?.repository || ''), number:item?.number, updated_at:stamp,
});
const save = value => storage?.setItem(ownerKey(), JSON.stringify({ version:1, ...value }));
const partition = items => {
const acknowledged = read().items;
const needsReview = [];
const reviewed = [];
(items || []).forEach(item => {
const isReviewed = item?.is_completed &&
acknowledged[identity(item)] === String(item.updated_at || '');
(isReviewed ? reviewed : needsReview).push(item);
});
reviewed.sort((left, right) => String(right.updated_at || '').localeCompare(String(left.updated_at || '')));
return { needsReview, reviewed };
};
return {
partition,
visible(items) {
return partition(items).needsReview;
},
pending() {
const state = read();
return Object.entries(state.pending).map(([itemIdentity, stamp]) => {
const boundary = itemIdentity.lastIndexOf('#');
return receipt({
repository:itemIdentity.slice(0, boundary),
number:Number(itemIdentity.slice(boundary + 1)),
}, stamp);
});
},
adopt(snapshot) {
const state = read();
let changed = false;
(snapshot?.receipts || []).forEach(remote => {
const itemIdentity = identity(remote);
const stamp = String(remote?.updated_at || '');
if (!remote?.repository || !Number.isInteger(remote?.number) || !stamp) return;
if (!state.items[itemIdentity] || state.items[itemIdentity] < stamp) {
state.items[itemIdentity] = stamp;
changed = true;
}
if (state.pending[itemIdentity] && state.pending[itemIdentity] <= stamp) {
delete state.pending[itemIdentity];
}
});
try { save(state); } catch (_error) { return false; }
return changed;
},
acknowledge(item) {
const stamp = String(item?.updated_at || '');
if (!login() || !item?.is_completed || !item?.repository || !Number.isInteger(item?.number) || !stamp) {
return false;
}
const state = read();
const entries = Object.entries({ ...state.items, [identity(item)]: stamp }).slice(-200);
const pending = Object.entries({ ...state.pending, [identity(item)]: stamp }).slice(-200);
try {
save({ items:Object.fromEntries(entries), pending:Object.fromEntries(pending) });
return true;
} catch (_error) { return false; }
},
};
}
function createFiledHistoryTabs({ root, review, onSelect }) {
const buttons = Array.from(root.querySelectorAll('[data-filed-view]'));
let partition = { needsReview:[], reviewed:[] };
buttons.forEach(button => button.addEventListener('click', () => onSelect(button.dataset.filedView)));
return {
prepare(items) {
partition = review.partition(items);
root.querySelector('#filed-needs-review-count').textContent =
partition.needsReview.filter(item => item.is_filed).length;
root.querySelector('#filed-reviewed-count').textContent = partition.reviewed.length;
return partition.needsReview;
},
render(selected, visible) {
root.hidden = !visible;
buttons.forEach(button =>
button.setAttribute('aria-pressed', String(button.dataset.filedView === selected))
);
return selected === 'reviewed' ? partition.reviewed : partition.needsReview;
},
};
}
function agendaMyWork(items, now = new Date()) {
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const today = localDay(start);
const tomorrowDate = new Date(start); tomorrowDate.setDate(tomorrowDate.getDate() + 1);
const tomorrow = localDay(tomorrowDate);
const afterTomorrowDate = new Date(start); afterTomorrowDate.setDate(afterTomorrowDate.getDate() + 2);
const afterTomorrow = localDay(afterTomorrowDate);
const horizonDate = new Date(start); horizonDate.setDate(horizonDate.getDate() + 7);
const horizon = localDay(horizonDate);
return (items || []).flatMap(item => {
if (item?.kind !== 'issue' || !item.is_assigned || !item.due_date) return [];
const due = calendarDay(item.due_date);
if (!due || due >= horizon) return [];
const group = due < today ? 'Overdue' : due < tomorrow ? 'Today' :
due < afterTomorrow ? 'Tomorrow' : 'Next 7 days';
return [{ ...item, agenda_group: group, _agenda_due: due }];
}).sort((left, right) =>
left._agenda_due.localeCompare(right._agenda_due) ||
String(left.repository || '').localeCompare(String(right.repository || '')) ||
Number(left.number || 0) - Number(right.number || 0)
).map(({ _agenda_due, ...item }) => item);
}
function milestoneLanes(items) {
const lanes = new Map();
(items || []).forEach(item => {
@@ -627,13 +871,14 @@ function createWorkSessionCheckpoint({
function createWorkSession({
getItems, getFilter, getMilestone = () => 'all', checkpoint = null,
checkpointEnabled = () => true,
checkpointEnabled = () => true, checkpointedEnabled = checkpointEnabled,
onOpen, onProgress, onFinish,
}) {
let currentIdentity = '';
let currentIndex = -1;
let running = false;
let durable = false;
const activeCheckpoint = () => typeof checkpoint === 'function' ? checkpoint() : checkpoint;
const queue = () => filterMyWork(getItems() || [], getFilter(), getMilestone());
const report = (items, index) => onProgress({
@@ -648,7 +893,7 @@ function createWorkSession({
durable = false;
currentIdentity = '';
currentIndex = -1;
if (clearCheckpoint) checkpoint?.clear();
if (clearCheckpoint) activeCheckpoint()?.clear();
onFinish();
return false;
};
@@ -656,7 +901,7 @@ function createWorkSession({
if (!items.length || index < 0 || index >= items.length) return finish();
currentIndex = index;
currentIdentity = workIdentity(items[index]);
if (durable) checkpoint?.save(currentIdentity, currentIndex);
if (durable) activeCheckpoint()?.save(currentIdentity, currentIndex);
report(items, index);
onOpen(items[index]);
return true;
@@ -664,9 +909,9 @@ function createWorkSession({
return {
active: () => running,
checkpointed: item => running && durable && (!item || workIdentity(item) === currentIdentity),
checkpointed: item => running && durable && checkpointedEnabled() && (!item || workIdentity(item) === currentIdentity),
end: () => finish(),
resumable: () => Boolean(checkpoint?.read()),
resumable: () => Boolean(activeCheckpoint()?.read()),
reopen(requested = null) {
if (!running) return false;
const items = queue();
@@ -675,13 +920,13 @@ function createWorkSession({
if (index < 0) return false;
currentIdentity = requestedIdentity;
currentIndex = index;
if (durable && requested) checkpoint?.save(currentIdentity, currentIndex);
if (durable && requested) activeCheckpoint()?.save(currentIdentity, currentIndex);
report(items, index);
onOpen(items[index]);
return true;
},
resume(requested = null) {
const saved = checkpoint?.read();
const saved = activeCheckpoint()?.read();
if (!saved) return false;
durable = true;
const items = queue();
@@ -695,7 +940,7 @@ function createWorkSession({
const items = queue();
if (!items.length) return finish();
running = true;
durable = Boolean(checkpoint && checkpointEnabled());
durable = Boolean(activeCheckpoint() && checkpointEnabled());
const requested = item ? items.findIndex(candidate => workIdentity(candidate) === workIdentity(item)) : 0;
return openAt(items, requested >= 0 ? requested : 0);
},
@@ -743,7 +988,7 @@ function createWorkSession({
if (!items.length) return null;
if (action === 'start') return items[0];
if (action === 'resume') {
const saved = checkpoint?.read();
const saved = activeCheckpoint()?.read();
if (!saved) return null;
const exact = items.findIndex(item => workIdentity(item) === saved.identity);
return items[exact >= 0 ? exact : Math.min(saved.index, items.length - 1)];
@@ -824,10 +1069,17 @@ function findQueueItems(items, query) {
});
}
function filedFollowUpTarget(items) {
const item = (items || []).find(candidate => candidate?.is_filed);
if (!item) return null;
return { kind:item.has_update ? 'update' : 'issue', item };
}
function countMyWork(items) {
return {
all: items.length,
attention: items.filter(needsAttention).length,
filed: items.filter((item) => item.is_filed).length,
issue: items.filter((item) => item.kind === 'issue').length,
pull: items.filter((item) => item.kind === 'pull' && !item.is_review).length,
review: items.filter((item) => item.is_review).length,
@@ -837,6 +1089,9 @@ function countMyWork(items) {
if (typeof module !== 'undefined' && module.exports) {
buildMyWork.filterMyWork = filterMyWork;
buildMyWork.createCompletedFiledReview = createCompletedFiledReview;
buildMyWork.createFiledHistoryTabs = createFiledHistoryTabs;
buildMyWork.agendaMyWork = agendaMyWork;
buildMyWork.milestoneLanes = milestoneLanes;
buildMyWork.createWorkSession = createWorkSession;
buildMyWork.createWorkSessionCheckpoint = createWorkSessionCheckpoint;
@@ -848,6 +1103,7 @@ if (typeof module !== 'undefined' && module.exports) {
buildMyWork.removeIssue = removeIssue;
buildMyWork.summarizeMyWork = summarizeMyWork;
buildMyWork.findQueueItems = findQueueItems;
buildMyWork.filedFollowUpTarget = filedFollowUpTarget;
buildMyWork.countMyWork = countMyWork;
buildMyWork.acknowledgeNotification = acknowledgeNotification;
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
+115 -6
View File
@@ -1,14 +1,26 @@
function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelection = () => {}, onFacets = () => {} }) {
let available = [];
let pagination = { page: 1, total: 0, has_more: false };
let loadRequest = null;
let loadGeneration = 0;
let query = '';
let filters = { repositories: [], labels: [] };
let facetView;
let claimRequest = null;
const previewed = new Set();
let selecting = false;
const selected = new Map();
function itemKey(item) {
return String(item?.repository || '') + '#' + String(item?.number || '');
}
function emitSelection() {
const state = { active: selecting, count: selected.size, ids: Array.from(selected.keys()) };
onSelection(state);
return state;
}
function apply(result, append) {
const incoming = Array.isArray(result?.items) ? result.items : [];
if (append) {
@@ -31,13 +43,31 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
};
onItems(available.slice());
onPagination({ ...pagination });
if (result?.facets) onFacets({
repositories: Array.isArray(result.facets.repositories) ? result.facets.repositories.slice() : [],
labels: Array.isArray(result.facets.labels) ? result.facets.labels.slice() : [],
});
if (result?.facets && typeof document !== 'undefined') {
facetView ||= attachFindWorkFacets(document.querySelector('#find-work-filters'), value =>
String(value).replace(/[&<>"']/g, character => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[character])),
next => { filters = next; loadPage(1, false); });
facetView(result.facets);
}
}
function loadPage(page, append) {
if (loadRequest) return loadRequest;
loadRequest = fetchJson('api/v1/available-issues?page=' + page, {
function loadPage(page, append, requestedQuery = query) {
if (loadRequest && requestedQuery === query && append) return loadRequest;
const generation = ++loadGeneration;
const params = new URLSearchParams();
params.set('facets', 'true');
if (requestedQuery) params.set('q', requestedQuery);
filters.repositories.forEach(value => params.append('repository', value));
filters.labels.forEach(value => params.append('label', value));
const queryPart = params.toString() ? '&' + params.toString() : '';
const request = fetchJson('api/v1/available-issues?page=' + page + queryPart, {
headers: { Accept: 'application/json' },
}).then(result => {
if (generation !== loadGeneration) return result;
apply(result, append);
if (result?.refresh_failed === true) {
onStatus('Showing saved available work. Catalog refresh failed; retrying shortly.');
@@ -45,8 +75,9 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
onStatus('Showing saved available work while the catalog refreshes…');
}
return result;
}).finally(() => { loadRequest = null; });
return loadRequest;
}).finally(() => { if (loadRequest === request) loadRequest = null; });
loadRequest = request;
return request;
}
return {
@@ -60,9 +91,66 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
if (!pagination.has_more) return Promise.resolve(false);
return loadPage(pagination.page + 1, true);
},
search(value) {
query = String(value || '').trim();
return loadPage(1, false, query);
},
query() {
return query;
},
setFilters(next) {
filters = {
repositories: Array.from(new Set((next?.repositories || []).map(String))).slice(0, 10),
labels: Array.from(new Set((next?.labels || []).map(String))).slice(0, 10),
};
return loadPage(1, false);
},
filters() {
return { repositories: filters.repositories.slice(), labels: filters.labels.slice() };
},
items() {
return available.slice();
},
startSelection() {
selecting = true;
selected.clear();
return emitSelection();
},
cancelSelection() {
selecting = false;
selected.clear();
return emitSelection();
},
toggleSelection(item) {
if (!selecting || !item) return emitSelection();
const key = itemKey(item);
if (selected.has(key)) selected.delete(key);
else selected.set(key, item);
return emitSelection();
},
fillSelection(limit) {
limit = Math.max(0, Math.floor(Number(limit) || 0));
if (!limit) return { selected: selected.size, added: 0, limit };
selecting = true;
const before = selected.size;
available.some(item => {
if (selected.size >= limit) return true;
selected.set(itemKey(item), item);
return false;
});
emitSelection();
return { selected: selected.size, added: selected.size - before, limit };
},
isSelected(item) {
return selected.has(itemKey(item));
},
selectedItems() {
return Array.from(selected.values());
},
selection() {
return { active: selecting, count: selected.size, ids: Array.from(selected.keys()) };
},
togglePreview(item) {
const key = itemKey(item);
if (previewed.has(key)) previewed.delete(key);
@@ -91,6 +179,8 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
pagination.total = Math.max(available.length, pagination.total - 1);
pagination.has_more = available.length < pagination.total;
previewed.delete(itemKey(item));
selected.delete(itemKey(item));
emitSelection();
onItems(available.slice());
onPagination({ ...pagination });
onStatus('Assigned ' + key + ' to you.');
@@ -101,4 +191,23 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
};
}
function attachFindWorkFacets(root, escapeHtml, onChange) {
const values = name => Array.from(root.querySelectorAll('input[name="' + name + '"]:checked'), input => input.value);
root.onchange = event => event.target.type === 'checkbox' && onChange({ repositories:values('find-work-repository'), labels:values('find-work-label') });
root.querySelector('#clear-find-work-filters').onclick = () => {
root.querySelectorAll('input').forEach(input => { input.checked = false; });
onChange({ repositories:[], labels:[] });
};
return facets => {
const active = { repositories:values('find-work-repository'), labels:values('find-work-label') };
const render = (id, choices, selected, name) => {
root.querySelector(id).innerHTML = choices.map(value => '<label class="find-work-filter-option"><input type="checkbox" name="' + name + '" value="' + escapeHtml(value) + '"' + (selected.includes(value) ? ' checked' : '') + ' />' + escapeHtml(value) + '</label>').join('');
};
render('#find-work-repository-filters', facets.repositories || [], active.repositories, 'find-work-repository');
render('#find-work-label-filters', facets.labels || [], active.labels, 'find-work-label');
const count = active.repositories.length + active.labels.length;
root.querySelector('#find-work-filter-count').textContent = count ? '(' + count + ' active)' : '';
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createFindWork;
+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 };
}
};
});
+12
View File
@@ -0,0 +1,12 @@
(function (root) {
const databases = Object.freeze([
'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',
]);
if (typeof module !== 'undefined' && module.exports) module.exports = databases;
else root.stackchainPrivateDatabases = databases;
})(typeof globalThis !== 'undefined' ? globalThis : this);
+10 -6
View File
@@ -1,5 +1,10 @@
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
if (typeof module !== 'undefined' && module.exports) {
module.exports = options => factory({
...options,
privateDatabases: require('./private-data-registry.js'),
});
}
else root.stackchainPrivateDeviceData = factory({
localStorage: root.localStorage,
sessionStorage: root.sessionStorage,
@@ -7,9 +12,10 @@
caches: root.caches,
serviceWorker: root.navigator?.serviceWorker,
MessageChannel: root.MessageChannel,
privateDatabases: root.stackchainPrivateDatabases,
});
})(typeof window !== 'undefined' ? window : this, function createPrivateDeviceDataPurger({
localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel,
localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, privateDatabases,
}) {
function removeOwnedStorage(storage) {
if (!storage) return;
@@ -49,12 +55,10 @@
}
return async function clearPrivateDeviceData() {
await stopWorkerOutbox();
removeOwnedStorage(localStorage);
if (sessionStorage !== localStorage) removeOwnedStorage(sessionStorage);
await stopWorkerOutbox();
await deletePrivateDatabase('stackchain-background-outbox-v1');
await deletePrivateDatabase('stackchain-offline-work-v2');
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
for (const name of privateDatabases) await deletePrivateDatabase(name);
const keys = await caches?.keys?.() || [];
await Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
+46
View File
@@ -0,0 +1,46 @@
function createProtectToday() {
function propose({ agenda = [], today = [], identity, limit = 5 }) {
const key = item => String(identity?.(item) || '');
const urgent = [];
const seen = new Set();
for (const item of [...agenda, ...today]) {
const id = key(item);
const eligible = item?.kind === 'issue' && item.is_assigned === true && item.state !== 'closed' &&
['Overdue', 'Today'].includes(item.agenda_group);
if (!eligible || !id || seen.has(id)) continue;
seen.add(id);
urgent.push(item);
}
urgent.sort((left, right) =>
String(left.due_date || '').localeCompare(String(right.due_date || '')) ||
String(left.repository || '').localeCompare(String(right.repository || '')) ||
Number(left.number || 0) - Number(right.number || 0)
);
if (!urgent.length) return {
selected:[], protected:[], displaced:[],
summary:'No overdue or due-today work needs protection.',
};
const existing = [];
for (const item of today) {
const id = key(item);
if (!id || seen.has(id)) continue;
seen.add(id);
existing.push(item);
}
const combined = [...urgent, ...existing];
const selected = combined.slice(0, limit);
const selectedIds = new Set(selected.map(key));
const displaced = today.filter(item => !selectedIds.has(key(item)) && !urgent.some(candidate => key(candidate) === key(item)));
return {
selected,
protected:urgent,
displaced,
summary:urgent.length + ' urgent ' + (urgent.length === 1 ? 'item' : 'items') + ' protected · ' +
displaced.length + (displaced.length ? ' displaced for review' : ' displaced'),
};
}
return { propose };
}
const protectToday = createProtectToday();
if (typeof module !== 'undefined' && module.exports) module.exports = protectToday;
+206 -9
View File
@@ -2,9 +2,114 @@
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createPushNotifications = factory;
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({
control, status, notification, serviceWorker, fetchJson,
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines,
notification, serviceWorker, fetchJson,
}) {
let configuration = null;
let pendingIntent = null;
let recoveryPromise = null;
function blockedReadiness() {
return {
state:'blocked',
detail:'Notifications are blocked. Allow them in browser settings, then check again.',
actionLabel:'Check again',
};
}
function pendingRecoveryReadiness() {
return notification.permission === 'granted'
? {state:'blocked', detail:'Notification permission changed. Check again to finish setup.', actionLabel:'Check again'}
: blockedReadiness();
}
function notificationReadiness() {
if (!configuration?.available || control?.disabled) {
return {state:'unavailable', detail:status?.textContent || 'Update notifications are unavailable.'};
}
if (configuration.subscribed) {
return {state:'complete', detail:'New update notifications are enabled.'};
}
if (pendingIntent === 'updates') return pendingRecoveryReadiness();
if (notification.permission === 'denied') return blockedReadiness();
return {state:'incomplete', detail:status?.textContent || 'New update notifications are off.'};
}
function renderDeliveryHealth() {
const health = Object.values(configuration?.delivery_health || {});
const degraded = health.find(item => item?.state === 'degraded');
if (testControl) testControl.hidden = !configuration?.subscribed;
if (configuration?.subscribed && degraded) {
const count = Number(degraded.consecutive_failures || 1);
status.textContent = `Update notifications need attention after ${count} failed ${count === 1 ? 'delivery' : 'deliveries'}. Send a test notification.`;
return;
}
status.textContent = configuration?.subscribed
? 'New update notifications enabled for this device.'
: 'New update notifications are off for this device.';
}
async function testDelivery() {
if (!testControl) return;
testControl.disabled = true;
status.textContent = 'Sending a test notification…';
try {
await fetchJson('api/v1/push-subscription/test', {method:'POST'});
configuration.delivery_health = {unread:{state:'healthy',consecutive_failures:0}};
status.textContent = 'Test delivered. Update notifications are working on this device.';
} catch (error) {
status.textContent = 'Test delivery failed. Check your connection, then try again.';
} finally {
testControl.disabled = false;
}
}
function formattedHour(value) {
return `${String(Number(value)).padStart(2, '0')}:00`;
}
function enabledDeadlineText(hour, days) {
const value = Number(days ?? 2);
const horizon = value === 0 ? 'due today' : `next ${value} days`;
return `Deadline reminders enabled for ${formattedHour(hour)} local time, ${horizon}.`;
}
function renderDeadlineSnooze() {
if (!deadlineSnooze) return;
const wakeAt = Number(configuration?.snoozed_until || 0);
deadlineSnooze.hidden = !wakeAt;
if (wakeAt && deadlineSnoozeStatus) {
const localTime = new Intl.DateTimeFormat(undefined, {
hour:'numeric', minute:'2-digit',
}).format(new Date(wakeAt * 1000));
deadlineSnoozeStatus.textContent = `Deadline reminders snoozed until ${localTime}.`;
}
}
async function reviewSnoozedDeadlines() {
deadlineSnoozeReview.disabled = true;
try {
await fetchJson('api/v1/push-subscription/deadlines/snooze', {method:'DELETE'});
configuration.snoozed_until = null;
renderDeadlineSnooze();
onReviewDeadlines?.();
} catch (error) {
deadlineSnoozeStatus.textContent = 'Could not resume deadline reminders. Check your connection and try again.';
} finally {
deadlineSnoozeReview.disabled = false;
}
}
function deadlineReadiness() {
if (!configuration?.available || deadlineControl?.disabled) {
return {state:'unavailable', detail:deadlineStatus?.textContent || 'Deadline reminders are unavailable.'};
}
if (pendingIntent === 'deadline') return pendingRecoveryReadiness();
return configuration.deadline_enabled
? {state:'complete', detail:enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)}
: {state:'incomplete', detail:deadlineStatus?.textContent || 'Choose when to receive deadline reminders.'};
}
function applicationServerKey(value) {
const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4);
@@ -20,15 +125,23 @@
await fetchJson('api/v1/push-subscription', {method:'DELETE'});
await subscription?.unsubscribe?.();
control.checked = false;
if (testControl) testControl.hidden = true;
if (deadlineControl) deadlineControl.checked = false;
configuration.subscribed = false;
configuration.deadline_enabled = false;
pendingIntent = null;
status.textContent = 'New update notifications are off for this device.';
if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.';
}
async function enable() {
const permission = await notification.requestPermission();
async function ensureSubscription() {
const permission = notification.permission === 'granted'
? 'granted'
: await notification.requestPermission();
if (permission !== 'granted') {
control.checked = false;
status.textContent = 'Notifications are blocked. Allow them in your browser settings to enable updates.';
return;
status.textContent = blockedReadiness().detail;
return null;
}
const registration = await serviceWorker.ready;
let subscription = await registration.pushManager.getSubscription();
@@ -45,6 +158,16 @@
});
control.checked = true;
status.textContent = 'New update notifications enabled for this device.';
configuration.subscribed = true;
if (testControl) testControl.hidden = false;
return subscription;
}
async function enable() {
pendingIntent = 'updates';
const subscription = await ensureSubscription();
if (subscription) pendingIntent = null;
return subscription;
}
async function change() {
@@ -60,20 +183,94 @@
}
}
async function changeDeadline() {
deadlineControl.disabled = true;
if (deadlineHour) deadlineHour.disabled = true;
if (deadlineDays) deadlineDays.disabled = true;
try {
const registration = await serviceWorker.ready;
let subscription = await registration.pushManager.getSubscription();
if (deadlineControl.checked) pendingIntent = 'deadline';
if (deadlineControl.checked && !subscription) subscription = await ensureSubscription();
if (deadlineControl.checked && !subscription) {
deadlineControl.checked = false;
deadlineStatus.textContent = status.textContent;
return false;
}
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
const reminderHour = Number(deadlineHour?.value ?? configuration?.reminder_hour ?? 9);
const reminderDays = Number(deadlineDays?.value ?? configuration?.reminder_days ?? 2);
await fetchJson('api/v1/push-subscription/deadlines', {
method:'PUT',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({enabled:deadlineControl.checked, timezone, reminder_hour:reminderHour, reminder_days:reminderDays}),
});
configuration.deadline_enabled = deadlineControl.checked;
configuration.reminder_hour = reminderHour;
configuration.reminder_days = reminderDays;
configuration.timezone = timezone;
pendingIntent = null;
deadlineStatus.textContent = deadlineControl.checked
? enabledDeadlineText(reminderHour, reminderDays)
: 'Deadline reminders are off for this device.';
return true;
} catch (error) {
deadlineControl.checked = !deadlineControl.checked;
deadlineStatus.textContent = 'Could not change deadline reminders. Check your connection and try again.';
return false;
} finally {
deadlineControl.disabled = false;
if (deadlineHour) deadlineHour.disabled = false;
if (deadlineDays) deadlineDays.disabled = false;
}
}
async function enableDeadline() {
deadlineControl.checked = true;
return changeDeadline();
}
async function recoverPermission(intent = null) {
if (!pendingIntent && (intent === 'updates' || intent === 'deadline')) pendingIntent = intent;
if (!pendingIntent || notification.permission !== 'granted') return false;
if (recoveryPromise) return recoveryPromise;
recoveryPromise = (async () => {
if (pendingIntent === 'deadline') {
deadlineControl.checked = true;
return changeDeadline();
}
return Boolean(await enable());
})();
try {
return await recoveryPromise;
} finally {
recoveryPromise = null;
}
}
async function init() {
if (!control || !notification || !serviceWorker) return;
control.addEventListener('change', change);
testControl?.addEventListener('click', testDelivery);
deadlineControl?.addEventListener('change', changeDeadline);
deadlineSnoozeReview?.addEventListener('click', reviewSnoozedDeadlines);
configuration = await fetchJson('api/v1/push-subscription');
if (!configuration.available) {
control.disabled = true;
if (deadlineControl) deadlineControl.disabled = true;
status.textContent = 'New update notifications are not available on this server.';
return;
}
control.checked = Boolean(configuration.subscribed);
status.textContent = configuration.subscribed
? 'New update notifications enabled for this device.'
: 'New update notifications are off for this device.';
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
if (deadlineHour) deadlineHour.value = String(configuration.reminder_hour ?? 9);
if (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2);
renderDeliveryHealth();
if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled
? enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)
: 'Deadline reminders are off for this device.';
renderDeadlineSnooze();
}
return {init, change};
return {init, change, changeDeadline, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
});
+44
View File
@@ -0,0 +1,44 @@
function createReconnectOutboxes({
refresh,
restoreIdentity,
flushIssue,
flushAuthored,
flushNotificationReads,
}) {
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 &&
!freshness?.stale && !freshness?.degraded && !freshness?.revalidating;
const login = identityFresh ? String(snapshot.context.user?.login || '').trim() : '';
if (!login) return false;
restoreIdentity(login);
await Promise.all([
flushIssue(),
flushAuthored(),
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;
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createReconnectOutboxes;
+201
View File
@@ -0,0 +1,201 @@
function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, dialog = null, statusNode = null, checksNode = null, releaseNode = null, listNode = null, documentRef = null, windowRef = null, setTimer = setTimeout, clearTimer = clearTimeout, pollMs = 30000 }) {
const prefix = 'stackchain.release-receipt.v1:';
const limit = 12;
let entries = [];
let refreshing = null;
let timer = null;
let bound = false;
documentRef ||= typeof document !== 'undefined' ? document : null;
windowRef ||= typeof window !== 'undefined' ? window : null;
const login = () => String(getLogin?.() || '').trim().toLowerCase();
const key = () => prefix + login();
const identity = value => String(value.repository || '') + '@' + String(value.commit_sha || '');
const path = value => 'api/v1/repos/' + String(value.repository || '').split('/')
.map(encodeURIComponent).join('/') + '/release-receipt/' + encodeURIComponent(value.commit_sha);
function valid(value, account) {
return value && value.account === account && value.repository && value.commit_sha;
}
function persist() {
if (!entries.length) storage?.removeItem(key());
else storage?.setItem(key(), JSON.stringify({ version: 2, account: login(), entries }));
}
const hasPending = () => entries.some(entry => !entry.status?.release && entry.status?.label !== 'Checks failed');
function schedule(delay = pollMs) {
if (!bound || documentRef?.hidden || !hasPending()) return;
if (timer !== null) clearTimer(timer);
timer = setTimer(async () => {
timer = null;
try { await refresh(); }
catch (error) { if (statusNode) statusNode.textContent = error.message + ' Retry when connected.'; }
schedule();
}, delay);
}
function restore() {
const account = login();
if (!account) return [];
try {
const parsed = JSON.parse(storage?.getItem(prefix + account) || 'null');
const stored = parsed?.version === 2 && parsed.account === account
? parsed.entries : (valid(parsed, account) ? [parsed] : []);
entries = Array.isArray(stored) ? stored.filter(value => valid(value, account)).slice(-limit) : [];
if (entries.length && parsed?.version !== 2) persist();
} catch (_error) { entries = []; }
render();
schedule();
return entries.map(value => ({ ...value }));
}
function capture(item, mergeResult) {
const account = login();
const commitSha = String(mergeResult?.merge_commit_sha || '').trim();
if (!account || !item?.repository || !commitSha) throw new Error('The exact merge commit is unavailable.');
if (!entries.length) restore();
const entry = {
account,
repository: item.repository,
number: Number(item.number),
key: item.key || item.repository + '#' + item.number,
commit_sha: commitSha,
status: null,
captured_at: new Date().toISOString(),
};
const existing = entries.findIndex(value => identity(value) === identity(entry));
if (existing >= 0) {
entry.status = entries[existing].status || null;
entries.splice(existing, 1, entry);
} else {
entries.push(entry);
entries = entries.slice(-limit);
}
persist();
render();
schedule();
return { ...entry };
}
function summarize(payload) {
const checks = Array.isArray(payload?.checks) ? payload.checks : [];
const failing = checks.filter(check => ['failure', 'error'].includes(check.state)).map(check => check.name).filter(Boolean);
const pending = checks.filter(check => check.state === 'pending').map(check => check.name).filter(Boolean);
if (payload?.release) return { ...payload, label: 'Released · ' + payload.release.tag, checks: [] };
if (failing.length) return { ...payload, label: 'Checks failed', checks: failing };
if (payload?.ci_state === 'success') return { ...payload, label: 'Checks passed · waiting for release', checks: [] };
return { ...payload, label: 'Checks running', checks: pending };
}
function render() {
const visible = entries.map((entry, index) => ({ entry, index }))
.sort((a, b) => Number(b.entry.status?.label === 'Checks failed') - Number(a.entry.status?.label === 'Checks failed') || a.index - b.index)
.map(value => value.entry);
const first = visible[0] || null;
const failed = visible.filter(entry => entry.status?.label === 'Checks failed').length;
if (launcher) {
launcher.hidden = !entries.length;
launcher.textContent = failed
? failed + ' release ' + (failed === 1 ? 'failure' : 'failures') + ' · ' + visible.length + ' tracked'
: visible.length + ' ' + (visible.length === 1 ? 'merge' : 'merges') + ' · tracking release';
}
if (statusNode) statusNode.textContent = first?.status?.label || 'Checking the exact merge commit…';
if (checksNode) checksNode.textContent = (first?.status?.checks || []).join(', ');
if (releaseNode) {
releaseNode.hidden = !first?.status?.release?.url;
if (first?.status?.release?.url) {
releaseNode.href = first.status.release.url;
releaseNode.textContent = 'Open release ' + first.status.release.tag;
}
}
if (listNode) {
const rows = visible.map(entry => {
const row = document.createElement('article');
row.className = 'release-watchlist-item';
const copy = document.createElement('div');
const title = document.createElement('strong');
title.textContent = entry.key;
const state = document.createElement('span');
state.className = 'small';
state.textContent = entry.status?.label || 'Checking the exact merge commit…';
copy.append(title, state);
if (entry.status?.release?.url) {
const link = document.createElement('a');
link.href = entry.status.release.url;
link.textContent = 'Open release ' + entry.status.release.tag;
copy.append(link);
}
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Dismiss';
button.setAttribute('aria-label', 'Dismiss ' + entry.key + ' from release tracking');
button.addEventListener('click', () => dismiss(entry.repository, entry.commit_sha));
row.append(copy, button);
return row;
});
listNode.replaceChildren(...rows);
}
}
async function refreshEntry(entry) {
const payload = await fetchJson(path(entry), { headers: { Accept: 'application/json' } });
if (payload?.commit_sha !== entry.commit_sha) throw new Error('Release evidence did not match the merged commit.');
entry.status = summarize(payload);
return entry.status;
}
async function refresh() {
if (refreshing) return refreshing;
if (!entries.length) restore();
if (!entries.length) return [];
refreshing = (async () => {
const statuses = [];
for (const entry of entries) {
try { statuses.push(await refreshEntry(entry)); }
catch (error) {
entry.status = { label: 'Status unavailable', checks: [], error: String(error?.message || error) };
statuses.push(entry.status);
}
}
persist();
render();
return statuses;
})();
try { return await refreshing; }
finally { refreshing = null; }
}
function dismiss(repository, commitSha) {
if (repository && commitSha) entries = entries.filter(entry => identity(entry) !== repository + '@' + commitSha);
else entries = [];
persist();
render();
schedule();
if (!entries.length && dialog?.open) dialog.close();
return entries.map(value => ({ ...value }));
}
function bind() {
bound = true;
launcher?.addEventListener('click', async () => {
dialog?.showModal?.();
try { await refresh(); }
catch (error) { if (statusNode) statusNode.textContent = error.message + ' Retry when connected.'; }
});
documentRef?.addEventListener('visibilitychange', () => {
if (documentRef.hidden) {
if (timer !== null) clearTimer(timer);
timer = null;
} else schedule(0);
});
windowRef?.addEventListener('online', () => schedule(0));
schedule();
}
return { capture, restore, refresh, dismiss, bind, fetchJson };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createReleaseReceipt;
+159
View File
@@ -0,0 +1,159 @@
(function (root, factory) {
const createSavedSearches = factory();
if (typeof module === 'object' && module.exports) module.exports = createSavedSearches;
if (root) root.createSavedSearches = createSavedSearches;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
function createSavedSearches(options) {
const fetchJson = options.fetchJson;
const createId = options.createId || (() => crypto.randomUUID().replace(/-/g, ''));
const onOpen = options.onOpen;
const onState = options.onState || (() => {});
let current = { revision:0, views:[] };
function snapshot() {
return { revision:current.revision, views:current.views.map(view => ({ ...view })) };
}
function publish(status, message) {
onState({ ...snapshot(), status, ...(message ? { message } : {}) });
}
function normalizeSearch(name, search, id) {
const cleanName = String(name || '').trim();
const query = String(search?.query || '').trim();
if (!cleanName || cleanName.length > 60) throw new Error('Name must be between 1 and 60 characters.');
if (query.length < 2 || query.length > 200) throw new Error('Search query must be between 2 and 200 characters.');
return {
id, name:cleanName, query,
kind:['all', 'issue', 'pull'].includes(search?.kind) ? search.kind : 'all',
state:['all', 'open', 'closed'].includes(search?.state) ? search.state : 'all',
repository:String(search?.repository || '').trim(),
};
}
async function replace(views) {
publish('saving');
try {
current = await fetchJson('api/v1/saved-searches', {
method:'PUT', headers:{ 'Content-Type':'application/json' },
body:JSON.stringify({ revision:current.revision, views }),
});
publish('ready');
return snapshot();
} catch (error) {
const conflict = error?.status === 409 && error?.payload?.detail?.snapshot;
if (conflict) {
current = conflict;
publish('conflict', 'Saved searches changed on another device.');
} else {
publish('error', 'Saved searches could not sync. Search still works.');
}
throw error;
}
}
return {
async load() {
publish('loading');
try {
current = await fetchJson('api/v1/saved-searches');
publish('ready');
} catch (error) {
publish('error', 'Saved searches could not load. Search still works.');
}
return snapshot();
},
save(name, search) {
if (current.views.length >= 20) return Promise.reject(new Error('Saved searches are limited to 20.'));
const created = normalizeSearch(name, search, createId());
return replace(current.views.concat(created));
},
rename(id, name) {
const existing = current.views.find(view => view.id === id);
if (!existing) return Promise.reject(new Error('Saved search no longer exists.'));
const changed = normalizeSearch(name, existing, existing.id);
return replace(current.views.map(view => view.id === id ? changed : view));
},
remove(id) {
if (!current.views.some(view => view.id === id)) return Promise.resolve(snapshot());
return replace(current.views.filter(view => view.id !== id));
},
open(id) {
const selected = current.views.find(view => view.id === id);
if (selected) onOpen({ ...selected });
},
snapshot,
};
}
createSavedSearches.mount = function mountSavedSearches(document, fetch, search, applyScope, history) {
const query = selector => document.querySelector(selector);
const input = query('#cmd-input');
const request = async (url, init) => {
const response = await fetch(url, init);
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(payload.detail?.message || payload.detail || 'Saved Search request failed.');
error.status = response.status;
error.payload = payload;
throw error;
}
return payload;
};
let controller;
function render(state) {
const list = query('#saved-search-list');
const status = query('#saved-search-status');
list.replaceChildren();
status.textContent = ({loading:'Loading…', saving:'Syncing…', ready:'',
conflict:state.message, error:state.message})[state.status] || '';
state.views.forEach(view => {
const row = document.createElement('div');
row.className = 'saved-search-row';
const open = document.createElement('button');
open.type = 'button'; open.className = 'saved-search-open saved-search-action';
open.textContent = view.name; open.title = view.query;
open.addEventListener('click', () => controller.open(view.id));
const actions = document.createElement('div');
actions.className = 'saved-search-row-actions';
for (const action of ['Rename', 'Delete']) {
const button = document.createElement('button');
button.type = 'button'; button.className = 'saved-search-action'; button.textContent = action;
button.setAttribute('aria-label', action + ' ' + view.name);
button.addEventListener('click', async () => {
if (action === 'Rename') {
const name = globalThis.prompt('Rename saved search', view.name);
if (name !== null && name.trim()) await controller.rename(view.id, name).catch(() => {});
} else if (globalThis.confirm('Delete saved search “' + view.name + '”?')) {
await controller.remove(view.id).catch(() => {});
}
});
actions.append(button);
}
row.append(open, actions); list.append(row);
});
}
controller = createSavedSearches({ fetchJson:request, onState:render, onOpen:view => {
search.setQuery('');
input.value = view.query;
applyScope(view);
search.setQuery(view.query);
history.update({query:view.query, scope:view});
input.focus();
}});
query('#save-current-search').addEventListener('click', async () => {
const name = query('#saved-search-name').value.trim();
const current = {query:input.value.trim(), kind:query('#cmd-search-kind').value,
state:query('#cmd-search-state').value, repository:query('#cmd-search-repository').value.trim()};
if (!name || current.query.length < 2) {
query('#saved-search-status').textContent = 'Enter a name and at least 2 search characters.';
return;
}
await controller.save(name, current).then(() => { query('#saved-search-name').value = ''; })
.catch(error => { query('#saved-search-status').textContent = error.message; });
});
return controller;
};
return createSavedSearches;
});
+329
View File
@@ -0,0 +1,329 @@
(function (root, factory) {
const createSearchBatchPlan = factory();
if (typeof module === 'object' && module.exports) module.exports = createSearchBatchPlan;
if (root) {
root.createSearchBatchPlan = createSearchBatchPlan;
root.mountSearchBatchPlanning = createSearchBatchPlan.mount;
}
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
function createSearchBatchPlan({
limit = 50,
resolve = item => Promise.resolve(item),
claim = item => Promise.resolve(item),
onChange = () => {},
} = {}) {
const maximum = Number.isInteger(limit) && limit > 0 ? limit : 50;
const selected = new Map();
let active = false;
const identity = item => [item?.kind || '', item?.repository || '', item?.number || ''].join(':');
const eligible = item => item?.kind === 'issue' && item?.state === 'open' &&
Boolean(item.repository) && Number.isInteger(item.number);
function snapshot() {
return { active, count:selected.size, items:Array.from(selected.values()) };
}
function changed() {
const state = snapshot();
onChange(state);
return state;
}
function start() {
active = true;
selected.clear();
return changed();
}
function cancel() {
active = false;
selected.clear();
return changed();
}
function toggle(item) {
if (!active) return 'inactive';
if (!eligible(item)) return 'ineligible';
const key = identity(item);
if (selected.has(key)) {
selected.delete(key);
changed();
return 'removed';
}
if (selected.size >= maximum) return 'limit';
selected.set(key, { ...item });
changed();
return 'selected';
}
async function prepare(item) {
const detail = await resolve(item);
if (detail?.assigned_to_me) return detail;
if (detail?.claimable) return claim(detail);
throw new Error('This issue is no longer available to assign.');
}
function releaseRepository() {
const repositories = new Set(Array.from(selected.values()).map(item => item.repository));
return repositories.size === 1 ? repositories.values().next().value : null;
}
return { identity, eligible, start, cancel, toggle, snapshot, prepare, releaseRepository, limit:maximum };
}
createSearchBatchPlan.mount = function mountSearchBatchPlanning(
document, batchFactory, todayWork, getOwner, fetchJson, previewPath, queueToday,
todaySync, acceptIssue, lookup, render, escapeHtml, escapeAttribute,
laterWork = null, laterPicker = null
) {
const get = selector => document.querySelector(selector);
let processor;
let laterProcessor;
let releaseProcessor;
let releaseContext = null;
const plan = createSearchBatchPlan({
resolve:item => fetchJson(previewPath(item), {headers:{Accept:'application/json'}}),
claim:detail => fetchJson(
'api/v1/repos/' + detail.repository.split('/').map(encodeURIComponent).join('/') +
'/issues/' + encodeURIComponent(detail.number) + '/claim',
{method:'PATCH', headers:{Accept:'application/json'}}
),
onChange:state => {
get('#search-batch-actions').hidden = !state.active;
get('#select-search-results').hidden = state.active;
get('#queue-selected-search-results').disabled = state.count === 0;
get('#defer-selected-search-results').disabled = state.count === 0;
get('#plan-selected-search-results').disabled = state.count === 0;
get('#search-selection-status').textContent = state.count ?
state.count + ' issue' + (state.count === 1 ? '' : 's') + ' selected.' : 'No issues selected.';
render();
},
});
processor = batchFactory({
capacity:() => Math.max(0, todayWork.limit - todayWork.read().length),
owner:getOwner,
timeBudget:() => {
const planning = todayWork.planning();
return {
capacity_minutes:planning.capacity_minutes,
planned_minutes:Object.values(planning.estimates || {}).reduce((sum, minutes) => sum + minutes, 0),
};
},
journalName:'search-today-batch',
autoMount:false,
claim:item => plan.prepare(item),
queue:confirmed => queueToday(acceptIssue(confirmed)),
persistEstimate:(confirmed, minutes) => {
const planning = todayWork.planning();
planning.estimates[todayWork.identity(confirmed)] = minutes;
todayWork.replacePlanning(planning);
todaySync.enqueueConfiguration(planning.capacity_minutes, planning.estimates);
todaySync.flush();
},
onProgress:progress => {
get('#queue-selected-search-results').disabled = progress.status === 'running';
if (progress.status === 'running') {
get('#search-selection-status').textContent = 'Planning ' + progress.processed + ' of ' + progress.selected + '…';
} else if (progress.status === 'full') {
get('#search-selection-status').textContent = 'Today has ' + progress.available + ' remaining slot' +
(progress.available === 1 ? '.' : 's.');
} else if (progress.status === 'estimates-required') {
get('#search-batch-estimate-summary').textContent = 'Estimate every selected issue.';
} else if (progress.status === 'over-budget') {
get('#search-batch-estimate-summary').textContent = progress.requested_minutes +
' min selected · ' + progress.remaining_minutes + ' min remain.';
} else if (progress.status === 'complete') {
get('#cmd-search-action-status').textContent = progress.failed.length ?
progress.queued.length + ' queued · ' + progress.failed.length + ' need retry.' :
progress.queued.length + ' added to Today.';
get('#resume-search-batch').hidden = processor.pending() === 0;
closeEstimateReview();
if (!progress.failed.length) plan.cancel();
}
},
});
laterProcessor = batchFactory({
capacity:() => plan.limit,
owner:getOwner,
journalName:'search-later-batch',
queueFailureReason:'assigned but Later could not be saved',
autoMount:false,
claim:item => plan.prepare(item),
queue:(confirmed, context) => {
const outcome = laterWork?.defer(acceptIssue(confirmed), context?.until);
return outcome === 'deferred' ? 'queued' : outcome;
},
onProgress:progress => {
get('#defer-selected-search-results').disabled = progress.status === 'running';
if (progress.status === 'running') {
get('#search-selection-status').textContent = 'Deferring ' + progress.processed + ' of ' + progress.selected + '…';
} else if (progress.status === 'complete') {
get('#cmd-search-action-status').textContent = progress.failed.length ?
progress.queued.length + ' deferred · ' + progress.failed.length + ' need retry.' :
progress.queued.length + ' added to Later.';
restore();
if (!progress.failed.length) plan.cancel();
}
},
});
releaseProcessor = batchFactory({
capacity:() => plan.limit,
owner:getOwner,
journalName:'search-release-batch',
queueFailureReason:'assigned but release planning could not be confirmed',
autoMount:false,
claim:item => plan.prepare(item),
queue:async (confirmed, context) => {
const issuePath = 'api/v1/repos/' + confirmed.repository.split('/').map(encodeURIComponent).join('/') +
'/issues/' + encodeURIComponent(confirmed.number);
const releasePlan = await fetchJson(issuePath + '/release-plan', {
method:'PATCH', headers:{Accept:'application/json','Content-Type':'application/json'},
body:JSON.stringify({milestone_id:context.milestone_id, due_date:context.due_date || null}),
});
if (releasePlan?.number !== confirmed.number ||
releasePlan?.milestone?.id !== context.milestone_id ||
(context.due_date && releasePlan?.due_date !== context.due_date)) {
throw new Error('Issue release plan was not confirmed.');
}
return 'queued';
},
onProgress:progress => {
get('#confirm-search-release').disabled = progress.status === 'running';
if (progress.status === 'running') {
get('#search-release-summary').textContent = 'Planning ' + progress.processed + ' of ' + progress.selected + '…';
} else if (progress.status === 'complete') {
get('#cmd-search-action-status').textContent = progress.failed.length ?
progress.queued.length + ' planned · ' + progress.failed.length + ' need retry.' :
progress.queued.length + ' planned for ' + (releaseContext?.milestone_title || 'release') + '.';
get('#resume-search-batch').hidden = releaseProcessor.pending() === 0;
closeReleaseReview();
if (!progress.failed.length) plan.cancel();
}
},
});
function estimateValues() {
return Object.fromEntries(Array.from(document.querySelectorAll('[data-search-batch-estimate]')).map(input =>
[input.dataset.searchBatchEstimate, Number(input.value)]
));
}
function closeEstimateReview() {
get('#search-batch-estimate-review').hidden = true;
get('#search-batch-actions').hidden = !plan.snapshot().active;
}
function openEstimateReview(items) {
const planning = todayWork.planning();
const planned = Object.values(planning.estimates || {}).reduce((sum, minutes) => sum + minutes, 0);
const remaining = Math.max(0, planning.capacity_minutes - planned);
get('#search-batch-estimate-summary').textContent = remaining +
' min remain. Estimate before assigning.';
get('#search-batch-estimate-list').innerHTML = items.map(item => {
const id = String(item.repository || '') + '#' + String(item.number || '');
const estimate = planning.estimates?.[id] || '';
return '<div class="search-batch-estimate-row"><div><span class="small">' + escapeHtml(id) +
'</span><strong>' + escapeHtml(item.title || 'Untitled work') + '</strong></div><label class="small">' +
'<input type="number" inputmode="numeric" min="5" max="1440" step="5" value="' +
escapeAttribute(estimate) + '" data-search-batch-estimate="' + escapeAttribute(id) +
'" aria-label="Estimate for ' + escapeAttribute(item.title || id) + ' in minutes" /> min</label></div>';
}).join('');
get('#search-batch-actions').hidden = true;
get('#search-batch-estimate-review').hidden = false;
document.querySelector('[data-search-batch-estimate]')?.focus();
}
function closeReleaseReview() {
get('#search-release-review').hidden = true;
get('#search-batch-actions').hidden = !plan.snapshot().active;
}
async function openReleaseReview() {
const items = plan.snapshot().items;
const repository = plan.releaseRepository();
if (!items.length) return;
if (!repository) {
get('#search-selection-status').textContent = 'Select issues from one repository to plan a release.';
return;
}
get('#plan-selected-search-results').disabled = true;
get('#search-selection-status').textContent = 'Loading release milestones…';
try {
const milestones = await fetchJson(
'api/v1/repos/' + repository.split('/').map(encodeURIComponent).join('/') + '/milestones',
{headers:{Accept:'application/json'}}
);
const open = (Array.isArray(milestones) ? milestones : []).filter(item => item?.state !== 'closed');
get('#search-release-milestone').innerHTML = '<option value="">Choose milestone</option>' + open.map(item =>
'<option value="' + Number(item.id) + '">' + escapeHtml(item.title) + '</option>'
).join('');
get('#search-release-list').innerHTML = items.map(item => '<div class="search-release-row"><span>' +
escapeHtml(item.repository + ' #' + item.number) + '</span><strong>' +
escapeHtml(item.title || 'Untitled work') + '</strong></div>').join('');
get('#search-release-summary').textContent = items.length + ' issues in ' + repository + '. Assignment starts only after confirmation.';
get('#search-batch-actions').hidden = true;
get('#search-release-review').hidden = false;
get('#search-release-milestone').focus();
} catch (error) {
get('#search-selection-status').textContent = error?.message || 'Milestones could not be loaded. Try again.';
} finally {
get('#plan-selected-search-results').disabled = false;
}
}
get('#select-search-results').addEventListener('click', () => plan.start());
get('#cancel-search-selection').addEventListener('click', () => plan.cancel());
get('#queue-selected-search-results').addEventListener('click', () => {
const items = plan.snapshot().items;
const capacity = todayWork.planning().capacity_minutes;
if (Number.isInteger(capacity) && capacity > 0) openEstimateReview(items);
else processor.run(items);
});
get('#defer-selected-search-results').addEventListener('click', event => {
const items = plan.snapshot().items;
if (!items.length) return;
laterPicker?.open({ items, confirm:until => () => laterProcessor.run(
items, {}, { until:new Date(until).toISOString() }
) }, event.currentTarget, 'search-batch');
});
get('#plan-selected-search-results').addEventListener('click', openReleaseReview);
get('#cancel-search-release').addEventListener('click', closeReleaseReview);
get('#confirm-search-release').addEventListener('click', () => {
const milestoneSelect = get('#search-release-milestone');
const milestoneId = Number(milestoneSelect.value);
if (!Number.isInteger(milestoneId) || milestoneId < 1) {
get('#search-release-summary').textContent = 'Choose an open milestone.';
return;
}
const selectedOption = milestoneSelect.options?.[milestoneSelect.selectedIndex];
const milestoneTitle = selectedOption?.textContent || selectedOption?.text || 'release';
const dueDay = get('#search-release-due-date').value;
releaseContext = {
milestone_id:milestoneId, milestone_title:milestoneTitle,
due_date:dueDay ? dueDay + 'T23:59:59Z' : null,
};
releaseProcessor.run(plan.snapshot().items, {}, releaseContext);
});
get('#cancel-search-batch-estimates').addEventListener('click', closeEstimateReview);
get('#confirm-search-batch-estimates').addEventListener('click', () =>
processor.run(plan.snapshot().items, estimateValues())
);
get('#resume-search-batch').addEventListener('click', () => {
if (processor.pending()) return processor.resume();
if (laterProcessor.pending()) return laterProcessor.resume();
return releaseProcessor.resume();
});
get('#open-palette').addEventListener('click', () => restore());
get('#cmd-results').addEventListener('change', event => {
const index = event.target?.dataset?.searchSelect;
if (index === undefined) return;
const result = lookup(Number(index));
if (result) plan.toggle(result);
});
function restore() {
const pending = processor.pending() + laterProcessor.pending() + releaseProcessor.pending();
get('#resume-search-batch').hidden = pending === 0;
get('#resume-search-batch').textContent = 'Resume ' + pending + ' interrupted';
}
function resultHtml(result, index) {
const allowed = result.kind === 'issue' && result.state === 'open';
const selected = plan.snapshot().items.some(item => plan.identity(item) === plan.identity(result));
return '<label class="cmd-select-result"' + (allowed ? '' : ' aria-disabled="true"') + '>' +
'<input type="checkbox" data-search-select="' + index + '" ' + (selected ? 'checked ' : '') +
(allowed ? '' : 'disabled ') + 'aria-label="Select ' + escapeAttribute(result.title) + '" />' +
'<span>' + escapeHtml(result.title) + '</span><span class="cmd-meta">' +
escapeHtml(result.repository) + ' #' + escapeHtml(result.number) + ' · ' +
(allowed ? 'Open issue' : 'Not eligible') + '</span></label>';
}
return { plan, processor, laterProcessor, releaseProcessor, restore, resultHtml };
};
return createSearchBatchPlan;
});
+28
View File
@@ -0,0 +1,28 @@
function createSearchDefer({ claim, accept, defer, refresh, announce, formatTime }) {
let request = null;
function run(detail, until) {
if (request) return request;
const wasClaimable = Boolean(detail?.claimable);
request = Promise.resolve(wasClaimable ? claim(detail) : detail)
.then(confirmed => {
const item = accept(confirmed);
const outcome = defer(item, until);
refresh();
if (outcome === 'deferred') {
announce((wasClaimable ? 'Assigned and deferred until ' : 'Deferred until ') + formatTime(until) + '.');
} else {
announce(wasClaimable
? 'Assignment succeeded, but Later could not be saved. Open this issue in My Work to retry.'
: 'Could not save Later on this device. Retry scheduling.');
}
return outcome;
})
.finally(() => { request = null; });
return request;
}
return { run, pending:() => Boolean(request) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createSearchDefer;
+325 -9
View File
@@ -1,49 +1,347 @@
(function (root, factory) {
const createSearchPreview = factory();
if (typeof module === 'object' && module.exports) module.exports = createSearchPreview;
if (root) root.createSearchPreview = createSearchPreview;
if (root) {
root.createSearchPreview = createSearchPreview;
root.safeSearchUrl = value => {
try {
const url = new URL(value);
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
} catch (_) { return ''; }
};
root.searchPreviewUrl = (state, defaultScope, location) => {
const { query, preview, scope = defaultScope } = state;
const params = new URLSearchParams({
search:query || '', preview:preview.kind + ':' + preview.repository + ':' + preview.number,
search_kind:scope.kind, search_state:scope.state,
});
if (scope.repository) params.set('search_repository', scope.repository);
return new URL('?' + params, location.origin + location.pathname).href;
};
root.searchPreviewPath = item => {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
'/preview?kind=' + encodeURIComponent(item.kind);
};
root.searchPreviewConversationPath = (item, page) => {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
const query = new URLSearchParams({ kind:item.kind, limit:'20' });
if (page) query.set('page', String(page));
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
'/preview/conversation?' + query.toString();
};
root.searchPreviewReplyPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
'/comments?kind=' + encodeURIComponent(item.kind);
root.searchPreviewReplyOptions = (fetchJson, storage, crypto) => ({
storage,
createOperationId:() => crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
postReply:(item,body,operationId) => fetchJson(root.searchPreviewReplyPath(item), {
method:'POST', headers:{Accept:'application/json','Content-Type':'application/json','Idempotency-Key':operationId},
body:JSON.stringify({body}),
}),
});
root.renderSearchPreviewConversation = (conversation, document, escapeHtml, formatTime, renderMarkdown) => {
const comments = document.querySelector('#search-preview-comments');
const status = document.querySelector('#search-preview-conversation-status');
const retry = document.querySelector('#retry-search-preview-conversation');
const older = document.querySelector('#load-older-search-preview-comments');
if (!conversation) {
comments.textContent = status.textContent = '';
retry.hidden = older.hidden = true;
return;
}
comments.innerHTML = (conversation.comments || []).map(comment =>
'<article class="search-preview-comment" data-comment-id="' + Number(comment.id || 0) + '">' +
'<div class="small">' + escapeHtml(comment.author || 'Unknown author') +
(comment.created_at ? ' · ' + escapeHtml(formatTime(comment.created_at)) : '') + '</div>' +
'<div class="markdown-content">' + renderMarkdown(comment.body || '') + '</div></article>'
).join('');
retry.hidden = conversation.status !== 'error';
older.hidden = conversation.status !== 'ready' || !conversation.olderPage;
older.disabled = conversation.status === 'loading';
if (conversation.status === 'loading') status.textContent = conversation.comments?.length
? 'Loading older messages…' : 'Loading current conversation…';
else if (conversation.status === 'error') status.textContent =
'Conversation unavailable. Preview and planning actions still work.';
else if (!conversation.comments?.length) status.textContent = 'No conversation yet.';
else status.textContent = conversation.comments.length +
(conversation.comments.length === 1 ? ' message.' : ' messages.');
};
root.renderSearchPreviewReply = (state, detail, preview, document) => {
const section = document.querySelector('.search-preview-reply');
const input = document.querySelector('#search-preview-reply');
const status = document.querySelector('#search-preview-reply-status');
const buttons = ['#send-search-preview-reply', '#send-search-preview-reply-next']
.map(selector => document.querySelector(selector));
section.hidden = detail?.commentable !== true;
status.textContent = '';
if (section.hidden) {
buttons.forEach(button => { button.disabled = true; });
return;
}
input.value = preview.replyDraft();
const replying = state.status === 'replying';
buttons.forEach(button => {
button.disabled = replying || (!input.value.trim() && !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, mutate, 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();
onNavigate = session[2];
navigationRoot = document;
}
let generation = 0;
let current = null;
let mutationRequest = null;
let shareRequest = null;
let moveRequest = null;
let replyRequest = null;
let conversation = null;
function sameItem(left, right) {
return left && right && left.kind === right.kind && left.repository === right.repository &&
Number(left.number) === Number(right.number);
}
function replyKey(item, suffix) {
return 'stackchain.search-reply.' + [item?.kind, item?.repository, item?.number]
.map(value => encodeURIComponent(String(value || ''))).join('.') + '.' + suffix;
}
function stored(key) {
try { return storage?.getItem(key) || ''; }
catch (_) { return ''; }
}
function save(key, value) {
try {
if (value) storage?.setItem(key, value);
else storage?.removeItem(key);
} catch (_) { /* Volatile drafts still remain in the textarea. */ }
}
function navigation(item) {
const session = typeof getSession === 'function' ? getSession() : null;
const items = Array.isArray(session?.items) ? session.items : [];
const index = items.findIndex(candidate => sameItem(candidate, item));
if (index < 0) return null;
return {
position:index + 1,
total:items.length,
hasPrevious:index > 0,
hasNext:index < items.length - 1 || session.more === true,
};
}
function publish(state) {
const position = navigation(state.item || current);
if (navigationRoot) {
const bar = navigationRoot.querySelector('.search-preview-navigation');
bar.hidden = !position;
if (position) {
navigationRoot.querySelector('#search-preview-position').textContent = position.position + ' of ' + position.total;
navigationRoot.querySelector('#previous-search-result').disabled = !position.hasPrevious;
navigationRoot.querySelector('#next-search-result').disabled = !position.hasNext;
}
}
onState(position ? { ...state, navigation:position } : state);
if (state.complete && navigationRoot) {
navigationRoot.querySelector('#search-preview-status').textContent = 'Search pass complete.';
}
}
function run(action, pending, success, detail) {
if (mutationRequest) return mutationRequest;
onState({ status: pending, item: current, detail });
publish({ status: pending, item: current, detail });
mutationRequest = mutate(detail, action).then(result => {
onState({ status: success, item: current, detail, result });
publish({ status: success, item: current, detail, result });
return result;
}).catch(error => {
onState({ status: 'ready', item: current, detail, error });
publish({ status: 'ready', item: current, detail, error });
throw error;
}).finally(() => { mutationRequest = null; });
return mutationRequest;
}
return {
function loadConversation(detail, requestGeneration, page) {
if (typeof fetchConversation !== 'function') return Promise.resolve(null);
const previousComments = page && Array.isArray(conversation?.comments)
? conversation.comments : [];
conversation = { status:'loading', comments:previousComments, olderPage:page ?? null };
publish({ status:'ready', item:current, detail, conversation });
return fetchConversation(detail, page).then(result => {
if (requestGeneration !== generation) return result;
const incoming = Array.isArray(result?.comments) ? result.comments : [];
const liveComments = Array.isArray(conversation?.comments) ? conversation.comments : previousComments;
const comments = [...incoming, ...liveComments].filter((comment, index, all) =>
all.findIndex(candidate => candidate?.id === comment?.id) === index);
conversation = {
status:'ready',
comments,
olderPage:result?.older_page ?? null,
};
publish({ status:'ready', item:current, detail:current, conversation });
return result;
}).catch(error => {
if (requestGeneration === generation) {
conversation = {
status:'error', comments:previousComments,
olderPage:page ?? conversation?.olderPage ?? null, error,
};
publish({ status:'ready', item:current, detail:current, conversation });
}
return null;
});
}
const api = {
hasReplyAttachments() {
return Boolean(hasAttachments?.());
},
open(item) {
if (current && !sameItem(current, item) && hasAttachments?.()) clearAttachments?.();
generation += 1;
const requestGeneration = generation;
current = { ...item };
onState({ status: 'loading', item: current });
conversation = null;
publish({ status: 'loading', item: current });
return fetchJson(current).then(detail => {
if (requestGeneration === generation) {
onState({ status: 'ready', item: current, detail });
current = { ...current, ...detail };
if (typeof fetchConversation === 'function') {
loadConversation(current, requestGeneration);
} else {
publish({ status: 'ready', item: current, detail });
}
}
return detail;
}).catch(error => {
if (requestGeneration === generation) {
onState({ status: 'error', item: current, error });
publish({ status: 'error', item: current, error });
}
throw error;
});
},
close() {
clearAttachments?.();
generation += 1;
current = null;
onState({ status: 'closed' });
},
previous() {
const session = typeof getSession === 'function' ? getSession() : null;
const items = Array.isArray(session?.items) ? session.items : [];
const index = items.findIndex(item => sameItem(item, current));
if (index <= 0) return Promise.resolve(current);
const item = items[index - 1];
if (typeof onNavigate === 'function') onNavigate(item);
return api.open(item);
},
next() {
if (moveRequest) return moveRequest;
moveRequest = (async () => {
let session = typeof getSession === 'function' ? getSession() : null;
let items = Array.isArray(session?.items) ? session.items : [];
let index = items.findIndex(item => sameItem(item, current));
if (index < 0) return current;
if (index === items.length - 1 && session?.more === true && typeof loadMore === 'function') {
await loadMore();
session = getSession();
items = Array.isArray(session?.items) ? session.items : [];
index = items.findIndex(item => sameItem(item, current));
}
if (index >= 0 && index < items.length - 1) {
const item = items[index + 1];
if (typeof onNavigate === 'function') onNavigate(item);
return api.open(item);
}
publish({ status:'ready', item:current, detail:current, complete:true });
return current;
})().finally(() => { moveRequest = null; });
return moveRequest;
},
retryConversation() {
if (!current) return Promise.resolve(null);
return loadConversation(current, generation);
},
loadOlderConversation() {
if (!current || !conversation?.olderPage) return Promise.resolve(null);
return loadConversation(current, generation, conversation.olderPage);
},
saveReplyDraft(body) {
if (!current) return '';
const key = replyKey(current, 'draft');
const next = String(body || '');
if (stored(key) !== next) save(replyKey(current, 'operation'), '');
save(key, next);
return next;
},
replyDraft() {
return current ? stored(replyKey(current, 'draft')) : '';
},
reply({ advance = false } = {}) {
if (replyRequest) return replyRequest;
if (!current || (typeof postReply !== 'function' && typeof queueReply !== 'function')) {
return Promise.reject(new Error('Replying is unavailable.'));
}
const body = api.replyDraft().trim();
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);
if (!operationId) {
operationId = String(createOperationId?.() || Date.now()).slice(0, 128);
save(operationKey, operationId);
}
publish({ status:'replying', item:current, detail:current, conversation });
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, '');
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 });
throw error;
}).finally(() => { replyRequest = null; });
return replyRequest;
},
share(url) {
if (shareRequest) return shareRequest;
if (!current || typeof share !== 'function') return Promise.reject(new Error('Sharing is unavailable.'));
const detail = current;
publish({ status:'sharing', item:current, detail });
shareRequest = share(url).then(result => {
publish({ status:result, item:current, detail });
return result;
}).catch(error => {
publish({ status:error?.name === 'AbortError' ? 'share-canceled' : 'share-error', item:current, detail, error });
throw error;
}).finally(() => { shareRequest = null; });
return shareRequest;
},
claim(detail) {
return run('claim', 'claiming', 'claimed', detail);
},
@@ -51,5 +349,23 @@
return run('reopen', 'reopening', 'reopened', detail);
},
};
if (navigationRoot) {
navigationRoot.querySelector('#previous-search-result').addEventListener('click', () => api.previous().catch(() => {}));
navigationRoot.querySelector('#next-search-result').addEventListener('click', () => api.next().catch(() => {}));
navigationRoot.querySelector('#retry-search-preview-conversation').addEventListener('click', () => api.retryConversation());
navigationRoot.querySelector('#load-older-search-preview-comments').addEventListener('click', () => api.loadOlderConversation());
const reply = navigationRoot.querySelector('#search-preview-reply');
reply?.addEventListener('input', event => {
api.saveReplyDraft(event.target.value);
const disabled = !event.target.value.trim();
navigationRoot.querySelector('#send-search-preview-reply').disabled = disabled;
navigationRoot.querySelector('#send-search-preview-reply-next').disabled = disabled;
});
reply?.addEventListener('focus', event => event.target.scrollIntoView({ block:'center', behavior:'smooth' }));
navigationRoot.querySelector('#send-search-preview-reply')?.addEventListener('click', () => api.reply().catch(() => {}));
navigationRoot.querySelector('#send-search-preview-reply-next')?.addEventListener('click', () =>
api.reply({ advance:true }).catch(() => {}));
}
return api;
};
});
+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 };
};
});
+117 -6
View File
@@ -1,7 +1,7 @@
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
else root.attachSecurityCenter = boundary => factory({ root, boundary });
})(typeof window !== 'undefined' ? window : this, function attachSecurityCenter({ root, boundary }) {
else root.attachSecurityCenter = (boundary, options = {}) => factory({ root, boundary, ...options });
})(typeof window !== 'undefined' ? window : this, function attachSecurityCenter({ root, boundary, onOpen = () => {}, onClose = () => {} }) {
const devicesButton = root.document.getElementById('active-devices');
const devicesSheet = root.document.getElementById('active-devices-sheet');
const devicesList = root.document.getElementById('active-devices-list');
@@ -12,7 +12,99 @@
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;
let backgroundInert = null;
const backgroundElements = ['header', 'main', '#mobile-task-dock']
.map(selector => root.document.querySelector?.(selector))
.filter(Boolean);
const focusableControls = () => [...(devicesSheet?.querySelectorAll?.(
'button:not([disabled]), select:not([disabled]), input:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'
) || [])].filter(control => !control.hidden && !control.disabled);
const containBackground = () => {
if (!backgroundInert) {
backgroundInert = new Map(backgroundElements.map(element => [element, element.inert]));
}
backgroundInert.forEach((_wasInert, element) => { element.inert = true; });
};
const releaseBackground = () => {
if (!backgroundInert) return;
backgroundInert.forEach((wasInert, element) => { element.inert = wasInert; });
backgroundInert = null;
};
const finishClose = () => {
devicesSheet.hidden = true;
releaseBackground();
devicesButton?.focus();
onClose();
};
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();
});
const handleRouteChange = section => {
if (section && !devicesSheet.hidden) {
containBackground();
navigate(section, { history: false });
return;
}
if (!devicesSheet.hidden) {
finishClose();
}
};
root.addEventListener?.('popstate', event => {
handleRouteChange(event.state?.stackchainSecuritySection);
});
root.addEventListener?.('hashchange', () => {
handleRouteChange(root.history?.state?.stackchainSecuritySection);
});
root.document.addEventListener?.('keydown', event => {
if (devicesSheet.hidden) return;
if (event.key === 'Escape') {
event.preventDefault();
if (root.history?.state?.stackchainSecuritySection) root.history.back();
else finishClose();
return;
}
if (event.key !== 'Tab') return;
const controls = focusableControls();
if (!controls.length) return;
const first = controls[0];
const last = controls[controls.length - 1];
if (event.shiftKey && event.target === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && event.target === last) {
event.preventDefault();
first.focus();
}
});
const renderDevices = async () => {
devicesStatus.textContent = 'Loading active devices…';
@@ -39,8 +131,11 @@
revoke.type = 'button';
revoke.textContent = device.current ? 'Sign out' : 'Revoke';
revoke.addEventListener('click', async () => {
if (device.current) await boundary.signOut();
else if (await boundary.revokeActiveDevice(device)) await renderDevices();
if (device.current && boundary.requestSignOut) await boundary.requestSignOut(revoke);
else if (device.current) await boundary.signOut();
else if (await boundary.revokeActiveDevice(device)) {
await Promise.all([renderDevices(), renderSecurityActivity()]);
}
});
row.append(details, revoke);
devicesList.append(row);
@@ -82,6 +177,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 +218,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);
});
}
@@ -131,6 +231,7 @@
passkey_counter_anomaly: 'Passkey counter anomaly',
device_revoked: 'Device access revoked', all_sessions_revoked: 'All device access revoked',
issue_closed: 'Issue closed', pull_merged: 'Pull request merged',
comment_deleted: 'Comment deleted',
pull_review_approved: 'Pull request approved',
pull_review_changes_requested: 'Changes requested', gitea_time_logged: 'Gitea time logged',
};
@@ -149,6 +250,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;
@@ -166,8 +274,11 @@
const open = () => {
if (!devicesSheet) return;
onOpen();
containBackground();
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);
@@ -185,5 +296,5 @@
enrollPasskey.disabled = false;
}
});
return { open, renderDevices, renderPasskeys, renderSecurityActivity };
return { open, navigate, renderDevices, renderPasskeys, renderSecurityActivity };
});
+313 -10
View File
@@ -1,10 +1,12 @@
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-v97';
const CACHE = 'stackchain-dashboard-shell-v123';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
const PUSH_ACTION_TIMEOUT_MS = self.__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS || 8000;
const PRIVATE_DATABASES = self.stackchainPrivateDatabases;
const SHELL = [
BASE,
BASE + 'manifest.webmanifest',
@@ -13,18 +15,29 @@ const SHELL = [
BASE + 'static/icons/stackchain-192.png',
BASE + 'static/icons/stackchain-512.png',
BASE + 'static/session.js',
BASE + 'static/sign-out-review.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',
BASE + 'static/unfiled-captures.js',
BASE + 'static/unfiled-draft-sync.js',
BASE + 'static/shared-image-capture.js',
BASE + 'static/draft-filing-session.js',
BASE + 'static/draft-capacity-dialog.js',
BASE + 'static/outbox-coordinator.js',
BASE + 'static/issue-outbox.js',
BASE + 'static/reconnect-outboxes.js',
BASE + 'static/authored-outbox.js',
BASE + 'static/offline-issue-close.js',
BASE + 'static/offline-issue-blocker.js',
@@ -32,12 +45,22 @@ const SHELL = [
BASE + 'static/offline-work.js',
BASE + 'static/offline-today.js',
BASE + 'static/my-work.js',
BASE + 'static/agenda-replan.js',
BASE + 'static/agenda-calendar.js',
BASE + 'static/protect-today.js',
BASE + 'static/notification-undo.js',
BASE + 'static/card-planning.js',
BASE + 'static/work-selection.js',
BASE + 'static/today-work.js',
BASE + 'static/today-timer.js',
BASE + 'static/today-break.js',
BASE + 'static/today-progress.js',
BASE + 'static/today-lock-screen.js',
BASE + 'static/today-session-sync.js',
BASE + 'static/today-recap.js',
BASE + 'static/today-wrap-up.js',
BASE + 'static/today-summary.js',
BASE + 'static/today-handoff.js',
BASE + 'static/today-completion.js',
BASE + 'static/today-readiness.js',
BASE + 'static/comment-next.js',
@@ -55,34 +78,123 @@ const SHELL = [
BASE + 'static/detail-defer.js',
BASE + 'static/later-picker.js',
BASE + 'static/pick-work.js',
BASE + 'static/batch-find-work.js',
BASE + 'static/search-batch-plan.js',
BASE + 'static/conversation.js',
BASE + 'static/comment-actions.js',
BASE + 'static/issue-evidence-review.js',
BASE + 'static/issue-evidence-editor.js',
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',
BASE + 'static/queue-today.js',
BASE + 'static/pull-sheet.js',
BASE + 'static/review-sheet.js',
BASE + 'static/release-receipt.js',
BASE + 'static/work-route.js',
BASE + 'static/task-overlay-history.js',
BASE + 'static/context-poller.js',
BASE + 'static/live-data-status.js',
BASE + 'static/mobile-today-command-bar.js',
BASE + 'static/mobile-task-dock.js',
BASE + 'static/mobile-first-task.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',
BASE + 'static/update-read-position.js',
BASE + 'static/work-detail-position.js',
BASE + 'static/update-triage-launcher.js',
BASE + 'static/update-triage-gesture.js',
BASE + 'static/update-decision-transaction.js',
BASE + 'static/agenda-session-launcher.js',
BASE + 'static/mobile-launch.js',
BASE + 'static/mobile-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',
BASE + 'static/mention-composer.js',
BASE + 'static/push-notifications.js',
BASE + 'static/issue-filing-receipt.js',
BASE + 'static/background-issue-sync.js',
];
const OPTIONAL_FEATURES = [
];
const SHARED_IMAGE_ID = 'shared-image';
const SHARED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
const MAX_SHARED_IMAGE_BYTES = 12 * 1024 * 1024;
const SHARE_TARGET_PATH = BASE + 'share-target';
const sharedAttachmentStore = self.__STACKCHAIN_SHARED_ATTACHMENT_STORE || createUnfiledAttachmentStore();
function boundedShareField(form, name, limit) {
const value = form.get(name);
return typeof value === 'string' ? value.trim().slice(0, limit) : '';
}
function sharedContentRedirect(marker) {
const target = new URL(BASE, self.location.origin);
target.searchParams.set('launch', 'new');
if (marker) target.searchParams.set('shared', marker);
return new Response(null, {status:303, headers:{Location:target.pathname + target.search}});
}
async function acceptSharedContent(request) {
if (request.headers.get('Sec-Fetch-Site') === 'cross-site') {
return new Response('Cross-site share submissions are not accepted.', {
status:403,
headers:{'Content-Type':'text/plain; charset=utf-8','Cache-Control':'no-store'},
});
}
const form = await request.formData();
const images = form.getAll('image').filter(value => typeof value !== 'string' && value?.size > 0);
const content = {
title:boundedShareField(form, 'title', 255),
text:boundedShareField(form, 'text', 10000),
url:boundedShareField(form, 'url', 2048),
};
if (images.length > 5) return sharedContentRedirect('multiple');
const supported = images.every(image =>
SHARED_IMAGE_TYPES.has(String(image.type || '')) && image.size <= MAX_SHARED_IMAGE_BYTES
);
const totalBytes = images.reduce((total, image) => total + image.size, 0);
if (!supported || totalBytes > MAX_SHARED_IMAGE_BYTES) return sharedContentRedirect('unsupported');
if (!images.length && !Object.values(content).some(Boolean)) return sharedContentRedirect('unsupported');
const attachments = images.map(image => ({
filename:String(image.name || 'shared-screenshot').slice(0, 255),
contentType:String(image.type), blob:image,
}));
try {
await sharedAttachmentStore.put(SHARED_IMAGE_ID, {...content, attachments});
} catch (_error) {
return sharedContentRedirect('unavailable');
}
return sharedContentRedirect('bundle');
}
async function fetchNavigation(request) {
const controller = new AbortController();
let timeout;
@@ -138,9 +250,13 @@ async function deletePrivateDatabase(name) {
});
}
async function deletePrivateDatabases() {
for (const name of PRIVATE_DATABASES) await deletePrivateDatabase(name);
}
async function purgeRevokedSessionData() {
await issueSync.purge();
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
await deletePrivateDatabases();
const keys = await caches.keys();
await Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
@@ -177,7 +293,7 @@ async function offlineLeaseState(cache) {
async function expiredOfflineResponse() {
await issueSync.purge();
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
await deletePrivateDatabases();
const keys = await caches.keys();
await Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
@@ -261,7 +377,7 @@ async function flushAndNotify() {
body: needsAuthorization ? 'Tap to authorize it in the Delivery center.' :
needsAttention ? 'Tap to review it in Drafts.' : 'Tap to open it in Stackchain.',
tag: 'stackchain-delivery-' + receipt.id,
data: { route: receipt.route },
data: receipt.url ? { url: receipt.url } : { route: receipt.route },
});
}
}
@@ -270,15 +386,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();
})());
});
@@ -287,6 +428,39 @@ self.addEventListener('sync', event => {
if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(flushAndNotify());
});
async function updateTodayLockScreen(active, running, rawActionToken = '', rawBreakDeadline = 0) {
const tag = 'stackchain-today-session';
if (!active) {
const notifications = await self.registration.getNotifications({ tag });
notifications.forEach(notification => notification.close());
return;
}
const actionToken = /^[A-Za-z0-9_-]{16,128}$/.test(rawActionToken) ? rawActionToken : '';
const now = Date.now();
const breakDeadline = Number(rawBreakDeadline);
const onBreak = !running && actionToken && Number.isSafeInteger(breakDeadline) &&
breakDeadline > now && breakDeadline <= now + 120 * 60 * 1000;
const title = onBreak ? 'On a Today break' :
running ? 'Today session running' : 'Today session paused';
const body = onBreak ? 'Return at ' + new Date(breakDeadline).toLocaleTimeString([], {
hour:'numeric', minute:'2-digit',
}) : running ? 'Your active Today timer is running.' : 'Your active Today timer is paused.';
await self.registration.showNotification(title, {
body,
tag,
renotify:false,
silent:true,
actions: onBreak ? [
{ action:'resume-today', title:'Resume now' },
{ action:'open-today', title:'Open Today' },
] : [
{ action:running ? 'pause-today' : 'resume-today', title:running ? 'Pause' : 'Resume' },
...(actionToken ? [{ action:'finish-today', title:'Finish current' }] : []),
],
data: { route:'#/my-work/today', ...(actionToken ? { actionToken } : {}) },
});
}
self.addEventListener('message', event => {
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil((async () => {
await issueSync.resume();
@@ -298,12 +472,21 @@ self.addEventListener('message', event => {
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
try {
await issueSync.purge();
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
await deletePrivateDatabases();
await updateTodayLockScreen(false, false);
event.ports?.[0]?.postMessage({ ok: true });
} catch (error) {
event.ports?.[0]?.postMessage({ ok: false, error: String(error?.message || 'Outbox purge failed.') });
}
})());
if (event.data?.type === 'stackchain-today-lock-screen') {
event.waitUntil(updateTodayLockScreen(
event.data.active === true,
event.data.running === true,
String(event.data.actionToken || ''),
Number(event.data.breakDeadlineAt || 0)
));
}
});
self.addEventListener('push', event => {
@@ -311,9 +494,33 @@ self.addEventListener('push', event => {
try { payload = event.data?.json?.() || {}; }
catch (_error) { return; }
const route = String(payload.route || '');
const protectRoute = String(payload.protect_route || '');
const tag = String(payload.tag || '');
const notificationId = Number(payload.notification_id);
const updateCount = Number(payload.update_count);
const deadlineCount = Number(payload.deadline_count);
if (
route === '#/my-work/agenda'
&& protectRoute === '#/my-work/agenda/protect-today'
&& /^stackchain-deadline-digest-\d{4}-\d{2}-\d{2}$/.test(tag)
&& Number.isSafeInteger(deadlineCount)
&& deadlineCount > 0
&& deadlineCount <= 50
) {
event.waitUntil(self.registration.showNotification(
deadlineCount + ' deadline' + (deadlineCount === 1 ? '' : 's') + ' need' + (deadlineCount === 1 ? 's' : '') + ' attention',
{
body: 'Open Agenda to review or replan ' + (deadlineCount === 1 ? 'it.' : 'them.'),
tag,
actions: [
{ action: 'protect-today', title: 'Protect Today' },
{ action: 'snooze-deadline', title: 'Remind in 1 hour' },
],
data: {route, protectRoute},
}
));
return;
}
if (
route === '#/my-work/updates'
&& tag === 'stackchain-update-digest'
@@ -357,8 +564,96 @@ async function openWorkRoute(route) {
return client.focus();
}
async function openCanonicalIssueUrl(rawUrl) {
let target;
try { target = new URL(rawUrl); } catch (_error) { return; }
if (
target.origin !== self.location.origin
|| !/^\/git\/[^/]+\/[^/]+\/issues\/\d+$/.test(target.pathname)
|| target.search || target.hash
) return;
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE));
if (!client) return self.clients.openWindow(target.href);
if (client.navigate) await client.navigate(target.href);
return client.focus();
}
async function applyTodayTimerAction(action, actionToken = '') {
if (!['pause', 'resume', 'complete'].includes(action)) return;
if (actionToken && !/^[A-Za-z0-9_-]{16,128}$/.test(actionToken)) return;
if (action === 'complete' && !actionToken) return;
const route = '#/my-work/today';
const windows = await self.clients.matchAll({ type:'window', includeUncontrolled:true });
const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE));
if (client) {
client.postMessage?.({
type:'stackchain-today-timer-action', action,
...(actionToken ? { actionToken } : {}),
});
return client.focus?.();
}
const query = '?today_timer_action=' + action +
(actionToken ? '&today_action_token=' + encodeURIComponent(actionToken) : '');
const target = new URL(BASE + query + route, self.location.origin).href;
return self.clients.openWindow(target);
}
self.addEventListener('notificationclick', event => {
const route = String(event.notification.data?.route || '');
const issueUrl = String(event.notification.data?.url || '');
if (
event.notification.tag === 'stackchain-today-session'
&& route === '#/my-work/today'
&& ['pause-today', 'resume-today', 'finish-today', 'open-today', ''].includes(event.action)
) {
event.notification.close();
if (['pause-today', 'resume-today', 'finish-today'].includes(event.action)) {
const action = event.action === 'pause-today' ? 'pause' :
event.action === 'resume-today' ? 'resume' : 'complete';
event.waitUntil(applyTodayTimerAction(action, String(event.notification.data?.actionToken || '')));
} else {
event.waitUntil(openWorkRoute(route));
}
return;
}
if (issueUrl) {
event.notification.close();
event.waitUntil(openCanonicalIssueUrl(issueUrl));
return;
}
if (event.action === 'protect-today') {
const protectRoute = String(event.notification.data?.protectRoute || route);
if (
protectRoute !== '#/my-work/agenda/protect-today'
|| !['#/my-work/agenda', protectRoute].includes(route)
) return;
event.notification.close();
event.waitUntil(openWorkRoute(protectRoute));
return;
}
if (event.action === 'snooze-deadline') {
if (
route !== '#/my-work/agenda'
|| !/^stackchain-deadline-digest-\d{4}-\d{2}-\d{2}$/.test(event.notification.tag)
) return;
event.waitUntil((async () => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), PUSH_ACTION_TIMEOUT_MS);
try {
await fetchJson(BASE + 'api/v1/push-subscription/deadlines/snooze', {
method: 'PATCH', headers: { Accept: 'application/json' }, signal: controller.signal,
});
event.notification.close();
} catch (_error) {
await openWorkRoute(route);
event.notification.close();
} finally {
clearTimeout(timeout);
}
})());
return;
}
if (event.action === 'tomorrow') {
const notificationId = Number(event.notification.data?.notificationId);
if (
@@ -419,6 +714,14 @@ self.addEventListener('notificationclick', event => {
self.addEventListener('fetch', event => {
const request = event.request;
const requestUrl = new URL(request.url);
if (
request.method === 'POST' && request.mode === 'navigate'
&& requestUrl.origin === self.location.origin && requestUrl.pathname === SHARE_TARGET_PATH
) {
event.respondWith(acceptSharedContent(request));
return;
}
if (request.method !== 'GET' || request.url.includes('/api/')) return;
const url = new URL(request.url);
if (url.origin !== self.location.origin || !url.pathname.startsWith(BASE)) return;
@@ -451,6 +754,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));
}
});
+55 -20
View File
@@ -1,5 +1,10 @@
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
if (typeof module !== 'undefined' && module.exports) {
module.exports = options => factory({
...options,
privateDatabases: require('./private-data-registry.js'),
});
}
else {
const base = new URL('./', root.location.href).pathname;
const originalFetch = root.fetch.bind(root);
@@ -15,6 +20,7 @@
serviceWorker: root.navigator?.serviceWorker,
credentials: root.navigator?.credentials,
MessageChannel: root.MessageChannel,
privateDatabases: root.stackchainPrivateDatabases,
location: root.location,
addActivityListener: (type, listener, options) => root.addEventListener(type, listener, options),
confirmAction: message => root.confirm(message),
@@ -30,36 +36,55 @@
root.fetch = boundary.fetch;
const attach = () => {
boundary.startActivityHeartbeat();
boundary.startReconnectResume();
const button = root.document.getElementById('sign-out');
if (button) button.addEventListener('click', () => boundary.signOut());
const allDevicesButton = root.document.getElementById('sign-out-all');
if (allDevicesButton) allDevicesButton.addEventListener('click', () => boundary.signOutAllDevices());
const allButton = root.document.getElementById('sign-out-all');
let review;
const features = root.createFeatureLoader({
document: root.document,
urls: {
'sign-out': root.document.querySelector('meta[name="stackchain-feature-sign-out"]')?.content || '',
'security-center': root.document.querySelector('meta[name="stackchain-feature-security-center"]')?.content || '',
},
});
const openReview = (mode, source) => features.run(
'sign-out', { trigger: source }, () => {
if (!review) review = root.createSignOutReview.mount(boundary);
review.show(mode, source);
},
);
[[button, 'current'], [allButton, 'all']].forEach(([source, mode]) =>
source?.addEventListener('click', () => openReview(mode, source))
);
boundary.requestSignOut = source => openReview('current', source || button);
const devicesButton = root.document.getElementById('active-devices');
const devicesSheet = root.document.getElementById('active-devices-sheet');
const devicesStatus = root.document.getElementById('active-devices-status');
const closeDevices = root.document.getElementById('close-active-devices');
const securityFeatures = root.createFeatureLoader({
document: root.document,
urls: {
'security-center': root.document.querySelector(
'meta[name="stackchain-feature-security-center"]'
)?.content || '',
},
});
const returnFromSecurity = root.document.getElementById('return-from-security-center');
const beginSecurityDetour = () => {
if (root.matchMedia?.('(max-width: 600px)')?.matches) {
root.stackchainTodayTimerView?.beginDetour?.('security-center');
}
};
let loadingSecurityCenter = false;
const openSecurityCenter = async () => {
if (loadingSecurityCenter) return;
loadingSecurityCenter = true;
beginSecurityDetour();
devicesSheet.hidden = false;
closeDevices?.focus();
try {
await securityFeatures.run('security-center', {
await features.run('security-center', {
trigger: devicesButton,
status: devicesStatus,
retryLabel: 'Tap Active devices to retry.',
}, () => {
devicesButton.removeEventListener('click', openSecurityCenter);
root.attachSecurityCenter(boundary).open();
root.attachSecurityCenter(boundary, {
onOpen: beginSecurityDetour,
onClose: () => root.stackchainTodayTimerView?.finishDetour?.('security-center'),
}).open();
});
} finally {
loadingSecurityCenter = false;
@@ -71,6 +96,11 @@
if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => {
devicesSheet.hidden = true;
devicesButton?.focus();
root.stackchainTodayTimerView?.finishDetour?.('security-center');
});
if (returnFromSecurity && devicesSheet) returnFromSecurity.addEventListener('click', () => {
if (root.history?.state?.stackchainSecuritySection) root.history.back();
else devicesSheet.hidden = true;
});
boundary.refreshOfflineLease().then(valid => {
if (valid) boundary.resumeQueuedWork();
@@ -82,7 +112,7 @@
root.stackchainSession = boundary;
}
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, credentials, MessageChannel, location, confirmAction, addActivityListener,
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, credentials, MessageChannel, privateDatabases, location, confirmAction, addActivityListener,
promptAuthorization = () => null,
onExpired = () => {},
onClearError = () => {},
@@ -381,6 +411,12 @@
});
}
function startReconnectResume() {
addActivityListener?.('online', async () => {
if (await refreshOfflineLease()) await resumeQueuedWork();
});
}
async function refreshOfflineLease() {
try {
const response = await sessionFetch(base + 'api/v1/session');
@@ -454,9 +490,7 @@
if (sessionStorage !== localStorage) removeDashboardStorage(sessionStorage);
try {
await stopWorkerOutbox();
await deletePrivateDatabase('stackchain-background-outbox-v1');
await deletePrivateDatabase('stackchain-offline-work-v2');
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
for (const name of privateDatabases) await deletePrivateDatabase(name);
} catch (_error) {
const error = new Error('Could not clear private queued work from this device.');
onClearError(error);
@@ -581,8 +615,8 @@
}
}
async function signOutAllDevices() {
const confirmed = confirmAction?.('Sign out every device? You will need to sign in again everywhere.');
async function signOutAllDevices({ reviewed = false } = {}) {
const confirmed = reviewed || confirmAction?.('Sign out every device? You will need to sign in again everywhere.');
if (!confirmed) return false;
const response = await sessionFetch(base + 'api/v1/sessions', { method: 'DELETE' });
if (!response.ok) throw new Error('Could not sign out all devices');
@@ -613,6 +647,7 @@
handleServiceWorkerMessage,
refreshOfflineLease,
resumeQueuedWork,
startReconnectResume,
recordActivity,
startActivityHeartbeat,
};
+62
View File
@@ -0,0 +1,62 @@
(function(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.sharedImageCapture = api;
})(typeof self !== 'undefined' ? self : this, function() {
'use strict';
const RECORD_ID = 'shared-image';
const TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
async function consume({marker, store, restore, restoreContent = () => {}, status = () => {}}) {
if (!marker) return false;
const value = await store?.get(RECORD_ID);
const attachments = Array.isArray(value?.attachments) ? value.attachments : null;
if (marker === 'bundle') {
const content = {
title:typeof value?.title === 'string' ? value.title.slice(0, 255) : '',
text:typeof value?.text === 'string' ? value.text.slice(0, 10000) : '',
url:typeof value?.url === 'string' ? value.url.slice(0, 2048) : '',
};
const invalidAttachments = !attachments || attachments.length > 5 || attachments.some(attachment =>
!attachment?.blob || !attachment.filename || !TYPES.has(String(attachment.contentType || '')));
if (invalidAttachments || (!attachments.length && !Object.values(content).some(Boolean))) {
status('The shared content is unavailable. Share it again.');
return false;
}
restoreContent(content);
if (attachments.length) restore(attachments);
await store.delete(RECORD_ID);
const noun = attachments.length === 1 ? 'screenshot' : attachments.length > 1 ? 'screenshots' : 'content';
status(attachments.length
? 'Shared content and ' + (attachments.length > 1 ? attachments.length + ' ' : '') + noun + ' ready to file with this issue.'
: 'Shared content ready to file with this issue.');
return true;
}
if (!['image', 'images'].includes(marker)) {
status('Share one PNG, JPEG, or WebP screenshot.');
return false;
}
if (marker === 'images') {
if (!attachments?.length || attachments.length > 5 || attachments.some(attachment =>
!attachment?.blob || !attachment.filename || !TYPES.has(String(attachment.contentType || '')))) {
status('The shared screenshots are unavailable. Share them again.');
return false;
}
restore(attachments);
await store.delete(RECORD_ID);
status(attachments.length + ' shared screenshots ready to file with this issue.');
return true;
}
if (!value?.blob || !value.filename || !TYPES.has(String(value.contentType || ''))) {
status('The shared screenshot is unavailable. Share it again.');
return false;
}
restore(value);
await store.delete(RECORD_ID);
status('Shared screenshot ready to file with this issue.');
return true;
}
return {consume, RECORD_ID};
});
+131
View File
@@ -0,0 +1,131 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else {
root.createSignOutReview = factory;
root.createSignOutReview.mount = boundary => {
const document = root.document;
const review = factory({
sheet: document.getElementById('sign-out-review-sheet'),
heading: document.getElementById('sign-out-review-heading'),
summary: document.getElementById('sign-out-review-summary'),
warning: document.getElementById('sign-out-review-warning'),
cancelButton: document.getElementById('cancel-sign-out'),
confirmButton: document.getElementById('confirm-sign-out'),
escapeTarget: document,
historyTarget: root,
history: root.history,
backgroundTargets: Array.from(document.querySelectorAll('body > :not(#sign-out-review-sheet)')),
getActiveElement: () => document.activeElement,
localStorage: root.localStorage,
sessionStorage: root.sessionStorage,
privateDatabases: root.stackchainPrivateDatabases,
inspectPrivateDatabases: root.inspectStackchainPrivateDatabases,
onConfirm: mode => mode === 'all'
? boundary.signOutAllDevices({ reviewed: true })
: boundary.signOut(),
});
review.start();
return review;
};
}
})(typeof globalThis !== 'undefined' ? globalThis : this, function createSignOutReview(options) {
let mode = 'current';
let launcher = null;
let open = false;
let historyEntry = false;
function ownedItemCount(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;
}
async function show(nextMode, source) {
mode = nextMode;
launcher = source;
const itemCount = ownedItemCount(options.localStorage)
+ (options.sessionStorage === options.localStorage ? 0 : ownedItemCount(options.sessionStorage));
let inventory = { recordCount: 0, unavailable: true };
try {
inventory = await options.inspectPrivateDatabases?.(options.privateDatabases) || inventory;
} catch (_error) { /* Unknown private work must use the guarded confirmation path. */ }
const recordCount = Math.max(0, Number(inventory.recordCount) || 0);
options.heading.textContent = mode === 'all' ? 'Review sign out on every device' : 'Review sign out';
options.summary.textContent = inventory.unavailable
? `This device has ${itemCount} private browser item${itemCount === 1 ? '' : 's'}; private work status is unknown.`
: `This device has ${itemCount} private browser item${itemCount === 1 ? '' : 's'} and ${recordCount} private work record${recordCount === 1 ? '' : 's'}.`;
const atRisk = itemCount > 0 || recordCount > 0 || inventory.unavailable;
if (mode === 'all') {
options.warning.textContent = atRisk
? 'Every device will need to sign in again. Private drafts or queued work on this device may not be synced and will be erased.'
: 'Every device will need to sign in again. This device will be cleared after its session is revoked.';
} else {
options.warning.textContent = atRisk
? 'Private drafts or queued work may not be synced. Signing out erases them from this device.'
: 'Signing out clears this device after the session is revoked.';
}
options.confirmButton.textContent = atRisk
? 'Sign out and erase private work'
: (mode === 'all' ? 'Sign out all devices' : 'Sign out');
options.sheet.hidden = false;
(options.backgroundTargets || []).forEach(target => { target.inert = true; });
open = true;
options.history?.pushState?.({ stackchainSignOutReview: true }, '');
historyEntry = Boolean(options.history?.back);
options.cancelButton.focus();
}
function close({ restoreFocus = true } = {}) {
if (!open) return;
options.sheet.hidden = true;
(options.backgroundTargets || []).forEach(target => { target.inert = false; });
open = false;
if (restoreFocus) launcher?.focus?.();
}
function dismiss() {
if (historyEntry) {
historyEntry = false;
options.history.back();
} else close();
}
async function confirm() {
options.confirmButton.disabled = true;
try {
await options.onConfirm(mode);
close({ restoreFocus: false });
} catch (_error) {
options.warning.textContent = 'Sign out could not finish clearing private work. Close other Stackchain tabs, then retry.';
} finally {
options.confirmButton.disabled = false;
}
}
function start() {
options.launcher?.addEventListener('click', event => show('current', event.currentTarget || options.launcher));
options.allLauncher?.addEventListener('click', event => show('all', event.currentTarget || options.allLauncher));
options.cancelButton?.addEventListener('click', dismiss);
options.confirmButton?.addEventListener('click', confirm);
options.escapeTarget?.addEventListener('keydown', event => {
if (!open) return;
if (event.key === 'Escape') { event.preventDefault(); dismiss(); return; }
if (event.key !== 'Tab') return;
const focusable = Array.from(options.sheet.querySelectorAll('button:not([disabled])'));
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = options.getActiveElement?.();
if (!event.shiftKey && active === last) { event.preventDefault(); first?.focus(); }
if (event.shiftKey && active === first) { event.preventDefault(); last?.focus(); }
});
options.historyTarget?.addEventListener('popstate', () => {
historyEntry = false;
close();
});
}
return { start, show, close };
});
+154 -22
View File
@@ -6,21 +6,132 @@
'use strict';
const allowed = new Set(['new', 'find', 'search', 'search-preview', 'plan-today', 'plan-today-preview', 'today-readiness']);
const searchKinds = new Set(['search', 'search-preview']);
const maxQueryLength = 200;
return function createTaskOverlayHistory({ history, eventTarget, onChange }) {
let active = allowed.has(history.state?.taskOverlay) ? history.state.taskOverlay : null;
function cleanQuery(value) {
const query = typeof value === 'string' ? value.trim() : '';
return query.length <= maxQueryLength ? query : '';
}
function cleanScope(value) {
const repository = typeof value?.repository === 'string' &&
/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value.repository.trim())
? value.repository.trim() : '';
return {
kind:['all', 'issue', 'pull'].includes(value?.kind) ? value.kind : 'all',
state:['all', 'open', 'closed'].includes(value?.state) ? value.state : 'all',
repository,
};
}
function cleanPreview(value) {
if (!value || !['issue', 'pull'].includes(value.kind)) return null;
const repository = typeof value.repository === 'string' ? value.repository : '';
const parts = repository.split('/');
const validPart = part => /^[A-Za-z0-9_.-]+$/.test(part);
const number = Number(value.number);
if (parts.length !== 2 || !parts.every(validPart) || !Number.isInteger(number) || number < 1) return null;
return { kind:value.kind, repository, number };
}
function previewToken(preview) {
return preview ? `${preview.kind}:${preview.repository}:${preview.number}` : '';
}
function parsePreview(value) {
const match = /^(issue|pull):([^/:]+\/[^/:]+):(\d+)$/.exec(value || '');
return match ? cleanPreview({ kind:match[1], repository:match[2], number:Number(match[3]) }) : null;
}
return function createTaskOverlayHistory({ history, location, eventTarget, onChange }) {
let started = false;
let restoredFromUrl = false;
function stateKind(state = history.state) {
return allowed.has(state?.taskOverlay) ? state.taskOverlay : null;
function stateDetail(state = history.state) {
const kind = allowed.has(state?.taskOverlay) ? state.taskOverlay : null;
if (!searchKinds.has(kind)) return { kind };
const query = cleanQuery(state.searchQuery);
const scope = state.searchScope === undefined ? null : cleanScope(state.searchScope);
const preview = kind === 'search-preview' ? cleanPreview(state.searchPreview) : null;
if (kind === 'search-preview' && state.searchPreview !== undefined && !preview) {
return { kind:'search', ...(query ? { query } : {}) };
}
return { kind, ...(query ? { query } : {}), ...(scope ? { scope } : {}), ...(preview ? { preview } : {}) };
}
function urlDetail() {
if (!location || typeof URLSearchParams === 'undefined') return { kind:null };
const params = new URLSearchParams(location.search || '');
const rawQuery = params.get('search');
if (rawQuery === null) return { kind:null };
const query = cleanQuery(rawQuery);
if (rawQuery.trim() && !query) return { kind:null };
const preview = parsePreview(params.get('preview'));
const hasScope = params.has('search_kind') || params.has('search_state') || params.has('search_repository');
const scope = hasScope ? cleanScope({
kind:params.get('search_kind'), state:params.get('search_state'),
repository:params.get('search_repository'),
}) : null;
return {
kind:preview ? 'search-preview' : 'search',
...(query ? { query } : {}),
...(scope ? { scope } : {}),
...(preview ? { preview } : {}),
};
}
function toState(detail, base = history.state) {
const state = { ...(base || {}), taskOverlay:detail.kind };
delete state.searchQuery;
delete state.searchPreview;
delete state.searchScope;
if (searchKinds.has(detail.kind)) {
if (detail.query) state.searchQuery = detail.query;
if (detail.scope) state.searchScope = cleanScope(detail.scope);
if (detail.kind === 'search-preview' && detail.preview) state.searchPreview = detail.preview;
}
return state;
}
function urlFor(detail) {
if (!location || typeof URLSearchParams === 'undefined') return undefined;
const params = new URLSearchParams(location.search || '');
params.delete('search');
params.delete('preview');
params.delete('search_kind');
params.delete('search_state');
params.delete('search_repository');
if (searchKinds.has(detail.kind)) {
params.set('search', detail.query || '');
if (detail.scope) {
params.set('search_kind', detail.scope.kind);
params.set('search_state', detail.scope.state);
if (detail.scope.repository) params.set('search_repository', detail.scope.repository);
}
if (detail.kind === 'search-preview' && detail.preview) params.set('preview', previewToken(detail.preview));
}
const query = params.toString();
return `${location.pathname || ''}${query ? `?${query}` : ''}${location.hash || ''}`;
}
let initial = stateDetail();
if (!initial.kind) {
const fromUrl = urlDetail();
if (fromUrl.kind) {
initial = fromUrl;
restoredFromUrl = true;
history.replaceState(toState(fromUrl), '', urlFor(fromUrl));
}
}
let active = initial;
function apply(state) {
const next = stateKind(state);
if (next === active) return;
const previous = active;
const next = stateDetail(state);
if (JSON.stringify(next) === JSON.stringify(active)) return;
const previous = active.kind;
active = next;
onChange(next, previous);
onChange(next.kind, previous, next);
}
return {
@@ -28,33 +139,54 @@
if (started) return;
started = true;
eventTarget.addEventListener('popstate', event => apply(event.state));
if (restoredFromUrl) onChange(active.kind, null, active);
},
open(kind) {
open(kind, detail = {}) {
if (!allowed.has(kind)) return false;
if (active === kind) return true;
const previous = active;
const state = { ...(history.state || {}), taskOverlay: kind };
history.pushState(state, '');
active = kind;
onChange(kind, previous);
const next = stateDetail(toState({
kind, query:cleanQuery(detail.query),
scope:detail.scope === undefined ? undefined : cleanScope(detail.scope),
preview:cleanPreview(detail.preview),
}));
if (active.kind === next.kind && JSON.stringify(active) === JSON.stringify(next)) return true;
const previous = active.kind;
history.pushState(toState(next), '', urlFor(next));
active = next;
onChange(next.kind, previous, next);
return true;
},
update(detail = {}) {
if (!searchKinds.has(active.kind)) return false;
const next = stateDetail(toState({
kind:active.kind,
query:detail.query === undefined ? active.query : cleanQuery(detail.query),
scope:detail.scope === undefined ? active.scope : cleanScope(detail.scope),
preview:detail.preview === undefined ? active.preview : cleanPreview(detail.preview),
}));
history.replaceState(toState(next), '', urlFor(next));
active = next;
return true;
},
close() {
if (!active) return false;
if (!active.kind) return false;
history.back();
return true;
},
leave() {
if (!active) return false;
const previous = active;
if (!active.kind) return false;
const previous = active.kind;
const state = { ...(history.state || {}) };
delete state.taskOverlay;
history.replaceState(state, '');
active = null;
onChange(null, previous);
delete state.searchQuery;
delete state.searchPreview;
delete state.searchScope;
active = { kind:null };
history.replaceState(state, '', urlFor(active));
onChange(null, previous, active);
return true;
},
current() { return active; },
current() { return active.kind; },
currentState() { return { ...active, ...(active.preview ? { preview:{ ...active.preview } } : {}) }; },
};
};
});
+106
View File
@@ -0,0 +1,106 @@
function createTodayBreak({
timer, sheet, openButtons = null, cancelButton, presetButtons = null, form = null,
minutesInput = null, error = null, status, resumeButton,
qs = null, queryAll = null,
historyRef = null, windowRef = null,
now = () => Date.now(), setIntervalRef = setInterval, clearIntervalRef = clearInterval,
onChange = () => {}, onResume = () => {},
}) {
queryAll ||= selector => globalThis.document?.querySelectorAll(selector) || [];
historyRef ||= globalThis.history;
windowRef ||= globalThis.window;
sheet ||= qs?.('#today-break-sheet');
openButtons ||= queryAll?.('[data-today-break-open]') || [];
cancelButton ||= qs?.('#cancel-today-break');
presetButtons ||= queryAll?.('[data-today-break-minutes]') || [];
form ||= qs?.('#today-break-custom-form');
minutesInput ||= qs?.('#today-break-custom-minutes');
error ||= qs?.('#today-break-error');
status ||= qs?.('#today-break-status');
resumeButton ||= qs?.('#resume-today-break');
let ticker = null;
let historyEntry = false;
const stopTicker = () => {
if (ticker !== null) clearIntervalRef(ticker);
ticker = null;
};
const render = () => {
const pending = timer.breakSnapshot();
if (!pending) {
stopTicker();
status.hidden = true;
resumeButton.hidden = true;
openButtons.forEach(button => { button.hidden = false; });
return false;
}
status.hidden = false;
resumeButton.hidden = false;
openButtons.forEach(button => { button.hidden = true; });
const remaining = Math.max(0, pending.deadline_at - now());
if (pending.expired || remaining <= 0) {
status.textContent = 'Break over · ready to resume';
} else {
const seconds = Math.ceil(remaining / 1000);
const minutes = Math.floor(seconds / 60);
status.textContent = 'On break · resume in ' + minutes + ':' + String(seconds % 60).padStart(2, '0');
}
if (ticker === null) ticker = setIntervalRef(render, 1000);
return true;
};
const close = () => {
if (sheet.open) sheet.close();
if (historyEntry) {
historyEntry = false;
historyRef?.back();
}
};
const start = minutes => {
if (!timer.startBreak(Number(minutes))) return false;
if (error) error.textContent = '';
close();
render();
onChange();
return true;
};
const open = () => {
if (!timer.snapshot().identity) return false;
if (!sheet.open) {
sheet.showModal();
if (historyRef) {
historyRef.pushState({...(historyRef.state || {}), todayBreak:true}, '');
historyEntry = true;
}
}
return true;
};
openButtons.forEach(button => button.addEventListener('click', open));
cancelButton?.addEventListener('click', close);
windowRef?.addEventListener('popstate', () => {
historyEntry = false;
if (sheet.open) sheet.close();
});
presetButtons.forEach(button => button.addEventListener('click', () =>
start(button.dataset.todayBreakMinutes)
));
form?.addEventListener('submit', event => {
event.preventDefault();
if (!start(Number(minutesInput?.value))) {
if (error) error.textContent = 'Choose a whole number from 1 to 120 minutes.';
minutesInput?.focus();
}
});
resumeButton.addEventListener('click', () => {
const pending = timer.breakSnapshot();
if (!timer.resumeBreak()) return;
onResume(pending.identity);
render();
onChange();
});
render();
return { open, close, start, render };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayBreak;
+65 -3
View File
@@ -1,6 +1,25 @@
function createTodayCompletion({ todayWork, todaySync, workSession, refresh, warm, announce, advance = null }) {
return function completeTodayItem(item, options = {}) {
if (!item || !todayWork.remove(item)) {
function createTodayCompletion({ todayWork, todaySync, workSession, refresh, warm, announce, advance = null,
now = Date.now, ttlMs = 10000, onOffer = null, onClear = null,
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout,
undo = [globalThis.document?.getElementById('today-completion-undo'),
globalThis.document?.getElementById('undo-today-completion')] }) {
const [undoReceipt, undoControl] = undo;
let pending = null;
let expiryTimer = null;
function clear() {
if (expiryTimer !== null) clearTimer?.(expiryTimer);
expiryTimer = null;
pending = null;
if (undoReceipt) undoReceipt.hidden = true;
onClear?.();
}
function completeTodayItem(item, options = {}) {
const identity = item && todayWork.identity(item);
const snapshot = item && (todayWork.capture?.(item) ||
(identity ? { id: identity, index: 0, ids: [identity], plan: { capacity_minutes: null, estimates: {} } } : null));
if (!snapshot || !todayWork.remove(item)) {
announce(options.failureMessage || 'Could not update Today on this device. Try again.');
return false;
}
@@ -10,8 +29,51 @@ function createTodayCompletion({ todayWork, todaySync, workSession, refresh, war
warm();
(advance || (() => workSession.complete()))();
announce(options.successMessage || 'Done for Today. The Gitea item is unchanged.');
pending = { snapshot, expires_at: now() + ttlMs };
if (undoReceipt) undoReceipt.hidden = false;
if (undoControl) undoControl.disabled = false;
onOffer?.({ identity: snapshot.id, expires_at: pending.expires_at });
expiryTimer = setTimer?.(clear, ttlMs) ?? null;
expiryTimer?.unref?.();
return true;
}
completeTodayItem.undo = () => {
if (!pending) return 'missing';
if (now() >= pending.expires_at) {
clear();
announce('Undo expired. Add the item back to Today from My Work.');
return 'expired';
}
const snapshot = pending.snapshot;
const result = todayWork.restore?.(snapshot) || 'unavailable';
if (result !== 'restored') {
const messages = {
full: 'Today is full. Remove another item before adding this work back.',
changed: 'Today changed on this device. Add the item back from My Work.',
unavailable: 'Could not restore Today on this device. Add the item back from My Work.',
};
clear();
announce(messages[result] || messages.unavailable);
return result;
}
todaySync.enqueue('add', snapshot.id);
for (let index = snapshot.ids.length - 1; index > snapshot.index; index -= 1) {
todaySync.enqueue('move', snapshot.id, 'up');
}
todaySync.flush();
refresh();
warm();
clear();
announce('Restored to Today. Your current work session is unchanged.');
return 'restored';
};
completeTodayItem.clear = clear;
undoControl?.addEventListener?.('click', () => {
undoControl.disabled = true;
if (completeTodayItem.undo() !== 'restored') undoControl.disabled = false;
});
return completeTodayItem;
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayCompletion;
+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 };
});
+178
View File
@@ -0,0 +1,178 @@
function createTodayLockScreen({
storage,
getLogin,
serviceWorker,
NotificationRef,
control,
status,
locationRef,
historyRef,
randomToken = () => {
const bytes = new Uint8Array(16);
globalThis.crypto.getRandomValues(bytes);
return Array.from(bytes, value => value.toString(16).padStart(2, '0')).join('');
},
fingerprint = async (token, identity) => {
const input = new TextEncoder().encode(token + '\0' + identity);
const digest = await globalThis.crypto.subtle.digest('SHA-256', input);
return Array.from(new Uint8Array(digest), value =>
value.toString(16).padStart(2, '0')).join('');
},
onAction = () => {},
}) {
let activeIdentity = '';
let activeActionIdentity = '';
let activeBreakDeadline = 0;
const preferenceKey = () => {
const login = String(getLogin?.() || '').trim().toLowerCase();
return login ? 'stackchain.today-lock-screen.v1.' + encodeURIComponent(login) : '';
};
const supported = Boolean(serviceWorker && NotificationRef);
const actionKey = () => {
const login = String(getLogin?.() || '').trim().toLowerCase();
return login ? 'stackchain.today-lock-screen-action.v1.' + encodeURIComponent(login) : '';
};
const readAction = () => {
const key = actionKey();
if (!key) return null;
try {
const value = JSON.parse(storage?.getItem(key) || 'null');
return typeof value?.token === 'string' && value.token &&
typeof value.fingerprint === 'string' && value.fingerprint ? value : null;
} catch (_error) { return null; }
};
const clearAction = () => {
const key = actionKey();
try { if (key) storage?.removeItem(key); }
catch (_error) { return false; }
return true;
};
const actionFor = async identity => {
const existing = readAction();
if (existing && await fingerprint(existing.token, identity) === existing.fingerprint) return existing;
const key = actionKey();
if (!key) return null;
try {
const token = String(randomToken() || '');
const value = { token, fingerprint:token ? await fingerprint(token, identity) : '' };
if (!value.token) return null;
storage?.setItem(key, JSON.stringify(value));
return value;
} catch (_error) { return null; }
};
const enabled = () => {
const key = preferenceKey();
return Boolean(key && storage?.getItem(key) === '1');
};
const setStatus = message => {
if (status) status.textContent = message;
};
const post = async message => {
const target = serviceWorker?.controller || (await serviceWorker?.ready)?.active;
target?.postMessage?.(message);
};
const hide = () => post({
type:'stackchain-today-lock-screen', active:false, running:false,
});
const render = () => {
if (control) {
control.checked = enabled();
control.disabled = !supported;
}
if (!supported) setStatus('Lock-screen controls are not supported on this device.');
else if (enabled()) setStatus('Lock-screen Today controls are on.');
};
const consumeAction = async (action, token = '') => {
if (!enabled() || !['pause', 'resume', 'complete'].includes(action)) return false;
const requiresToken = action === 'complete' || (action === 'resume' && activeBreakDeadline > 0);
if (!requiresToken) {
onAction(action, null);
return true;
}
const pending = readAction();
if (!pending || !token || !activeIdentity || pending.token !== token ||
await fingerprint(token, activeActionIdentity) !== pending.fingerprint) return false;
if (!clearAction()) return false;
onAction(action, activeIdentity);
return true;
};
const api = {
enabled,
async enable() {
if (!supported) {
render();
return false;
}
const permission = NotificationRef.permission === 'granted' ?
'granted' : await NotificationRef.requestPermission();
if (permission !== 'granted') {
if (control) control.checked = false;
setStatus(permission === 'denied' ?
'Lock-screen controls are blocked in browser settings.' :
'Lock-screen controls were not enabled.');
return false;
}
const key = preferenceKey();
if (!key) return false;
storage?.setItem(key, '1');
render();
return true;
},
async disable() {
const key = preferenceKey();
if (key) storage?.removeItem(key);
clearAction();
if (control) control.checked = false;
setStatus('Lock-screen Today controls are off.');
await hide();
return true;
},
async sync(snapshot, active) {
if (!enabled() || NotificationRef?.permission !== 'granted') return false;
const visible = Boolean(active && snapshot?.identity);
activeIdentity = visible ? snapshot.identity : '';
const rawBreakDeadline = Number(snapshot?.break_deadline_at);
activeBreakDeadline = visible && Number.isSafeInteger(rawBreakDeadline) && rawBreakDeadline > 0 ?
rawBreakDeadline : 0;
activeActionIdentity = activeIdentity + (activeBreakDeadline ? '\0break:' + activeBreakDeadline : '');
const pending = visible ? await actionFor(activeActionIdentity) : null;
if (!visible) clearAction();
await post({
type:'stackchain-today-lock-screen',
active:visible,
running:visible && Boolean(snapshot.running),
...(activeBreakDeadline ? { breakDeadlineAt:activeBreakDeadline } : {}),
...(pending?.token ? { actionToken:pending.token } : {}),
});
if (visible && !pending) setStatus('Finish current is unavailable because its one-time action could not be saved.');
return true;
},
consumeAction,
async consumeLaunchAction() {
let url;
try { url = new URL(locationRef?.href || ''); }
catch (_error) { return false; }
const action = url.searchParams.get('today_timer_action');
if (!['pause', 'resume', 'complete'].includes(action)) return false;
const token = url.searchParams.get('today_action_token') || '';
url.searchParams.delete('today_timer_action');
url.searchParams.delete('today_action_token');
historyRef?.replaceState?.(null, '', url.pathname + url.search + url.hash);
return await consumeAction(action, token);
},
render,
};
control?.addEventListener?.('change', () => {
if (control.checked) api.enable();
else api.disable();
});
serviceWorker?.addEventListener?.('message', event => {
if (event.data?.type === 'stackchain-today-timer-action') {
consumeAction(String(event.data.action || ''), String(event.data.actionToken || ''));
}
});
render();
return api;
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayLockScreen;
+553
View File
@@ -0,0 +1,553 @@
function createTodayProgress({ storage, getLogin = () => '', admit, makeId = () =>
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
maxLength = 2000, maxItems = 20 }) {
const prefix = 'stackchain.today-progress.v1.';
const login = () => String(getLogin() || '').trim().toLowerCase();
const storageKey = () => login() ? prefix + encodeURIComponent(login()) : '';
const validIdentity = identity => typeof identity === 'string' && identity.length > 0 && identity.length <= 500;
const attachmentFingerprint = attachments => JSON.stringify((Array.isArray(attachments) ? attachments : []).filter(Boolean).slice(0, 5).map(value => ({
filename:String(value.filename || ''), contentType:String(value.contentType || ''),
note:String(value.note || '').replace(/\s+/g, ' ').trim().slice(0, 240),
operationId:String(value.operationId || '').slice(0, 128),
markdown:String(value.confirmed?.markdown || ''),
})));
const payloadFingerprint = (body, attachments) => JSON.stringify({body, attachments:attachmentFingerprint(attachments)});
const validRecord = record => record && typeof record.body === 'string' && (record.body.length > 0 || record.has_attachments === true) &&
record.body.length <= maxLength && typeof record.operation_id === 'string' && record.operation_id.length > 0 &&
record.operation_id.length <= 128;
function read() {
const key = storageKey();
if (!storage || !key) return {};
try {
const saved = JSON.parse(storage.getItem(key) || 'null');
if (!saved || saved.version !== 1 || !saved.drafts || typeof saved.drafts !== 'object' ||
Array.isArray(saved.drafts)) return {};
const entries = Object.entries(saved.drafts).filter(([identity, record]) =>
validIdentity(identity) && validRecord(record)
).slice(0, maxItems);
return Object.fromEntries(entries);
} catch (_error) {
return {};
}
}
function write(drafts) {
const key = storageKey();
if (!storage || !key) return false;
try {
if (Object.keys(drafts).length) storage.setItem(key, JSON.stringify({ version:1, drafts }));
else storage.removeItem(key);
return true;
} catch (_error) {
return false;
}
}
function load(identity) {
if (!validIdentity(identity)) return '';
return read()[identity]?.body || '';
}
function save(identity, value, attachments = []) {
if (!validIdentity(identity) || !storageKey()) return false;
const body = String(value || '').trim();
const hasAttachments = attachments === true || (Array.isArray(attachments) && attachments.filter(Boolean).length > 0);
if (body.length > maxLength) return false;
const drafts = read();
if (!body && !hasAttachments) {
delete drafts[identity];
return write(drafts);
}
if (!drafts[identity] && Object.keys(drafts).length >= maxItems) return false;
const previous = drafts[identity];
const fingerprint = payloadFingerprint(body, Array.isArray(attachments) ? attachments : []);
const previousFingerprint = previous?.payload_fingerprint ||
(previous && !previous.has_attachments ? payloadFingerprint(previous.body, []) : '');
drafts[identity] = {
body,
operation_id: previousFingerprint === fingerprint ? previous.operation_id : String(makeId()).slice(0, 128),
payload_fingerprint:fingerprint,
...(hasAttachments ? {has_attachments:true} : {}),
};
return write(drafts);
}
function validTarget(target) {
return target && ['issue', 'pull'].includes(target.kind) && validIdentity(target.identity) &&
typeof target.repository === 'string' && target.repository.length > 0 && target.repository.length <= 200 &&
Number.isInteger(target.number) && target.number > 0;
}
async function post(target, value, attachments = [], completeEvidence) {
if (!validTarget(target)) throw new Error('An active Today issue or pull request is required.');
const pendingRecord = read()[target.identity];
if (pendingRecord?.cleanup_pending === true) {
try {
if (typeof completeEvidence === 'function') await completeEvidence();
} catch (_error) {
const error = new Error('Progress update is already queued; photo cleanup is pending.');
error.deliveryAdmitted = true;
throw error;
}
const pendingDrafts = read();
if (pendingDrafts[target.identity]?.operation_id === pendingRecord.operation_id) {
delete pendingDrafts[target.identity];
write(pendingDrafts);
}
return { background:true, alreadyAdmitted:true, cleanupRecovered:true };
}
const evidence = (Array.isArray(attachments) ? attachments : [attachments]).filter(Boolean).slice(0, 5);
if ((value !== undefined || evidence.length) && !save(target.identity, value ?? load(target.identity), evidence)) {
throw new Error('Progress update could not be saved on this device.');
}
const record = read()[target.identity];
if (!record) throw new Error('Write a progress update before posting.');
if (typeof admit !== 'function') throw new Error('Progress update delivery is unavailable.');
const admission = await admit({
kind:target.kind + '-comment', repository:target.repository, number:target.number,
body:record.body, operationId:record.operation_id,
...(evidence.length ? {attachments:evidence} : {}),
});
const admittedDrafts = read();
if (admittedDrafts[target.identity]?.operation_id === record.operation_id) {
admittedDrafts[target.identity] = { ...admittedDrafts[target.identity], cleanup_pending:true };
write(admittedDrafts);
}
try {
if (typeof completeEvidence === 'function') await completeEvidence();
} catch (_error) {
const error = new Error('Progress update is already queued; photo cleanup is pending.');
error.deliveryAdmitted = true;
throw error;
}
const drafts = read();
if (drafts[target.identity]?.operation_id === record.operation_id) {
delete drafts[target.identity];
write(drafts);
}
return admission;
}
async function postBlocker(target, value, attachments = [], completeEvidence, until, transition) {
if (!validTarget(target)) throw new Error('An active Today issue or pull request is required.');
if (typeof transition !== 'function') throw new Error('Today planning is unavailable.');
let record = read()[target.identity];
let admission = { background:true, alreadyAdmitted:true };
if (record?.blocker_pending !== true) {
const body = String(value ?? record?.body ?? '').trim();
if (!body) throw new Error('Describe what is blocking this Today item.');
const evidence = (Array.isArray(attachments) ? attachments : [attachments]).filter(Boolean).slice(0, 5);
if (!save(target.identity, body, evidence)) {
throw new Error('Blocker update could not be saved on this device.');
}
record = read()[target.identity];
if (typeof admit !== 'function') throw new Error('Progress update delivery is unavailable.');
admission = await admit({
kind:target.kind + '-comment', repository:target.repository, number:target.number,
body:record.body, operationId:record.operation_id,
...(evidence.length ? {attachments:evidence} : {}),
});
const drafts = read();
if (drafts[target.identity]?.operation_id === record.operation_id) {
drafts[target.identity] = { ...drafts[target.identity], blocker_pending:true, blocker_until:String(until || '') };
write(drafts);
}
} else {
const replacementUntil = String(until || '').trim();
until = replacementUntil || record.blocker_until;
if (replacementUntil && replacementUntil !== record.blocker_until) {
const drafts = read();
if (drafts[target.identity]?.operation_id === record.operation_id) {
drafts[target.identity] = { ...drafts[target.identity], blocker_until:replacementUntil };
write(drafts);
record = drafts[target.identity];
}
}
}
try {
if (typeof completeEvidence === 'function') await completeEvidence();
} catch (_error) {
const error = new Error('Blocker is already queued; photo cleanup is pending.');
error.deliveryAdmitted = true;
throw error;
}
if (await transition(target, until) !== true) {
const error = new Error('Blocker was posted, but Today could not move on. Retry the planning step.');
error.deliveryAdmitted = true;
throw error;
}
const drafts = read();
if (drafts[target.identity]?.operation_id === record.operation_id) {
delete drafts[target.identity];
write(drafts);
}
return admission;
}
function blockerRecovery(identity) {
if (!validIdentity(identity)) return null;
const record = read()[identity];
return record?.blocker_pending === true ? { pending:true, until:String(record.blocker_until || '') } : null;
}
return { load, save, discard:identity => save(identity, ''), post, postBlocker, blockerRecovery };
}
function createTodayProgressActivity({ fetchJson, createPager, getActions, onActions, surfaceStatus, paint = () => {}, setStatus = () => {} }) {
let requestToken = 0;
let pager = null;
let target = null;
let actions = null;
const pathFor = (value, page) => {
const repository = String(value.repository || '').split('/').map(encodeURIComponent).join('/');
const resource = value.kind === 'pull' ? 'pulls' : 'issues';
return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(value.number) +
'/comments?' + (Number.isInteger(page) ? 'page=' + encodeURIComponent(page) + '&' : '') + 'limit=20';
};
const loadPage = page => fetchJson(pathFor(target, page));
async function open(value) {
const token = ++requestToken;
target = { ...value };
setStatus('Loading recent activity…');
try {
const page = await loadPage();
if (token !== requestToken || target.identity !== value.identity) return false;
pager = createPager({ loadPage });
actions = typeof getActions === 'function' ? await getActions() : null;
if (token !== requestToken || target.identity !== value.identity) return false;
onActions?.(actions);
paint(pager.reset(page));
setStatus('');
return true;
} catch (error) {
if (token === requestToken) setStatus('Recent activity unavailable. Retry.');
return false;
}
}
async function loadOlder() {
if (!pager || !target) return false;
const token = requestToken;
setStatus('Loading older activity…');
try {
const page = await pager.loadOlder();
if (token !== requestToken) return false;
paint(page);
setStatus('');
return true;
} catch (_error) {
if (token === requestToken) setStatus('Older activity unavailable. Retry.');
return false;
}
}
return {
open,
retry:() => target ? open(target) : Promise.resolve(false),
loadOlder,
actionHtml:comment => actions?.actionHtml?.(comment) || '',
surface:() => ({
context:{ kind:target?.kind, item:target }, pager,
status:surfaceStatus, render:paint,
}),
close:() => { requestToken++; target = null; pager = null; actions = null; setStatus(''); },
};
}
function mountTodayProgressActivity(qs, fetchJson, actionSource = {}, render) {
const options = actionSource.get && !actionSource.getActions ?
{ getActions:actionSource.get, isOffline:() => !navigator.onLine } : actionSource;
const escape = options.escape || (value => String(value).replace(/[&<>"']/g,
character => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[character]));
render ||= options.render;
const list = qs('#today-progress-activity-list');
const optionsStatus = qs('#today-progress-activity-status');
let actionsWired = false;
const activity = createTodayProgressActivity({
fetchJson,
createPager:options.createPager || createConversationPager,
getActions:options.getActions,
surfaceStatus:optionsStatus,
onActions:actions => {
if (actionsWired || !actions?.wire) return;
actions.wire({ root:list, getSurface:() => activity.surface(),
isOffline:options.isOffline || (() => false), escapeHtml:escape });
actionsWired = true;
},
paint:state => {
list.innerHTML = (state.comments || []).map(comment => {
const author = comment.author || comment.user?.login || 'Unknown author';
const timing = comment.created_at ? ' · ' + new Date(comment.created_at).toLocaleString() : '';
return '<article class="today-progress-activity-item issue-comment" data-comment-id="' +
escape(String(comment.id)) + '"><div class="small muted">' +
escape(author) + escape(timing) + '</div><div class="markdown-content">' +
render(comment.body || 'No message body provided.') + '</div>' +
activity.actionHtml(comment) + '</article>';
}).join('');
qs('#today-progress-activity-count').textContent = state.total ?
String(state.comments.length) + ' of ' + String(state.total) + ' messages' : '';
qs('#load-older-today-progress-activity').hidden = !Number.isInteger(state.older_page);
},
setStatus:message => {
optionsStatus.textContent = message;
qs('#retry-today-progress-activity').hidden = !message.includes('unavailable');
},
});
qs('#retry-today-progress-activity').addEventListener('click', () => activity.retry());
qs('#load-older-today-progress-activity').addEventListener('click', async () => {
const list = qs('#today-progress-activity-list');
const previousHeight = list.scrollHeight;
const button = qs('#load-older-today-progress-activity');
button.disabled = true;
await activity.loadOlder();
list.scrollTop += list.scrollHeight - previousHeight;
button.disabled = false;
});
return activity;
}
function createTodayProgressView({ progress, currentTarget, qs, photos, voice, activity, mentions, announce = () => {}, onAdmitted = () => {}, moveOn = null, now = () => new Date() }) {
const sheet = qs('#today-progress-sheet');
const panel = qs('.today-progress-panel');
const title = qs('#today-progress-title');
const body = qs('#today-progress-body');
const bodyLabel = qs('#today-progress-body-label');
const status = qs('#today-progress-status');
const launcher = qs('[data-mobile-today-update]');
const blockerLauncher = qs('[data-mobile-today-blocked]');
const blockerButton = qs('#post-today-blocker');
const blockerReturn = qs('#today-blocker-return-at');
const blockerRecovery = qs('#today-progress-blocker-recovery');
const saveButton = qs('#save-today-progress');
const postButton = qs('#post-today-progress');
let openedTarget = null;
const localDateTime = value => {
const date = new Date(value || '');
if (!Number.isFinite(date.getTime())) return '';
return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
};
const showBlockerRecovery = recovery => {
const pending = recovery?.pending === true;
body.disabled = pending;
saveButton.disabled = pending;
postButton.disabled = pending;
if (blockerButton) blockerButton.textContent = pending ? 'Finish moving on' : 'Post blocker & move on';
if (blockerReturn) blockerReturn.value = pending ? localDateTime(recovery.until) : '';
if (blockerRecovery) {
blockerRecovery.hidden = !pending;
blockerRecovery.textContent = pending ? 'Blocker queued—finish moving on. The posted update cannot be changed.' : '';
}
};
const update = () => {
const target = currentTarget();
launcher.hidden = !target;
if (blockerLauncher) blockerLauncher.hidden = !target;
if (openedTarget && target?.identity !== openedTarget.identity) mentions?.dismiss?.();
};
const checkpoint = async () => {
if (!openedTarget) return false;
try {
await photos?.checkpoint?.();
const checkpointAttachments = await photos?.serialize?.() || [];
if (progress.save(openedTarget.identity, body.value, checkpointAttachments)) return true;
status.textContent = 'Update must be 2,000 characters or fewer and device storage must be available.';
return false;
} catch (error) {
status.textContent = error.message + ' Your photos remain here; retry.';
return false;
}
};
const close = () => {
voice?.cancel?.();
activity?.close?.();
mentions?.dismiss?.();
if (sheet.open) sheet.close();
panel?.classList?.remove('blocker-mode');
openedTarget = null;
};
const open = async blockerMode => {
const target = currentTarget();
if (!target) return;
mentions?.dismiss?.();
openedTarget = target;
panel?.classList?.[blockerMode ? 'add' : 'remove']('blocker-mode');
if (title) title.textContent = blockerMode ? 'Report blocker' : 'Add progress update';
if (bodyLabel) bodyLabel.textContent = blockerMode ? 'Blocker description' : 'Update';
qs('#today-progress-target').textContent = target.label + (target.title ? ' · ' + target.title : '');
body.value = progress.load(target.identity);
showBlockerRecovery(progress.blockerRecovery?.(target.identity));
sheet.showModal();
if (!blockerMode) {
status.textContent = 'Restoring saved photo evidence…';
try {
await voice?.open?.(target.identity);
await photos?.open?.(target);
await activity?.open?.(target);
status.textContent = '';
}
catch (error) { status.textContent = error.message + ' You can retry by reopening this update.'; }
}
else status.textContent = '';
body.focus();
};
launcher.addEventListener('click', () => open(false));
blockerLauncher?.addEventListener('click', () => open(true));
const setReturn = preset => {
const date = new Date(now());
if (preset === 'later-today') date.setHours(date.getHours() + 4);
else {
date.setDate(date.getDate() + 1);
date.setHours(9, 0, 0, 0);
}
blockerReturn.value = localDateTime(date);
};
qs('[data-today-blocker-preset="later-today"]')?.addEventListener('click', () => setReturn('later-today'));
qs('[data-today-blocker-preset="tomorrow"]')?.addEventListener('click', () => setReturn('tomorrow'));
qs('[data-today-blocker-custom]')?.addEventListener('click', () => blockerReturn?.focus?.());
qs('#cancel-today-progress').addEventListener('click', async () => {
if (await checkpoint()) close();
});
sheet.addEventListener('cancel', async event => {
event.preventDefault();
if (await checkpoint()) close();
});
qs('#save-today-progress').addEventListener('click', async () => {
if (!await checkpoint()) return;
announce('Progress update saved privately to this Today item.');
close();
});
blockerButton?.addEventListener('click', async () => {
const target = currentTarget();
if (!target || target.identity !== openedTarget?.identity) {
status.textContent = 'The active Today item changed. Close and open its update again.';
return;
}
const returnInput = blockerReturn;
const wakeAt = new Date(returnInput?.value || '');
if (!body.value.trim()) {
status.textContent = 'Describe what is blocking this Today item.';
body.focus();
return;
}
if (!Number.isFinite(wakeAt.getTime()) || wakeAt.getTime() <= Date.now()) {
status.textContent = 'Choose a valid future return time.';
returnInput?.focus?.();
return;
}
blockerButton.disabled = true;
status.textContent = 'Posting blocker before moving Today…';
try {
await photos?.checkpoint?.();
const attachments = await photos?.serialize?.() || [];
const admission = await progress.postBlocker(
target, body.value, attachments, () => photos?.complete?.(), wakeAt.toISOString(), moveOn
);
onAdmitted(admission);
announce('Blocker queued, deferred to Later, and Today moved on.');
close();
} catch (error) {
status.textContent = error.deliveryAdmitted ? error.message : error.message + ' Your blocker remains here; retry.';
body.focus();
} finally {
blockerButton.disabled = false;
}
});
qs('#post-today-progress').addEventListener('click', async () => {
const target = currentTarget();
if (!target || target.identity !== openedTarget?.identity) {
status.textContent = 'The active Today item changed. Close and open its update again.';
return;
}
const button = qs('#post-today-progress');
button.disabled = true;
status.textContent = 'Saving for delivery…';
try {
await photos?.checkpoint?.();
const attachments = await photos?.serialize?.() || [];
const admission = await progress.post(target, body.value, attachments, () => photos?.complete?.());
onAdmitted(admission);
announce(admission.background ?
'Progress update queued for delivery. Today is still on the same item.' :
'Progress update saved for next launch. Today is still on the same item.');
close();
} catch (error) {
status.textContent = error.deliveryAdmitted ?
error.message + ' Retry to finish local cleanup; delivery will not be queued again.' :
error.message + ' Your update remains on this item; retry.';
body.focus();
} finally {
button.disabled = false;
}
});
update();
return { update };
}
function createTodayProgressPhotos({ qs, document, issueAttachment, fetchJson, createStore, createDrafts, getLogin }) {
let target = null;
let drafts = null;
const controller = issueAttachment.mount({
maxFiles:5, input:qs('#today-progress-attachment'),
inputs:[qs('#take-today-progress-photo'), qs('#today-progress-attachment')],
preview:qs('#today-progress-attachment-preview'), image:qs('#today-progress-attachment-image'),
meta:qs('#today-progress-attachment-meta'), remove:qs('#remove-today-progress-attachment'),
tray:qs('#today-progress-attachment-tray'), earlier:qs('#move-today-progress-attachment-earlier'),
later:qs('#move-today-progress-attachment-later'), note:qs('#today-progress-attachment-note'),
noteLabel:qs('#today-progress-attachment-note-label'), status:qs('#today-progress-status'),
onChange:() => drafts?.checkpoint('today').catch(() => {}),
onCheckpoint:() => drafts.checkpoint('today'),
readyMessage:'Photo ready to post with this Today update.',
removedMessage:'Photo removed. Your progress text is unchanged.',
editor:{
document, edit:qs('#edit-today-progress-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 ready for this Today update.',
},
createObjectURL:file => URL.createObjectURL(file), revokeObjectURL:url => URL.revokeObjectURL(url),
upload:payload => {
if (!target || target.repository !== payload.repository || Number(target.number) !== Number(payload.number)) {
return Promise.reject(new Error('The active Today item changed. Reopen its update before posting.'));
}
const repository = payload.repository.split('/').map(encodeURIComponent).join('/');
const resource = target.kind === 'pull' ? 'pulls' : 'issues';
return fetchJson('api/v1/repos/' + repository + '/' + resource + '/' +
encodeURIComponent(payload.number) + '/attachments', {
method:'POST', headers:{Accept:'application/json','Idempotency-Key':payload.operation_id},
body:issueAttachment.multipart(payload),
});
},
});
const store = createStore({ indexedDB:globalThis.indexedDB, getOwnerLogin:getLogin, scope:'today-progress' });
drafts = createDrafts({ store, lanes:{ today:{ controller, onError:error => {
qs('#today-progress-status').textContent = error.message + ' Your photos remain here; retry.';
} } } });
return {
has:() => Boolean(controller.state()), serialize:() => controller.serialize(),
checkpoint:() => drafts.checkpoint('today'),
open:async value => {
if (drafts.hasTarget('today')) await drafts.switchTo('today', value);
else await drafts.open('today', value);
target = { ...value };
},
complete:async () => { await drafts.complete('today'); target = null; },
};
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = createTodayProgress;
module.exports.createView = createTodayProgressView;
module.exports.createPhotos = createTodayProgressPhotos;
module.exports.createActivity = createTodayProgressActivity;
module.exports.mountActivity = mountTodayProgressActivity;
}
+7 -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();
@@ -283,6 +283,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
qs('#open-today-recaps').focus();
};
const saveDraft = async button => {
const workedItems = todayRecapFeedbackRows(recap.snapshot(), describeWork);
button.disabled = true;
qs('#today-recap-status').textContent = 'Saving recap…';
try {
@@ -290,7 +291,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, workedItems);
} catch (error) {
qs('#today-recap-status').textContent = error.message || 'Recap could not be saved. Your timer is unchanged.';
} finally { button.disabled = false; }
@@ -303,6 +304,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
return;
}
const actualMinutes = Object.fromEntries(recap.snapshot().items.map(item => [item.identity, item.actual_minutes]));
const workedItems = todayRecapFeedbackRows(recap.snapshot(), describeWork);
button.disabled = true;
qs('#today-recap-status').textContent = 'Logging selected time…';
try {
@@ -310,7 +312,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, workedItems);
} catch (error) {
qs('#today-recap-status').textContent = error.message || 'Time could not be logged. Your recap is ready to retry.';
render();
@@ -345,8 +347,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,
+225
View File
@@ -0,0 +1,225 @@
function createTodaySessionSync({
fetchJson, getDeviceId, timer, onRemote = () => {}, onTransferred = () => {}, onStatus = () => {},
setInterval = globalThis.setInterval, clearInterval = globalThis.clearInterval,
}) {
let current = null;
let ownedRevision = 0;
let pollTimer = null;
let requestTail = Promise.resolve();
let latestSnapshot = null;
let publishRequested = 0;
let publishSent = 0;
const endpoint = 'api/v1/today/session';
const deviceId = () => String(getDeviceId?.() || '').trim();
function serialize(operation) {
const result = requestTail.then(operation, operation);
requestTail = result.catch(() => null);
return result;
}
function adopt(session) {
if (!session || !Number.isInteger(session.revision)) return null;
const previousOwned = current?.device_id === deviceId() &&
(current?.running || Number.isFinite(current?.break_deadline_at));
current = session;
if (session.device_id === deviceId()) {
ownedRevision = session.revision;
onRemote(null);
} else if ((session.running || Number.isFinite(session.break_deadline_at)) && session.identity) {
if (previousOwned) {
timer?.pause?.();
onTransferred(session);
}
onRemote(session);
} else {
onRemote(null);
}
return session;
}
async function refreshNow() {
try {
onStatus('syncing');
const session = await fetchJson(endpoint);
onStatus('online');
return adopt(session);
} catch (error) {
onStatus('offline', error);
return null;
}
}
const refresh = () => serialize(refreshNow);
function claim() {
return serialize(async () => {
if ((!current?.running && !Number.isFinite(current?.break_deadline_at)) ||
!current.identity || current.device_id === deviceId() || !deviceId()) return null;
try {
onStatus('syncing');
const body = {
base_revision:current.revision, device_id:deviceId(), identity:current.identity,
elapsed_ms:current.elapsed_ms, running:true,
};
if (Object.hasOwn(current, 'break_deadline_at')) body.break_deadline_at = null;
const session = await fetchJson(endpoint, {
method:'PATCH', headers:{'Content-Type':'application/json'},
body:JSON.stringify(body),
});
adopt(session);
timer?.adopt?.(session.identity, session.elapsed_ms, session.running);
return session;
} catch (error) {
if (error?.status === 409 || error?.code === 'session_changed') {
onStatus('conflict', error);
await refreshNow();
} else {
onStatus('offline', error);
}
return null;
}
});
}
async function publishNow(snapshot) {
if (!snapshot?.identity || !deviceId()) return null;
try {
onStatus('syncing');
const body = {
base_revision:ownedRevision, device_id:deviceId(), identity:snapshot.identity,
elapsed_ms:Math.max(0, Math.floor(Number(snapshot.elapsed_ms) || 0)),
running:Boolean(snapshot.running),
};
if (Object.hasOwn(snapshot, 'break_deadline_at') || Object.hasOwn(current || {}, 'break_deadline_at')) {
body.break_deadline_at = Number.isFinite(snapshot.break_deadline_at) ?
Math.floor(snapshot.break_deadline_at) : null;
}
const session = await fetchJson(endpoint, {
method:'PATCH', headers:{'Content-Type':'application/json'},
body:JSON.stringify(body),
});
adopt(session);
return session;
} catch (error) {
if (error?.status === 409 || error?.code === 'session_changed') {
onStatus('conflict', error);
await refreshNow();
} else {
onStatus('offline', error);
}
return null;
}
}
function publish(snapshot = timer?.snapshot?.()) {
snapshot = snapshot?.identity ? snapshot : latestSnapshot;
if (!snapshot?.identity || !deviceId()) return Promise.resolve(null);
latestSnapshot = snapshot;
publishRequested += 1;
return serialize(async () => {
if (publishSent === publishRequested) return current;
if (current?.device_id && current.device_id !== deviceId()) {
publishSent = publishRequested;
return current;
}
const version = publishRequested;
const result = await publishNow(latestSnapshot);
publishSent = current?.device_id && current.device_id !== deviceId()
? publishRequested
: version;
return result;
});
}
const pulse = () => current?.device_id === deviceId() && current?.running
? publish()
: refresh();
return {
refresh, claim, publish,
session:() => current,
start(intervalMs = 15000) {
if (pollTimer !== null) return false;
pulse();
pollTimer = setInterval?.(pulse, intervalMs);
pollTimer?.unref?.();
return true;
},
stop() {
if (pollTimer === null) return false;
clearInterval?.(pollTimer);
pollTimer = null;
return true;
},
};
}
function todaySessionHandoffSummary(session, item) {
if (Number.isFinite(session?.break_deadline_at)) {
const end = new Date(session.break_deadline_at).toLocaleTimeString([], {
hour:'numeric', minute:'2-digit',
});
return `On break until ${end} · ready to resume here`;
}
const minutes = Math.max(0, Math.floor(Number(session?.elapsed_ms || 0) / 60000));
return `${item?.title || 'Current Today item'} · ${minutes} min elapsed`;
}
function attachTodaySessionHandoff({
fetchJson, storage, timer, qs, items, identity, selectToday, startItem, announce, renderTimer,
}) {
const deviceKey = 'stackchain.today-session-device.v1';
const getDeviceId = () => {
let value = storage.getItem(deviceKey);
if (!value) {
value = globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2);
storage.setItem(deviceKey, value);
}
return value;
};
let offered = null;
const sessionStatus = qs('#today-session-sync-status');
const showSessionStatus = message => {
sessionStatus.textContent = message;
sessionStatus.hidden = !message;
};
const sync = createTodaySessionSync({
fetchJson, getDeviceId, timer,
onStatus:state => showSessionStatus(
state === 'syncing' ? 'Session syncing…' :
(state === 'offline' ? 'Session offline · will retry.' : '')
),
onRemote:session => {
offered = session;
const handoff = qs('#today-session-handoff');
handoff.hidden = !session;
if (!session) return;
const item = items().find(entry => identity(entry) === session.identity);
qs('#today-session-handoff-summary').textContent = todaySessionHandoffSummary(session, item);
qs('#continue-today-session').textContent = Number.isFinite(session.break_deadline_at) ?
'Resume Today here' : 'Continue here';
},
onTransferred:() => {
showSessionStatus('Session continued on another device.');
announce('Today continued on another device. Timer paused here.');
renderTimer();
},
});
qs('#continue-today-session').addEventListener('click', async () => {
const target = offered;
const claimed = await sync.claim();
if (!claimed || !target) return;
selectToday();
const item = items().find(entry => identity(entry) === claimed.identity);
if (item) startItem(item);
else announce('Today session moved here; refresh work to open its item.');
renderTimer();
});
sync.start(5000);
return sync;
}
if (typeof module !== 'undefined' && module.exports) module.exports = {
createTodaySessionSync, attachTodaySessionHandoff, todaySessionHandoffSummary,
};
+359
View File
@@ -0,0 +1,359 @@
function createTodaySummary({
storage = null, getLogin = () => '', share = null, copy = null,
resolveTarget = null, enqueueDurably = null,
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
} = {}) {
let draft = null;
let draftKey = '';
let postPromise = null;
const storageKey = () => {
const login = String(getLogin() || '').trim().toLowerCase();
return login ? 'stackchain.today-summary-draft.v1.' + login : '';
};
const syncAccount = () => {
const key = storageKey();
if (key !== draftKey) {
draft = null;
draftKey = key;
}
};
const copyRows = rows => rows.map(row => ({ ...row }));
const snapshot = () => {
syncAccount();
return draft ? {
...draft,
worked:copyRows(draft.worked),
tomorrow:copyRows(draft.tomorrow),
target:draft.target ? { ...draft.target } : null,
} : null;
};
const persist = () => {
const key = storageKey();
if (storage && key && draft) storage.setItem(key, JSON.stringify(draft));
};
const validRows = (rows, includeTime) => Array.isArray(rows) && rows.length <= 20 && rows.every(row =>
row && typeof row.identity === 'string' && row.identity.length > 0 && row.identity.length <= 500 &&
typeof row.title === 'string' && row.title.length <= 180 && typeof row.context === 'string' &&
row.context.length <= 180 && typeof row.include === 'boolean' && (!includeTime ||
(Number.isInteger(row.actual_minutes) && row.actual_minutes >= 0 && row.actual_minutes <= 1440))
);
const load = () => {
const key = storageKey();
if (!storage || !key) return null;
try {
const saved = JSON.parse(storage.getItem(key) || 'null');
const validTarget = Boolean(saved) && (saved.target === null || saved.target === undefined || (saved.target &&
typeof saved.target.repository === 'string' && /^[a-z0-9_.-]+\/[a-z0-9_.-]+$/.test(saved.target.repository) &&
Number.isInteger(saved.target.number) && saved.target.number > 0 &&
['issue', 'pull'].includes(saved.target.kind) && typeof saved.target.title === 'string' &&
typeof saved.target.state === 'string'));
if (!saved || !validRows(saved.worked, true) || !validRows(saved.tomorrow, false) ||
typeof saved.include_actuals !== 'boolean' || typeof saved.note !== 'string' || saved.note.length > 1000 ||
!['string', 'undefined'].includes(typeof saved.destination) || !validTarget ||
!['string', 'undefined'].includes(typeof saved.operation_id)) {
if (saved !== null) storage.removeItem(key);
return null;
}
return saved;
} catch (_error) {
try { storage.removeItem(key); } catch (_ignored) {}
return null;
}
};
const cleanRow = (row, includeTime) => {
const cleaned = {
identity:String(row.identity),
title:String(row.title || row.label || row.identity).slice(0, 180),
context:String(row.context || '').slice(0, 180),
};
if (includeTime) cleaned.actual_minutes = Math.min(1440, Math.max(0, Number(row.actual_minutes) || 0));
cleaned.include = true;
return cleaned;
};
const selectedLines = (rows, showTime = false) => rows.filter(row => row.include).map(row =>
'- ' + row.title + (showTime ? ' (' + row.actual_minutes + 'm)' : '')
);
const discard = () => {
const key = storageKey();
if (storage && key) storage.removeItem(key);
draft = null;
};
return {
begin(worked, tomorrow) {
draftKey = storageKey();
draft = {
worked:(worked || []).filter(row => row?.identity).slice(0, 20).map(row => cleanRow(row, true)),
tomorrow:(tomorrow || []).filter(row => row?.identity).slice(0, 20).map(row => cleanRow(row, false)),
include_actuals:false,
note:'',
destination:'',
target:null,
operation_id:'',
};
persist();
return snapshot();
},
snapshot,
restore() {
draftKey = storageKey();
draft = load();
return Boolean(draft);
},
choose(section, identity, include) {
syncAccount();
const row = draft?.[section]?.find(candidate => candidate.identity === identity);
if (!row || typeof include !== 'boolean') return false;
row.include = include;
persist();
return true;
},
includeActuals(include) {
syncAccount();
if (!draft || typeof include !== 'boolean') return false;
draft.include_actuals = include;
persist();
return true;
},
setNote(note) {
syncAccount();
if (!draft) return false;
draft.note = String(note || '').trim().slice(0, 1000);
persist();
return true;
},
setDestination(value) {
syncAccount();
if (!draft) return null;
const match = String(value || '').trim().match(/^([a-z0-9_.-]+)\s*\/\s*([a-z0-9_.-]+)\s*#\s*([1-9][0-9]*)$/i);
draft.destination = String(value || '').trim().slice(0, 260);
draft.target = null;
draft.operation_id = '';
persist();
if (!match) return null;
const repository = (match[1] + '/' + match[2]).toLowerCase();
return { repository, number:Number(match[3]), label:repository + '#' + Number(match[3]) };
},
async validateDestination() {
syncAccount();
const parsed = this.setDestination(draft?.destination || '');
if (!parsed) throw new Error('Enter a destination like owner/repo#42.');
if (typeof resolveTarget !== 'function') throw new Error('Destination validation is unavailable.');
const resolved = await resolveTarget({ repository:parsed.repository, number:parsed.number });
if (!resolved || resolved.repository !== parsed.repository || Number(resolved.number) !== parsed.number ||
!['issue', 'pull'].includes(resolved.kind)) throw new Error('Choose an exact visible issue or pull request.');
draft.target = {
repository:parsed.repository, number:parsed.number, kind:resolved.kind,
title:String(resolved.title || '').slice(0, 180), state:String(resolved.state || ''),
};
draft.operation_id = String(createOperationId()).slice(0, 128);
persist();
return { ...draft.target };
},
postSummary() {
if (postPromise) return postPromise;
syncAccount();
const body = this.text();
if (!body) throw new Error('Select at least one summary item or add a note.');
if (!draft?.target || !draft.operation_id) throw new Error('Validate an exact Gitea destination first.');
if (typeof enqueueDurably !== 'function') throw new Error('Gitea posting is unavailable.');
const target = { ...draft.target };
const message = {
kind:'search-reply', repository:target.repository, number:target.number,
targetKind:target.kind, body, operationId:draft.operation_id,
};
postPromise = (async () => {
try {
const admission = await enqueueDurably(message);
discard();
return { status:'queued', durability:admission?.durability || 'foreground-only', target };
} finally {
postPromise = null;
}
})();
return postPromise;
},
discard,
text() {
syncAccount();
if (!draft) return '';
const sections = [];
const worked = selectedLines(draft.worked, draft.include_actuals);
const tomorrow = selectedLines(draft.tomorrow);
if (worked.length) sections.push(['Today', ...worked].join('\n'));
if (tomorrow.length) sections.push(['Tomorrow', ...tomorrow].join('\n'));
if (draft.note) sections.push('Note\n' + draft.note);
return sections.join('\n\n');
},
async shareSummary() {
const text = this.text();
if (!text) throw new Error('Select at least one summary item or add a note.');
if (typeof share === 'function') {
try {
await share({ title:'Today summary', text });
discard();
return { status:'shared' };
} catch (error) {
if (error?.name === 'AbortError') return { status:'canceled' };
throw error;
}
}
if (typeof copy !== 'function') throw new Error('Sharing is unavailable on this device.');
await copy(text);
discard();
return { status:'copied' };
},
};
}
function createTodaySummaryView({ summary, qs, escapeHtml }) {
const rowMarkup = (section, row) => '<label class="today-summary-item"><input type="checkbox" data-summary-section="' +
section + '" data-summary-identity="' + escapeHtml(row.identity) + '"' + (row.include ? ' checked' : '') +
'><span><strong>' + escapeHtml(row.title) + '</strong><span class="small muted">' +
escapeHtml(row.context) + '</span></span></label>';
function close() {
qs('#today-summary-sheet').hidden = true;
document.body.classList.remove('task-overlay-open');
qs('#start-work-session')?.focus();
}
function render() {
const draft = summary.snapshot();
if (!draft) return;
qs('#today-summary-worked').innerHTML = draft.worked.map(row => rowMarkup('worked', row)).join('') ||
'<p class="small muted">No worked items selected.</p>';
qs('#today-summary-tomorrow').innerHTML = draft.tomorrow.map(row => rowMarkup('tomorrow', row)).join('') ||
'<p class="small muted">Nothing scheduled for tomorrow.</p>';
qs('#today-summary-include-actuals').checked = draft.include_actuals;
qs('#today-summary-note').value = draft.note;
qs('#today-summary-preview').textContent = summary.text();
qs('#today-summary-destination').value = draft.destination || '';
qs('#today-summary-target').textContent = draft.target ?
(draft.target.repository + '#' + draft.target.number + ' · ' + draft.target.kind + ' · ' + draft.target.title) : '';
qs('#post-today-summary').disabled = !draft.target;
qs('#today-summary-sheet').querySelectorAll('[data-summary-identity]').forEach(input => {
input.addEventListener('change', () => {
summary.choose(input.dataset.summarySection, input.dataset.summaryIdentity, input.checked);
render();
});
});
}
function show() {
qs('#today-summary-status').textContent = '';
render();
qs('#today-summary-sheet').hidden = false;
document.body.classList.add('task-overlay-open');
requestAnimationFrame(() => (qs('#today-summary-worked input') || qs('#share-today-summary')).focus());
}
function open(worked, tomorrow) {
summary.begin(worked, tomorrow);
show();
}
async function shareDraft(button) {
button.disabled = true;
qs('#today-summary-status').textContent = 'Opening share options…';
try {
const result = await summary.shareSummary();
if (result.status === 'canceled') {
qs('#today-summary-status').textContent = 'Share canceled. Your private draft is still here.';
return;
}
qs('#my-work-action-status').textContent = result.status === 'shared' ?
'Today summary shared.' : 'Today summary copied to your clipboard.';
close();
} catch (error) {
qs('#today-summary-status').textContent = error.message || 'Summary could not be shared. Your draft is unchanged.';
} finally {
button.disabled = false;
}
}
async function validateDestination(button) {
button.disabled = true;
qs('#today-summary-status').textContent = 'Checking destination…';
try {
await summary.validateDestination();
render();
qs('#today-summary-status').textContent = 'Destination confirmed. Review it, then post once.';
} catch (error) {
qs('#today-summary-status').textContent = error.message || 'Destination could not be confirmed.';
} finally {
button.disabled = false;
}
}
async function postDraft(button) {
button.disabled = true;
qs('#today-summary-status').textContent = 'Saving comment for delivery…';
try {
const result = await summary.postSummary();
qs('#my-work-action-status').textContent = 'Today summary queued for ' +
result.target.repository + '#' + result.target.number + '.';
close();
} catch (error) {
qs('#today-summary-status').textContent = error.message || 'Summary could not be queued. Your review is unchanged.';
button.disabled = false;
}
}
function bind() {
qs('#close-today-summary').addEventListener('click', close);
qs('#discard-today-summary').addEventListener('click', () => { summary.discard(); close(); });
qs('#today-summary-include-actuals').addEventListener('change', event => {
summary.includeActuals(event.currentTarget.checked); render();
});
qs('#today-summary-note').addEventListener('input', event => {
summary.setNote(event.currentTarget.value);
qs('#today-summary-preview').textContent = summary.text();
});
qs('#today-summary-destination').addEventListener('input', event => {
summary.setDestination(event.currentTarget.value);
qs('#today-summary-target').textContent = '';
qs('#post-today-summary').disabled = true;
});
qs('#validate-today-summary-destination').addEventListener('click', event => validateDestination(event.currentTarget));
qs('#post-today-summary').addEventListener('click', event => postDraft(event.currentTarget));
qs('#share-today-summary').addEventListener('click', event => shareDraft(event.currentTarget));
}
return {
open, close, render, bind, shareDraft, validateDestination, postDraft,
resume() { if (summary.restore()) show(); },
};
}
function setupTodaySummary({ qs, escapeHtml, getLogin, resolveTarget, enqueueDurably, fetchJson }) {
const targetResolver = resolveTarget || (async target => {
if (typeof fetchJson !== 'function') throw new Error('Destination validation is unavailable.');
const item = await fetchJson('api/v1/repos/' + target.repository + '/issues/' + target.number +
'/preview?kind=issue');
if (String(item.repository || '').toLowerCase() !== target.repository ||
Number(item.number) !== target.number || !['issue', 'pull'].includes(item.kind)) {
throw new Error('That exact issue or pull request is not visible to this account.');
}
return { repository:target.repository, number:target.number, kind:item.kind,
title:item.title || '', state:item.state || '' };
});
const summary = createTodaySummary({
storage:localStorage,
getLogin,
share:typeof navigator.share === 'function' ? payload => navigator.share(payload) : null,
copy:typeof navigator.clipboard?.writeText === 'function' ? text => navigator.clipboard.writeText(text) : null,
resolveTarget:targetResolver,
enqueueDurably,
});
const view = createTodaySummaryView({ summary, qs, escapeHtml });
view.bind();
return view;
}
if (typeof module !== 'undefined' && module.exports) {
createTodaySummary.createView = createTodaySummaryView;
createTodaySummary.setup = setupTodaySummary;
module.exports = createTodaySummary;
}
+38 -9
View File
@@ -10,6 +10,8 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
let retryTimer = null;
let retryAttempt = 0;
let expiredCount = 0;
let discardedCount = 0;
let recoveryNotice = { discarded: 0, until: 0 };
const knownOperationKeys = new Set();
function cancelRetry() {
@@ -108,10 +110,28 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
const candidate = storage.key?.(index);
if (candidate?.startsWith(recordPrefix)) keys.add(candidate);
}
const records = [...keys].map(recordKey => {
const record = JSON.parse(storage.getItem(recordKey) || 'null');
return record ? { ...record, recordKey } : null;
}).filter(record => record?.operation);
const records = [];
for (const recordKey of keys) {
let record;
try {
record = JSON.parse(storage.getItem(recordKey) || 'null');
} catch (_error) {
storage.removeItem(recordKey);
knownOperationKeys.delete(recordKey);
discardedCount += 1;
continue;
}
const operation = record?.operation;
const valid = operation && typeof operation.operation_id === 'string' &&
['add', 'remove', 'move', 'configure', 'rollover'].includes(operation.action) &&
typeof operation.item_id === 'string' && Number.isFinite(Number(record.queued_at));
if (valid) records.push({ ...record, recordKey });
else {
storage.removeItem(recordKey);
knownOperationKeys.delete(recordKey);
discardedCount += 1;
}
}
const expired = records.filter(record => Number(record.queued_at) >= 1_000_000_000_000 &&
now() - Number(record.queued_at) > maxOfflineMs);
expired.forEach(record => {
@@ -173,7 +193,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
let saved = false;
try {
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() + operations.length }));
knownOperationKeys.add(recordKey);
saved = true;
coordinator?.notify('today');
@@ -272,14 +292,23 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
operations = pending();
}
adopt(plan);
onStatus?.(pending().length ? 'pending' : hadConflict ? 'full' :
expiredCount ? 'expired' : 'saved', expiredCount ? { count: expiredCount } : {});
const stillPending = pending().length;
if (discardedCount) recoveryNotice = { discarded: discardedCount, until: now() + 5000 };
const recovered = recoveryNotice.until > now() ? recoveryNotice.discarded : 0;
onStatus?.(stillPending ? 'pending' : hadConflict ? 'full' :
expiredCount ? 'expired' : recovered ? 'recovered' : 'saved',
expiredCount ? { count: expiredCount } : recovered ? { discarded: recovered } : {});
if (!stillPending) discardedCount = 0;
retryAttempt = 0;
cancelRetry();
return !hadConflict;
} catch (error) {
if (pending().length) scheduleRetry(error, ownerKey);
else onStatus?.('error');
const stillPending = pending().length;
if (discardedCount) recoveryNotice = { discarded: discardedCount, until: now() + 5000 };
const recovered = recoveryNotice.until > now() ? recoveryNotice.discarded : 0;
if (stillPending) scheduleRetry(error, ownerKey);
else onStatus?.(recovered ? 'recovered' : 'error', recovered ? { discarded: recovered } : {});
discardedCount = 0;
return false;
}
}
+370 -9
View File
@@ -1,11 +1,11 @@
function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange = () => {} }) {
const key = () => {
const login = String(getLogin?.() || '').trim().toLowerCase();
return login ? 'stackchain.today-timer.v1.' + encodeURIComponent(login) : '';
};
const empty = () => ({
version:1, active_identity:'', entries:{}, away_at:null,
pending_interruption:null, attention_interruption:null,
pending_interruption:null, attention_interruption:null, capture_interruption:null, search_interruption:null, detour_interruption:null, timed_break:null,
});
const read = () => {
const ownerKey = key();
@@ -24,6 +24,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
if (!ownerKey || !storage) return false;
try {
storage.setItem(ownerKey, JSON.stringify(state));
onChange(snapshot());
return true;
} catch (_error) {
return false;
@@ -41,6 +42,30 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
typeof pending.resume === 'boolean' ?
{ identity:pending.identity, resume:pending.resume } : null;
};
const validCapture = state => {
const pending = state.capture_interruption;
return pending && typeof pending.identity === 'string' && pending.identity &&
typeof pending.resume === 'boolean' ?
{ identity:pending.identity, resume:pending.resume } : null;
};
const validSearch = state => {
const pending = state.search_interruption;
return pending && typeof pending.identity === 'string' && pending.identity &&
typeof pending.resume === 'boolean' ?
{ identity:pending.identity, resume:pending.resume } : null;
};
const validDetour = state => {
const pending = state.detour_interruption;
return pending && typeof pending.identity === 'string' && pending.identity &&
typeof pending.resume === 'boolean' && ['find', 'queues', 'insights', 'device-setup', 'security-center', 'live-data-status'].includes(pending.reason) ?
{ identity:pending.identity, resume:pending.resume, reason:pending.reason } : null;
};
const validBreak = state => {
const value = state.timed_break;
return value && typeof value.identity === 'string' && value.identity &&
Number.isFinite(value.deadline_at) && value.deadline_at >= 0 ?
{ identity:value.identity, deadline_at:value.deadline_at, expired:now() >= value.deadline_at } : null;
};
const settle = (state, at = now()) => {
const entry = state.entries[state.active_identity];
if (!entry?.running) return state;
@@ -54,16 +79,43 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
const state = read();
const selected = identity || state.active_identity;
const entry = state.entries[selected];
if (!selected || !entry) return { identity:selected, elapsed_ms:0, running:false };
const elapsed = Math.max(0, Number(entry.elapsed_ms) || 0) + (entry.running ?
Math.max(0, now() - Number(entry.started_at ?? now())) : 0);
return { identity:selected, elapsed_ms:elapsed, running:Boolean(entry.running) };
const result = !selected || !entry ?
{ identity:selected, elapsed_ms:0, running:false } :
{
identity:selected,
elapsed_ms:Math.max(0, Number(entry.elapsed_ms) || 0) + (entry.running ?
Math.max(0, now() - Number(entry.started_at ?? now())) : 0),
running:Boolean(entry.running),
};
const timedBreak = validBreak(state);
if (timedBreak?.identity === selected) result.break_deadline_at = timedBreak.deadline_at;
return result;
};
return {
adopt(identity, elapsedMs, running) {
if (!key() || typeof identity !== 'string' || !identity ||
!Number.isFinite(Number(elapsedMs)) || Number(elapsedMs) < 0) return false;
const state = read();
settle(state);
state.active_identity = identity;
state.entries[identity] = {
elapsed_ms:Math.floor(Number(elapsedMs)),
started_at:running ? now() : null,
running:Boolean(running),
};
state.away_at = null;
state.pending_interruption = null;
state.attention_interruption = null;
state.search_interruption = null;
state.detour_interruption = null;
state.timed_break = null;
return write(state);
},
activate(identity) {
if (!key() || typeof identity !== 'string' || !identity) return false;
const state = read();
if (state.active_identity === identity && state.entries[identity]?.running) return true;
if (state.active_identity === identity &&
(state.entries[identity]?.running || validBreak(state)?.identity === identity)) return true;
settle(state);
state.active_identity = identity;
const entry = state.entries[identity] || { elapsed_ms:0, started_at:null, running:false };
@@ -73,6 +125,9 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
state.away_at = null;
state.pending_interruption = null;
state.attention_interruption = null;
state.search_interruption = null;
state.detour_interruption = null;
state.timed_break = null;
return write(state);
},
pause() {
@@ -89,6 +144,32 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
entry.started_at = now();
entry.running = true;
}
state.timed_break = null;
return write(state);
},
startBreak(minutes) {
const duration = Number(minutes);
if (!Number.isInteger(duration) || duration < 1 || duration > 120) return false;
const state = read();
const identity = state.active_identity;
if (!identity || !state.entries[identity]) return false;
settle(state);
state.away_at = null;
state.pending_interruption = null;
state.timed_break = { identity, deadline_at:now() + duration * 60000 };
return write(state) ? validBreak(state) : false;
},
breakSnapshot() {
return validBreak(read());
},
resumeBreak() {
const state = read();
const pending = validBreak(state);
const entry = pending && state.entries[pending.identity];
if (!pending || !entry || state.active_identity !== pending.identity || entry.running) return false;
state.timed_break = null;
entry.started_at = now();
entry.running = true;
return write(state);
},
stop() {
@@ -97,6 +178,9 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
state.away_at = null;
state.pending_interruption = null;
state.attention_interruption = null;
state.search_interruption = null;
state.detour_interruption = null;
state.timed_break = null;
return write(state);
},
beginAttention() {
@@ -129,6 +213,113 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
state.away_at = null;
return write(state) ? { identity:pending.identity, resumed:pending.resume } : null;
},
beginCapture() {
const state = read();
const existing = validCapture(state);
if (existing) return existing;
const identity = state.active_identity;
const entry = state.entries[identity];
if (!identity || !entry) return null;
const resume = Boolean(entry.running);
if (resume) settle(state);
state.away_at = null;
state.capture_interruption = { identity, resume };
return write(state) ? { ...state.capture_interruption } : null;
},
captureInterruption() {
return validCapture(read());
},
abandonCapture() {
const state = read();
const pending = validCapture(state);
if (!pending) return null;
state.capture_interruption = null;
state.away_at = null;
return write(state) ? { identity:pending.identity, resumed:false } : null;
},
returnFromCapture() {
const state = read();
const pending = validCapture(state);
const entry = pending && state.entries[pending.identity];
if (!pending || !entry) return null;
const resumed = pending.resume && state.active_identity === pending.identity;
if (resumed && !entry.running) {
entry.started_at = now();
entry.running = true;
}
state.capture_interruption = null;
state.away_at = null;
return write(state) ? { identity:pending.identity, resumed } : null;
},
beginSearch() {
const state = read();
const existing = validSearch(state);
if (existing) return existing;
const identity = state.active_identity;
const entry = state.entries[identity];
if (!identity || !entry?.running) return null;
const resume = true;
settle(state);
state.away_at = null;
state.search_interruption = { identity, resume };
return write(state) ? { ...state.search_interruption } : null;
},
searchInterruption() {
return validSearch(read());
},
abandonSearch() {
const state = read();
const pending = validSearch(state);
if (!pending) return null;
state.search_interruption = null;
state.away_at = null;
return write(state) ? { identity:pending.identity, resumed:false } : null;
},
returnFromSearch() {
const state = read();
const pending = validSearch(state);
const entry = pending && state.entries[pending.identity];
if (!pending || !entry) return null;
const resumed = pending.resume && state.active_identity === pending.identity;
if (resumed && !entry.running) {
entry.started_at = now();
entry.running = true;
}
state.search_interruption = null;
state.away_at = null;
return write(state) ? { identity:pending.identity, resumed } : null;
},
beginDetour(reason) {
if (!['find', 'queues', 'insights', 'device-setup', 'security-center', 'live-data-status'].includes(reason)) return null;
const state = read();
const existing = validDetour(state);
if (existing) return existing;
const identity = state.active_identity;
const entry = state.entries[identity];
if (!identity || !entry?.running) return null;
settle(state);
state.away_at = null;
state.detour_interruption = { identity, resume:true, reason };
return write(state) ? { ...state.detour_interruption } : null;
},
detourInterruption() {
return validDetour(read());
},
returnFromDetour(reason = '') {
const state = read();
const pending = validDetour(state);
if (reason && pending?.reason !== reason) return null;
const entry = pending && state.entries[pending.identity];
if (!pending || !entry || state.active_identity !== pending.identity) return null;
const resumed = pending.resume && !entry.running;
if (resumed) {
entry.started_at = now();
entry.running = true;
}
state.detour_interruption = null;
state.away_at = null;
return write(state) ? { identity:pending.identity, resumed, reason:pending.reason } : null;
},
markAway() {
const state = read();
const entry = state.entries[state.active_identity];
@@ -200,9 +391,28 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
};
}
function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRunway, getItem, onReopen, onComplete }) {
function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRunway, getItem, onReopen, onResume, onComplete, onCapture }) {
let progress = null;
let runway = null;
let breakView = null;
if (typeof createTodayBreak === 'function') {
breakView = createTodayBreak({timer, qs:selector => queryAll(selector)[0], queryAll, onChange:() => render(), onResume:onResume || onReopen});
}
const captureView = createTodayCaptureInterruption({
timer,
banner:queryAll('#today-capture-interruption')[0],
label:queryAll('#today-capture-interruption-label')[0],
getItem,
onReturn:() => render(),
});
const searchView = createTodaySearchInterruption({
timer, queryAll, getItemLabel:identity => getItem?.(identity)?.title, onChange:() => render(),
onReturn:identity => onReopen?.(identity),
});
const detourView = createTodayDetourInterruption({
timer, queryAll, getItemLabel:identity => getItem?.(identity)?.title, onChange:() => render(),
onReturn:identity => onReopen?.(identity),
});
queryAll('[data-mobile-today-open]').forEach(button =>
button.addEventListener('click', () => {
const identity = timer.snapshot().identity;
@@ -248,6 +458,7 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
};
const render = () => {
const snapshot = timer.snapshot();
breakView?.render();
const active = Boolean(progress && isActive() && snapshot.identity);
queryAll('[data-mobile-today-hud]').forEach(element => { element.hidden = !active; });
queryAll('[data-mobile-today-open]').forEach(element => {
@@ -302,19 +513,45 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
button.setAttribute('aria-pressed', String(!snapshot.running));
});
};
return {
const view = {
open(identity, active) {
const current = timer.snapshot();
if (active && current.identity !== identity) timer.activate(identity);
else if (!active) timer.stop();
render();
},
reopen(identity) { onReopen?.(identity); },
search(action, ...args) { return searchView[action]?.(...args); },
beginDetour(reason) { return detourView.open(reason); },
finishDetour(reason) { return detourView.finish(reason); },
finish() { timer.stop(); progress = null; runway = null; render(); },
update(nextProgress, nextRunway) { progress = nextProgress; runway = nextRunway; render(); },
reset() { progress = null; runway = null; render(); },
toggle() { const state = timer.snapshot(); state.running ? timer.pause() : timer.resume(); render(); },
beginCapture() {
const result = captureView.open();
this.restoreCapture();
render();
return result;
},
restoreCapture() {
const result = captureView.restore();
const button = queryAll('#save-unfiled-issue')[0];
if (button) button.textContent = result ? 'Save & return to Today' : 'Save to Drafts';
return result;
},
finishCapture() { return captureView.finish(); },
transferCapture() { return captureView.transfer(); },
render,
};
queryAll('#new-issue').forEach(button => button.addEventListener('click', () => {
view.beginCapture();
onCapture?.();
}));
if (timer.captureInterruption) view.restoreCapture();
searchView.restore();
detourView.restore();
return view;
}
function createTodayBudgetReplan({ timer, openPlan }) {
@@ -366,9 +603,133 @@ function createTodayInterruptionPrompt({ timer, sheet, description, getItemLabel
};
}
function createTodaySearchInterruption({ timer, queryAll, getItemLabel, onChange, onReturn }) {
const render = (pending = timer.searchInterruption?.()) => {
queryAll('[data-search-today-interruption]').forEach(element => { element.hidden = !pending; });
queryAll('[data-search-today-label]').forEach(element => {
element.textContent = pending ? 'Today paused · ' +
String(getItemLabel?.(pending.identity) || 'Current Today item') : '';
});
onChange?.();
return pending;
};
const view = {
open() {
const wasRunning = timer.snapshot().running;
const pending = timer.beginSearch?.();
render(pending || timer.searchInterruption?.());
if (wasRunning && !pending) {
const status = queryAll('#cmd-search-action-status')[0];
if (status) status.textContent = 'Search is open, but Today timing could not be paused on this device.';
}
return pending;
},
restore() { return render(); },
finish() {
const result = timer.returnFromSearch?.();
render(null);
return result;
},
transfer() {
const result = timer.abandonSearch?.();
render(null);
return result;
},
};
queryAll('#open-palette').forEach(button => button.addEventListener('click', () => view.open()));
queryAll('[data-return-from-search]').forEach(button => button.addEventListener('click', () => {
const pending = timer.searchInterruption?.();
view.finish();
if (pending) onReturn?.(pending.identity);
}));
if (typeof MutationObserver !== 'undefined') {
const overlays = [...queryAll('#cmd-palette'), ...queryAll('#search-preview')];
const observer = new MutationObserver(() => {
if (overlays.every(element => !element.classList.contains('open'))) view.finish();
});
overlays.forEach(element => observer.observe(element, {attributes:true, attributeFilter:['class']}));
}
return view;
}
function createTodayCaptureInterruption({ timer, banner, label, getItem, getItemLabel, onReturn }) {
banner ||= typeof document === 'undefined' ? null : document.querySelector('#today-capture-interruption');
label ||= typeof document === 'undefined' ? null : document.querySelector('#today-capture-interruption-label');
const render = pending => {
if (!banner || !label) return;
banner.hidden = !pending;
label.textContent = pending ? 'Today paused · ' +
String(getItem?.(pending.identity)?.title || getItemLabel?.(pending.identity) || 'Current Today item') : '';
};
return {
open() {
const pending = timer.beginCapture();
render(pending);
return pending;
},
restore() {
const pending = timer.captureInterruption();
render(pending);
return pending;
},
finish() {
const result = timer.returnFromCapture();
render(null);
if (result) onReturn?.(result);
return result;
},
transfer() {
const result = timer.abandonCapture();
render(null);
return result;
},
};
}
function createTodayDetourInterruption({ timer, queryAll, getItemLabel, onChange, onReturn }) {
const render = (pending = timer.detourInterruption?.()) => {
queryAll('[data-today-detour]').forEach(element => { element.hidden = !pending; });
queryAll('[data-today-detour-label]').forEach(element => {
element.textContent = pending ? 'Today paused · ' +
String(getItemLabel?.(pending.identity) || 'Current Today item') : '';
});
onChange?.();
return pending;
};
const view = {
open(reason) {
const pending = timer.beginDetour?.(reason);
render(pending || timer.detourInterruption?.());
return pending;
},
restore() { return render(); },
finish(reason) {
const result = timer.returnFromDetour?.(reason);
if (result || !reason) render(null);
return result;
},
};
queryAll('[data-return-from-detour]').forEach(button => button.addEventListener('click', () => {
const pending = timer.detourInterruption?.();
view.finish();
if (pending) onReturn?.(pending.identity);
}));
if (typeof MutationObserver !== 'undefined') {
queryAll('#find-work-sheet').forEach(sheet => new MutationObserver(() => {
const pending = timer.detourInterruption?.();
if (sheet.classList.contains('open')) view.open('find');
else if (pending?.reason === 'find') view.finish();
}).observe(sheet, {attributes:true, attributeFilter:['class']}));
}
return view;
}
if (typeof module !== 'undefined' && module.exports) {
createTodayTimer.createView = createTodayTimerView;
createTodayTimer.createInterruptionPrompt = createTodayInterruptionPrompt;
createTodayTimer.createBudgetReplan = createTodayBudgetReplan;
createTodayTimer.createSearchInterruption = createTodaySearchInterruption;
createTodayTimer.createCaptureInterruption = createTodayCaptureInterruption;
createTodayTimer.createDetourInterruption = createTodayDetourInterruption;
module.exports = createTodayTimer;
}
+24 -1
View File
@@ -127,6 +127,29 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
return saved;
}
function capture(item) {
const id = identity(item);
const ids = read();
const index = ids.indexOf(id);
if (index < 0) return null;
return { id, index, ids: ids.slice(), plan: planning() };
}
function restore(snapshot) {
if (!snapshot || typeof snapshot.id !== 'string' || !Array.isArray(snapshot.ids)) return 'unavailable';
const current = read();
const expected = snapshot.ids.filter(id => id !== snapshot.id);
if (current.includes(snapshot.id)) return 'changed';
if (current.length >= limit) return 'full';
if (current.length !== expected.length || current.some((id, index) => id !== expected[index])) return 'changed';
if (!write(snapshot.ids)) return 'unavailable';
if (!replacePlanning(snapshot.plan)) {
write(current);
return 'unavailable';
}
return 'restored';
}
function move(item, direction) {
const ids = read();
const index = ids.indexOf(identity(item));
@@ -177,7 +200,7 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
};
}
return { identity, read, replace, planning, replacePlanning, runway, add, addMany, remove, move, reconcile, contains, position, limit };
return { identity, read, replace, planning, replacePlanning, runway, add, addMany, remove, capture, restore, move, reconcile, contains, position, limit };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;
+129
View File
@@ -0,0 +1,129 @@
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 = {};
let workedItems = [];
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 = {}, recapWorkedItems = []) {
actualMinutes = { ...recapActualMinutes };
workedItems = recapWorkedItems.map(item => ({...item}));
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 tomorrowItems = controller.snapshot().filter(row => row.schedule).map(row => ({
identity:row.identity,
title:String(row.item.title || row.identity).slice(0, 180),
context:String(row.item.key || row.item.repository || 'Work item').slice(0, 180),
}));
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, workedItems, tomorrowItems);
} 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;
}
+295 -11
View File
@@ -1,3 +1,43 @@
function withFilingEstimate(draft, value) {
draft.estimateMinutes = Number(value) || null;
return draft;
}
function unfiledDraftSummary(item) {
const plan = item.filingPlan || {};
const ready = Boolean(plan.repository);
const startsToday = plan.completionIntent === 'create-and-start';
const evidenceCount = Number(item.attachmentCount || (item.hasAttachment ? 1 : 0));
const evidence = evidenceCount ? `${evidenceCount} screenshot${evidenceCount === 1 ? '' : 's'}` : 'No screenshots';
return {
label:ready ? 'Ready to file' : 'Needs filing',
repository:plan.repository || '', ready,
action:ready ? (startsToday ? 'Review & start' : 'Review & file') : 'Choose repository',
details:[startsToday ? 'Create & start' : 'Create issue',
plan.estimateMinutes ? `${plan.estimateMinutes} min` : '', evidence].filter(Boolean).join(' · '),
};
}
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.';
}
function applyIssueFilingMode(qs, enabled) {
qs('#create-issue-filing').hidden = !enabled;
qs('.create-issue-capture-actions').hidden = false;
qs('#file-new-issue').hidden = enabled;
qs('#create-issue-heading').textContent = enabled ? 'File issue' : 'Capture work';
}
function createUnfiledCaptures({
storage,
attachmentStore = null,
@@ -8,6 +48,8 @@ function createUnfiledCaptures({
maxItems = 20,
}) {
const storageKey = 'stackchain.unfiled-issues.v1';
const listeners = new Set();
const onChange = (id, removed) => listeners.forEach(listener => listener(id, removed));
function read() {
try {
@@ -15,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 []; }
}
@@ -35,26 +78,108 @@ function createUnfiledCaptures({
return {full:items.length >= maxItems, count:items.length, maxItems, oldest:items.at(-1) || null};
}
function filingPlan(value = {}, remote = false) {
const get = (local, wire) => value?.[remote ? wire : local];
const repository = String(get('repository', 'repository') || '').trim();
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) return null;
const labelIds = Array.from(new Set((Array.isArray(get('labelIds', 'label_ids')) ?
get('labelIds', 'label_ids') : []).filter(id => Number.isInteger(id) && id > 0))).slice(0, 20);
const plan = {repository, labelIds};
const milestoneId = Number(get('milestoneId', 'milestone_id'));
if (Number.isInteger(milestoneId) && milestoneId > 0) plan.milestoneId = milestoneId;
const dueDate = String(get('dueDate', 'due_date') || '');
if (/^\d{4}-\d{2}-\d{2}$/.test(dueDate)) plan.dueDate = dueDate;
const bounded = (local, wire, limit) => {
const text = String(get(local, wire) || '').trim();
if (text) plan[local] = text.slice(0, limit);
};
bounded('templateName', 'template_name', 80);
bounded('templateId', 'template_id', 80);
bounded('capturedBody', 'captured_body', 10000);
if (get('unassigned', 'unassigned') === true) plan.unassigned = true;
const assignee = String(get('assignee', 'assignee') || '').trim();
if (!plan.unassigned && /^[A-Za-z0-9_.-]+$/.test(assignee)) {
plan.assignee = assignee;
bounded('assigneeName', 'assignee_name', 255);
}
const estimateMinutes = Number(get('estimateMinutes', 'estimate_minutes'));
if (Number.isInteger(estimateMinutes) && estimateMinutes >= 5 && estimateMinutes <= 1440) {
plan.estimateMinutes = estimateMinutes;
}
const completionIntent = String(get('completionIntent', 'completion_intent') || '');
if (['create', 'create-and-start'].includes(completionIntent)) plan.completionIntent = completionIntent;
return plan;
}
function exportFilingPlan(plan) {
if (!plan) return null;
return {
repository:plan.repository, label_ids:plan.labelIds,
...(plan.milestoneId ? {milestone_id:plan.milestoneId} : {}),
...(plan.dueDate ? {due_date:plan.dueDate} : {}),
...(plan.templateName ? {template_name:plan.templateName} : {}),
...(plan.templateId ? {template_id:plan.templateId} : {}),
...(plan.capturedBody ? {captured_body:plan.capturedBody} : {}),
...(plan.unassigned ? {unassigned:true} : {}),
...(plan.assignee ? {assignee:plan.assignee} : {}),
...(plan.assigneeName ? {assignee_name:plan.assigneeName} : {}),
...(plan.estimateMinutes ? {estimate_minutes:plan.estimateMinutes} : {}),
...(plan.completionIntent ? {completion_intent:plan.completionIntent} : {}),
};
}
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) {
throw new Error('You can attach up to 5 screenshots.');
}
const validAttachments = Array.isArray(attachments) && attachments.length > 0 &&
attachments.every(value => value?.blob && value?.filename &&
['image/png', 'image/jpeg', 'image/webp'].includes(String(value?.contentType || '')));
if (attachments && !validAttachments) {
throw new Error('One or more screenshots are unavailable. Choose them again before saving.');
}
const hasAttachment = Boolean(attachment?.blob && attachment?.filename &&
['image/png', 'image/jpeg', 'image/webp'].includes(String(attachment?.contentType || '')));
if (attachment && !hasAttachment) throw new Error('The screenshot is unavailable. Choose it again before saving.');
if (hasAttachment && !attachmentStore) {
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.');
}
return {title, body, ownerLogin, attachment, hasAttachment};
const seenBlockers = new Set();
const blockers = (Array.isArray(note?.blockers) ? note.blockers : []).reduce((items, blocker) => {
const repository = String(blocker?.repository || '').trim();
const number = Number(blocker?.number);
const key = repository + '#' + number;
if (items.length >= 5 || seenBlockers.has(key) ||
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) ||
!Number.isInteger(number) || number < 1) return items;
seenBlockers.add(key);
items.push({repository, number,
title:String(blocker?.title || '').replace(/\s+/g, ' ').trim().slice(0, 255)});
return items;
}, []);
return {
title, body, ownerLogin, attachment, attachments, blockers,
filingPlan:filingPlan(note),
hasAttachment:hasAttachment || validAttachments,
attachmentCount:validAttachments ? attachments.length : (hasAttachment ? 1 : 0),
};
}
function persist(prepared, existing, removed = null) {
const item = {
id:String(createId()), ownerLogin:prepared.ownerLogin, title:prepared.title, body:prepared.body,
savedAt:Number(now()), ...(prepared.hasAttachment ? {hasAttachment:true} : {}),
savedAt:Number(now()), ...(prepared.hasAttachment ? {
hasAttachment:true, attachmentCount:prepared.attachmentCount,
} : {}), ...(prepared.blockers.length ? {
blockers:prepared.blockers, blockerCount:prepared.blockers.length,
} : {}), ...(prepared.filingPlan ? {filingPlan:prepared.filingPlan} : {}),
};
const items = [item, ...existing.filter(candidate => candidate.id !== item.id)];
const writeItems = () => {
@@ -63,10 +188,17 @@ function createUnfiledCaptures({
if (prepared.hasAttachment) Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
throw error;
}
onChange(item.id, false);
return item;
};
const stage = prepared.hasAttachment
? Promise.resolve(attachmentStore.put(item.id, {
? Promise.resolve(attachmentStore.put(item.id, prepared.attachments ? {
attachments:prepared.attachments.map(value => ({
filename:String(value.filename).slice(0, 255),
contentType:String(value.contentType), blob:value.blob,
...(String(value.note || '').trim() ? {note:String(value.note).trim().slice(0, 240)} : {}),
})),
} : {
filename:String(prepared.attachment.filename).slice(0, 255),
contentType:String(prepared.attachment.contentType), blob:prepared.attachment.blob,
}))
@@ -113,6 +245,7 @@ function createUnfiledCaptures({
const removed = items.find(item => item.id === id);
if (!removed?.hasAttachment) {
write(remaining);
onChange(id, true);
return true;
}
return Promise.resolve(attachmentStore?.get(id)).then(removedAttachment =>
@@ -122,6 +255,7 @@ function createUnfiledCaptures({
if (removedAttachment) await Promise.resolve(attachmentStore?.put(id, removedAttachment)).catch(() => {});
throw error;
}
onChange(id, true);
return true;
})
);
@@ -133,10 +267,18 @@ function createUnfiledCaptures({
if (!confirmedLogin || String(confirmedLogin).trim() !== item.ownerLogin) {
throw new Error('Reconnect with the account that saved this capture.');
}
const draft = {repository:'', title:item.title, body:item.body, labelIds:[]};
const draft = {...(item.filingPlan || {repository:'', labelIds:[]}), title:item.title, body:item.body,
...(Array.isArray(item.blockers) && item.blockers.length ? {blockers:item.blockers} : {})};
if (!item.hasAttachment) return draft;
if (!attachmentStore) throw new Error('The saved screenshot is unavailable. Retry after reloading.');
return Promise.resolve(attachmentStore.get(id)).then(attachment => {
if (Array.isArray(attachment?.attachments) && attachment.attachments.length) {
if (attachment.attachments.length > 5 || attachment.attachments.some(value =>
!value?.blob || !value?.filename || !value?.contentType)) {
throw new Error('The saved screenshots are unavailable. Keep this Draft and retry.');
}
return {...draft, attachments:attachment.attachments};
}
if (!attachment?.blob || !attachment?.filename || !attachment?.contentType) {
throw new Error('The saved screenshot is unavailable. Keep this Draft and retry.');
}
@@ -146,7 +288,149 @@ function createUnfiledCaptures({
function completeResume(id) { return discard(id); }
return {list, capacity, save, replaceOldest, discard, resume, completeResume};
function encodeBytes(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
}
return btoa(binary);
}
function decodeBytes(value, contentType) {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
return new Blob([bytes], {type:contentType});
}
async function exportOwned(login) {
const owner = String(login || '').trim();
const exported = [];
for (const item of read().filter(candidate => candidate.ownerLogin === owner)) {
let evidence = [];
if (item.hasAttachment) {
const stored = await attachmentStore?.get(item.id);
const attachments = Array.isArray(stored?.attachments) ? stored.attachments :
(stored?.blob ? [stored] : []);
if (attachments.length !== Number(item.attachmentCount || 1)) {
throw new Error('The saved screenshots are unavailable. Keep this Draft and retry.');
}
evidence = await Promise.all(attachments.map(async attachment => ({
filename:attachment.filename,
content_type:attachment.contentType,
...(attachment.note ? {note:attachment.note} : {}),
data:encodeBytes(await attachment.blob.arrayBuffer()),
})));
}
exported.push({
id:item.id, title:item.title, body:item.body, saved_at:Number(item.savedAt),
...(item.filingPlan ? {filing_plan:exportFilingPlan(item.filingPlan)} : {}),
...(Array.isArray(item.blockers) && item.blockers.length ? {blockers:item.blockers} : {}),
...(evidence.length ? {evidence} : {}),
});
}
return exported;
}
async function mergeRemote(drafts, login) {
const ownerLogin = String(login || '').trim();
if (!ownerLogin || !Array.isArray(drafts)) return 0;
const existing = read();
const known = new Set(existing.map(item => item.id));
const imported = [];
for (const remote of drafts) {
if (!remote || known.has(remote.id) || imported.length + existing.length >= maxItems) continue;
const evidence = Array.isArray(remote.evidence) ? remote.evidence : [];
if (evidence.length && !attachmentStore) continue;
const plan = filingPlan(remote.filing_plan, true);
const item = {
id:String(remote.id), ownerLogin, title:String(remote.title || ''), body:String(remote.body || ''),
savedAt:Number(remote.saved_at),
...(plan ? {filingPlan:plan} : {}),
...(Array.isArray(remote.blockers) && remote.blockers.length ? {
blockers:remote.blockers, blockerCount:remote.blockers.length,
} : {}),
...(evidence.length ? {hasAttachment:true, attachmentCount:evidence.length} : {}),
};
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),
blob:decodeBytes(String(entry.data), String(entry.content_type)),
...(entry.note ? {note:String(entry.note)} : {}),
}))});
}
known.add(item.id);
imported.push(item);
}
if (imported.length) write(imported.concat(existing));
return imported.length;
}
async function reconcileRemote(drafts, login) {
const ownerLogin = String(login || '').trim();
if (!ownerLogin || !Array.isArray(drafts)) return 0;
const existing = read();
const retained = existing.filter(item => item.ownerLogin !== ownerLogin);
const reconciled = [];
const stagedEvidence = new Map();
for (const remote of drafts) {
if (!remote || reconciled.length + retained.length >= maxItems) continue;
const evidence = Array.isArray(remote.evidence) ? remote.evidence : [];
if (evidence.length && !attachmentStore) {
throw new Error('The synchronized screenshots cannot be stored on this device.');
}
const plan = filingPlan(remote.filing_plan, true);
const item = {
id:String(remote.id || ''), ownerLogin, title:String(remote.title || ''), body:String(remote.body || ''),
savedAt:Number(remote.saved_at),
...(plan ? {filingPlan:plan} : {}),
...(Array.isArray(remote.blockers) && remote.blockers.length ? {
blockers:remote.blockers, blockerCount:remote.blockers.length,
} : {}),
...(evidence.length ? {hasAttachment:true, attachmentCount:evidence.length} : {}),
};
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),
blob:decodeBytes(String(entry.data), String(entry.content_type)),
...(entry.note ? {note:String(entry.note)} : {}),
}))});
}
reconciled.push(item);
}
const evidenceIds = new Set(reconciled.filter(item => item.hasAttachment).map(item => item.id));
const affectedIds = new Set(stagedEvidence.keys());
for (const item of existing) if (item.ownerLogin === ownerLogin && item.hasAttachment) affectedIds.add(item.id);
const previousEvidence = new Map();
for (const id of affectedIds) previousEvidence.set(id, await attachmentStore?.get(id));
try {
for (const [id, value] of stagedEvidence) await attachmentStore.put(id, value);
for (const item of existing) {
if (item.ownerLogin === ownerLogin && item.hasAttachment && !evidenceIds.has(item.id)) {
await attachmentStore?.delete(item.id);
}
}
write(reconciled.concat(retained));
} catch (error) {
for (const [id, value] of previousEvidence) {
await Promise.resolve(value ? attachmentStore?.put(id, value) : attachmentStore?.delete(id)).catch(() => {});
}
throw error;
}
return reconciled.length;
}
return {list, capacity, save, replaceOldest, discard, resume, completeResume,
exportOwned, mergeRemote, reconcileRemote, currentLogin:getCurrentLogin,
subscribe:listener => (listeners.add(listener), () => listeners.delete(listener))};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures;
if (typeof module !== 'undefined' && module.exports) {
module.exports = createUnfiledCaptures;
module.exports.summary = unfiledDraftSummary;
module.exports.displayTitle = unfiledDraftDisplayTitle;
module.exports.shouldFocusTitle = unfiledShouldFocusTitle;
}
+222
View File
@@ -0,0 +1,222 @@
(function (root, factory) {
const createUnfiledDraftSync = factory();
if (typeof module === 'object' && module.exports) module.exports = createUnfiledDraftSync;
if (root) root.createUnfiledDraftSync = createUnfiledDraftSync;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
function createUnfiledDraftSync(options, mountedFetch) {
if (mountedFetch) return createUnfiledDraftSync.mount(options, mountedFetch);
const {
captures, fetchJson, storage, getLogin = () => '', onState = () => {}, maxConflictRetries = 2,
createConflictId = () => 'conflict-' + (globalThis.crypto?.randomUUID?.() || Date.now().toString(36)),
} = options;
const key = 'stackchain.unfiled-draft-sync.v1';
let inFlight = null;
let rerun = false;
let queuedOwner = '';
function read(login) {
try {
const all = JSON.parse(storage?.getItem(key) || '{}');
const state = all?.[login];
return {
all:all && typeof all === 'object' ? all : {},
known:new Set(Array.isArray(state?.known) ? state.known : []),
deleted:new Set(Array.isArray(state?.deleted) ? state.deleted : []),
base:state?.base && typeof state.base === 'object' ? state.base : {},
};
} catch (_error) {
return {all:{}, known:new Set(), deleted:new Set(), base:{}};
}
}
function write(login, state) {
state.all[login] = {known:[...state.known], deleted:[...state.deleted], base:state.base};
storage?.setItem(key, JSON.stringify(state.all));
}
function publish(status, message, details = {}) {
onState({status, ...(message ? {message} : {}), ...details});
const visibleStatus = globalThis.document?.querySelector?.('#my-work-action-status');
if (message && visibleStatus) visibleStatus.textContent = message;
}
function sameDrafts(left, right) {
return JSON.stringify(left) === JSON.stringify(right);
}
async function fingerprint(draft) {
if (!draft) return null;
const text = JSON.stringify(draft);
if (globalThis.crypto?.subtle && globalThis.TextEncoder) {
const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('');
}
let first = 2166136261, second = 3335557771;
for (let index = 0; index < text.length; index += 1) {
first = Math.imul(first ^ text.charCodeAt(index), 16777619);
second = Math.imul(second ^ text.charCodeAt(index), 2246822519);
}
return text.length + ':' + (first >>> 0).toString(16) + (second >>> 0).toString(16);
}
async function reconcile(captures, drafts, owner) {
if (typeof captures.reconcileRemote === 'function') {
await captures.reconcileRemote(drafts, owner);
return;
}
const finalIds = new Set(drafts.map(item => item.id));
for (const item of await captures.exportOwned(owner)) {
if (!finalIds.has(item.id)) await captures.discard(item.id);
}
await captures.mergeRemote(drafts, owner);
}
async function runSync(login) {
const owner = String(login || '').trim();
if (!owner) return false;
publish('syncing', 'Syncing Drafts…');
const state = read(owner);
try {
const remote = await fetchJson('api/v1/unfiled-drafts');
const remoteDrafts = Array.isArray(remote?.drafts) ? remote.drafts : [];
const local = await captures.exportOwned(owner);
const merged = [];
const localById = new Map(local.map(item => [item.id, item]));
const remoteById = new Map(remoteDrafts.map(item => [item.id, item]));
const ids = new Set([...state.known, ...localById.keys(), ...remoteById.keys()]);
let preservedConflict = false;
const usedIds = new Set(ids);
for (const id of ids) {
const localDraft = state.deleted.has(id) ? null : localById.get(id);
const remoteDraft = remoteById.get(id);
const localHash = await fingerprint(localDraft);
const remoteHash = await fingerprint(remoteDraft);
const baseHash = state.base[id] || null;
const localChanged = localHash !== baseHash;
const remoteChanged = remoteHash !== baseHash;
if (localChanged && remoteChanged && localHash !== remoteHash) {
preservedConflict = true;
if (remoteDraft) merged.push(remoteDraft);
if (localDraft) {
let conflictId = String(createConflictId()).slice(0, 100);
for (let suffix = 2; usedIds.has(conflictId); suffix += 1) {
conflictId = (String(createConflictId()).slice(0, 95) + '-' + suffix).slice(0, 100);
}
usedIds.add(conflictId);
const suffix = ' (conflict copy)';
merged.push({...localDraft, id:conflictId,
title:(String(localDraft.title).slice(0, 255 - suffix.length) + suffix)});
}
continue;
}
const selected = localChanged ? localDraft : (remoteChanged ? remoteDraft : (localDraft || remoteDraft));
if (selected) merged.push(selected);
}
merged.sort((left, right) => Number(right.saved_at) - Number(left.saved_at));
let finalSnapshot = remote;
if (!sameDrafts(merged, remoteDrafts)) {
finalSnapshot = await fetchJson('api/v1/unfiled-drafts', {
method:'PUT', headers:{'Content-Type':'application/json'},
body:JSON.stringify({revision:Number(remote.revision || 0), drafts:merged}),
});
}
await reconcile(captures, finalSnapshot.drafts || [], owner);
state.known = new Set((finalSnapshot.drafts || []).map(item => item.id));
state.deleted.clear();
state.base = {};
for (const item of finalSnapshot.drafts || []) state.base[item.id] = await fingerprint(item);
write(owner, state);
publish(preservedConflict ? 'conflict-preserved' : 'ready', preservedConflict
? 'Draft changed on two devices · both versions were saved.'
: 'Drafts synced across devices.');
return true;
} catch (error) {
if (error?.status !== 409) {
publish('pending', 'Saved on this device · sync pending');
}
throw error;
}
}
function sync(login) {
const owner = String(login || '').trim();
if (!owner) return Promise.resolve(false);
if (inFlight) {
rerun = true;
queuedOwner = owner;
return inFlight;
}
inFlight = (async () => {
let result = false;
let conflictRetries = 0;
let activeOwner = owner;
do {
rerun = false;
try {
result = await runSync(activeOwner);
} catch (error) {
if (error?.status !== 409) throw error;
if (conflictRetries >= maxConflictRetries) {
publish('conflict', 'Draft sync needs attention. Retry when your devices finish saving.', {retryable:true});
throw error;
}
conflictRetries += 1;
rerun = true;
}
if (queuedOwner) {
if (queuedOwner !== activeOwner) conflictRetries = 0;
activeOwner = queuedOwner;
queuedOwner = '';
rerun = true;
}
} while (rerun);
return result;
})().finally(() => { inFlight = null; });
return inFlight;
}
async function remove(id, login) {
const owner = String(login || '').trim();
if (!owner) return false;
const state = read(owner);
state.deleted.add(String(id));
write(owner, state);
await captures.discard(String(id));
return sync(owner);
}
function retry() {
return sync(getLogin());
}
captures.subscribe?.((id, removed) => {
const login = getLogin();
if (login) void (removed ? remove(id, login) : sync(login)).catch(() => {});
});
return {sync, remove, retry};
}
createUnfiledDraftSync.mount = (captures, fetchJson) => {
const getLogin = captures.currentLogin;
const retryButton = globalThis.document?.querySelector?.('#retry-unfiled-draft-sync');
const controller = createUnfiledDraftSync({
captures, fetchJson, storage:globalThis.localStorage, getLogin,
onState:state => { if (retryButton) retryButton.hidden = !state.retryable; },
});
retryButton?.addEventListener?.('click', async () => {
retryButton.disabled = true;
try { await controller.retry(); } catch (_error) { /* state remains actionable */ }
finally { retryButton.disabled = false; }
});
const connect = () => {
const login = getLogin();
if (login) void controller.sync(login).catch(() => {});
else globalThis.setTimeout(connect, 250);
};
globalThis.setTimeout(connect, 0);
globalThis.addEventListener?.('online', connect);
return controller;
};
return createUnfiledDraftSync;
});
+38
View File
@@ -0,0 +1,38 @@
(function(r){
function transaction({controls=[],status}) {
let busy=false, disabled=[];
async function run(message, action, {origin,failure}={}) {
if (busy) return false;
busy=true;
disabled=controls.map(control=>control.disabled);
controls.forEach(control=>{ control.disabled=true; });
status.textContent=message;
let completed=false;
try { completed=Boolean(await action()); }
catch (error) { failure=failure || error.message || 'Could not complete decision. Retry.'; }
if (!completed && failure) status.textContent=failure;
busy=false;
controls.forEach((control,index)=>{ control.disabled=disabled[index]; });
if (!completed) origin?.focus?.();
return completed;
}
return {run,busy:()=>busy};
}
function actions(o) {
return {
keepUnread:()=>o.transaction.run('Keeping unread…',()=>{
if(o.triage.active()) o.triage.keepUnreadAndNext(); else o.close();
return true;
},{origin:o.keepControl,failure:'Could not keep this update unread. Retry.'}),
markRead:()=>o.transaction.run('Marking read…',async()=>{
const result=await o.reader.markReadAndNext(o.items());
if(!result) return false;
o.undo.offer(result.item,result.items);
if(o.triage.active()) o.triage.acceptCompleted();
return true;
},{origin:o.readControl,failure:'Could not mark update read. Retry.'}),
};
}
if(typeof module==='object'&&module.exports){module.exports=transaction;module.exports.createActions=actions;}
else {r.createUpdateDecisionTransaction=transaction;r.createUpdateDecisionActions=actions;}
})(typeof self!=='undefined'?self:this);
+49
View File
@@ -0,0 +1,49 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createUpdateReadPosition = factory;
})(typeof self !== 'undefined' ? self : this, function createUpdateReadPosition(options) {
const panel = options.panel;
const jump = options.jump;
let identity = '';
let openingTop = 0;
let movedByUser = false;
let positioned = false;
let target = null;
function moveToTarget() {
if (!target) return false;
target.classList?.add('update-new-activity');
target.scrollIntoView({ block:'end' });
positioned = true;
jump.hidden = true;
return true;
}
panel.addEventListener('scroll', () => {
if (identity && !positioned && panel.scrollTop !== openingTop) movedByUser = true;
}, { passive:true });
return {
open(nextIdentity) {
identity = String(nextIdentity || '');
openingTop = panel.scrollTop;
movedByUser = false;
positioned = false;
target = null;
jump.hidden = true;
},
ready(readyIdentity, newest) {
if (!identity || String(readyIdentity || '') !== identity || positioned || !newest) return false;
target = newest;
target.classList?.add('update-new-activity');
if (movedByUser) {
jump.hidden = false;
return false;
}
return moveToTarget();
},
jump() {
return moveToTarget();
},
};
});
+62
View File
@@ -0,0 +1,62 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createUpdateReviewHandoff = factory;
})(typeof self !== 'undefined' ? self : this, function createUpdateReviewHandoff(options) {
let source = null;
let completing = null;
function eligible(detail, item = detail) {
const merged = { ...(item || {}), ...(detail || {}) };
return Boolean(
merged.is_review && merged.update_reason === 'Review requested' &&
merged.subject_type === 'Pull' && /^[^/]+\/[^/]+$/.test(String(merged.repository || '')) &&
Number.isInteger(Number(merged.number)) && Number(merged.number) > 0 &&
Number.isInteger(Number(merged.notification_id))
);
}
function reviewItem(item) {
return {
...item,
kind: 'review',
is_review: true,
key: item.key || item.repository + '#' + item.number,
};
}
function begin(item) {
if (!eligible(item) || source) return false;
source = item;
options.openReview(reviewItem(item));
return true;
}
function cancel() {
if (!source || completing) return false;
const item = source;
source = null;
options.restoreUpdate?.(item);
return true;
}
function complete() {
if (!source) return Promise.resolve(false);
if (completing) return completing;
const item = source;
completing = Promise.resolve(options.admitRead(item.notification_id)).then(() => {
options.advance(item);
source = null;
return true;
}).finally(() => { completing = null; });
return completing;
}
return {
eligible,
begin,
cancel,
complete,
active: () => Boolean(source),
source: () => source,
};
});
+92
View File
@@ -0,0 +1,92 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createUpdateTriageGesture = factory;
})(typeof self !== 'undefined' ? self : this, function createUpdateTriageGesture(options) {
const viewportWidth = options.viewportWidth || (() => root.innerWidth);
const surface = options.surface;
const threshold = options.threshold || 72;
const edgeInset = options.edgeInset || 24;
const interactive = 'a,button,input,textarea,select,label,summary,[contenteditable="true"],[role="button"]';
let active = null;
let busy = false;
function horizontalScroller(target) {
for (let node = target; node && node !== surface; node = node.parentElement) {
if (Number(node.scrollWidth) > Number(node.clientWidth) + 1) return true;
}
return false;
}
function ignored(event) {
return event.pointerType === 'mouse' || event.clientX <= edgeInset ||
event.clientX >= viewportWidth() - edgeInset ||
Boolean(event.target?.closest?.(interactive)) || horizontalScroller(event.target);
}
function reset() {
active = null;
delete surface.dataset.triageGesture;
}
function start(event) {
if (busy || !options.enabled() || viewportWidth() > 700 || ignored(event) || active) return;
active = {id:event.pointerId, x:event.clientX, y:event.clientY, cancelled:false};
surface.setPointerCapture?.(event.pointerId);
}
function move(event) {
if (!active || active.id !== event.pointerId) return;
const dx = event.clientX - active.x;
const dy = event.clientY - active.y;
if (Math.abs(dy) > Math.abs(dx) || Math.abs(dy) > 36) {
active.cancelled = true;
delete surface.dataset.triageGesture;
return;
}
if (Math.abs(dx) < 12) return;
event.preventDefault?.();
surface.dataset.triageGesture = dx > 0 ? 'keep' : 'read';
}
function decide(callback) {
busy = true;
let outcome;
try {
outcome = callback();
} catch (error) {
busy = false;
throw error;
}
if (outcome && typeof outcome.then === 'function') {
Promise.resolve(outcome).finally(() => { busy = false; });
} else {
busy = false;
}
}
function finish(event) {
if (!active || active.id !== event.pointerId) return;
const gesture = active;
const dx = event.clientX - gesture.x;
const dy = event.clientY - gesture.y;
surface.releasePointerCapture?.(event.pointerId);
reset();
if (gesture.cancelled || Math.abs(dx) < threshold || Math.abs(dx) <= Math.abs(dy)) return;
decide(dx > 0 ? options.keepUnread : options.markRead);
}
surface.addEventListener('pointerdown', start);
surface.addEventListener('pointermove', move);
surface.addEventListener('pointerup', finish);
surface.addEventListener('pointercancel', reset);
function destroy() {
surface.removeEventListener('pointerdown', start);
surface.removeEventListener('pointermove', move);
surface.removeEventListener('pointerup', finish);
surface.removeEventListener('pointercancel', reset);
reset();
}
return {destroy};
});
+39
View File
@@ -0,0 +1,39 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createUpdateTriageLauncher = factory;
})(typeof self !== 'undefined' ? self : this, function createUpdateTriageLauncher(options) {
let pending = null;
function open() {
if (pending) return pending;
options.selectUpdates();
options.announce('Checking all unread updates…');
pending = Promise.resolve()
.then(() => options.discover())
.then(discovery => {
if (!options.isUpdatesSelected()) return 'cancelled';
if (discovery === false || discovery?.complete === false) {
options.announce('Updates check paused. Retry to check older unread updates.');
return 'incomplete';
}
if (discovery?.complete) options.applySnapshot?.(discovery);
if (options.hasMore()) {
options.announce('Updates check paused. Retry to check older unread updates.');
return 'incomplete';
}
const opened = options.hasCheckpoint() ? options.resume() : options.start();
if (!opened) options.announce('No unread updates are ready.');
return opened ? 'opened' : 'empty';
})
.catch(() => {
if (options.isUpdatesSelected()) {
options.announce('Updates check paused. Retry to check older unread updates.');
}
return 'incomplete';
})
.finally(() => { pending = null; });
return pending;
}
return {open};
});
+137
View File
@@ -0,0 +1,137 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createUpdateTriageSession = factory;
})(typeof self !== 'undefined' ? self : this, function createUpdateTriageSession(options) {
const key = options.key || 'stackchain.update-triage.v1';
const identity = item => Number.isInteger(item?.notification_id) ? String(item.notification_id) : '';
const login = () => String(options.getLogin() || '').trim();
let state = null;
let running = false;
let lastOutcome = null;
function read() {
const owner = login();
if (!owner) return null;
try {
const value = JSON.parse(options.storage.getItem(key) || 'null');
if (![1, 2].includes(value?.version) || value.login !== owner || !Array.isArray(value.identities) ||
!value.identities.length || typeof value.current !== 'string' || !Array.isArray(value.completed)) return null;
const identities = value.identities.filter(value => typeof value === 'string' && value);
const completed = value.completed.filter(value => identities.includes(value));
const kept = Array.isArray(value.kept) ? value.kept.filter(value => identities.includes(value)) : [];
if (!identities.length || !identities.includes(value.current)) return null;
return { version:2, login:owner, identities, current:value.current, completed, kept };
} catch (_) {
return null;
}
}
function persist() {
if (!state) return;
options.storage.setItem(key, JSON.stringify(state));
}
function available() {
const byIdentity = new Map((options.getItems() || []).map(item => [identity(item), item]));
return state.identities.filter(id => !state.completed.includes(id) && byIdentity.has(id))
.map(id => byIdentity.get(id));
}
function nextAvailable() {
if (!running || !state) return null;
const candidates = available().filter(item => identity(item) !== state.current);
return candidates.find(item => state.identities.indexOf(identity(item)) > state.identities.indexOf(state.current)) || candidates[0] || null;
}
function finish() {
const unread = new Set((options.getItems() || []).map(identity).filter(Boolean));
const keptIdentities = state ? state.kept.filter(id => unread.has(id)) : [];
const reviewed = state ? state.completed.length : 0;
const outcome = { reviewed, resolved:reviewed - keptIdentities.length, kept:keptIdentities.length, keptIdentities };
lastOutcome = outcome;
running = false;
state = null;
options.storage.removeItem(key);
options.onFinish(outcome);
return false;
}
function openCurrent(preferredIndex = null) {
const items = available();
if (!items.length) return finish();
let index = items.findIndex(item => identity(item) === state.current);
if (index < 0) index = Math.min(preferredIndex ?? 0, items.length - 1);
state.current = identity(items[index]);
persist();
const originalIndex = state.identities.indexOf(state.current);
const progress = {
index:originalIndex + 1,
total:state.identities.length,
};
if (items[index]?.update_reason) progress.reason = String(items[index].update_reason);
options.onProgress(progress);
options.onOpen(items[index]);
return true;
}
function advance(openNext = true) {
if (!running || !state) return false;
const originalIndex = state.identities.indexOf(state.current);
if (!state.completed.includes(state.current)) state.completed.push(state.current);
const candidates = available();
const next = candidates.find(item => state.identities.indexOf(identity(item)) > originalIndex) || candidates[0];
if (!next) return finish();
state.current = identity(next);
if (openNext) return openCurrent();
persist();
const progress = {
index:state.identities.indexOf(state.current) + 1,
total:state.identities.length,
};
if (next?.update_reason) progress.reason = String(next.update_reason);
options.onProgress(progress);
return true;
}
return {
active: () => running,
resumable: () => Boolean(read()),
start() {
const identities = (options.getItems() || []).map(identity).filter(Boolean);
if (!identities.length) return finish();
state = { version:2, login:login(), identities, current:identities[0], completed:[], kept:[] };
if (!state.login) return false;
running = true;
return openCurrent();
},
resume() {
state = read();
if (!state) return false;
running = true;
return openCurrent(state.identities.indexOf(state.current));
},
reconcile() {
if (!running || !state) return false;
return openCurrent(state.identities.indexOf(state.current));
},
completeAndNext: advance,
acceptCompleted: () => advance(false),
keepUnreadAndNext() {
if (!running || !state) return false;
if (!state.kept.includes(state.current)) state.kept.push(state.current);
return advance();
},
reviewKept(identities = lastOutcome?.keptIdentities || []) {
const unread = new Set((options.getItems() || []).map(identity).filter(Boolean));
const kept = (identities || []).filter(id => unread.has(id));
if (!kept.length) return false;
state = { version:2, login:login(), identities:kept, current:kept[0], completed:[], kept:[] };
if (!state.login) return false;
running = true;
return openCurrent();
},
next: nextAvailable,
items: () => state ? available().slice() : [],
end: finish,
};
});
+145
View File
@@ -0,0 +1,145 @@
function createVoiceConversationCapture({
Recognition, elements, transcriptStore = null, getLogin = () => '',
createEvent = name => new Event(name, {bubbles:true}),
maxLength = 0, draftLabel = '',
}) {
const supported = typeof Recognition === 'function';
const label = String(draftLabel || elements.root.dataset?.draftLabel || 'reply').trim() || 'reply';
let recognition = null;
let target = '';
let generation = 0;
elements.root.hidden = !supported;
if (!supported) {
elements.status.textContent = 'Voice capture is unavailable; type the ' + label + ' instead.';
}
const normalize = value => String(value || '').replace(/\s+/g, ' ').trim();
const login = () => String(getLogin() || '').trim();
const composerLimit = Number(elements.draft.maxLength);
const limit = Number.isInteger(maxLength) && maxLength > 0 ? maxLength :
Number.isInteger(composerLimit) && composerLimit > 0 ? composerLimit : 10000;
function showReview(value, recovered = false) {
const transcript = normalize(value).slice(0, limit);
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, limit);
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, limit);
elements.draft.value = replace ? transcript :
[elements.draft.value.trim(), transcript].filter(Boolean).join('\n\n').slice(0, limit);
elements.draft.dispatchEvent(createEvent('input'));
clearCheckpoint();
elements.review.hidden = true;
elements.status.textContent = 'Transcript added. Review the ' + label + ' 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};
}
function mountVoiceConversation({
kind, draftSelector, qs, Recognition, transcriptStore, getLogin,
createCapture = createVoiceConversationCapture,
}) {
return createCapture({
Recognition, transcriptStore, getLogin,
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),
},
});
}
if (typeof module !== 'undefined') module.exports = {createVoiceConversationCapture, mountVoiceConversation};
+120
View File
@@ -0,0 +1,120 @@
function mapVoiceTranscript(value) {
const transcript = String(value || '').replace(/\s+/g, ' ').trim();
if (!transcript) return {title:'', body:''};
const sentence = transcript.match(/^(.+?[.!?])(?:\s+|$)(.*)$/);
const title = (sentence ? sentence[1] : transcript).trim().slice(0, 255);
const body = (sentence ? sentence[2] : '').trim().slice(0, 10000);
return {title, body};
}
function createVoiceIssueCapture({
Recognition, elements, createEvent = name => new Event(name, {bubbles:true}),
transcriptStore = null, getLogin = () => '',
}) {
const supported = typeof Recognition === 'function';
const login = String(getLogin() || '').trim();
const ready = supported && login && transcriptStore ? Promise.resolve(transcriptStore.load(login)).then(value => {
const recovered = String(value || '').replace(/\s+/g, ' ').trim().slice(0, 10255);
if (recovered) showReview(recovered, {persist:false, recovered:true});
}) : Promise.resolve();
elements.root.hidden = !supported;
if (!supported) {
elements.status.textContent = 'Voice capture is unavailable; type the title and note instead.';
return {supported:false, ready, cancel() {}};
}
let recognition = null;
function showReview(value, {persist = true, recovered = false} = {}) {
elements.transcript.value = value;
const activeLogin = String(getLogin() || '').trim();
if (persist && activeLogin && transcriptStore) void transcriptStore.save(activeLogin, value);
const hasDraft = Boolean(elements.title.value.trim() || elements.body.value.trim());
elements.append.textContent = hasDraft ? 'Append to draft' : 'Use transcript';
elements.replace.hidden = !hasDraft;
elements.review.hidden = false;
elements.status.textContent = recovered ?
'Recovered transcript. Review it before using or discarding it.' :
'Transcript ready. Review it before using it.';
}
elements.start.addEventListener('click', () => {
recognition = new Recognition();
recognition.continuous = true;
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) {
const accumulated = [elements.transcript.value.trim(), finalText]
.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim().slice(0, 10255);
showReview(accumulated);
}
};
recognition.onerror = event => {
elements.start.hidden = false;
elements.stop.hidden = true;
elements.status.textContent = event.error === 'not-allowed' || event.error === 'service-not-allowed' ?
'Microphone permission was denied. Your draft and transcript are unchanged.' :
'Voice capture stopped. Your draft and transcript are unchanged.';
};
recognition.onend = () => {
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…';
});
function clearCheckpoint() {
const activeLogin = String(getLogin() || '').trim();
if (activeLogin && transcriptStore) void transcriptStore.clear(activeLogin);
}
function commit(mode) {
const mapped = mapVoiceTranscript(elements.transcript.value);
if (mode === 'replace') {
elements.title.value = mapped.title;
elements.body.value = mapped.body;
} else if (elements.title.value.trim()) {
elements.body.value = [elements.body.value.trim(), mapped.title, mapped.body]
.filter(Boolean).join('\n\n').slice(0, 10000);
} else {
elements.title.value = mapped.title;
elements.body.value = [elements.body.value.trim(), mapped.body]
.filter(Boolean).join('\n\n').slice(0, 10000);
}
elements.title.dispatchEvent(createEvent('input'));
elements.body.dispatchEvent(createEvent('input'));
clearCheckpoint();
elements.review.hidden = true;
elements.status.textContent = 'Transcript added. Review the title and note before saving.';
}
elements.append.addEventListener('click', () => commit('append'));
elements.replace.addEventListener('click', () => commit('replace'));
elements.discard?.addEventListener('click', () => {
clearCheckpoint();
elements.transcript.value = '';
elements.review.hidden = true;
elements.status.textContent = 'Recovered transcript discarded.';
});
return {supported:true, ready, cancel() { recognition?.abort(); recognition = null; }};
}
if (typeof module !== 'undefined') module.exports = {mapVoiceTranscript, createVoiceIssueCapture};
+93
View File
@@ -0,0 +1,93 @@
(function (root, factory) {
const createVoiceTranscriptStore = factory(root.indexedDB);
if (typeof module !== 'undefined' && module.exports) module.exports = createVoiceTranscriptStore;
else root.createVoiceTranscriptStore = createVoiceTranscriptStore;
})(typeof globalThis !== 'undefined' ? globalThis : this, function buildFactory(defaultIndexedDB) {
const dbName = 'stackchain-voice-transcripts-v1';
const STORE_NAME = 'checkpoints';
const MAX_TRANSCRIPT_LENGTH = 10255;
function requestResult(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error('Private transcript storage failed.'));
});
}
function createIndexedDbTransaction(indexedDB) {
if (!indexedDB) return null;
let databasePromise = null;
function openDatabase() {
if (databasePromise) return databasePromise;
databasePromise = new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, 1);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains(STORE_NAME)) {
request.result.createObjectStore(STORE_NAME, {keyPath:'login'});
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error('Private transcript storage is unavailable.'));
});
return databasePromise;
}
return async (work, mode = 'readonly') => {
const database = await openDatabase();
const transaction = database.transaction(STORE_NAME, mode);
const store = transaction.objectStore(STORE_NAME);
const completed = new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error || new Error('Private transcript storage failed.'));
transaction.onabort = () => reject(transaction.error || new Error('Private transcript storage was interrupted.'));
});
const result = await work({
get:key => requestResult(store.get(key)),
put:value => requestResult(store.put(value)),
delete:key => requestResult(store.delete(key)),
});
await completed;
return result;
};
}
function normalizeLogin(value) {
return String(value || '').trim().toLowerCase();
}
function normalizeTranscript(value) {
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, 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, 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, target = '') {
const key = storageKey(login, target);
if (!key || !transaction) return;
await transaction(store => store.delete(key), 'readwrite');
}
return {load, save, clear};
}
createVoiceTranscriptStore.databaseName = dbName;
return createVoiceTranscriptStore;
});
Loaded 100 of 275 files, more files were not shown because too many files have changed in this diff. Show more