Compare commits

...

1187 Commits

Author SHA1 Message Date
f9d602f85f Merge pull request 'Pin the current item from mobile detail' (#1490) from timmy/1489-pin-current-mobile-detail into main
Some checks failed
CI / lint (push) Successful in 3m39s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Failing after 1h14m26s
CI / release-candidate (push) Has been cancelled
2026-08-28 02:11:46 +00:00
2a06cf1daa feat: pin current mobile detail item (Closes #1489)
Some checks failed
CI / lint (pull_request) Successful in 3m50s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 1h10m38s
CI / release-candidate (pull_request) Has been cancelled
2026-08-27 23:30:06 +00:00
b0dbcb5a25 Merge pull request 'Compact and rank pinned mobile work by recent use' (#1488) from timmy/1487-compact-rank-pinned-mobile-work into main
All checks were successful
CI / lint (push) Successful in 3m47s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 7m38s
CI / release-candidate (push) Successful in 6s
2026-08-27 22:46:49 +00:00
2766a75f24 Compact and rank pinned mobile work
All checks were successful
CI / lint (pull_request) Successful in 3m43s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 7m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 22:34:37 +00:00
5f9b5b3a7f Merge pull request 'Make mobile Recent Work sync generation-safe and self-healing' (#1486) from timmy/1485-mobile-recent-work-sync into main
All checks were successful
CI / lint (push) Successful in 3m46s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 7m49s
CI / release-candidate (push) Successful in 7s
2026-08-27 21:55:39 +00:00
cec5b93665 fix: make mobile recent work sync generation-safe (Closes #1485)
All checks were successful
CI / lint (pull_request) Successful in 3m50s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 8m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 21:42:47 +00:00
5af34508b4 Merge pull request 'feat: Stabilize Today-to-Week dialog closure in packaged browser CI' (#1484) from timmy/1483-stabilize-today-to-week-dialog-closure-in-packag into main
All checks were successful
CI / lint (push) Successful in 3m50s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m38s
CI / release-candidate (push) Successful in 7s
2026-08-27 21:15:15 +00:00
9b1abf37d0 fix: keep reschedule dialog closed after successful move
All checks were successful
CI / lint (pull_request) Successful in 3m39s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m42s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 20:48:02 +00:00
b0b28a936c Merge pull request 'Require fresh authorization and audit CI job reruns' (#1482)
Some checks failed
CI / lint (push) Successful in 3m41s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Failing after 7m52s
CI / release-candidate (push) Has been skipped
2026-08-27 20:20:50 +00:00
f23be99ab4 security: fresh-authorize CI job reruns (Closes #1481)
All checks were successful
CI / lint (pull_request) Successful in 4m0s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 7m40s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 20:08:15 +00:00
1484c579f9 Merge pull request 'feat: Disk usage 86%' (#1480) from timmy/1479-disk-usage-86 into main
All checks were successful
CI / lint (push) Successful in 3m55s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m47s
CI / release-candidate (push) Successful in 8s
2026-08-27 19:42:11 +00:00
afec9b11d1 ops: add disk capacity incident assessment
All checks were successful
CI / lint (pull_request) Successful in 3m55s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 7m50s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 19:14:25 +00:00
ca37d8a1bb Merge pull request 'Pin frequent work across signed-in mobile devices' (#1478) from timmy/1477-pin-mobile-frequent-work into main
All checks were successful
CI / lint (push) Successful in 3m48s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m23s
CI / release-candidate (push) Successful in 8s
2026-08-27 18:46:56 +00:00
94bd2cafba feat: pin frequent mobile work (Closes #1477)
All checks were successful
CI / lint (pull_request) Successful in 4m1s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 7m46s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 18:34:01 +00:00
2b630d3fa8 Merge pull request 'Sync recent work across signed-in mobile devices' (#1476) from timmy/1475-sync-mobile-recent-work into main
All checks were successful
CI / lint (push) Successful in 3m48s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 7m27s
CI / release-candidate (push) Successful in 7s
2026-08-27 17:45:02 +00:00
19732ea5a6 feat: sync mobile recent work across devices (Closes #1475)
All checks were successful
CI / lint (pull_request) Successful in 3m47s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m29s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 17:32:46 +00:00
12a7a3c4b0 Merge pull request 'feat: Restore bare-environment full-suite cache-policy test' (#1474) from timmy/1473-restore-bare-environment-full-suite-cache-policy into main
All checks were successful
CI / lint (push) Successful in 3m49s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m27s
CI / release-candidate (push) Successful in 7s
2026-08-27 16:49:42 +00:00
454e2dcffc test: restore cache-policy suite setup (Closes #1473)
All checks were successful
CI / lint (pull_request) Successful in 3m43s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
CI / browser-journey (pull_request) Successful in 7m33s
2026-08-27 16:16:52 +00:00
0e70fb9b62 Merge pull request 'Reopen recent work from mobile Queues' (#1472) from timmy/1471-mobile-recent-work into main
All checks were successful
CI / lint (push) Successful in 3m44s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m32s
CI / release-candidate (push) Successful in 7s
2026-08-27 15:53:08 +00:00
eb101bf5ce feat: reopen recent work from mobile queues (Closes #1471)
All checks were successful
CI / lint (pull_request) Successful in 3m39s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m53s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 15:40:41 +00:00
46e06c1a37 Merge pull request 'Demand-load Today and Planning workspace on mobile' (#1469) from timmy/1468-demand-load-mobile-workspace into main
All checks were successful
CI / lint (push) Successful in 3m48s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m30s
CI / release-candidate (push) Successful in 6s
Merge pull request 'Demand-load Today and Planning workspace on mobile' (#1469)
2026-08-27 14:46:10 +00:00
02b9fdd2e4 fix: finish identity recovery before workspace readiness
All checks were successful
CI / lint (pull_request) Successful in 3m45s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 7m28s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 14:34:13 +00:00
5ccf499b1a test: isolate workspace retry from service worker
Some checks failed
CI / lint (pull_request) Successful in 4m3s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 7m38s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 13:10:30 +00:00
e8b2f31e5e fix: restore hydrated session state before action replay
Some checks failed
CI / lint (pull_request) Successful in 3m57s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 7m24s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 12:27:44 +00:00
c1d719842f test: await hydrated state in reload journeys
Some checks failed
CI / lint (pull_request) Successful in 3m52s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 6m56s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 12:25:22 +00:00
4a99f54f12 test: hydrate demand workspace after release reloads
Some checks failed
CI / lint (pull_request) Successful in 3m56s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 7m16s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 12:22:42 +00:00
8909bedd78 test: align packaged journeys with demand hydration
Some checks failed
CI / lint (pull_request) Successful in 4m2s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 7m47s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 12:17:20 +00:00
5d9cec1dd7 perf: demand-load mobile workspace features (Closes #1468)
Some checks failed
CI / lint (pull_request) Successful in 3m49s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 9m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 11:47:09 +00:00
2a44cf20fc Merge pull request 'Make mobile queue priority self-healing across devices' (#1467) from timmy/1466-self-healing-queue-priority into main
All checks were successful
CI / lint (push) Successful in 3m37s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m31s
CI / release-candidate (push) Successful in 7s
2026-08-27 10:43:43 +00:00
1e8d27c18f feat: make mobile queue priority self-healing (Closes #1466)
All checks were successful
CI / lint (pull_request) Successful in 3m44s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m12s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 10:32:02 +00:00
c5b801460d Merge pull request 'Sync personalized mobile queue priority across devices' (#1465)
All checks were successful
CI / lint (push) Successful in 3m27s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 7m27s
CI / release-candidate (push) Successful in 7s
Closes #1464
2026-08-27 09:39:51 +00:00
b18aa5ed63 feat: sync mobile queue priority across devices (Closes #1464)
All checks were successful
CI / lint (pull_request) Successful in 3m42s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m11s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 09:27:27 +00:00
dbc305108a Merge pull request 'Scope Security activity to the signed-in Gitea identity' (#1463) from timmy/1462-principal-scoped-security-activity into main
All checks were successful
CI / lint (push) Successful in 3m33s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 7m20s
CI / release-candidate (push) Successful in 7s
2026-08-27 08:22:10 +00:00
3b9f184a47 security: scope activity journal by principal (Closes #1462)
All checks were successful
CI / lint (pull_request) Successful in 3m29s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 7m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 08:10:27 +00:00
bf716cab6e Merge pull request 'Personalize routine priority in adaptive mobile Queues' (#1461) from timmy/1460-personalized-mobile-queue-priority into main
All checks were successful
CI / lint (push) Successful in 3m41s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m34s
CI / release-candidate (push) Successful in 6s
2026-08-27 07:21:38 +00:00
dbabd8640b feat: personalize adaptive mobile queue priority (Closes #1460)
All checks were successful
CI / lint (pull_request) Successful in 3m53s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m39s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 07:09:06 +00:00
890fd57531 Merge pull request 'Keep adaptive mobile Work actionable while offline' (#1459) from timmy/1458-connectivity-aware-mobile-work into main
Some checks failed
CI / lint (push) Successful in 3m56s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Failing after 7m45s
CI / release-candidate (push) Has been skipped
2026-08-27 06:11:57 +00:00
b0b051184b feat: keep adaptive mobile Work actionable offline (Closes #1458)
All checks were successful
CI / lint (pull_request) Successful in 3m52s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 7m49s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 05:59:13 +00:00
2432d4fc03 Merge pull request 'Make Following and My PRs actionable from adaptive mobile Work' (#1457) from timmy/1456-actionable-following-authored into main
All checks were successful
CI / lint (push) Successful in 3m39s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m33s
CI / release-candidate (push) Successful in 8s
2026-08-27 04:46:58 +00:00
c97383fccb feat: continue Following and My PRs from mobile Work (Closes #1456)
All checks were successful
CI / lint (pull_request) Successful in 3m53s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m35s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 04:34:34 +00:00
89b1ad1f08 Merge pull request 'Scope Security Center device revocation to the signed-in identity' (#1455) from timmy/1454-principal-scoped-device-revocation into main
All checks were successful
CI / lint (push) Successful in 3m44s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 7m20s
CI / release-candidate (push) Successful in 7s
Scope Security Center device revocation to the signed-in identity (#1455)
2026-08-27 03:21:57 +00:00
0328e197ac security: scope device revocation by principal (Closes #1454)
All checks were successful
CI / lint (pull_request) Successful in 3m50s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m18s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 03:09:59 +00:00
7a09452bef Make mobile Queues census include Following and My PRs (#1453)
All checks were successful
CI / lint (push) Successful in 3m48s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m16s
CI / release-candidate (push) Successful in 8s
Closes #1452
2026-08-27 02:14:24 +00:00
4b4637fadb feat: include Following and My PRs in queue census (Closes #1452)
All checks were successful
CI / lint (pull_request) Successful in 3m54s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m29s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 02:02:02 +00:00
977493907e Merge pull request 'Give mobile Queues one adaptive Start / Continue action' (#1451) from timmy/1450-adaptive-mobile-queue-action into main
All checks were successful
CI / lint (push) Successful in 3m49s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m16s
CI / release-candidate (push) Successful in 7s
2026-08-27 01:24:49 +00:00
9381ff9d84 feat: unify mobile queue start and continue action (Closes #1450)
All checks were successful
CI / lint (pull_request) Successful in 3m55s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m18s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 01:12:38 +00:00
c2b7ce67d8 Bind enrolled passkeys to the upstream Gitea identity (#1449)
All checks were successful
CI / lint (push) Successful in 3m42s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 7m4s
CI / release-candidate (push) Successful in 6s
Closes #1448
2026-08-27 00:25:09 +00:00
43b0216bda security: bind passkeys to upstream identity (Closes #1448)
All checks were successful
CI / lint (pull_request) Successful in 3m52s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m15s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 00:12:57 +00:00
ff469d96d1 Recover Human Gate decisions after interrupted mobile responses
All checks were successful
CI / lint (push) Successful in 3m40s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m10s
CI / release-candidate (push) Successful in 7s
Closes #1446
2026-08-26 23:05:32 +00:00
94c18c7a35 test: classify deferred offline context rejection
All checks were successful
CI / lint (pull_request) Successful in 3m45s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 7m15s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 22:53:44 +00:00
a77a73a269 test: stabilize Human Gate recovery journey
Some checks failed
CI / lint (pull_request) Successful in 3m52s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 7m13s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 22:25:00 +00:00
b303683ba1 feat: recover interrupted Human Gate decisions (Closes #1446)
Some checks failed
CI / lint (pull_request) Successful in 3m56s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 7m20s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 22:10:01 +00:00
60f140d26a Merge pull request 'Paginate durable Human Gate decision history on mobile' (#1445) from timmy/1444-paginated-human-gate-history into main
All checks were successful
CI / lint (push) Successful in 3m53s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 7m30s
CI / release-candidate (push) Successful in 8s
2026-08-26 21:23:05 +00:00
0f1cac0e25 feat: paginate mobile Human Gate history (Closes #1444)
All checks were successful
CI / lint (pull_request) Successful in 3m53s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m21s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 21:10:10 +00:00
57c7373079 Merge pull request 'Review durable Human Gate decision history and receipts' (#1442) from timmy/1441-human-gate-decision-history into main
All checks were successful
CI / lint (push) Successful in 3m43s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 7m6s
CI / release-candidate (push) Successful in 7s
2026-08-26 20:01:02 +00:00
a5d05d7590 feat: review durable Human Gate decision history (Closes #1441)
All checks were successful
CI / lint (pull_request) Successful in 4m43s
CI / build-release (pull_request) Successful in 13s
CI / browser-journey (pull_request) Successful in 7m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 19:43:50 +00:00
efa01cf47f Merge pull request 'Keep Human Gate release decisions visible and actionable on mobile' (#1440)
All checks were successful
CI / lint (push) Successful in 4m19s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 7m51s
CI / release-candidate (push) Successful in 8s
Closes #1439
2026-08-26 18:38:56 +00:00
62f92105ec feat: keep Human Gate decisions in reach on mobile (Closes #1439)
All checks were successful
CI / lint (pull_request) Successful in 4m12s
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Successful in 8m4s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 18:10:22 +00:00
38206b90e4 Merge pull request 'Notify operators when Human Gate decisions are waiting' (#1438) from timmy/1437-human-gate-push-notifications into main
All checks were successful
CI / lint (push) Successful in 4m15s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 8m11s
CI / release-candidate (push) Successful in 9s
2026-08-26 17:15:26 +00:00
de3c68fc0e docs: explain private Human Gate alerts
All checks were successful
CI / lint (pull_request) Successful in 4m21s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 7m42s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 17:02:54 +00:00
7158e00b97 feat: notify pending Human Gate decisions (Closes #1437)
Some checks failed
CI / lint (pull_request) Successful in 4m18s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Failing after 7m57s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 16:47:57 +00:00
ad15f15e3c Merge pull request 'Resume Human Gate evidence reviews after mobile artifact handoffs' (#1436) from timmy/1435-resume-human-gate-review into main
All checks were successful
CI / lint (push) Successful in 4m14s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m40s
CI / release-candidate (push) Successful in 8s
Merge pull request #1436: resume Human Gate evidence reviews
2026-08-26 15:29:27 +00:00
aba217f89b feat: resume Human Gate evidence reviews (Closes #1435)
All checks were successful
CI / lint (pull_request) Successful in 4m12s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m38s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 15:16:03 +00:00
1e9c3444fa Merge pull request 'Refresh Human Gates atomically on every reopen' (#1434) from timmy/1433-refresh-human-gates-reopen into main
All checks were successful
CI / lint (push) Successful in 4m10s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 8m15s
CI / release-candidate (push) Successful in 9s
2026-08-26 14:03:30 +00:00
2e9d108c6f feat: refresh Human Gates review sessions (Closes #1433)
All checks were successful
CI / lint (pull_request) Successful in 3m58s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m28s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 13:50:11 +00:00
7dc32138e4 Merge pull request 'Recover progressive mobile identity after partial live snapshots' (#1432) from timmy/1431-recover-progressive-mobile-identity into main
All checks were successful
CI / lint (push) Successful in 3m58s
CI / build-release (push) Successful in 10s
CI / browser-journey (push) Successful in 7m40s
CI / release-candidate (push) Successful in 9s
2026-08-26 12:55:55 +00:00
26288d364c feat: recover progressive identity after partial snapshots
All checks were successful
CI / lint (pull_request) Successful in 4m5s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 7m24s
CI / release-candidate (pull_request) Has been skipped
Closes #1431
2026-08-26 12:42:47 +00:00
97a36ea22f Merge pull request 'Preserve healthy live sections during partial upstream timeouts' (#1430) from timmy/1429-section-isolated-live-snapshot-deadlines into main
All checks were successful
CI / lint (push) Successful in 3m59s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 7m19s
CI / release-candidate (push) Successful in 8s
2026-08-26 11:55:23 +00:00
dc9e64e98e feat: isolate live snapshot section deadlines (Closes #1429)
All checks were successful
CI / lint (pull_request) Successful in 4m2s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m12s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 11:27:35 +00:00
065cc46553 Merge pull request 'Make mobile Work and Queues usable before optional hydration' (#1428) from timmy/1427-progressive-mobile-dock into main
All checks were successful
CI / lint (push) Successful in 3m51s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m12s
CI / release-candidate (push) Successful in 7s
Merge pull request #1428: progressive mobile dock
2026-08-26 10:40:10 +00:00
664317212d test: gate progressive dock browser journey
All checks were successful
CI / lint (pull_request) Successful in 3m59s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m7s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 10:28:27 +00:00
62de2e6011 feat: make mobile dock progressive (Closes #1427)
Some checks failed
CI / lint (pull_request) Failing after 4m5s
CI / build-release (pull_request) Has been skipped
CI / browser-journey (pull_request) Has been skipped
CI / release-candidate (pull_request) Has been skipped
2026-08-26 10:18:44 +00:00
edd653eaaf Merge pull request 'Share one cold-launch live snapshot across progressive Human Gates and My Work' (#1426) from timmy/1425-shared-progressive-live-snapshot into main
All checks were successful
CI / lint (push) Successful in 3m48s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m53s
CI / release-candidate (push) Successful in 7s
2026-08-26 09:31:18 +00:00
15d7bc9234 feat: share progressive live snapshot (Closes #1425)
All checks were successful
CI / lint (pull_request) Successful in 3m52s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m54s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 08:55:08 +00:00
bea0e0e429 Merge pull request 'Adaptive mobile Queues next-up flow' (#1424) from timmy/1423-adaptive-mobile-queues into main
All checks were successful
CI / lint (push) Successful in 3m47s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m57s
CI / release-candidate (push) Successful in 8s
2026-08-26 07:43:07 +00:00
b7d496600e test: exercise collapsed all queues
All checks were successful
CI / lint (pull_request) Successful in 3m42s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 6m37s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 07:32:16 +00:00
8b2fd305ca feat: prioritize adaptive mobile queues
Some checks failed
CI / lint (pull_request) Successful in 3m38s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 9m7s
CI / release-candidate (pull_request) Has been skipped
Closes #1423
2026-08-26 07:12:27 +00:00
1e8d9a27be Merge pull request 'Fresh-authorize and audit Human Gate decisions' (#1422)
All checks were successful
CI / lint (push) Successful in 3m53s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 8m50s
CI / release-candidate (push) Successful in 8s
Closes #1421
2026-08-26 06:22:50 +00:00
1c66905b4a feat: fresh-authorize and audit Human Gate decisions
All checks were successful
CI / lint (pull_request) Successful in 3m50s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m39s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 06:11:28 +00:00
4319c161cb Merge pull request 'Open Human Gates before optional workspace hydration' (#1420) from timmy/1419-open-human-gates-before-optional-hydration into main
All checks were successful
CI / lint (push) Successful in 4m22s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 7m21s
CI / release-candidate (push) Successful in 9s
2026-08-26 05:05:08 +00:00
5ec4dd7f6e feat: open Human Gates before optional hydration
All checks were successful
CI / lint (pull_request) Successful in 4m13s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 7m51s
CI / release-candidate (pull_request) Has been skipped
Closes #1419
2026-08-26 04:52:14 +00:00
718f0d1209 feat: surface Human Gates in mobile preparation (#1418)
Some checks failed
CI / lint (push) Successful in 4m11s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Failing after 8m3s
CI / release-candidate (push) Has been skipped
Closes #1417
2026-08-26 03:42:45 +00:00
e9d257e203 Merge pull request 'feat: Human Gates inbox: one hash-bound review queue to zero' (#1416) from timmy/1415-human-gates-inbox-one-hash-bound-review-queue-to into main
All checks were successful
CI / lint (push) Successful in 4m13s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m54s
CI / release-candidate (push) Successful in 8s
2026-08-26 02:09:24 +00:00
af16f987f1 fix: restore timed break after identity hydration
All checks were successful
CI / lint (pull_request) Successful in 4m19s
CI / build-release (pull_request) Successful in 10s
CI / browser-journey (pull_request) Successful in 7m16s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 01:56:33 +00:00
f4d6f9176c docs: record Human Gates identity invariants
Some checks failed
CI / lint (pull_request) Successful in 3m55s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 7m22s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 01:30:39 +00:00
ab329aa126 fix: isolate overlapping Human Gate loads
Some checks failed
CI / lint (pull_request) Successful in 4m23s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Failing after 7m28s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 01:04:25 +00:00
9d84d5c5c4 fix: close Human Gates review races
All checks were successful
CI / lint (pull_request) Successful in 4m9s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 7m31s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 00:56:52 +00:00
dbb8ff4d4e fix: harden Human Gates review flow
All checks were successful
CI / lint (pull_request) Successful in 3m53s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 7m38s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 00:45:40 +00:00
364fc60116 test: show Human Gate project in review detail
All checks were successful
CI / lint (pull_request) Successful in 4m5s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
CI / browser-journey (pull_request) Successful in 7m2s
2026-08-26 00:10:27 +00:00
e02115bd71 Add durable Human Gates inbox 2026-08-26 00:05:27 +00:00
7ec78ac182 Merge pull request 'Open progressive mobile My Work in-app before hydration' (#1414) from timmy/1413-progressive-my-work-detail into main
All checks were successful
CI / lint (push) Successful in 3m53s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 6m53s
CI / release-candidate (push) Successful in 7s
2026-08-25 23:43:17 +00:00
94728d81ff feat: open progressive My Work in app (Closes #1413)
All checks were successful
CI / lint (pull_request) Successful in 3m44s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m47s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 23:31:34 +00:00
7960f14b5b Merge pull request 'Keep progressive mobile My Work live through delayed hydration' (#1412) from timmy/1411-progressive-my-work-live-recovery into main
All checks were successful
CI / lint (push) Successful in 3m50s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m23s
CI / release-candidate (push) Successful in 7s
2026-08-25 22:44:51 +00:00
f9b29d8d14 feat: keep progressive My Work live (Closes #1411)
All checks were successful
CI / lint (pull_request) Successful in 3m40s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m38s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 22:30:14 +00:00
e7e8c24d05 Recover progressive mobile captures after reload (#1410)
All checks were successful
CI / lint (push) Successful in 4m18s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 8m13s
CI / release-candidate (push) Successful in 11s
Closes #1409
2026-08-25 21:41:01 +00:00
8b56618e62 feat: recover progressive mobile captures (Closes #1409)
All checks were successful
CI / lint (pull_request) Successful in 3m44s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 6m18s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 21:29:50 +00:00
dcad1da1da Merge pull request 'Enable mobile capture before optional workspace hydration' (#1408) from timmy/1407-progressive-mobile-capture into main
All checks were successful
CI / lint (push) Successful in 3m35s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m39s
CI / release-candidate (push) Successful in 7s
2026-08-25 20:37:00 +00:00
8bb8fb0cd9 feat: enable progressive mobile capture
All checks were successful
CI / lint (pull_request) Successful in 3m36s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 6m26s
CI / release-candidate (pull_request) Has been skipped
Closes #1407
2026-08-25 20:11:13 +00:00
a23276f0b8 Merge pull request 'Reuse the progressive live snapshot during mobile hydration' (#1406) from timmy/1405-progressive-live-snapshot-handoff into main
All checks were successful
CI / lint (push) Successful in 3m42s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m39s
CI / release-candidate (push) Successful in 8s
Merge pull request 'Reuse the progressive live snapshot during mobile hydration' (#1406)
2026-08-25 19:25:21 +00:00
54cb078d1a perf: reuse progressive live snapshot during hydration (Closes #1405)
All checks were successful
CI / lint (pull_request) Successful in 3m42s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 6m25s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 19:13:23 +00:00
1822004f3d Merge pull request 'Preserve mobile My Work queue truth through hydration' (#1404) from timmy/1403-progressive-queue-handoff into main
Some checks failed
CI / lint (push) Successful in 3m32s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Failing after 7m21s
CI / release-candidate (push) Has been skipped
2026-08-25 18:14:32 +00:00
c15f7768e4 feat: preserve progressive My Work queue truth (Closes #1403)
All checks were successful
CI / lint (pull_request) Successful in 3m43s
CI / build-release (pull_request) Successful in 10s
CI / browser-journey (pull_request) Successful in 6m52s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 18:01:12 +00:00
024087d051 Merge pull request 'Hydrate mobile My Work before optional tools' (#1402) from timmy/1401-progressive-my-work-hydration into main
All checks were successful
CI / lint (push) Successful in 3m32s
CI / build-release (push) Successful in 10s
CI / browser-journey (push) Successful in 6m29s
CI / release-candidate (push) Successful in 8s
2026-08-25 16:56:49 +00:00
297ae7550a feat: hydrate mobile My Work before optional tools
All checks were successful
CI / lint (pull_request) Successful in 3m33s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m40s
CI / release-candidate (pull_request) Has been skipped
Closes #1401
2026-08-25 16:41:27 +00:00
e44f49708a Merge pull request 'Fix 320px active-Today week reschedule completion' (#1400) from timmy/1398-320-active-today-week-reschedule-dialog into main
All checks were successful
CI / lint (push) Successful in 3m35s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m45s
CI / release-candidate (push) Successful in 7s
2026-08-25 15:34:32 +00:00
3e7d758782 test: cover reschedule retry recovery
All checks were successful
CI / lint (pull_request) Successful in 3m26s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 6m43s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 15:12:47 +00:00
185f3f9706 fix: close mobile week reschedule while saving
Some checks failed
CI / lint (pull_request) Successful in 3m28s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Failing after 7m3s
CI / release-candidate (pull_request) Has been skipped
Closes #1398
2026-08-25 14:55:54 +00:00
aed278629b Merge pull request 'feat: Find photo-only reply drafts from mobile My Work' (#1399) from timmy/1396-find-photo-only-reply-drafts-from-mobile-my-work into main
All checks were successful
CI / lint (push) Successful in 3m30s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 6m26s
CI / release-candidate (push) Successful in 6s
2026-08-25 14:12:39 +00:00
d273de3506 fix: preserve completed mobile onboarding during draft sync
All checks were successful
CI / lint (pull_request) Successful in 3m21s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 6m25s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 13:35:51 +00:00
fa876e1e38 feat: surface photo-only reply drafts in My Work (Closes #1396)
Some checks failed
CI / lint (pull_request) Successful in 3m40s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 6m31s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 13:17:23 +00:00
306d6e3056 Merge pull request 'feat: CI regression: active timed break hidden after corrupt Today recovery on main 553a012' (#1395) from timmy/1394-ci-regression-active-timed-break-hidden-after-co into main
All checks were successful
CI / lint (push) Successful in 3m37s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m25s
CI / release-candidate (push) Successful in 6s
2026-08-25 12:22:32 +00:00
978351dff2 fix: preserve active break over reload checkpoint (Closes #1394)
All checks were successful
CI / lint (pull_request) Successful in 3m43s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m49s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 11:56:50 +00:00
553a01217a Merge pull request 'Schedule mobile notification quiet hours with one catch-up digest' (#1393) from timmy/1392-mobile-notification-quiet-hours into main
Some checks failed
CI / lint (push) Successful in 3m47s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Failing after 6m30s
CI / release-candidate (push) Has been skipped
2026-08-25 11:28:45 +00:00
cc74b7b8db feat: schedule mobile notification quiet hours (Closes #1392)
All checks were successful
CI / lint (pull_request) Successful in 3m38s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 6m29s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 11:17:40 +00:00
2acd9431e0 Merge pull request 'Keep mobile Search and Preview focus-contained through Back navigation' (#1391) from timmy/1390-mobile-search-modal-focus into main
All checks were successful
CI / lint (push) Successful in 3m26s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 6m11s
CI / release-candidate (push) Successful in 8s
2026-08-25 10:20:10 +00:00
4855bc7c8d feat: contain mobile Search focus through Preview (Closes #1390)
All checks were successful
CI / lint (pull_request) Successful in 3m42s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m32s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 10:08:55 +00:00
6e19639b19 Merge pull request 'Make Search and Following conversations actionable on mobile' (#1389) from timmy/1388-search-following-comment-actions into main
All checks were successful
CI / lint (push) Successful in 3m32s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m29s
CI / release-candidate (push) Successful in 7s
2026-08-25 09:22:04 +00:00
7662cca9e6 feat: act on Search and Following comments (Closes #1388)
All checks were successful
CI / lint (pull_request) Successful in 3m36s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 6m19s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 09:11:14 +00:00
0682bbcc2c Merge pull request 'Apply reviewer suggestions across files atomically' (#1387) from timmy/1386-atomic-multi-file-review-suggestions into main
All checks were successful
CI / lint (push) Successful in 3m34s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m21s
CI / release-candidate (push) Successful in 7s
2026-08-25 08:12:56 +00:00
1c02dcb217 feat: apply review suggestions across files
All checks were successful
CI / lint (pull_request) Successful in 3m32s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m13s
CI / release-candidate (pull_request) Has been skipped
Closes #1386
2026-08-25 08:02:16 +00:00
7bb22e3525 Merge pull request 'Apply same-file review suggestions as one atomic mobile fix' (#1385) from timmy/1384-atomic-review-suggestion-batch into main
All checks were successful
CI / lint (push) Successful in 3m40s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m9s
CI / release-candidate (push) Successful in 7s
2026-08-25 07:26:00 +00:00
869543e992 feat: batch mobile review suggestions (Closes #1384)
All checks were successful
CI / lint (pull_request) Successful in 3m38s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m20s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 07:14:54 +00:00
c8ffde7d96 Merge pull request 'Apply reviewer code suggestions from mobile' (#1383) from timmy/1382-mobile-review-suggestions into main
All checks were successful
CI / lint (push) Successful in 3m22s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m11s
CI / release-candidate (push) Successful in 7s
2026-08-25 06:19:21 +00:00
ed662c3a3a feat: apply mobile review suggestions (Closes #1382)
All checks were successful
CI / lint (pull_request) Successful in 3m40s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m59s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 06:08:22 +00:00
8a881419db Merge pull request 'React to conversation comments without leaving mobile work' (#1381) from timmy/1380-mobile-comment-reactions into main
All checks were successful
CI / lint (push) Successful in 3m26s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m3s
CI / release-candidate (push) Successful in 7s
2026-08-25 05:21:13 +00:00
b4f3c6d419 feat: react to mobile conversation comments (Closes #1380)
All checks were successful
CI / lint (pull_request) Successful in 3m46s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 6m29s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 05:09:32 +00:00
fc7a83fbd7 Merge pull request 'Pin release pipeline actions and enforce least-privilege permissions' (#1379) from timmy/1378-pin-release-actions into main
All checks were successful
CI / lint (push) Successful in 3m5s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 5m27s
CI / release-candidate (push) Successful in 7s
2026-08-25 04:21:41 +00:00
22fe5dd1d8 ci: pin release pipeline actions (Closes #1378)
All checks were successful
CI / lint (pull_request) Successful in 3m11s
CI / build-release (pull_request) Successful in 24s
CI / browser-journey (pull_request) Successful in 5m31s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 03:57:29 +00:00
9d97b2e84a Merge pull request 'Commit bounded review feedback fixes from mobile' (#1377) from timmy/1376-mobile-review-feedback-fix into main
All checks were successful
CI / lint (push) Successful in 3m10s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m29s
CI / release-candidate (push) Successful in 6s
2026-08-25 03:17:59 +00:00
502fcb30f5 feat: commit review feedback fixes from mobile (Closes #1376)
All checks were successful
CI / lint (pull_request) Successful in 3m3s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m18s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 03:08:53 +00:00
b541c707fc Merge pull request 'Bind background push delivery to upstream operator identity' (#1375) from timmy/1374-bind-push-session-identity into main
All checks were successful
CI / lint (push) Successful in 3m17s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m40s
CI / release-candidate (push) Successful in 6s
2026-08-25 02:16:17 +00:00
b40a44fc45 fix: bind background push to operator identity (Closes #1374)
All checks were successful
CI / lint (pull_request) Successful in 3m18s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m47s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 02:06:25 +00:00
d1adbe72d6 Merge pull request 'Bind operator sessions to upstream Gitea identity' (#1373) from timmy/1372-bind-sessions-upstream-identity into main
All checks were successful
CI / lint (push) Successful in 3m21s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m48s
CI / release-candidate (push) Successful in 7s
2026-08-25 01:33:53 +00:00
d6e6cf2e67 feat: bind operator sessions to upstream identity (Closes #1372)
All checks were successful
CI / lint (pull_request) Successful in 3m24s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m49s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 01:12:42 +00:00
53706f18ab Merge pull request 'Prepare conflict-safe rollback pull requests from failed releases' (#1371) from timmy/1370-conflict-safe-release-rollback into main
All checks were successful
CI / lint (push) Successful in 3m20s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m31s
CI / release-candidate (push) Successful in 6s
2026-08-25 00:31:40 +00:00
f0be3a44a6 ci: retry transient mobile navigation journey
All checks were successful
CI / lint (pull_request) Successful in 3m15s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m40s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 00:21:58 +00:00
b753cdd922 feat: prepare conflict-safe release rollback pulls (Closes #1370)
Some checks failed
CI / lint (pull_request) Successful in 3m11s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 6m7s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 23:55:33 +00:00
400534f446 Merge pull request 'Complete secure source-branch deletion in operator mode' (#1369) from timmy/1368-secure-source-branch-deletion into main
All checks were successful
CI / lint (push) Successful in 3m15s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m13s
CI / release-candidate (push) Successful in 6s
Closes #1368
2026-08-24 22:40:22 +00:00
a43ecac94b fix: authorize secure source branch deletion (Closes #1368)
All checks were successful
CI / lint (pull_request) Successful in 3m18s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m20s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 22:30:56 +00:00
d6993c40fb Merge pull request 'Recover failed post-merge checks from mobile release receipts' (#1367) from timmy/1366-post-merge-release-check-recovery into main
All checks were successful
CI / lint (push) Successful in 3m14s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m18s
CI / release-candidate (push) Successful in 7s
2026-08-24 21:45:51 +00:00
966116e5ee feat: recover failed post-merge checks (Closes #1366)
All checks were successful
CI / lint (pull_request) Successful in 3m16s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m30s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 21:36:16 +00:00
d4ddac07ab Merge pull request 'Delete merged source branches safely from mobile receipts' (#1365) from timmy/1364-delete-merged-source-branches into main
All checks were successful
CI / lint (push) Successful in 3m14s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m37s
CI / release-candidate (push) Successful in 6s
2026-08-24 20:54:32 +00:00
b6684bb7ba feat: label source branch deletion security events
All checks were successful
CI / lint (pull_request) Successful in 3m30s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m49s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 20:44:42 +00:00
07f5770aa8 feat: delete merged source branches safely (Closes #1364)
Some checks failed
CI / lint (pull_request) Successful in 3m33s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 5m42s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 20:29:32 +00:00
f3876d359d Merge pull request 'feat: Create pull requests from pushed branches on mobile' (#1363) from timmy/1362-create-pull-requests-from-pushed-branches-on-mob into main
All checks were successful
CI / lint (push) Successful in 3m22s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m22s
CI / release-candidate (push) Successful in 7s
2026-08-24 19:52:48 +00:00
e5b9833096 feat: create mobile pull requests from pushed branches (Closes #1362)
All checks were successful
CI / lint (pull_request) Successful in 3m19s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
CI / browser-journey (pull_request) Successful in 5m16s
2026-08-24 19:31:16 +00:00
ae98564d9b Merge pull request 'Update outdated authored pull branches from mobile' (#1361) from timmy/1360-update-authored-pull-branch into main
All checks were successful
CI / lint (push) Successful in 3m11s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m23s
CI / release-candidate (push) Successful in 6s
2026-08-24 18:12:25 +00:00
b85fe4a0a3 feat: update authored pull branches from mobile (Closes #1360)
All checks were successful
CI / lint (pull_request) Successful in 3m28s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m31s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 18:02:23 +00:00
a9becd079f Merge pull request 'Recover closed authored pull requests from mobile Search' (#1359) from timmy/1358-reopen-closed-authored-pull-search into main
All checks were successful
CI / lint (push) Successful in 3m18s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 5m32s
CI / release-candidate (push) Successful in 7s
2026-08-24 17:22:36 +00:00
99750d44f5 feat: recover closed authored pulls from search (Closes #1358)
All checks were successful
CI / lint (pull_request) Successful in 3m20s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m31s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 17:12:51 +00:00
e4d0cc9115 Merge pull request 'Close obsolete authored pull requests from mobile with recovery' (#1357) from timmy/1356-close-authored-pull-recovery into main
All checks were successful
CI / lint (push) Successful in 3m26s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m20s
CI / release-candidate (push) Successful in 7s
2026-08-24 16:28:53 +00:00
77f22f3cd5 feat: close authored pulls with recovery (Closes #1356)
All checks were successful
CI / lint (pull_request) Successful in 3m44s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m58s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 16:18:24 +00:00
fde3ba21ca Merge pull request 'Recover failed CI checks from the mobile pull workspace' (#1355) from timmy/1354-mobile-ci-recovery into main
All checks were successful
CI / lint (push) Successful in 3m30s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m55s
CI / release-candidate (push) Successful in 6s
2026-08-24 15:16:09 +00:00
8cb92c6858 feat: recover failed CI checks from mobile (Closes #1354)
All checks were successful
CI / lint (pull_request) Successful in 3m30s
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Successful in 5m44s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 15:06:01 +00:00
3e7a6c81fc Merge pull request 'feat: CI browser journey: Insights overlay does not inert page header at 320px' (#1353) from timmy/1352-ci-browser-journey-insights-overlay-does-not-ine into main
All checks were successful
CI / lint (push) Successful in 3m31s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m32s
CI / release-candidate (push) Successful in 7s
2026-08-24 14:12:39 +00:00
084c0b5732 Fix mobile overlay inert ownership (#1352)
All checks were successful
CI / lint (pull_request) Successful in 3m19s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m45s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 13:49:48 +00:00
c73fbf8379 Merge pull request 'Load pull workspaces through one authorized snapshot' (#1351) from timmy/1350-authorized-pull-snapshot into main
Some checks failed
CI / lint (push) Successful in 3m20s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Failing after 5m27s
CI / release-candidate (push) Has been skipped
2026-08-24 13:24:47 +00:00
b47ed5d86e perf: reuse authorized pull snapshots (Closes #1350)
All checks were successful
CI / lint (pull_request) Successful in 3m31s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m32s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 13:14:48 +00:00
f0f415c65d Merge pull request 'Complete authored pull request workspace access' (#1349) from timmy/1348-authored-pull-workspace into main
All checks were successful
CI / lint (push) Successful in 3m9s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m26s
CI / release-candidate (push) Successful in 6s
2026-08-24 12:20:21 +00:00
2102b0e5da feat: complete authored pull workspace access (Closes #1348)
All checks were successful
CI / lint (pull_request) Successful in 3m15s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m22s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 12:10:49 +00:00
050390d6f6 Merge pull request 'Surface authored pull requests in My Work' (#1347) from timmy/1346-authored-pull-queue into main
All checks were successful
CI / lint (push) Successful in 3m18s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m16s
CI / release-candidate (push) Successful in 6s
2026-08-24 11:24:14 +00:00
be5603d41b feat: surface authored pulls in My Work (Closes #1346)
All checks were successful
CI / lint (pull_request) Successful in 3m26s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m20s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 11:13:54 +00:00
4e074ae18e Merge pull request 'Publish draft pull requests from mobile' (#1345) from timmy/1344-publish-draft-pull into main
All checks were successful
CI / lint (push) Successful in 3m10s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m14s
CI / release-candidate (push) Successful in 7s
2026-08-24 10:16:34 +00:00
a4ae3e1c7f feat: publish draft pull requests from mobile (Closes #1344)
All checks were successful
CI / lint (pull_request) Successful in 3m22s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m33s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 10:06:51 +00:00
1a2297e943 Merge pull request 'Load pull review feedback on demand without blocking mobile review' (#1343) from timmy/1342-lazy-review-feedback into main
All checks were successful
CI / lint (push) Successful in 3m28s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m23s
CI / release-candidate (push) Successful in 7s
2026-08-24 09:26:53 +00:00
611e79fc1e feat: load pull review feedback on demand (Closes #1342)
All checks were successful
CI / lint (pull_request) Successful in 3m25s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 09:16:30 +00:00
50817bed9a Merge pull request 'Cancel and replace pending pull review requests from mobile' (#1341) from timmy/1340-cancel-pending-review into main
All checks were successful
CI / lint (push) Successful in 3m25s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m32s
CI / release-candidate (push) Successful in 7s
2026-08-24 08:25:56 +00:00
1f9f0f5c22 test: gate mobile review cancellation journey
All checks were successful
CI / lint (pull_request) Successful in 3m20s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m24s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 08:16:21 +00:00
d776a2b003 feat: cancel pending pull review requests (Closes #1340)
Some checks failed
CI / lint (pull_request) Successful in 3m17s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 5m18s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 08:01:25 +00:00
7517d81046 Merge pull request 'Address requested review feedback from mobile pull detail' (#1339) from timmy/1338-address-review-feedback into main
All checks were successful
CI / lint (push) Successful in 3m33s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m45s
CI / release-candidate (push) Successful in 7s
2026-08-24 06:53:19 +00:00
091a01ba67 feat: address requested review feedback (Closes #1338)
All checks were successful
CI / lint (pull_request) Successful in 3m35s
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Successful in 5m23s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 06:42:45 +00:00
bad14d1b94 Merge pull request 'Show requested-change feedback in mobile pull detail' (#1337) from timmy/1336-review-feedback into main
All checks were successful
CI / lint (push) Successful in 3m34s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m32s
CI / release-candidate (push) Successful in 7s
2026-08-24 05:48:23 +00:00
c4bb2f5eba feat: show requested-change feedback (Closes #1336)
All checks were successful
CI / lint (pull_request) Successful in 3m30s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m24s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 05:38:30 +00:00
d2a815c4bc Merge pull request 'Show reviewer decisions before merging a pull request' (#1335) from timmy/1334-reviewer-decisions into main
All checks were successful
CI / lint (push) Successful in 3m45s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m34s
CI / release-candidate (push) Successful in 9s
2026-08-24 04:41:15 +00:00
3bc1d2f602 test: exercise mobile reviewer status journey
All checks were successful
CI / lint (pull_request) Successful in 3m51s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m29s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 04:28:24 +00:00
bd96c3caa3 feat: show pull reviewer decisions (Closes #1334)
Some checks failed
CI / lint (pull_request) Successful in 3m51s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 5m47s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 04:09:05 +00:00
f8ab6977ed Merge pull request 'Revise pull request context before requesting review' (#1333) from timmy/1332-revise-pull-context into main
All checks were successful
CI / lint (push) Successful in 3m36s
CI / build-release (push) Successful in 9s
CI / browser-journey (push) Successful in 5m25s
CI / release-candidate (push) Successful in 8s
2026-08-24 03:21:15 +00:00
8285b39e06 feat: revise pull review context from mobile (Closes #1332)
All checks were successful
CI / lint (pull_request) Successful in 3m30s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m33s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 03:11:11 +00:00
8c4969cc44 Merge pull request 'Request teammate review from mobile pull detail' (#1331) from timmy/1330-request-teammate-review into main
All checks were successful
CI / lint (push) Successful in 3m40s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m31s
CI / release-candidate (push) Successful in 8s
2026-08-24 02:21:19 +00:00
1f950f93dd feat: request teammate pull reviews (Closes #1330)
All checks were successful
CI / lint (pull_request) Successful in 3m36s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m35s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 02:11:24 +00:00
505a40d077 Merge pull request 'Review watched pull request changes before acknowledgement' (#1329) from timmy/1328-review-watched-pr-changes into main
Some checks failed
CI / lint (push) Successful in 3m20s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Failing after 5m32s
CI / release-candidate (push) Has been skipped
2026-08-24 01:02:29 +00:00
0cabce66ed test: account for conditional Changes navigation
All checks were successful
CI / lint (pull_request) Successful in 3m24s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m30s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 00:52:55 +00:00
df90fb1e0e feat: review watched pull request changes (Closes #1328)
Some checks failed
CI / lint (pull_request) Successful in 3m22s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 5m6s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 00:40:28 +00:00
e39e92b025 Merge pull request 'Plan Following changes and continue review' (#1327) from timmy/1326-plan-following-change-continue-review into main
All checks were successful
CI / lint (push) Successful in 3m27s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m21s
CI / release-candidate (push) Successful in 7s
2026-08-23 23:43:39 +00:00
07836f7176 feat: plan Following changes and continue review (Closes #1326)
All checks were successful
CI / lint (pull_request) Successful in 3m25s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m46s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 23:33:36 +00:00
0f36d3077b Merge pull request 'Combine Following changes with Updates in the app badge' (#1325)
All checks were successful
CI / lint (push) Successful in 3m26s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m29s
CI / release-candidate (push) Successful in 8s
Closes #1324
2026-08-23 22:48:16 +00:00
33c8761e80 feat: combine review attention app badge (Closes #1324)
All checks were successful
CI / lint (pull_request) Successful in 3m29s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m24s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 22:38:21 +00:00
157703527a Merge pull request 'Explain Following changes before acknowledging review' (#1323) from timmy/1322-explain-following-changes into main
All checks were successful
CI / lint (push) Successful in 3m27s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m27s
CI / release-candidate (push) Successful in 7s
2026-08-23 21:39:31 +00:00
5921027e80 feat: explain Following changes before review (Closes #1322)
All checks were successful
CI / lint (pull_request) Successful in 4m4s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m48s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 21:28:50 +00:00
31700d5c71 Merge pull request 'Keep a Following change for later and continue review' (#1321) from timmy/1320-keep-following-for-later into main
All checks were successful
CI / lint (push) Successful in 3m47s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m53s
CI / release-candidate (push) Successful in 9s
2026-08-23 20:35:33 +00:00
c3cb498ce1 feat: keep Following changes for later (Closes #1320)
All checks were successful
CI / lint (pull_request) Successful in 3m50s
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Successful in 6m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 20:24:14 +00:00
f168a0187d Merge pull request 'feat: CI browser journey loses active break state after corrupt-record recovery' (#1319) from timmy/1315-ci-browser-journey-loses-active-break-state-afte into main
All checks were successful
CI / lint (push) Successful in 3m14s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m37s
CI / release-candidate (push) Successful in 8s
2026-08-23 19:46:52 +00:00
877c995b47 fix: preserve active break during session adoption (Closes #1315)
All checks were successful
CI / lint (pull_request) Successful in 3m35s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m18s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 19:26:02 +00:00
e572b3410e Merge pull request 'Open Following alerts directly in changed-first review' (#1318) from timmy/1317-open-following-alert-review into main
All checks were successful
CI / lint (push) Successful in 3m58s
CI / build-release (push) Successful in 10s
CI / browser-journey (push) Successful in 5m33s
CI / release-candidate (push) Successful in 7s
2026-08-23 18:57:50 +00:00
a2501e47c3 feat: open Following alerts in changed review (Closes #1317)
All checks were successful
CI / lint (pull_request) Successful in 3m29s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m14s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 18:37:57 +00:00
2b6730288a Merge pull request 'feat: Notify mobile operators when Following changes' (#1316) from timmy/1313-notify-mobile-operators-when-following-changes into main
All checks were successful
CI / lint (push) Successful in 3m48s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 6m6s
CI / release-candidate (push) Successful in 8s
2026-08-23 18:02:52 +00:00
0efd44fd90 feat: notify devices when Following changes (Closes #1313)
All checks were successful
CI / lint (pull_request) Successful in 3m45s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m8s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 17:16:28 +00:00
187b0decae Merge pull request 'feat: CI browser journey intermittently leaves week reschedule dialog open' (#1312) from timmy/1311-ci-browser-journey-intermittently-leaves-week-re into main
All checks were successful
CI / lint (push) Successful in 3m46s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 6m15s
CI / release-candidate (push) Successful in 9s
2026-08-23 16:07:23 +00:00
75846cf045 fix: retry unchanged reschedule intent after stale revision (Closes #1311)
All checks were successful
CI / lint (pull_request) Successful in 3m47s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 6m58s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 15:43:30 +00:00
544ffb4ae7 Merge pull request 'Watch assigned issues and pull requests from mobile detail' (#1310)
Some checks failed
CI / lint (push) Successful in 3m23s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Failing after 5m25s
CI / release-candidate (push) Has been skipped
Closes #1309
2026-08-23 15:14:34 +00:00
eea9097740 test: cover detail watch in packaged mobile CI
All checks were successful
CI / lint (pull_request) Successful in 3m25s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m13s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 15:05:19 +00:00
35a88bb398 feat: watch assigned work from mobile detail (Closes #1309)
Some checks failed
CI / lint (pull_request) Successful in 3m20s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 5m41s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 14:42:06 +00:00
4e3f67df3d Merge pull request 'Follow pull requests from mobile Search into Following' (#1308) from timmy/1307-follow-pulls-from-search into main
All checks were successful
CI / lint (push) Successful in 3m45s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 5m20s
CI / release-candidate (push) Successful in 9s
2026-08-23 13:51:13 +00:00
7131792ded feat: follow pull requests from mobile Search (Closes #1307)
All checks were successful
CI / lint (pull_request) Successful in 3m34s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m17s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 13:41:33 +00:00
41c28cc70a Merge pull request 'Retire closed issues during Following review' (#1306) from timmy/1305-retire-closed-following into main
All checks were successful
CI / lint (push) Successful in 3m47s
CI / build-release (push) Successful in 9s
CI / browser-journey (push) Successful in 5m50s
CI / release-candidate (push) Successful in 9s
2026-08-23 12:36:07 +00:00
aa12c4ab9d test: validate closed Following retirement on phone
All checks were successful
CI / lint (pull_request) Successful in 3m25s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m23s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 12:26:39 +00:00
c4bb7a9be1 feat: retire closed issues during Following review (Closes #1305)
Some checks failed
CI / lint (pull_request) Successful in 3m30s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 5m22s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 12:12:55 +00:00
fdf38a72ac Merge pull request 'Follow any open issue from mobile Search' (#1304) from timmy/1303-follow-any-open-issue into main
All checks were successful
CI / lint (push) Successful in 3m31s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m34s
CI / release-candidate (push) Successful in 7s
Follow any open issue from mobile Search (#1304)
2026-08-23 11:13:07 +00:00
29e3f55e07 feat: follow any open issue from search (Closes #1303)
All checks were successful
CI / lint (pull_request) Successful in 3m54s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m37s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 11:02:33 +00:00
0a18d1ac0f Review unseen Following changes during Prepare Today (#1302)
All checks were successful
CI / lint (push) Successful in 3m33s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m21s
CI / release-candidate (push) Successful in 7s
Closes #1301
2026-08-23 10:19:29 +00:00
12c0e18dfe feat: review Following during Prepare Today (Closes #1301)
All checks were successful
CI / lint (pull_request) Successful in 3m34s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 10:09:12 +00:00
875ec45928 Merge pull request 'Add a sequential review pass for Following' (#1300) from timmy/1299-following-review-pass into main
All checks were successful
CI / lint (push) Successful in 3m34s
CI / build-release (push) Successful in 9s
CI / browser-journey (push) Successful in 5m22s
CI / release-candidate (push) Successful in 7s
2026-08-23 09:26:23 +00:00
b79973fc2e feat: add sequential Following review pass (Closes #1299)
All checks were successful
CI / lint (pull_request) Successful in 3m52s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m45s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 09:15:57 +00:00
6b98eb5cbe Merge pull request 'Turn Following into a changed-first mobile review inbox' (#1298) from timmy/1297-changed-first-following into main
All checks were successful
CI / lint (push) Successful in 4m18s
CI / build-release (push) Successful in 9s
CI / browser-journey (push) Successful in 5m32s
CI / release-candidate (push) Successful in 8s
2026-08-23 08:04:15 +00:00
32e2a228b9 feat: make Following a changed-first inbox (Closes #1297)
All checks were successful
CI / lint (pull_request) Successful in 4m12s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 7m3s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 07:38:53 +00:00
bbf397454d Merge pull request 'Make Search watch admission truthful and recoverable' (#1296) from timmy/1295-truthful-search-watch-admission into main
All checks were successful
CI / lint (push) Successful in 3m48s
CI / build-release (push) Successful in 10s
CI / browser-journey (push) Successful in 6m41s
CI / release-candidate (push) Successful in 11s
2026-08-23 06:36:03 +00:00
d3ba5975a0 refactor: make Following sync outcome explicit
All checks were successful
CI / lint (pull_request) Successful in 4m13s
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Successful in 7m9s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 06:23:57 +00:00
6c12731215 fix: make Search watch admission recoverable (Closes #1295)
Some checks failed
CI / lint (pull_request) Successful in 4m17s
CI / build-release (pull_request) Successful in 10s
CI / browser-journey (pull_request) Failing after 7m36s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 06:05:48 +00:00
4fe07bcd52 Merge pull request 'Add a mobile Following queue for watched issues' (#1294) from timmy/1293-mobile-following-watched-issues into main
All checks were successful
CI / lint (push) Successful in 4m6s
CI / build-release (push) Successful in 9s
CI / browser-journey (push) Successful in 7m5s
CI / release-candidate (push) Successful in 8s
2026-08-23 04:57:11 +00:00
846c28001e fix: defer Following sync until user intent
All checks were successful
CI / lint (pull_request) Successful in 4m8s
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Successful in 6m50s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 04:45:27 +00:00
5c43a5678a feat: add mobile Following queue for watched issues (Closes #1293)
Some checks failed
CI / lint (pull_request) Successful in 4m15s
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Failing after 6m46s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 04:27:23 +00:00
4b4e55dbe2 Merge pull request 'feat: CI timing gate flakes under runner load' (#1292) from timmy/1289-ci-timing-gate-flakes-under-runner-load into main
All checks were successful
CI / lint (push) Successful in 4m4s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 7m13s
CI / release-candidate (push) Successful in 10s
2026-08-23 02:45:12 +00:00
fe1049ee99 test: make health timing gate deterministic (Closes #1289)
All checks were successful
CI / lint (pull_request) Successful in 4m10s
CI / build-release (pull_request) Successful in 8s
CI / release-candidate (pull_request) Has been skipped
CI / browser-journey (pull_request) Successful in 7m24s
2026-08-23 02:40:35 +00:00
73017c1a30 Merge pull request 'Watch an unassigned issue from mobile Search' (#1291) from timmy/1290-watch-mobile-search into main
All checks were successful
CI / lint (push) Successful in 4m15s
CI / build-release (push) Successful in 9s
CI / browser-journey (push) Successful in 6m52s
CI / release-candidate (push) Successful in 11s
2026-08-23 02:08:24 +00:00
9c187b5ae5 feat: watch issues from mobile search (Closes #1290)
All checks were successful
CI / lint (pull_request) Successful in 4m17s
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Successful in 7m6s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 01:55:53 +00:00
7fbbc2f942 Merge pull request 'feat: Disk usage 93%' (#1288) from timmy/1287-disk-usage-93 into main
Some checks failed
CI / lint (push) Successful in 4m7s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Failing after 7m30s
CI / release-candidate (push) Has been skipped
2026-08-23 01:37:44 +00:00
1b448fef23 docs: add disk capacity incident runbook (Closes #1287)
All checks were successful
CI / build-release (pull_request) Successful in 10s
CI / lint (pull_request) Successful in 3m53s
CI / browser-journey (pull_request) Successful in 6m55s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 01:10:16 +00:00
881653eef1 Merge pull request 'Make calendar-import performance gates deterministic' (#1286) from timmy/1285-calendar-import-performance-gate into main
Some checks failed
CI / lint (push) Successful in 5m17s
CI / build-release (push) Successful in 19s
CI / browser-journey (push) Failing after 12m8s
CI / release-candidate (push) Has been skipped
2026-08-23 00:32:32 +00:00
d8476c6f7b perf: reuse calendar timezone formatters (Closes #1285)
All checks were successful
CI / lint (pull_request) Successful in 4m53s
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Successful in 5m51s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 00:19:34 +00:00
6eb2709477 Merge pull request 'Keep the installed app badge current from background Updates' (#1283) from timmy/1281-background-updates-app-badge into main
Some checks failed
CI / lint (push) Successful in 4m14s
CI / build-release (push) Successful in 11s
CI / browser-journey (push) Failing after 9m59s
CI / release-candidate (push) Has been skipped
2026-08-22 22:36:24 +00:00
05405bf2e8 feat: keep background Updates badge current (Closes #1281)
All checks were successful
CI / lint (pull_request) Successful in 4m52s
CI / build-release (pull_request) Successful in 15s
CI / browser-journey (pull_request) Successful in 7m5s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 22:11:52 +00:00
b18a50fec5 Merge pull request 'Show unread Updates on the installed app icon' (#1279) from timmy/1278-unread-updates-app-icon-badge into main
All checks were successful
CI / lint (push) Successful in 3m34s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m39s
CI / release-candidate (push) Successful in 8s
2026-08-22 21:04:51 +00:00
be9de6596f feat: show unread Updates on app icon (Closes #1278)
All checks were successful
CI / lint (pull_request) Successful in 3m47s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 6m10s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 20:39:09 +00:00
71631c6731 Merge pull request 'Preserve complete Today timing ledger across device handoff' (#1277) from timmy/1276-complete-today-ledger-handoff into main
All checks were successful
CI / lint (push) Successful in 3m41s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m51s
CI / release-candidate (push) Successful in 8s
2026-08-22 19:49:04 +00:00
548b95ddb6 feat: preserve Today timing ledger across handoff (Closes #1276)
All checks were successful
CI / lint (pull_request) Successful in 3m48s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m42s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 19:38:30 +00:00
e0d8ab9f10 Merge pull request 'Recover active Today breaks after queue recovery' (#1275) from timmy/1274-recovered-today-break-status into main
All checks were successful
CI / lint (push) Successful in 3m53s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 5m58s
CI / release-candidate (push) Successful in 10s
2026-08-22 18:39:57 +00:00
a629b5a93e fix: recover server-confirmed Today breaks (Closes #1274)
All checks were successful
CI / lint (pull_request) Successful in 4m11s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 6m8s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 18:28:29 +00:00
ce9301f145 Merge pull request 'feat: CI regression: packaged mobile first-task coach remains hidden' (#1273) from timmy/1272-ci-regression-packaged-mobile-first-task-coach-r into main
Some checks failed
CI / lint (push) Successful in 3m58s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Failing after 6m19s
CI / release-candidate (push) Has been skipped
Fix packaged first-task coach visibility (Closes #1272)
2026-08-22 17:57:05 +00:00
d0e8378a02 fix: keep first-task coach host visible (Closes #1272)
All checks were successful
CI / lint (pull_request) Successful in 3m41s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m43s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 17:28:29 +00:00
424ab2bb6d Merge pull request 'Persist exact Week Ahead task times' (#1271) from timmy/1270-persist-week-task-times into main
Some checks failed
CI / lint (push) Successful in 4m11s
CI / build-release (push) Successful in 10s
CI / browser-journey (push) Failing after 6m23s
CI / release-candidate (push) Has been skipped
2026-08-22 16:57:39 +00:00
5fb4da30a4 feat: persist exact Week Ahead task times (Closes #1270)
All checks were successful
CI / lint (pull_request) Successful in 4m3s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 6m22s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 16:39:24 +00:00
f78da3dfc9 Merge pull request 'feat: CI regression: Plan Today remains visible after Save & start' (#1269) from timmy/1268-ci-regression-plan-today-remains-visible-after-s into main
All checks were successful
CI / lint (push) Successful in 4m12s
CI / build-release (push) Successful in 9s
CI / browser-journey (push) Successful in 6m29s
CI / release-candidate (push) Successful in 8s
2026-08-22 16:06:12 +00:00
14b3fb4048 fix: close rollover planner before Today handoff (Closes #1268)
All checks were successful
CI / lint (pull_request) Successful in 4m5s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m38s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 15:34:05 +00:00
d27a481d96 Merge pull request 'Refresh Week Ahead calendar availability with atomic reflow' (#1267) from timmy/1266-refresh-week-calendar-atomic-reflow into main
Some checks failed
CI / lint (push) Successful in 4m31s
CI / build-release (push) Successful in 9s
CI / browser-journey (push) Failing after 8m1s
CI / release-candidate (push) Has been skipped
2026-08-22 14:45:03 +00:00
24eb4d1e79 feat: refresh Week capacity with atomic reflow (Closes #1266)
All checks were successful
CI / lint (pull_request) Successful in 4m25s
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Successful in 7m11s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 14:32:08 +00:00
3c35aa31c1 Merge pull request 'Guard the Week Ahead reschedule journey from hidden issue sheets' (#1265) from timmy/1264-visible-week-reschedule-sheet into main
All checks were successful
CI / lint (push) Successful in 4m7s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m57s
CI / release-candidate (push) Successful in 9s
2026-08-22 13:35:20 +00:00
363a97aa5e test: guard hidden Week Ahead journey sheet (Closes #1264)
All checks were successful
CI / lint (pull_request) Successful in 5m1s
CI / build-release (pull_request) Successful in 10s
CI / browser-journey (pull_request) Successful in 6m48s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 13:22:21 +00:00
306a20c37f Merge pull request 'feat: CI regression: mobile Today break status is lost after recovery reload' (#1263) from timmy/1262-ci-regression-mobile-today-break-status-is-lost- into main
Some checks failed
CI / lint (push) Successful in 3m52s
CI / build-release (push) Successful in 10s
CI / browser-journey (push) Failing after 7m7s
CI / release-candidate (push) Has been skipped
2026-08-22 12:26:36 +00:00
e06bd8435d fix: restore timed break after unchanged fresh context (Closes #1262)
All checks were successful
CI / lint (pull_request) Successful in 3m56s
CI / build-release (pull_request) Successful in 11s
CI / browser-journey (pull_request) Successful in 6m4s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 12:04:23 +00:00
76f89df609 Merge pull request 'Export a saved Week Ahead plan directly from the overview' (#1261) from timmy/1260-export-saved-week-calendar into main
Some checks failed
CI / lint (push) Successful in 4m2s
CI / build-release (push) Successful in 9s
CI / browser-journey (push) Failing after 5m58s
CI / release-candidate (push) Has been skipped
2026-08-22 11:31:03 +00:00
f0a966d3a3 feat: export saved Week Ahead from overview (Closes #1260)
All checks were successful
CI / lint (pull_request) Successful in 4m18s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m0s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 11:15:10 +00:00
9eac3bb958 Merge pull request 'Persist privacy-safe calendar free windows with Week Ahead' (#1259)
All checks were successful
CI / lint (push) Successful in 4m16s
CI / build-release (push) Successful in 9s
CI / browser-journey (push) Successful in 6m53s
CI / release-candidate (push) Successful in 10s
Closes #1258
2026-08-22 10:04:09 +00:00
fc93620118 feat: persist calendar free windows with Week Ahead (Closes #1258)
All checks were successful
CI / lint (pull_request) Successful in 3m40s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m58s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 09:49:04 +00:00
15739f7370 Merge pull request 'Persist weekly availability defaults for rolling Week Ahead dates' (#1257) from timmy/1256-weekly-availability-defaults into main
All checks were successful
CI / lint (push) Successful in 4m26s
CI / build-release (push) Successful in 11s
CI / browser-journey (push) Successful in 6m16s
CI / release-candidate (push) Successful in 8s
Merge pull request 'Persist weekly availability defaults for rolling Week Ahead dates' (#1257)
2026-08-22 08:20:35 +00:00
1ae8533702 feat: persist weekly availability defaults (Closes #1256)
All checks were successful
CI / lint (pull_request) Successful in 3m37s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m47s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 08:10:24 +00:00
ea3204c78c Merge pull request 'Recover conflicted Week-to-Today transfers' (#1255) from timmy/1254-recover-conflicted-week-pulls into main
All checks were successful
CI / lint (push) Successful in 3m39s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 5m28s
CI / release-candidate (push) Successful in 8s
Recover conflicted Week-to-Today transfers (#1255)
2026-08-22 07:28:12 +00:00
6e72f59fcf feat: recover conflicted Week-to-Today transfers (Closes #1254)
All checks were successful
CI / lint (pull_request) Successful in 3m45s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m22s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 07:06:33 +00:00
24cadf88f8 Merge pull request 'Encrypt completed Filed review history at rest' (#1253) from timmy/1252-encrypt-completed-filed-reviews into main
Some checks failed
CI / lint (push) Successful in 3m21s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Failing after 5m14s
CI / release-candidate (push) Has been skipped
2026-08-22 06:31:00 +00:00
4b645d8460 feat: encrypt completed Filed review history (Closes #1252)
All checks were successful
CI / lint (pull_request) Successful in 3m8s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m27s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 06:09:06 +00:00
a4498fdbbc Merge pull request 'Queue multiple offline Week-to-Today pulls' (#1250) from timmy/1249-queue-offline-week-pulls into main
Some checks failed
CI / lint (push) Successful in 3m46s
CI / build-release (push) Successful in 9s
CI / browser-journey (push) Failing after 7m15s
CI / release-candidate (push) Has been skipped
2026-08-22 04:52:47 +00:00
2e8efdb2bb test: cover fallback lease for Week replay
All checks were successful
CI / lint (pull_request) Successful in 3m2s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m49s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 04:38:26 +00:00
e92e476f49 test: exercise real cross-tab replay coordinator
Some checks failed
CI / lint (pull_request) Failing after 57s
CI / build-release (pull_request) Has been skipped
CI / browser-journey (pull_request) Has been skipped
CI / release-candidate (pull_request) Has been skipped
2026-08-22 04:36:02 +00:00
4a6a829401 fix: serialize Week-to-Today replay across tabs
Some checks failed
CI / lint (pull_request) Failing after 3s
CI / build-release (pull_request) Has been skipped
CI / browser-journey (pull_request) Has been skipped
CI / release-candidate (pull_request) Has been skipped
2026-08-22 04:25:01 +00:00
c03d0a091f Revert "ci: pin checkout and Python setup actions"
Some checks failed
CI / lint (pull_request) Failing after 12s
CI / build-release (pull_request) Has been skipped
CI / browser-journey (pull_request) Has been skipped
CI / release-candidate (pull_request) Has been skipped
This reverts commit 59c87f06a2.
2026-08-22 03:41:03 +00:00
2f5df33020 Revert "ci: prepare act runner action mount"
This reverts commit 5d517861f0.
2026-08-22 03:41:03 +00:00
5d517861f0 ci: prepare act runner action mount
Some checks failed
CI / lint (pull_request) Failing after 3s
CI / build-release (pull_request) Has been skipped
CI / browser-journey (pull_request) Has been skipped
CI / release-candidate (pull_request) Has been skipped
2026-08-22 03:40:21 +00:00
59c87f06a2 ci: pin checkout and Python setup actions
Some checks failed
CI / lint (pull_request) Failing after 57s
CI / build-release (pull_request) Has been skipped
CI / browser-journey (pull_request) Has been skipped
CI / release-candidate (pull_request) Has been skipped
2026-08-22 03:38:27 +00:00
f76b2f8289 refactor: simplify pull queue admission
Some checks failed
CI / lint (pull_request) Failing after 1s
CI / build-release (pull_request) Has been skipped
CI / browser-journey (pull_request) Has been skipped
CI / release-candidate (pull_request) Has been skipped
2026-08-22 03:33:34 +00:00
7c4e798776 feat: queue offline Week-to-Today pulls (Closes #1249)
Some checks failed
CI / lint (pull_request) Failing after 33s
CI / build-release (pull_request) Has been skipped
CI / browser-journey (pull_request) Has been skipped
CI / release-candidate (pull_request) Has been skipped
2026-08-22 03:31:34 +00:00
317fc7371a Merge pull request 'Keep Week-to-Today pulls through offline reloads' (#1247) from timmy/1246-durable-week-today-pulls into main
All checks were successful
CI / lint (push) Successful in 3m10s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 4m56s
CI / release-candidate (push) Successful in 6s
2026-08-22 02:50:55 +00:00
c7d38593dd feat: keep Week-to-Today pulls through reloads (Closes #1246)
All checks were successful
CI / lint (pull_request) Successful in 3m7s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m2s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 02:41:29 +00:00
a13a13d90c Merge pull request 'Confirm Today capacity before pulling Week Ahead work' (#1245) from timmy/1244-capacity-confirm-week-pull into main
All checks were successful
CI / lint (push) Successful in 3m21s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m34s
CI / release-candidate (push) Successful in 6s
2026-08-22 01:43:15 +00:00
666671ee59 feat: confirm capacity before Week-to-Today pulls (Closes #1244)
All checks were successful
CI / lint (pull_request) Successful in 3m11s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m18s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 01:33:04 +00:00
d01f502e70 Merge pull request 'Pull one Week Ahead item into an active Today plan' (#1243) from timmy/1242-week-item-to-today into main
All checks were successful
CI / lint (push) Successful in 3m25s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m26s
CI / release-candidate (push) Successful in 8s
2026-08-22 00:24:40 +00:00
3fd5983df6 feat: pull Week Ahead work into Today (Closes #1242)
All checks were successful
CI / lint (pull_request) Successful in 3m22s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m11s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 00:10:39 +00:00
6afdad6fe4 Merge pull request 'Remove active work from Week Ahead with Undo' (#1241) from timmy/1240-week-ahead-unplan-undo into main
All checks were successful
CI / lint (push) Successful in 3m11s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m10s
CI / release-candidate (push) Successful in 6s
2026-08-21 22:59:20 +00:00
449ab78991 feat: unplan Week Ahead work with undo (Closes #1240)
All checks were successful
CI / lint (pull_request) Successful in 3m58s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m20s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 22:45:15 +00:00
95887d7493 Merge pull request 'Rotate shared private-state encryption keys without downtime' (#1238)
All checks were successful
CI / lint (push) Successful in 3m19s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m17s
CI / release-candidate (push) Successful in 6s
Closes #1237
2026-08-21 21:37:25 +00:00
965a73ab49 feat: rotate shared private-state keys (Closes #1237)
All checks were successful
CI / lint (pull_request) Successful in 3m24s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m2s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 21:12:54 +00:00
067fb76ebd Merge pull request 'Retire completed work from Week Ahead' (#1236) from timmy/1235-retire-completed-week-work into main
All checks were successful
CI / lint (push) Successful in 3m26s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m17s
CI / release-candidate (push) Successful in 7s
2026-08-21 20:30:03 +00:00
d47205a561 test: verify mobile Week Ahead retirement
All checks were successful
CI / lint (pull_request) Successful in 3m10s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m4s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 20:19:20 +00:00
1be9da3685 feat: retire completed Week Ahead work (Closes #1235) 2026-08-21 20:12:48 +00:00
a8c1329676 Merge pull request 'Queue multiple offline Today-to-Week moves' (#1234) from timmy/1233-queue-offline-reschedules into main
All checks were successful
CI / lint (push) Successful in 3m8s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m4s
CI / release-candidate (push) Successful in 7s
2026-08-21 19:12:01 +00:00
d63765a9b1 feat: queue offline Today-to-Week moves (Closes #1233)
All checks were successful
CI / lint (pull_request) Successful in 3m20s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m10s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 19:02:30 +00:00
c87d56c939 Merge pull request 'Keep Today-to-Week rescheduling through offline reloads' (#1232)
All checks were successful
CI / lint (push) Successful in 3m10s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m20s
CI / release-candidate (push) Successful in 7s
2026-08-21 18:27:28 +00:00
e234d6b6c3 feat: keep Today-to-Week moves through offline reloads (Closes #1231)
All checks were successful
CI / lint (pull_request) Successful in 3m21s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 4m57s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 18:18:14 +00:00
edaeba4a4e Merge pull request 'Reschedule an active Today item into Week Ahead' (#1229) from timmy/1228-reschedule-today-to-week into main
All checks were successful
CI / lint (push) Successful in 3m16s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 4m47s
CI / release-candidate (push) Successful in 7s
2026-08-21 17:13:00 +00:00
9d8a362583 feat: reschedule active Today work into Week Ahead (Closes #1228)
All checks were successful
CI / lint (pull_request) Successful in 3m33s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 4m57s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 16:59:40 +00:00
c63df3c46d Merge pull request 'Reflow overloaded Week Ahead plans' (#1227) from timmy/1226-week-ahead-reflow into main
All checks were successful
CI / lint (push) Successful in 3m31s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m38s
CI / release-candidate (push) Successful in 7s
2026-08-21 15:49:07 +00:00
ffba6f1212 feat: reflow overloaded Week Ahead plans (Closes #1226)
All checks were successful
CI / lint (pull_request) Successful in 3m55s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m47s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 15:34:31 +00:00
b7dd9757aa Merge pull request 'Start the next Week Ahead day early from mobile' (#1225) from timmy/1224-start-week-day-early into main
All checks were successful
CI / lint (push) Successful in 3m49s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m26s
CI / release-candidate (push) Successful in 10s
Start the next Week Ahead day early from mobile (#1225)
2026-08-21 14:48:16 +00:00
f64ce3acc4 feat: start Week Ahead days early (Closes #1224)
All checks were successful
CI / lint (pull_request) Successful in 3m53s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m38s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 14:37:50 +00:00
25597d1f05 Merge pull request 'Open Week Ahead planned work and return to the overview' (#1223) from timmy/1222-week-ahead-item-detail into main
All checks were successful
CI / lint (push) Successful in 3m50s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 5m38s
CI / release-candidate (push) Successful in 10s
2026-08-21 13:50:37 +00:00
9f5baaef6e feat: open Week Ahead planned work (Closes #1222)
All checks were successful
CI / lint (pull_request) Successful in 3m47s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m53s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 13:38:48 +00:00
a3861da125 Preserve Search-planned work details in offline Week Ahead (#1221)
All checks were successful
CI / lint (push) Successful in 3m52s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 5m31s
CI / release-candidate (push) Successful in 8s
Closes #1220
2026-08-21 12:27:32 +00:00
2b874d8427 feat: preserve Search work details offline (Closes #1220)
All checks were successful
CI / lint (pull_request) Successful in 3m46s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m16s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 12:03:25 +00:00
ba50784022 Merge pull request 'Keep the confirmed Week Ahead overview available offline' (#1219) from timmy/1218-offline-week-overview into main
All checks were successful
CI / lint (push) Successful in 3m39s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m6s
CI / release-candidate (push) Successful in 7s
2026-08-21 11:08:47 +00:00
37950bd0ed feat: keep Week Ahead available offline (Closes #1218)
All checks were successful
CI / lint (pull_request) Successful in 3m40s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 10:43:59 +00:00
bb6a4c4305 Merge pull request 'Make Week Ahead a read-first mobile overview' (#1217) from timmy/1216-week-ahead-overview into main
All checks were successful
CI / lint (push) Successful in 3m44s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 4m57s
CI / release-candidate (push) Successful in 8s
2026-08-21 09:31:56 +00:00
42e97b4a79 feat: add read-first Week Ahead overview (Closes #1216)
All checks were successful
CI / lint (pull_request) Successful in 3m37s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m19s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 09:07:27 +00:00
45e2798d37 Merge pull request 'Update repeated Week Ahead calendar exports without duplicate blocks' (#1215)
All checks were successful
CI / lint (push) Successful in 3m37s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 4m59s
CI / release-candidate (push) Successful in 9s
Closes #1214
2026-08-21 07:39:04 +00:00
218dd43764 feat: update repeated Week Ahead calendar exports (Closes #1214)
All checks were successful
CI / lint (pull_request) Successful in 3m45s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m2s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 07:29:23 +00:00
4d9c936b86 Merge pull request 'Make Week Ahead calendar import timezone-correct and range-bounded' (#1213) from timmy/1212-week-calendar-timezone into main
All checks were successful
CI / lint (push) Successful in 3m30s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m2s
CI / release-candidate (push) Successful in 9s
2026-08-21 06:57:16 +00:00
69a3a2c568 feat: make calendar import timezone-correct and bounded (Closes #1212)
All checks were successful
CI / lint (pull_request) Successful in 3m47s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m8s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 06:43:44 +00:00
7a9347bd2a Merge pull request 'Avoid imported busy time in Week Ahead calendar export' (#1211) from timmy/1210-week-calendar-availability into main
All checks were successful
CI / lint (push) Successful in 3m54s
CI / build-release (push) Successful in 9s
CI / browser-journey (push) Successful in 5m35s
CI / release-candidate (push) Successful in 12s
2026-08-21 06:00:10 +00:00
4e275b1518 feat: avoid imported busy time in Week Ahead export (Closes #1210)
All checks were successful
CI / lint (pull_request) Successful in 3m17s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 4m38s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 05:50:23 +00:00
865083a835 Merge pull request 'Make Week Ahead calendar import recurrence-aware and truthful' (#1209) from timmy/1208-week-calendar-recurrence into main
All checks were successful
CI / lint (push) Successful in 3m42s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m3s
CI / release-candidate (push) Successful in 7s
2026-08-21 05:03:41 +00:00
15be0ec026 test: cover private recurrence additions on mobile
All checks were successful
CI / lint (pull_request) Successful in 3m26s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 4m55s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 04:53:57 +00:00
ab74426cbc test: validate truthful mobile calendar import gating
Some checks failed
CI / lint (pull_request) Successful in 3m51s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Failing after 5m18s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 04:42:06 +00:00
f1adffef6b feat: make calendar capacity recurrence-aware (Closes #1208)
Some checks failed
CI / lint (pull_request) Successful in 4m1s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Failing after 5m15s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 04:27:11 +00:00
359d4337eb Merge pull request 'Set Week Ahead capacity from a private calendar import' (#1207) from timmy/1206-week-calendar-capacity into main
All checks were successful
CI / lint (push) Successful in 4m10s
CI / build-release (push) Successful in 10s
CI / browser-journey (push) Successful in 5m37s
CI / release-candidate (push) Successful in 8s
2026-08-21 03:28:00 +00:00
e77ad7c809 feat: import private calendar capacity for Week Ahead (Closes #1206)
All checks were successful
CI / lint (pull_request) Successful in 4m1s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m19s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 03:16:40 +00:00
aa46196d1e Merge pull request 'fix: restore Today break after sync recovery' (#1205) from timmy/1204-today-break-recovery into main
All checks were successful
CI / lint (push) Successful in 3m37s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 4m48s
CI / release-candidate (push) Successful in 7s
2026-08-21 02:17:28 +00:00
e5ccfb0c5e fix: restore Today break after sync recovery (Closes #1204)
All checks were successful
CI / lint (pull_request) Successful in 3m57s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m17s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 01:56:04 +00:00
e3c7df25c8 Merge pull request 'feat: CI regression: mobile Today handoff content is obscured on 568px viewport' (#1203) from timmy/1202-ci-regression-mobile-today-handoff-content-is-ob into main
Some checks failed
CI / lint (push) Successful in 3m30s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Failing after 4m57s
CI / release-candidate (push) Has been skipped
2026-08-21 01:03:10 +00:00
f11fc17a2c Fix mobile Today handoff clearance (#1202)
All checks were successful
CI / lint (pull_request) Successful in 3m27s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 4m37s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 00:42:07 +00:00
ecc79ce50f Merge pull request 'Reconcile unfinished Today with due Week Ahead work' (#1201) from timmy/1200-reconcile-week-today into main
Some checks failed
CI / lint (push) Successful in 3m17s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Failing after 4m23s
CI / release-candidate (push) Has been skipped
2026-08-21 00:18:01 +00:00
a10592c349 feat: reconcile Today with due Week Ahead work (Closes #1200)
All checks were successful
CI / lint (pull_request) Successful in 3m32s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 4m51s
CI / release-candidate (pull_request) Has been skipped
2026-08-21 00:08:43 +00:00
5ac90d01e6 Merge pull request 'Export Week Ahead as timed calendar blocks' (#1199) from timmy/1198-week-calendar-blocks into main
All checks were successful
CI / lint (push) Successful in 3m18s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 4m41s
CI / release-candidate (push) Successful in 7s
2026-08-20 23:24:36 +00:00
dd3016b96b feat: export Week Ahead as calendar blocks (Closes #1198)
All checks were successful
CI / lint (pull_request) Successful in 3m18s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 4m38s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 23:12:28 +00:00
875f4eea29 Merge pull request 'Batch-plan Search results into Week Ahead' (#1197)
All checks were successful
CI / lint (push) Successful in 3m24s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 4m36s
CI / release-candidate (push) Successful in 8s
2026-08-20 22:11:15 +00:00
78839a50fe feat: batch-plan Search results into Week Ahead (Closes #1196)
All checks were successful
CI / lint (pull_request) Successful in 3m20s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 4m51s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 22:01:58 +00:00
f557f06bc5 Merge pull request 'Plan Search results directly into Week Ahead' (#1195) from timmy/1194-search-week-ahead into main
All checks were successful
CI / lint (push) Successful in 3m19s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 4m47s
CI / release-candidate (push) Successful in 7s
2026-08-20 21:07:52 +00:00
c8998eb1ba test: require Search Week Ahead browser coverage
All checks were successful
CI / lint (pull_request) Successful in 3m33s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 4m55s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 20:58:36 +00:00
b5b1a3b0d7 ci: exercise Search Week Ahead mobile sheet
Some checks failed
CI / lint (pull_request) Failing after 3m23s
CI / build-release (pull_request) Has been skipped
CI / browser-journey (pull_request) Has been skipped
CI / release-candidate (pull_request) Has been skipped
2026-08-20 20:51:34 +00:00
ea1b5a77f5 feat: plan Search results into Week Ahead (Closes #1194)
Some checks failed
CI / lint (pull_request) Successful in 3m30s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 4m48s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 20:39:53 +00:00
5c74051aab Merge pull request 'Remind from due Week Ahead plans' (#1193) from timmy/1192-week-ahead-start-day-reminder into main
All checks were successful
CI / lint (push) Successful in 3m19s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 4m22s
CI / release-candidate (push) Successful in 7s
2026-08-20 19:38:32 +00:00
daffa894d8 feat: remind from due Week Ahead plans (Closes #1192)
All checks were successful
CI / lint (pull_request) Successful in 3m35s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 4m28s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 19:28:32 +00:00
67a3285412 Merge pull request 'Make Week Ahead review readable and editable' (#1191) from timmy/1190-readable-editable-week-review into main
All checks were successful
CI / lint (push) Successful in 3m37s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 4m48s
CI / release-candidate (push) Successful in 7s
2026-08-20 18:42:52 +00:00
9f40a4c5ac feat: make Week Ahead review readable and editable (Closes #1190)
All checks were successful
CI / lint (pull_request) Successful in 3m21s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 4m38s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 18:33:37 +00:00
356f11b9c1 Merge pull request 'Review and rebalance Week Ahead before confirmation' (#1189) from timmy/1188-review-rebalance-week-ahead into main
All checks were successful
CI / lint (push) Successful in 3m22s
CI / build-release (push) Successful in 10s
CI / browser-journey (push) Successful in 5m27s
CI / release-candidate (push) Successful in 9s
Merge pull request 'Review and rebalance Week Ahead before confirmation' (#1189)
2026-08-20 17:46:31 +00:00
d5f13c4afb feat: review and rebalance Week Ahead (Closes #1188)
All checks were successful
CI / lint (pull_request) Successful in 3m31s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 4m48s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 17:36:02 +00:00
69532bdc1f Merge pull request 'Plan Week Ahead in one continuous mobile pass' (#1187) from timmy/1186-continuous-week-ahead into main
All checks were successful
CI / lint (push) Successful in 3m31s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 4m36s
CI / release-candidate (push) Successful in 8s
2026-08-20 16:45:31 +00:00
55e1c3d6dd feat: plan Week Ahead in one mobile pass (Closes #1186)
All checks were successful
CI / lint (pull_request) Successful in 4m5s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 4m39s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 16:30:45 +00:00
6718160381 Merge pull request 'Reconcile concurrent Week Ahead edits per day' (#1185) from timmy/1184-reconcile-week-ahead-per-day into main
All checks were successful
CI / lint (push) Successful in 3m17s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 4m35s
CI / release-candidate (push) Successful in 8s
2026-08-20 15:54:53 +00:00
e0f7c711ed feat: reconcile Week Ahead edits per day (Closes #1184)
All checks were successful
CI / lint (pull_request) Successful in 3m52s
CI / build-release (pull_request) Successful in 10s
CI / browser-journey (pull_request) Successful in 5m36s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 15:43:35 +00:00
43066a0822 Merge pull request 'Preserve Today before Week Ahead rollover' (#1183) from timmy/1182-preserve-today-before-week-rollover into main
All checks were successful
CI / lint (push) Successful in 3m29s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 4m31s
CI / release-candidate (push) Successful in 7s
2026-08-20 14:44:23 +00:00
f0706e2e6f feat: preserve Today before Week Ahead rollover (Closes #1182)
All checks were successful
CI / lint (pull_request) Successful in 3m25s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m37s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 14:33:15 +00:00
3edc4e36b3 Merge pull request 'Keep Week Ahead plans through offline saves' (#1181) from timmy/1180-offline-week-ahead-sync into main
All checks were successful
CI / lint (push) Successful in 3m35s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 4m44s
CI / release-candidate (push) Successful in 7s
2026-08-20 13:57:04 +00:00
47cad77312 feat: keep Week Ahead plans through offline saves (Closes #1180)
All checks were successful
CI / lint (pull_request) Successful in 3m20s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 4m19s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 13:40:33 +00:00
99a2f28440 Merge pull request 'feat: Plan the next seven days from mobile' (#1179) from timmy/1177-plan-the-next-seven-days-from-mobile into main
All checks were successful
CI / lint (push) Successful in 3m29s
CI / build-release (push) Successful in 9s
CI / browser-journey (push) Successful in 4m24s
CI / release-candidate (push) Successful in 7s
2026-08-20 13:01:21 +00:00
53c4340d76 fix: tolerate invalid device timezones in Week Ahead (Closes #1177)
All checks were successful
CI / lint (pull_request) Successful in 3m19s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 4m30s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 12:26:35 +00:00
5a9cce4a08 test: surface Week Ahead browser launch errors (Refs #1177)
Some checks failed
CI / lint (pull_request) Successful in 3m33s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 4m33s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 12:07:09 +00:00
4fbc9c62d9 fix: allow Week Ahead while identity finishes restoring (Refs #1177)
Some checks failed
CI / lint (pull_request) Successful in 3m28s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 4m52s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 11:53:45 +00:00
a1b4698988 fix: open Week Ahead before account refresh (Refs #1177)
Some checks failed
CI / lint (pull_request) Successful in 3m27s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 4m41s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 11:41:04 +00:00
e01681af09 feat: plan the next seven days on mobile (Closes #1177)
Some checks failed
CI / lint (pull_request) Successful in 3m43s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Failing after 4m21s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 11:28:58 +00:00
880d1c4a1f Merge pull request 'Stabilize the release gate for the Tomorrow wrap-up handoff' (#1176) from timmy/1174-today-wrap-up-tomorrow-handoff-rc into main
Some checks failed
CI / lint (push) Successful in 3m28s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Failing after 4m10s
CI / release-candidate (push) Has been skipped
2026-08-20 10:24:38 +00:00
7c1292faa3 test: isolate first-task release probe (Refs #1174)
All checks were successful
CI / lint (pull_request) Successful in 3m22s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 4m4s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 10:12:57 +00:00
286e691fd2 Merge pull request 'Carry unfinished Today work into the Tomorrow plan' (#1175) from timmy/1174-today-wrap-up-tomorrow-handoff into main
Some checks failed
CI / lint (push) Successful in 3m27s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Failing after 4m5s
CI / release-candidate (push) Has been skipped
2026-08-20 09:58:30 +00:00
c99d03b9bc feat: carry wrap-up work into Tomorrow (Closes #1174)
All checks were successful
CI / lint (pull_request) Successful in 3m38s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 3m56s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 09:49:08 +00:00
58c2d3f5d8 Merge pull request 'Hand off Today lock-screen actions without URL capabilities' (#1173) from timmy/1172-private-today-action-handoff into main
All checks were successful
CI / lint (push) Successful in 3m18s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 3m55s
CI / release-candidate (push) Successful in 7s
2026-08-20 08:16:45 +00:00
988f6646c2 feat: hand off Today lock-screen actions privately (Closes #1172)
All checks were successful
CI / lint (pull_request) Successful in 3m4s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 4m2s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 08:02:45 +00:00
f56ca6373c Merge pull request 'Gate packaged mobile Tomorrow conflict recovery' (#1171) from timmy/1170-tomorrow-release-gate into main
All checks were successful
CI / lint (push) Successful in 2m56s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 3m41s
CI / release-candidate (push) Successful in 6s
2026-08-20 07:36:50 +00:00
3cfe6b9f75 ci: gate packaged Tomorrow conflict recovery (Closes #1170)
All checks were successful
CI / lint (pull_request) Successful in 3m14s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 3m50s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 07:28:40 +00:00
65180cbb94 Merge pull request 'Make start-day reminders lease-safe under fan-out' (#1169) from timmy/1168-start-day-lease-safe-fanout into main
All checks were successful
CI / lint (push) Successful in 3m12s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 3m44s
CI / release-candidate (push) Successful in 7s
Bound start-day reminder fan-out with configured concurrency and renew lease ownership before each admitted send.

Closes #1168
2026-08-20 06:46:16 +00:00
928184c581 feat: make start-day reminders lease-safe (Closes #1168)
All checks were successful
CI / lint (pull_request) Successful in 3m24s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 3m43s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 06:38:13 +00:00
e8b78de1a5 Merge pull request 'Launch planned days with a private morning reminder' (#1167) from timmy/1166-start-day-reminder into main
All checks were successful
CI / lint (push) Successful in 3m30s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 3m52s
CI / release-candidate (push) Successful in 9s
2026-08-20 06:01:50 +00:00
6fb547e8ce fix: preserve synchronous Today reconciliation
All checks were successful
CI / lint (pull_request) Successful in 3m3s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 3m40s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 05:48:08 +00:00
d86e84253a feat: launch planned days with morning reminders (Closes #1166)
Some checks failed
CI / lint (pull_request) Successful in 3m18s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Failing after 3m39s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 05:32:50 +00:00
99b60e5f5f Merge pull request 'Resolve offline Tomorrow plan conflicts on mobile' (#1165) from timmy/1164-tomorrow-conflict-recovery into main
All checks were successful
CI / lint (push) Successful in 3m28s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 3m58s
CI / release-candidate (push) Successful in 8s
2026-08-20 04:12:33 +00:00
059722de6c fix: bind Tomorrow conflict recovery to operator account
All checks were successful
CI / lint (pull_request) Successful in 3m36s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 3m50s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 04:01:25 +00:00
16a6ee1136 feat: resolve mobile Tomorrow plan conflicts (Closes #1164)
Some checks failed
CI / lint (pull_request) Successful in 3m26s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 3m49s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 03:46:22 +00:00
f9c4938401 Merge pull request 'feat: CI regression: mobile handoff break status assertion fails on main' (#1163) from timmy/1162-ci-regression-mobile-handoff-break-status-assert into main
All checks were successful
CI / lint (push) Successful in 3m14s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 3m54s
CI / release-candidate (push) Successful in 8s
2026-08-20 03:09:59 +00:00
bdf1ddaada fix: restore break status after account confirmation
All checks were successful
CI / lint (pull_request) Successful in 3m32s
CI / build-release (pull_request) Successful in 10s
CI / browser-journey (pull_request) Successful in 4m9s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 02:48:56 +00:00
cf94624aa9 Merge pull request 'Keep Tomorrow plans through offline saves' (#1161) from timmy/1160-offline-tomorrow-sync into main
Some checks failed
CI / lint (push) Successful in 4m11s
CI / build-release (push) Successful in 9s
CI / browser-journey (push) Failing after 4m29s
CI / release-candidate (push) Has been skipped
2026-08-20 02:19:59 +00:00
f42f89ca21 feat: keep Tomorrow plans through offline saves (Closes #1160)
All checks were successful
CI / lint (pull_request) Successful in 3m17s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 4m5s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 02:10:37 +00:00
ae632e3562 Merge pull request 'Make Tomorrow planning a first-class mobile queue' (#1159) from timmy/1158-mobile-tomorrow-queue into main
All checks were successful
CI / lint (push) Successful in 3m26s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 4m0s
CI / release-candidate (push) Successful in 7s
2026-08-20 01:29:18 +00:00
0fd6314391 feat: make Tomorrow a first-class mobile queue (Closes #1158)
All checks were successful
CI / lint (pull_request) Successful in 3m7s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 3m53s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 01:20:27 +00:00
1ae49df1b0 Merge pull request 'feat: CI: browser journey leaves first-task completion sync pending on main' (#1157) from timmy/1156-ci-browser-journey-leaves-first-task-completion- into main
All checks were successful
CI / lint (push) Successful in 3m22s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 4m1s
CI / release-candidate (push) Successful in 7s
2026-08-20 00:46:35 +00:00
16841f18e1 fix: drain first-task completion during Today refresh (Closes #1156)
All checks were successful
CI / lint (pull_request) Successful in 3m15s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 3m49s
CI / release-candidate (pull_request) Has been skipped
2026-08-20 00:19:50 +00:00
80863182d4 Merge pull request 'Recover Tomorrow plans after missed midnight and interrupted promotion' (#1155) from timmy/1154-tomorrow-rollover-recovery into main
Some checks failed
CI / lint (push) Successful in 3m14s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Failing after 4m30s
CI / release-candidate (push) Has been skipped
2026-08-19 23:56:19 +00:00
7c2e9861d7 fix: tolerate unavailable browser timezone data
All checks were successful
CI / lint (pull_request) Successful in 3m25s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 4m2s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 23:39:34 +00:00
7deb1c1de9 fix: recover Tomorrow promotion after missed rollover (Closes #1154)
Some checks failed
CI / lint (pull_request) Successful in 3m39s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Failing after 3m50s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 23:27:07 +00:00
559fd2dc89 Merge pull request 'Plan tomorrow without disrupting active Today work' (#1153) from timmy/1152-tomorrow-plan into main
All checks were successful
CI / lint (push) Successful in 3m14s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 4m6s
CI / release-candidate (push) Successful in 8s
2026-08-19 22:56:33 +00:00
0cb7328aa1 feat: plan tomorrow independently (Closes #1152)
All checks were successful
CI / lint (pull_request) Successful in 3m21s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 3m53s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 22:46:56 +00:00
664e5e999f Merge pull request 'Make Done for Today completion durably atomic' (#1151) from timmy/1150-atomic-today-completion into main
All checks were successful
CI / lint (push) Successful in 3m47s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 3m29s
CI / release-candidate (push) Successful in 7s
2026-08-19 22:00:26 +00:00
37e919f43f fix: preserve first-task completion delivery
All checks were successful
CI / lint (pull_request) Successful in 3m9s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 3m36s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 21:52:50 +00:00
d0780a72a7 feat: make Today completion durably atomic (Closes #1150)
Some checks failed
CI / lint (pull_request) Successful in 3m0s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 3m30s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 21:39:03 +00:00
f785134dfe Merge pull request 'Keep first-task completion across devices' (#1149) from timmy/1148-cross-device-first-task into main
All checks were successful
CI / lint (push) Successful in 3m17s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 3m48s
CI / release-candidate (push) Successful in 7s
2026-08-19 20:51:35 +00:00
de405efdbc test: verify first-task completion on a replacement phone
All checks were successful
CI / lint (pull_request) Successful in 3m18s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 3m43s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 20:44:03 +00:00
fa47ded3ab feat: sync first-task completion across devices (Closes #1148)
Some checks failed
CI / lint (pull_request) Successful in 3m6s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 3m27s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 20:34:10 +00:00
e2be14d7c7 Merge pull request 'Guide first mobile task through a completed Today outcome' (#1147) from timmy/1146-first-task-completed-outcome into main
All checks were successful
CI / lint (push) Successful in 3m9s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 3m55s
CI / release-candidate (push) Successful in 8s
Merge pull request 'Guide first mobile task through a completed Today outcome' (#1147)
2026-08-19 19:44:43 +00:00
e2b5f6042c feat: coach first mobile task to completion (Closes #1146)
All checks were successful
CI / lint (pull_request) Successful in 3m8s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 3m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 19:34:31 +00:00
941a508d4b Finish first-task onboarding with an active Today session
All checks were successful
CI / lint (push) Successful in 3m6s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 3m31s
CI / release-candidate (push) Successful in 7s
Closes #1144
2026-08-19 18:59:13 +00:00
ebb7a78cab feat: finish onboarding with active Today (Closes #1144)
All checks were successful
CI / lint (pull_request) Successful in 2m48s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 4m3s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 18:51:15 +00:00
b4a5401b22 Merge pull request 'Preserve timed break across reload' (#1143)
All checks were successful
CI / lint (push) Successful in 3m3s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 4m2s
CI / release-candidate (push) Successful in 6s
Closes #1142
2026-08-19 18:32:30 +00:00
870edda123 Preserve timed break when active work reopens
All checks were successful
CI / lint (pull_request) Successful in 2m55s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 3m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 18:08:35 +00:00
156d4b0ffc Merge pull request 'Guide new mobile operators to their first task' (#1141) from timmy/1140-first-task-activation into main
Some checks failed
CI / lint (push) Successful in 2m54s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Failing after 3m36s
CI / release-candidate (push) Has been skipped
2026-08-19 17:41:31 +00:00
304e4a8a24 feat: guide first mobile task activation (Closes #1140)
All checks were successful
CI / lint (pull_request) Successful in 3m8s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 3m29s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 17:33:26 +00:00
8731682090 Merge pull request 'Pause Today while checking Live Data Status' (#1139) from timmy/1138-live-data-status-detour into main
All checks were successful
CI / lint (push) Successful in 3m13s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 3m29s
CI / release-candidate (push) Successful in 7s
2026-08-19 16:44:43 +00:00
348f43f1bc feat: pause Today for live data status (Closes #1138)
All checks were successful
CI / lint (pull_request) Successful in 3m8s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 3m23s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 16:35:09 +00:00
161563d6f5 Merge pull request 'Contain focus inside mobile Security Center' (#1137)
All checks were successful
CI / lint (push) Successful in 3m10s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 3m25s
CI / release-candidate (push) Successful in 7s
Closes #1136
2026-08-19 15:45:00 +00:00
931c82361b feat: contain Security Center focus (Closes #1136)
All checks were successful
CI / lint (pull_request) Successful in 2m55s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 3m22s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 15:34:47 +00:00
61b332e814 Merge pull request 'Contain focus inside mobile Device Setup' (#1135) from timmy/1134-device-setup-focus-containment into main
All checks were successful
CI / lint (push) Successful in 2m59s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 3m21s
CI / release-candidate (push) Successful in 7s
2026-08-19 14:44:26 +00:00
4b3e53f4d0 feat: contain mobile Device Setup focus (Closes #1134)
All checks were successful
CI / lint (pull_request) Successful in 2m54s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 3m24s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 14:31:38 +00:00
f3c9e89920 Merge pull request 'Recover mobile notification setup after permission is blocked' (#1133) from timmy/1132-notification-permission-recovery into main
Some checks failed
CI / lint (push) Successful in 2m58s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Failing after 3m23s
CI / release-candidate (push) Has been skipped
2026-08-19 13:43:48 +00:00
1248647aa4 feat: recover blocked notification setup (Closes #1132)
All checks were successful
CI / lint (pull_request) Successful in 2m52s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 3m22s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 13:36:20 +00:00
b3832d107f Merge pull request 'Post reviewed Today summaries to a Gitea status thread' (#1131) from timmy/1130-post-today-summary into main
All checks were successful
CI / lint (push) Successful in 2m56s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 3m23s
CI / release-candidate (push) Successful in 6s
2026-08-19 12:45:00 +00:00
a0da8e9fb2 feat: post reviewed Today summaries to Gitea (Closes #1130)
All checks were successful
CI / lint (pull_request) Successful in 2m51s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 3m20s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 12:36:46 +00:00
8794200c2a Merge pull request 'Protect private work before mobile sign-out' (#1129) from timmy/1128-safe-sign-out-review into main
All checks were successful
CI / lint (push) Successful in 2m55s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 3m20s
CI / release-candidate (push) Successful in 6s
2026-08-19 11:45:23 +00:00
c2526969b1 feat: protect private work before sign out (Closes #1128)
All checks were successful
CI / lint (pull_request) Successful in 2m53s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 3m16s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 11:37:27 +00:00
3b4f6b39a5 Merge pull request 'Encrypt synchronized Saved Searches at rest' (#1127) from timmy/1126-encrypt-saved-searches into main
All checks were successful
CI / lint (push) Successful in 2m43s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 3m2s
CI / release-candidate (push) Successful in 6s
2026-08-19 11:03:52 +00:00
a9cf88b3ff security: encrypt synchronized Saved Searches (Closes #1126)
All checks were successful
CI / lint (pull_request) Successful in 2m49s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 3m12s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 10:57:01 +00:00
3d4be0a913 Merge pull request 'Recover mobile workspace loading in place' (#1125) from timmy/1124-workspace-load-recovery into main
All checks were successful
CI / lint (push) Successful in 2m36s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m58s
CI / release-candidate (push) Successful in 6s
2026-08-19 10:21:01 +00:00
9252807364 fix: recover mobile workspace loading in place (Closes #1124)
All checks were successful
CI / lint (pull_request) Successful in 2m50s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 3m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 10:10:52 +00:00
db85bfa5f9 Merge pull request 'Encrypt authored-action idempotency ledger at rest' (#1123)
All checks were successful
CI / lint (push) Successful in 2m44s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m55s
CI / release-candidate (push) Successful in 6s
Closes #1122
2026-08-19 09:35:55 +00:00
3822ec4536 security: encrypt idempotency ledger at rest (Closes #1122)
All checks were successful
CI / lint (pull_request) Successful in 2m49s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 2m57s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 09:29:16 +00:00
b278367e3b Merge pull request 'Verify Web Push delivery health per device' (#1121) from timmy/1120-push-delivery-health into main
All checks were successful
CI / lint (push) Successful in 2m47s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 3m10s
CI / release-candidate (push) Successful in 6s
2026-08-19 09:06:55 +00:00
13e73bd342 feat: verify per-device Push delivery health (Closes #1120)
All checks were successful
CI / lint (pull_request) Successful in 2m53s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 3m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 09:00:06 +00:00
1047f3ecdc Merge pull request 'Encrypt Security activity journal at rest' (#1119) from timmy/1118-encrypt-security-activity into main
All checks were successful
CI / lint (push) Successful in 2m39s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m53s
CI / release-candidate (push) Successful in 7s
2026-08-19 07:49:14 +00:00
f09c922c46 test: model admitted blocker receipt deterministically
All checks were successful
CI / lint (pull_request) Successful in 2m38s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m56s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 07:42:46 +00:00
be9ce8ede6 security: remove plaintext journal metadata columns 2026-08-19 07:37:35 +00:00
7f2681c1c6 security: encrypt Security activity journal (Closes #1118)
Some checks failed
CI / lint (pull_request) Successful in 2m39s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 3m7s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 07:25:49 +00:00
91fdd958c2 Merge pull request 'Fetch the mobile workspace concurrently with the core runtime' (#1117) from timmy/1116-parallel-workspace-fetch into main
All checks were successful
CI / lint (push) Successful in 2m51s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m55s
CI / release-candidate (push) Successful in 6s
2026-08-19 06:52:25 +00:00
b70a2fb607 test: accept queued or delivered blocker receipt
All checks were successful
CI / lint (pull_request) Successful in 2m33s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 3m5s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 06:46:05 +00:00
950d52d147 perf: parallelize mobile workspace fetch (Closes #1116)
Some checks failed
CI / lint (pull_request) Successful in 2m50s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 2m56s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 06:25:26 +00:00
d054cd23ed Merge pull request 'Encrypt synchronized Later plans at rest' (#1115) from timmy/1114-encrypt-later-state into main
All checks were successful
CI / lint (push) Successful in 2m45s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m39s
CI / release-candidate (push) Successful in 6s
2026-08-19 05:38:47 +00:00
f4d8d64b9d security: encrypt synchronized Later state (Closes #1114)
All checks were successful
CI / lint (pull_request) Successful in 2m43s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 2m51s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 05:32:20 +00:00
9763bb410b Merge pull request 'Encrypt synchronized Today journeys at rest' (#1113) from timmy/1112-encrypt-today-state into main
All checks were successful
CI / lint (push) Successful in 2m33s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m40s
CI / release-candidate (push) Successful in 6s
2026-08-19 04:41:16 +00:00
db3ca83f29 security: encrypt synchronized Today state (Closes #1112)
All checks were successful
CI / lint (pull_request) Successful in 2m36s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m43s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 04:34:55 +00:00
c219f130c8 Merge pull request 'Protect offline work from browser storage eviction' (#1111) from timmy/1110-protect-offline-storage into main
All checks were successful
CI / lint (push) Successful in 2m52s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m54s
CI / release-candidate (push) Successful in 7s
2026-08-19 03:43:54 +00:00
dc4f457b21 feat: protect offline work from storage eviction (Closes #1110)
All checks were successful
CI / lint (pull_request) Successful in 2m47s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m52s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 03:36:48 +00:00
9a471b7903 Merge pull request 'Encrypt Web Push subscriptions at rest' (#1109) from timmy/1108-encrypt-push-subscriptions into main
All checks were successful
CI / lint (push) Successful in 2m38s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m46s
CI / release-candidate (push) Successful in 6s
2026-08-19 02:41:03 +00:00
67d3242f37 security: encrypt Web Push subscriptions at rest (Closes #1108)
All checks were successful
CI / lint (pull_request) Successful in 2m44s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 2m47s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 02:34:32 +00:00
fb08bc5ce5 Merge pull request 'Encrypt worker-shared dashboard snapshots at rest' (#1107) from timmy/1106-encrypt-worker-snapshots into main
All checks were successful
CI / lint (push) Successful in 2m52s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m48s
CI / release-candidate (push) Successful in 7s
2026-08-19 02:04:52 +00:00
9d9b28af8e security: encrypt worker-shared snapshots (Closes #1106)
All checks were successful
CI / lint (pull_request) Successful in 2m48s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m55s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 01:58:22 +00:00
8ad02933c9 Merge pull request 'Review and resume snoozed mobile deadline reminders' (#1105) from timmy/1104-review-snoozed-deadlines into main
All checks were successful
CI / lint (push) Successful in 2m49s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m50s
CI / release-candidate (push) Successful in 7s
Review and resume snoozed mobile deadline reminders (#1105)
2026-08-19 01:09:49 +00:00
d5dc3f27db feat: review snoozed deadline reminders (Closes #1104)
All checks were successful
CI / lint (pull_request) Successful in 2m46s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m47s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 01:02:06 +00:00
625d937f64 Merge pull request 'Snooze mobile deadline reminders from the lock screen' (#1103) from timmy/1102-deadline-reminder-snooze into main
All checks were successful
CI / lint (push) Successful in 2m39s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 2m45s
CI / release-candidate (push) Successful in 6s
2026-08-19 00:08:03 +00:00
2bd20130b6 feat: snooze mobile deadline reminders (Closes #1102)
All checks were successful
CI / lint (pull_request) Successful in 2m39s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m58s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 00:01:38 +00:00
fdf6ae3ddd Merge pull request 'Rotate synchronized Draft encryption keys without downtime' (#1101) from timmy/1100-draft-key-rotation into main
All checks were successful
CI / lint (push) Successful in 2m31s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 2m42s
CI / release-candidate (push) Successful in 6s
2026-08-18 23:11:07 +00:00
fc9ef1f8ae feat: rotate synchronized Draft encryption keys (Closes #1100)
All checks were successful
CI / lint (pull_request) Successful in 2m39s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m35s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 23:04:30 +00:00
8cd1d1f684 Merge pull request 'Encrypt synchronized unfiled drafts at rest' (#1099) from timmy/1098-encrypt-unfiled-drafts into main
All checks were successful
CI / lint (push) Successful in 2m46s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 3m2s
CI / release-candidate (push) Successful in 7s
2026-08-18 22:27:53 +00:00
e79c457774 test: inject draft encryption key into release journey
All checks were successful
CI / lint (pull_request) Successful in 2m48s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m56s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 22:15:09 +00:00
d3f53b5a38 security: encrypt synchronized unfiled drafts (Closes #1098)
Some checks failed
CI / lint (pull_request) Successful in 2m58s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 2m53s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 22:07:02 +00:00
19a1184b1c Merge pull request 'Pin Web Push connections to validated public addresses' (#1097) from timmy/1096-pin-web-push-connections into main
All checks were successful
CI / lint (push) Successful in 2m46s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m54s
CI / release-candidate (push) Successful in 9s
2026-08-18 21:34:22 +00:00
ad40cb5fb9 fix: pin Web Push connections to validated addresses (Closes #1096)
All checks were successful
CI / lint (pull_request) Successful in 2m52s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m57s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 21:26:32 +00:00
a38934b099 Merge pull request 'Track multiple mobile merges through release' (#1095) from timmy/1094-release-watchlist into main
All checks were successful
CI / lint (push) Successful in 3m8s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 3m17s
CI / release-candidate (push) Successful in 8s
2026-08-18 20:49:38 +00:00
3cf33869d6 feat: track multiple mobile releases (Closes #1094)
All checks were successful
CI / lint (pull_request) Successful in 3m20s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 3m14s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 20:40:59 +00:00
41195f4c75 Merge pull request 'Track exact release progress after mobile merges' (#1093) from timmy/1092-mobile-release-receipt into main
All checks were successful
CI / lint (push) Successful in 3m5s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 3m2s
CI / release-candidate (push) Successful in 8s
2026-08-18 19:49:08 +00:00
6db0802461 feat: track exact post-merge release progress (Closes #1092)
All checks were successful
CI / lint (pull_request) Successful in 3m13s
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Successful in 3m28s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 19:41:00 +00:00
087300f9c3 Merge pull request 'Share a privacy-reviewed summary after mobile Today wrap-up' (#1091) from timmy/1090-mobile-today-summary into main
All checks were successful
CI / lint (push) Successful in 3m6s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 3m17s
CI / release-candidate (push) Successful in 8s
2026-08-18 19:01:10 +00:00
abc736d32e fix: isolate Today summary drafts by account
All checks were successful
CI / lint (pull_request) Successful in 3m1s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 3m7s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 18:53:21 +00:00
b3a359760c feat: share private Today summaries (Closes #1090)
Some checks failed
CI / lint (pull_request) Successful in 3m20s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 3m9s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 18:39:01 +00:00
5f86d48e6f Merge pull request 'Review and export mobile Agenda deadlines as a calendar snapshot' (#1089) from timmy/1088-agenda-calendar-export into main
All checks were successful
CI / lint (push) Successful in 3m2s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m55s
CI / release-candidate (push) Successful in 9s
2026-08-18 17:48:38 +00:00
1c17fcf337 feat: export Agenda calendar snapshots (Closes #1088)
All checks were successful
CI / lint (pull_request) Successful in 3m3s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 3m18s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 17:37:25 +00:00
1c6f5c685f Merge pull request 'Pause Today during mobile Security Center' (#1087) from timmy/1086-pause-today-security-center into main
All checks were successful
CI / lint (push) Successful in 3m17s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m42s
CI / release-candidate (push) Successful in 7s
2026-08-18 16:49:47 +00:00
473f592615 feat: pause Today during mobile Security Center (Closes #1086)
All checks were successful
CI / lint (pull_request) Successful in 3m27s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 2m59s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 16:41:59 +00:00
aba4b55ca0 Merge pull request 'Pause active Today timing during mobile Device Setup' (#1085) from timmy/1084-pause-today-device-setup into main
All checks were successful
CI / lint (push) Successful in 3m13s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 2m41s
CI / release-candidate (push) Successful in 10s
2026-08-18 15:39:39 +00:00
4a53a95b2d feat: pause Today during mobile device setup (Closes #1084)
All checks were successful
CI / lint (pull_request) Successful in 3m24s
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Successful in 3m0s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 15:30:55 +00:00
a6f34fbfde Merge pull request 'Pause active Today timing while reviewing mobile Insights' (#1083) from timmy/1082-pause-today-insights into main
All checks were successful
CI / lint (push) Successful in 3m15s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 3m0s
CI / release-candidate (push) Successful in 10s
2026-08-18 14:54:49 +00:00
472371d5a8 feat: pause Today during mobile Insights (Closes #1082)
All checks were successful
CI / lint (pull_request) Successful in 3m18s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 2m45s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 14:44:08 +00:00
d7b8cd5e71 Pause active Today timing across mobile Find Work and Queues (#1081)
All checks were successful
CI / lint (push) Successful in 3m8s
CI / build-release (push) Successful in 10s
CI / browser-journey (push) Successful in 2m47s
CI / release-candidate (push) Successful in 8s
Closes #1080
2026-08-18 14:02:05 +00:00
58cee5ea16 feat: pause Today during mobile work detours (Closes #1080)
All checks were successful
CI / lint (pull_request) Successful in 3m12s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 2m45s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 13:51:40 +00:00
38efd4eb7c Merge pull request 'Pause active Today timing during mobile Search' (#1079) from timmy/1078-pause-today-search into main
All checks were successful
CI / lint (push) Successful in 2m54s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 2m48s
CI / release-candidate (push) Successful in 9s
2026-08-18 12:54:06 +00:00
d4910b4398 feat: pause Today during mobile Search (Closes #1078)
All checks were successful
CI / lint (pull_request) Successful in 3m5s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 2m21s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 12:45:08 +00:00
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
All checks were successful
CI / lint (push) Successful in 3m13s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m35s
CI / release-candidate (push) Successful in 8s
2026-08-18 11:15:52 +00:00
f8c2541d30 feat: pause Today during quick capture (Closes #1076)
All checks were successful
CI / lint (pull_request) Successful in 3m23s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 2m35s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 11:06:44 +00:00
d9749e3a6f Compact the active Today HUD into a mobile command bar (#1075)
All checks were successful
CI / lint (push) Successful in 2m56s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m14s
CI / release-candidate (push) Successful in 7s
Closes #1074
2026-08-18 09:43:07 +00:00
b77be4f5b0 feat: compact active Today controls on mobile (Closes #1074)
All checks were successful
CI / lint (pull_request) Successful in 2m46s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m17s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 09:36:34 +00:00
fb7bfe0a5d Merge pull request 'Keep dashboard Gitea links on the configured forge origin' (#1073) from timmy/1072-configured-forge-link-boundary into main
All checks were successful
CI / lint (push) Successful in 2m41s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m9s
CI / release-candidate (push) Successful in 7s
2026-08-18 09:04:35 +00:00
79f6cecab4 fix: keep Gitea links on configured forge (Closes #1072)
All checks were successful
CI / lint (pull_request) Successful in 2m47s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m8s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 08:58:35 +00:00
0472e88d91 Merge pull request 'Open a focused blocker flow from the active Today HUD' (#1071)
All checks were successful
CI / lint (push) Successful in 2m43s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 2m12s
CI / release-candidate (push) Successful in 7s
2026-08-18 08:31:44 +00:00
f710b2ce1d feat: open focused blockers from Today HUD (Closes #1070)
All checks were successful
CI / lint (pull_request) Successful in 2m41s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m14s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 08:25:27 +00:00
027ded1f8f Merge pull request 'Recover admitted Today blockers across reloads' (#1069) from timmy/1068-recover-today-blocker-reload into main
All checks were successful
CI / lint (push) Successful in 2m42s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m7s
CI / release-candidate (push) Successful in 7s
2026-08-18 07:40:38 +00:00
3c8e2e2720 fix: recover Today blocker planning after reload (Closes #1068)
All checks were successful
CI / lint (pull_request) Successful in 2m42s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m6s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 07:27:30 +00:00
1bd87fc33e Merge pull request 'Report a Today blocker, defer it, and continue' (#1067) from timmy/1066-today-blocker-move-on into main
All checks were successful
CI / lint (push) Successful in 2m32s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m7s
CI / release-candidate (push) Successful in 6s
2026-08-18 06:47:24 +00:00
4009a4a4c1 feat: report Today blockers and move on (Closes #1066)
All checks were successful
CI / lint (pull_request) Successful in 2m41s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 2m7s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 06:38:55 +00:00
339baab7ee Merge pull request 'Mention teammates from active Today progress updates' (#1065) from timmy/1064-today-progress-mentions into main
All checks were successful
CI / lint (push) Successful in 2m37s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m2s
CI / release-candidate (push) Successful in 7s
2026-08-18 05:47:56 +00:00
a6c769901d feat: mention teammates from Today updates (Closes #1064)
All checks were successful
CI / lint (pull_request) Successful in 2m39s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m0s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 05:38:20 +00:00
a65aed5862 Merge pull request 'Correct or retract your own recent Today update in place' (#1063) from timmy/1062-correct-today-progress-update into main
All checks were successful
CI / lint (push) Successful in 2m35s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 1m48s
CI / release-candidate (push) Successful in 6s
2026-08-18 04:55:04 +00:00
8b4bd480cc feat: correct recent Today updates in place (Closes #1062)
All checks were successful
CI / lint (pull_request) Successful in 2m43s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m56s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 04:46:53 +00:00
9c46455c35 Merge pull request 'Review recent activity before posting a Today update' (#1061) from timmy/1060-today-progress-recent-activity into main
All checks were successful
CI / lint (push) Successful in 2m32s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m59s
CI / release-candidate (push) Successful in 7s
2026-08-18 04:06:40 +00:00
47626338ba feat: review activity before Today updates (Closes #1060)
All checks were successful
CI / lint (pull_request) Successful in 2m38s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m53s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 04:00:58 +00:00
e290293bf1 Merge pull request 'Dictate and review active Today progress updates' (#1059) from timmy/1058-today-progress-voice-dictation into main
All checks were successful
CI / lint (push) Successful in 2m44s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m4s
CI / release-candidate (push) Successful in 7s
2026-08-18 03:11:36 +00:00
688eadf17a feat: dictate active Today progress updates (Closes #1058)
All checks were successful
CI / lint (pull_request) Successful in 2m29s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 03:05:53 +00:00
daf34d7cdd Merge pull request 'Keep Today progress photo retries bound to the intended evidence' (#1057) from timmy/1056-today-progress-evidence-retries into main
All checks were successful
CI / lint (push) Successful in 2m39s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m3s
CI / release-candidate (push) Successful in 8s
2026-08-18 02:35:23 +00:00
1410a66a36 fix: bind Today progress retries to evidence (Closes #1056)
All checks were successful
CI / lint (pull_request) Successful in 2m45s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m2s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 02:29:13 +00:00
157d485685 Merge pull request 'Add photo evidence to active Today progress updates' (#1055) from timmy/1054-mobile-today-progress-photo-evidence into main
All checks were successful
CI / lint (push) Successful in 2m41s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m1s
CI / release-candidate (push) Successful in 6s
2026-08-18 01:48:47 +00:00
4f07402936 fix: preserve photo drafts while reopening Today progress
All checks were successful
CI / lint (pull_request) Successful in 2m45s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m5s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 01:42:05 +00:00
9a4a1711fe feat: add photo evidence to Today progress updates (Closes #1054) 2026-08-18 01:33:56 +00:00
7502d6f44d Merge pull request 'Add mobile Today progress updates without advancing work' (#1053) from timmy/1052-mobile-today-progress-updates into main
All checks were successful
CI / lint (push) Successful in 2m40s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 1m58s
CI / release-candidate (push) Successful in 6s
2026-08-18 00:52:44 +00:00
bed9bb4270 fix: keep Today completion undo above progress HUD
All checks were successful
CI / lint (pull_request) Successful in 2m39s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m2s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 00:43:46 +00:00
406d75287d feat: add in-session Today progress updates (Closes #1052)
Some checks failed
CI / lint (pull_request) Successful in 2m46s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 2m5s
CI / release-candidate (pull_request) Has been skipped
2026-08-18 00:34:37 +00:00
8c604e0b04 Merge pull request 'Show timed Today breaks on the mobile lock screen' (#1051) from timmy/1050-mobile-today-break-lock-screen into main
All checks were successful
CI / lint (push) Successful in 2m41s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m7s
CI / release-candidate (push) Successful in 8s
2026-08-18 00:05:53 +00:00
ed15392097 feat: show timed Today breaks on lock screen (Closes #1050)
All checks were successful
CI / lint (pull_request) Successful in 2m53s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m15s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 23:57:29 +00:00
af86034339 Merge pull request 'Make deployed mobile dashboard reachable through authenticated readiness' (#1049) from timmy/1048-authenticated-deployment-readiness into main
All checks were successful
CI / lint (push) Successful in 2m37s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m59s
CI / release-candidate (push) Successful in 7s
2026-08-17 23:38:24 +00:00
e8817056f7 feat: verify authenticated deployment readiness (Closes #1048)
All checks were successful
CI / lint (pull_request) Successful in 2m40s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m2s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 23:31:53 +00:00
a757bfe587 Merge pull request 'Make mobile issue capture a focus-contained modal journey' (#1047) from timmy/1046-mobile-issue-capture-modal into main
All checks were successful
CI / lint (push) Successful in 2m51s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m59s
CI / release-candidate (push) Successful in 7s
Merge pull request 'Make mobile issue capture a focus-contained modal journey' (#1047)
2026-08-17 23:07:36 +00:00
44aedfd623 fix: preserve capture confirmation dialogs
All checks were successful
CI / lint (pull_request) Successful in 2m34s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 1m59s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 22:59:31 +00:00
58299311d0 fix: reset capture viewport after keyboard navigation 2026-08-17 22:58:22 +00:00
89c25369fc fix: close capture without leaving the dashboard 2026-08-17 22:56:25 +00:00
be10dba4bd feat: contain mobile issue capture focus (Closes #1046) 2026-08-17 22:55:04 +00:00
c655cf5861 Merge pull request 'Build release bundles from the declared Git commit' (#1045) from timmy/1044-declared-git-release-bundles into main
All checks were successful
CI / lint (push) Successful in 2m29s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m47s
CI / release-candidate (push) Successful in 6s
2026-08-17 22:33:47 +00:00
2082fbd5eb ci: build releases from declared Git commits (Closes #1044)
All checks were successful
CI / lint (pull_request) Successful in 2m38s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m24s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 22:25:38 +00:00
55156f45c9 Merge pull request 'Recover valid Today edits from a corrupted offline queue' (#1043) from timmy/1042-recover-valid-today-edits into main
All checks were successful
CI / lint (push) Successful in 2m47s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m20s
CI / release-candidate (push) Successful in 7s
2026-08-17 22:00:18 +00:00
5bf0f97df8 fix: preserve truthful queue recovery status
All checks were successful
CI / lint (pull_request) Successful in 2m35s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 1m53s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 21:55:00 +00:00
ce2f04ab6d test: recover queue during active Today journey
Some checks failed
CI / lint (pull_request) Successful in 2m30s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Failing after 2m23s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 21:46:18 +00:00
f7b2a96206 test: stabilize mobile Today recovery notice
Some checks failed
CI / lint (pull_request) Successful in 2m59s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 2m24s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 21:39:20 +00:00
a6550f8b1f fix: recover valid Today edits from corrupt queue
Some checks failed
CI / lint (pull_request) Successful in 2m43s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Failing after 2m21s
CI / release-candidate (pull_request) Has been skipped
Closes #1042
2026-08-17 21:26:40 +00:00
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
All checks were successful
CI / lint (push) Successful in 2m55s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m18s
CI / release-candidate (push) Successful in 6s
2026-08-17 20:58:12 +00:00
442d0a157d fix: preserve Today sync enqueue order
All checks were successful
CI / lint (pull_request) Successful in 2m38s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 2m9s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 20:43:13 +00:00
4a85f02eae Merge pull request 'Continue a timed Today break across devices' (#1039) from timmy/1038-cross-device-today-break into main
Some checks failed
CI / lint (push) Successful in 2m44s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Failing after 2m10s
CI / release-candidate (push) Has been skipped
2026-08-17 20:23:06 +00:00
dfd33aee1c fix: reject contradictory running break sessions
All checks were successful
CI / lint (pull_request) Successful in 2m44s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m5s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 20:17:43 +00:00
c5b466f281 feat: continue timed breaks across devices (Closes #1038)
Some checks failed
CI / lint (pull_request) Successful in 2m53s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 2m8s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 20:08:05 +00:00
1d242bab75 Merge pull request 'Add a timed mobile break and return-to-Today flow' (#1037) from timmy/1036-timed-mobile-today-break into main
All checks were successful
CI / lint (push) Successful in 2m41s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m1s
CI / release-candidate (push) Successful in 7s
2026-08-17 19:37:52 +00:00
40284a7e30 fix: preserve Today order when resuming a break
All checks were successful
CI / lint (pull_request) Successful in 2m41s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m4s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 19:32:10 +00:00
ea08f566ff feat: add timed mobile Today breaks (Closes #1036)
Some checks failed
CI / lint (pull_request) Successful in 2m44s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Failing after 2m7s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 19:20:07 +00:00
ca671f0d00 Merge pull request 'Undo accidental Today completion on mobile' (#1035) from timmy/1034-undo-today-completion into main
All checks were successful
CI / lint (push) Successful in 2m52s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m14s
CI / release-candidate (push) Successful in 7s
2026-08-17 18:12:56 +00:00
6e677f8857 feat: undo accidental Today completion (Closes #1034)
All checks were successful
CI / lint (pull_request) Successful in 2m57s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m13s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 18:06:52 +00:00
e47e6329cc Merge pull request 'Finish the current Today item from the mobile lock screen' (#1033) from timmy/1032-finish-today-lock-screen into main
All checks were successful
CI / lint (push) Successful in 3m5s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m13s
CI / release-candidate (push) Successful in 12s
2026-08-17 17:09:29 +00:00
11395b655a feat: finish Today from the lock screen (Closes #1032)
All checks were successful
CI / lint (pull_request) Successful in 3m8s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 2m15s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 17:02:54 +00:00
ca520d2eb6 Merge pull request 'Make cross-device Today session sync conflict-convergent' (#1031) from timmy/1030-today-session-conflict-convergence into main
All checks were successful
CI / lint (push) Successful in 3m3s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m35s
CI / release-candidate (push) Successful in 11s
2026-08-17 16:09:21 +00:00
25dda21811 fix: converge Today session conflicts across devices (Closes #1030)
All checks were successful
CI / lint (pull_request) Successful in 2m58s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m17s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 16:00:59 +00:00
8a57ea73fb Merge pull request 'Make pull merges durably auditable before mutation' (#1029) from timmy/1028-durable-pull-merge-audit into main
All checks were successful
CI / lint (push) Successful in 2m51s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m9s
CI / release-candidate (push) Successful in 7s
2026-08-17 15:33:56 +00:00
6729359ba2 fix: make pull merges durably auditable (Closes #1028)
All checks were successful
CI / lint (pull_request) Successful in 2m54s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m8s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 15:28:00 +00:00
f687389046 Merge pull request 'Control an active Today session from the mobile lock screen' (#1027) from timmy/1026-today-lock-screen into main
All checks were successful
CI / lint (push) Successful in 2m46s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 2m2s
CI / release-candidate (push) Successful in 6s
2026-08-17 14:53:43 +00:00
a7f4f9714f feat: control Today from mobile lock screen (Closes #1026)
All checks were successful
CI / lint (pull_request) Successful in 2m55s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m21s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 14:47:07 +00:00
7ba238fb53 Merge pull request 'Make Prepare Today the primary truthful mobile work entry' (#1025) from timmy/1024-primary-prepare-today into main
All checks were successful
CI / lint (push) Successful in 3m4s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m35s
CI / release-candidate (push) Successful in 7s
2026-08-17 13:41:24 +00:00
b845f8ba95 feat: make Prepare Today the primary mobile entry (Closes #1024)
All checks were successful
CI / lint (pull_request) Successful in 3m5s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 2m27s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 13:34:58 +00:00
996309f52f Merge pull request 'Continue an active Today session across devices' (#1023) from timmy/1022-today-session-handoff into main
All checks were successful
CI / lint (push) Successful in 2m58s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m24s
CI / release-candidate (push) Successful in 7s
2026-08-17 12:51:50 +00:00
8d12a0384b feat: hand off active Today sessions across devices (Closes #1022)
All checks were successful
CI / lint (pull_request) Successful in 3m11s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m22s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 12:44:01 +00:00
51f4223f4b Merge pull request 'Make Security Center a Back-safe mobile investigation flow' (#1021) from timmy/1020-mobile-security-center-flow into main
All checks were successful
CI / lint (push) Successful in 3m11s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m34s
CI / release-candidate (push) Successful in 8s
2026-08-17 12:04:30 +00:00
241f0f3a2a feat: make Security Center Back-safe on mobile (Closes #1020)
All checks were successful
CI / lint (pull_request) Successful in 3m17s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 2m35s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 11:57:27 +00:00
34d5f86d53 Merge pull request 'Foreground mobile delivery attention handoffs' (#1019) from timmy/1018-mobile-delivery-attention-handoff into main
All checks were successful
CI / lint (push) Successful in 3m10s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m21s
CI / release-candidate (push) Successful in 7s
2026-08-17 10:48:48 +00:00
3fde7cf3ad fix: foreground mobile delivery attention handoffs (Closes #1018)
All checks were successful
CI / lint (pull_request) Successful in 3m1s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m22s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 10:42:19 +00:00
6913fc8257 Merge pull request 'Keep mobile delivery recovery single-flight until retry settles' (#1017) from timmy/1016-single-flight-delivery-recovery into main
All checks were successful
CI / lint (push) Successful in 3m7s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m13s
CI / release-candidate (push) Successful in 7s
2026-08-17 10:08:11 +00:00
437da72f3d fix: keep delivery recovery single-flight (Closes #1016)
All checks were successful
CI / lint (pull_request) Successful in 3m2s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m20s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 10:02:00 +00:00
9a2b8d97bd Merge pull request 'Guide mobile delivery recovery one item at a time' (#1015) from timmy/1014-guided-delivery-recovery into main
All checks were successful
CI / lint (push) Successful in 2m49s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m13s
CI / release-candidate (push) Successful in 7s
2026-08-17 09:12:25 +00:00
1e4f101219 feat: guide mobile delivery recovery (Closes #1014)
All checks were successful
CI / lint (pull_request) Successful in 2m43s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m4s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 09:04:41 +00:00
c0a02a05d8 Merge pull request 'Make actionable delivery recovery a first-class mobile queue' (#1013) from timmy/1012-mobile-delivery-recovery-queue into main
All checks were successful
CI / lint (push) Successful in 2m55s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m9s
CI / release-candidate (push) Successful in 7s
2026-08-17 08:36:15 +00:00
246f995b56 feat: prioritize mobile delivery recovery (Closes #1012)
All checks were successful
CI / lint (pull_request) Successful in 2m58s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m5s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 08:30:09 +00:00
db28cc5690 Merge pull request 'Guide blocked deliveries through Prepare Today' (#1011) from timmy/1010-prepare-today-delivery-recovery into main
All checks were successful
CI / lint (push) Successful in 2m40s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 1m51s
CI / release-candidate (push) Successful in 6s
2026-08-17 08:07:58 +00:00
26aa12019e feat: guide blocked deliveries through Prepare Today (Closes #1010)
All checks were successful
CI / lint (pull_request) Successful in 2m50s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m53s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 08:01:48 +00:00
1baa9c1f91 Merge pull request 'Continue mobile Work into Filed follow-up' (#1009) from timmy/1008-mobile-work-filed-follow-up into main
All checks were successful
CI / lint (push) Successful in 2m48s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m6s
CI / release-candidate (push) Successful in 7s
2026-08-17 07:39:03 +00:00
d64c646b68 feat: continue mobile Work into Filed follow-up (Closes #1008)
All checks were successful
CI / lint (pull_request) Successful in 2m43s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m56s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 07:28:00 +00:00
021af60cf3 Merge pull request 'Preserve mobile My Work section history across Back and reload' (#1007) from timmy/1006-mobile-work-section-history into main
All checks were successful
CI / lint (push) Successful in 2m40s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m53s
CI / release-candidate (push) Successful in 6s
2026-08-17 07:07:39 +00:00
09dbdb9961 feat: preserve mobile work section history (Closes #1006)
All checks were successful
CI / lint (pull_request) Successful in 2m58s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 2m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 06:59:20 +00:00
484dcc6b9c Merge pull request 'Resume private mobile share bundles after sign-in' (#1005)
All checks were successful
CI / lint (push) Successful in 2m49s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m2s
CI / release-candidate (push) Successful in 7s
Closes #1004
2026-08-17 06:32:24 +00:00
7571a4aa2d fix: resume private share bundles after sign-in (Closes #1004)
All checks were successful
CI / lint (pull_request) Successful in 2m53s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m30s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 06:25:58 +00:00
dc68305dcb Merge pull request 'Add mobile pull-to-refresh for My Work' (#1003) from timmy/1002-mobile-pull-to-refresh into main
All checks were successful
CI / lint (push) Successful in 2m54s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m55s
CI / release-candidate (push) Successful in 6s
2026-08-17 05:49:33 +00:00
386acda1dc feat: add mobile pull-to-refresh for My Work (Closes #1002)
All checks were successful
CI / lint (pull_request) Successful in 2m57s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 2m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 05:43:39 +00:00
a2606f1dc2 Merge pull request 'Guard IndexedDB private work cleanup' (#1001)
All checks were successful
CI / lint (push) Successful in 2m58s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m9s
CI / release-candidate (push) Successful in 7s
Closes #1000
2026-08-17 04:45:00 +00:00
a2c91e0296 fix: guard IndexedDB private work cleanup (Closes #1000)
All checks were successful
CI / lint (pull_request) Successful in 3m1s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m9s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 04:39:04 +00:00
8148545295 Merge pull request 'Show and safely clear private device storage' (#999) from timmy/998-private-device-storage into main
All checks were successful
CI / lint (push) Successful in 3m1s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m12s
CI / release-candidate (push) Successful in 7s
2026-08-17 03:43:21 +00:00
70c8ea6584 feat: manage private device storage (Closes #998)
All checks were successful
CI / lint (pull_request) Successful in 2m51s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m5s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 03:37:21 +00:00
f30143b5ea Merge pull request 'Defer the mobile workspace bundle behind an offline-safe bootstrap' (#997) from timmy/996-defer-mobile-workspace-bootstrap into main
All checks were successful
CI / lint (push) Successful in 2m44s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m59s
CI / release-candidate (push) Successful in 6s
2026-08-17 02:52:39 +00:00
cf76bc3636 test: model worker transport outage deterministically
All checks were successful
CI / lint (pull_request) Successful in 2m40s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m3s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 02:46:42 +00:00
0fc152f623 fix: recover deferred workspace reconnects
Some checks failed
CI / lint (pull_request) Successful in 2m53s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 2m45s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 02:16:48 +00:00
377fd889c5 fix: preserve reconnect across deferred workspace startup
Some checks failed
CI / lint (pull_request) Successful in 2m49s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 2m53s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 01:41:22 +00:00
0a4c9a0ae8 fix: replay reconnect after workspace bootstrap
Some checks failed
CI / lint (pull_request) Successful in 2m49s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 2m44s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 01:23:44 +00:00
1a4f930b50 test: allow workspace activation before offline delivery
Some checks failed
CI / lint (pull_request) Successful in 2m54s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 3m4s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 01:13:15 +00:00
49bbeadc48 perf: defer mobile workspace bootstrap (Closes #996)
Some checks failed
CI / lint (pull_request) Successful in 2m49s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 2m43s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 01:04:20 +00:00
5470e48e26 Make mobile Home task-first with a Back-safe Insights view
All checks were successful
CI / lint (push) Successful in 3m2s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m53s
CI / release-candidate (push) Successful in 7s
Closes #994
2026-08-17 00:14:58 +00:00
0fcb7f5152 feat: make mobile Home task-first with Insights (Closes #994)
All checks were successful
CI / lint (pull_request) Successful in 3m2s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 2m8s
CI / release-candidate (pull_request) Has been skipped
2026-08-17 00:08:48 +00:00
0f30ca94ab Merge pull request 'Review wrap-up commitments before rebuilding Today' (#993) from timmy/992-review-wrap-up-commitments into main
All checks were successful
CI / lint (push) Successful in 2m45s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m47s
CI / release-candidate (push) Successful in 7s
2026-08-16 23:20:55 +00:00
468676d2f4 feat: review wrap-up commitments in Today (Closes #992)
All checks were successful
CI / lint (pull_request) Successful in 2m56s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m50s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 23:15:00 +00:00
40623f939c Merge pull request 'Schedule unfinished Today work during session wrap-up' (#991) from timmy/990-schedule-unfinished-today-work into main
All checks were successful
CI / lint (push) Successful in 2m53s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m39s
CI / release-candidate (push) Successful in 7s
2026-08-16 22:12:24 +00:00
110fd860b3 feat: schedule unfinished work during Today wrap-up (Closes #990)
All checks were successful
CI / lint (pull_request) Successful in 2m44s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 1m36s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 22:07:04 +00:00
edf5d3fda4 Merge pull request 'Block releases with vulnerable runtime dependencies' (#989) from timmy/988-block-vulnerable-runtime-dependencies into main
All checks were successful
CI / lint (push) Successful in 2m51s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m33s
CI / release-candidate (push) Successful in 7s
2026-08-16 21:09:47 +00:00
e395878f92 security: block vulnerable runtime dependencies (Closes #988)
All checks were successful
CI / lint (pull_request) Successful in 2m48s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m20s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 21:04:44 +00:00
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
All checks were successful
CI / lint (push) Successful in 2m23s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 1m34s
CI / release-candidate (push) Successful in 6s
Merge pull request 'Gate releases on the packaged mobile Plan Today handoff journey' (#987)
2026-08-16 20:47:59 +00:00
971656b76c test: gate packaged mobile Plan Today handoff (Closes #985)
All checks were successful
CI / lint (pull_request) Successful in 2m17s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 20:27:25 +00:00
48ec66cc30 Merge pull request 'Sanitize image evidence at the server upload boundary' (#984) from timmy/983-server-image-sanitization into main
All checks were successful
CI / lint (push) Successful in 2m11s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m16s
CI / release-candidate (push) Successful in 6s
2026-08-16 19:38:34 +00:00
966028e0a6 security: sanitize image evidence server-side (Closes #983)
All checks were successful
CI / lint (pull_request) Successful in 2m11s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 1m11s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 19:33:46 +00:00
21eb223882 Merge pull request 'Gate releases on the packaged mobile Find Work journey' (#982)
All checks were successful
CI / lint (push) Successful in 2m10s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 1m12s
CI / release-candidate (push) Successful in 6s
Closes #981
2026-08-16 19:02:46 +00:00
e10bd2d261 test: gate packaged mobile Find Work journey (Closes #981)
All checks were successful
CI / lint (pull_request) Successful in 2m21s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 1m20s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 18:58:13 +00:00
0728bbdd6a Merge pull request 'Guide mobile Find Work from discovery through assignment' (#980) from timmy/979-mobile-find-work-navigation into main
All checks were successful
CI / lint (push) Successful in 2m11s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m6s
CI / release-candidate (push) Successful in 7s
2026-08-16 18:11:41 +00:00
53e556ddab feat: guide mobile Find Work workflow (Closes #979)
All checks were successful
CI / lint (pull_request) Successful in 2m20s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m18s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 18:04:55 +00:00
611b10b477 Merge pull request 'Preserve offline workflows across PWA activation' (#978) from timmy/977-preserve-offline-feature-cache-activation into main
All checks were successful
CI / lint (push) Successful in 2m6s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m6s
CI / release-candidate (push) Successful in 6s
2026-08-16 17:39:09 +00:00
85a000422d test: exercise migrated workflow offline
All checks were successful
CI / lint (pull_request) Successful in 2m9s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 1m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 17:35:24 +00:00
d80091d244 fix: preserve offline workflows across activation (Closes #977)
Some checks failed
CI / lint (pull_request) Successful in 2m3s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 1m43s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 17:25:48 +00:00
5217f6a86f Merge pull request 'Add persistent mobile Plan Today workflow navigation' (#976) from timmy/975-mobile-plan-today-navigation into main
All checks were successful
CI / lint (push) Successful in 1m58s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m43s
CI / release-candidate (push) Successful in 6s
2026-08-16 16:40:44 +00:00
fc33746d4c feat: add mobile Plan Today navigation (Closes #975)
All checks were successful
CI / lint (pull_request) Successful in 2m0s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m2s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 16:36:49 +00:00
cc449b3d2f Merge pull request 'Add persistent mobile Search Preview workflow navigation' (#974) from timmy/973-mobile-search-preview-navigation into main
All checks were successful
CI / lint (push) Successful in 2m4s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m2s
CI / release-candidate (push) Successful in 6s
2026-08-16 16:08:44 +00:00
8de2318f43 test: isolate Search Preview browser checks
All checks were successful
CI / lint (pull_request) Successful in 1m58s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 1m8s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 16:05:06 +00:00
24f781d398 feat: add mobile Search Preview navigation (Closes #973)
Some checks failed
CI / lint (pull_request) Failing after 2m7s
CI / build-release (pull_request) Has been skipped
CI / browser-journey (pull_request) Has been skipped
CI / release-candidate (pull_request) Has been skipped
2026-08-16 15:58:41 +00:00
25f752339f Merge pull request 'Add mobile navigation to the New issue workflow' (#972) from timmy/971-mobile-new-issue-navigation into main
All checks were successful
CI / lint (push) Successful in 2m1s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 59s
CI / release-candidate (push) Successful in 6s
2026-08-16 15:16:04 +00:00
fe95b4e32a fix: keep mobile filing navigation selected
All checks were successful
CI / lint (pull_request) Successful in 2m12s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 15:12:02 +00:00
2b671d91fd feat: add mobile new issue navigation (Closes #971)
Some checks failed
CI / lint (pull_request) Successful in 1m50s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 1m0s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 15:02:13 +00:00
dcc3348c12 Merge pull request 'Add persistent mobile requested-review workspace navigation' (#970) from timmy/969-mobile-requested-review-navigation into main
All checks were successful
CI / lint (push) Successful in 1m59s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 55s
CI / release-candidate (push) Successful in 6s
2026-08-16 14:32:36 +00:00
31fb3fc8ae feat: add mobile requested-review navigation (Closes #969)
All checks were successful
CI / lint (pull_request) Successful in 2m10s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 55s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 14:28:25 +00:00
a0b3db6895 Merge pull request 'Add persistent mobile update workspace navigation' (#968) from timmy/967-mobile-update-workspace-navigation into main
All checks were successful
CI / lint (push) Successful in 2m5s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 54s
CI / release-candidate (push) Successful in 7s
2026-08-16 13:46:40 +00:00
6c7b6ebd4d feat: add mobile update workspace navigation (Closes #967)
All checks were successful
CI / lint (pull_request) Successful in 2m25s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m3s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 13:42:19 +00:00
3c86e02efe Merge pull request 'Add persistent mobile pull request workspace navigation' (#966) from timmy/965-mobile-pull-workspace-navigation into main
All checks were successful
CI / lint (push) Successful in 1m59s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 51s
CI / release-candidate (push) Successful in 6s
2026-08-16 13:04:52 +00:00
c48baac1ea test: keep rendered checks out of unit CI
All checks were successful
CI / lint (pull_request) Successful in 1m55s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 54s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 13:01:03 +00:00
311396cfac feat: add mobile pull workspace navigation (Closes #965)
Some checks failed
CI / lint (pull_request) Failing after 1m52s
CI / build-release (pull_request) Has been skipped
CI / browser-journey (pull_request) Has been skipped
CI / release-candidate (pull_request) Has been skipped
2026-08-16 12:55:03 +00:00
762614b2d3 Merge pull request 'Preserve My Work conversation photo drafts' (#964) from timmy/963-preserve-my-work-photo-drafts into main
All checks were successful
CI / lint (push) Successful in 2m3s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 56s
CI / release-candidate (push) Successful in 7s
2026-08-16 12:38:07 +00:00
31f2c71575 feat: preserve My Work photo drafts (Closes #963)
All checks were successful
CI / lint (pull_request) Successful in 2m1s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 57s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 12:34:01 +00:00
50f1af83f6 Merge pull request 'Add persistent mobile issue detail navigation' (#962) from timmy/961-mobile-issue-detail-navigation into main
All checks were successful
CI / lint (push) Successful in 1m59s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 56s
CI / release-candidate (push) Successful in 6s
2026-08-16 12:05:55 +00:00
6c200d7c97 feat: add mobile issue detail navigation (Closes #961)
All checks were successful
CI / lint (pull_request) Successful in 2m3s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 56s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 12:02:02 +00:00
6954ffc050 Merge pull request 'Queue mobile Search replies for offline delivery' (#960) from timmy/959-offline-search-replies into main
All checks were successful
CI / lint (push) Successful in 2m2s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 54s
CI / release-candidate (push) Successful in 6s
2026-08-16 11:08:30 +00:00
c8a237aba4 test: tolerate container reconnect latency
All checks were successful
CI / lint (pull_request) Successful in 1m59s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 55s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 11:05:04 +00:00
7fa43a9e50 feat: queue offline Search replies (Closes #959)
Some checks failed
CI / lint (pull_request) Successful in 2m7s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Failing after 1m10s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 11:00:11 +00:00
2e6c20d68d Preserve mobile Search photo reply drafts across interruptions (#958)
All checks were successful
CI / lint (push) Successful in 2m1s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m0s
CI / release-candidate (push) Successful in 6s
Closes #957
2026-08-16 10:12:08 +00:00
5af45cda57 feat: preserve Search photo reply drafts (Closes #957)
All checks were successful
CI / lint (pull_request) Successful in 2m10s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 57s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 10:07:46 +00:00
cab5802547 Merge pull request 'Attach photo evidence to mobile Search preview replies' (#956) from timmy/955-search-preview-photo-evidence into main
All checks were successful
CI / lint (push) Successful in 1m58s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 51s
CI / release-candidate (push) Successful in 6s
2026-08-16 09:34:05 +00:00
d3a57f222e feat: attach photos from Search preview replies (Closes #955)
All checks were successful
CI / lint (pull_request) Successful in 2m0s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 51s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 09:30:18 +00:00
053f32c428 Merge pull request 'Dictate replies from mobile Search preview' (#954) from timmy/953-dictate-search-preview-replies into main
All checks were successful
CI / lint (push) Successful in 2m12s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 58s
CI / release-candidate (push) Successful in 6s
2026-08-16 08:59:30 +00:00
d63166b581 feat: dictate replies from mobile Search preview (Closes #953)
All checks were successful
CI / lint (pull_request) Successful in 1m55s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 51s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 08:55:49 +00:00
51710972eb Merge pull request 'Dictate, review, and send mobile conversation replies' (#952) from timmy/951-dictate-mobile-conversation-replies into main
All checks were successful
CI / lint (push) Successful in 2m2s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 56s
CI / release-candidate (push) Successful in 6s
2026-08-16 08:39:34 +00:00
0e13872311 feat: dictate mobile conversation replies (Closes #951)
All checks were successful
CI / lint (pull_request) Successful in 1m59s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 59s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 08:35:34 +00:00
eac5342fa5 Merge pull request 'Strip private metadata from mobile photo evidence' (#950) from timmy/949-strip-photo-metadata into main
All checks were successful
CI / lint (push) Successful in 2m2s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m2s
CI / release-candidate (push) Successful in 7s
2026-08-16 07:58:41 +00:00
a8e931f306 feat: strip metadata from photo evidence (Closes #949)
All checks were successful
CI / lint (pull_request) Successful in 1m58s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 57s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 07:55:04 +00:00
00fd96f4a4 Merge pull request 'Edit and redact mobile conversation photos before sending' (#948) from timmy/947-edit-redact-conversation-photos into main
All checks were successful
CI / lint (push) Successful in 2m0s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 55s
CI / release-candidate (push) Successful in 7s
2026-08-16 07:32:05 +00:00
0d5fa13d65 feat: edit photos in conversation replies (Closes #947)
All checks were successful
CI / lint (pull_request) Successful in 2m4s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 57s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 07:28:10 +00:00
6270253c24 Merge pull request 'Review and caption mobile conversation photo bundles before sending' (#946) from timmy/945-review-caption-conversation-photo-bundles into main
All checks were successful
CI / lint (push) Successful in 2m8s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m0s
CI / release-candidate (push) Successful in 6s
2026-08-16 07:02:09 +00:00
5da5d7cdc4 feat: review conversation photo bundles before sending (Closes #945)
All checks were successful
CI / lint (pull_request) Successful in 2m11s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 1m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 06:56:05 +00:00
55bd353daf Merge pull request 'Send multi-photo evidence bundles in mobile conversation replies' (#944) from timmy/943-mobile-conversation-photo-bundles into main
All checks were successful
CI / lint (push) Successful in 2m7s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m0s
CI / release-candidate (push) Successful in 7s
2026-08-16 06:13:37 +00:00
c5d31c1972 feat: send mobile conversation photo bundles (Closes #943)
All checks were successful
CI / lint (pull_request) Successful in 2m4s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 57s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 06:06:07 +00:00
843906208c Capture photo evidence in mobile conversation replies (#942)
All checks were successful
CI / lint (push) Successful in 2m3s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m0s
CI / release-candidate (push) Successful in 7s
Closes #941
2026-08-16 05:37:38 +00:00
2338a07bab feat: capture photo evidence in conversation replies (Closes #941)
All checks were successful
CI / lint (pull_request) Successful in 2m6s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m2s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 05:33:34 +00:00
67d8828aea Merge pull request 'Save photo-only mobile captures as untitled Drafts' (#940) from timmy/939-photo-only-untitled-drafts into main
All checks were successful
CI / lint (push) Successful in 2m2s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 59s
CI / release-candidate (push) Successful in 7s
2026-08-16 05:06:46 +00:00
caa0fbc2fd feat: save photo-only captures as drafts (Closes #939)
All checks were successful
CI / lint (pull_request) Successful in 2m9s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m2s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 04:59:08 +00:00
8a85b344f8 Merge pull request 'Capture camera-first photo evidence from mobile New issue' (#938) from timmy/937-camera-first-photo-evidence into main
All checks were successful
CI / lint (push) Successful in 2m7s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 59s
CI / release-candidate (push) Successful in 6s
Merge pull request #938 from timmy/937-camera-first-photo-evidence
2026-08-16 04:33:45 +00:00
b4662a175d feat: capture camera-first photo evidence (Closes #937)
All checks were successful
CI / lint (pull_request) Successful in 2m12s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m2s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 04:29:44 +00:00
fb08785ad5 Merge pull request 'Keep multi-segment mobile voice dictation free of repeated phrases' (#936) from timmy/935-deduplicate-mobile-voice-results into main
All checks were successful
CI / lint (push) Successful in 2m7s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 56s
CI / release-candidate (push) Successful in 7s
2026-08-16 04:00:46 +00:00
2bb155a9e4 fix: deduplicate cumulative voice results (Closes #935)
All checks were successful
CI / lint (pull_request) Successful in 2m3s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 03:56:50 +00:00
c4715fc95c Merge pull request 'Enable same-origin microphone access for voice capture' (#934) from timmy/933-same-origin-microphone into main
All checks were successful
CI / lint (push) Successful in 2m10s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 58s
CI / release-candidate (push) Successful in 7s
Merge PR #934: enable same-origin microphone access
2026-08-16 03:28:43 +00:00
bd637d0141 fix: enable same-origin voice capture (Closes #933)
All checks were successful
CI / lint (pull_request) Successful in 2m12s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 03:24:10 +00:00
785fb990b4 Merge pull request #932 from timmy/931-recover-mobile-voice-transcripts
All checks were successful
CI / lint (push) Successful in 2m1s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m4s
CI / release-candidate (push) Successful in 7s
Recover interrupted mobile voice transcripts privately

Closes #931
2026-08-16 02:42:02 +00:00
f8c2c080f9 feat: recover interrupted mobile voice transcripts (Closes #931)
All checks were successful
CI / lint (pull_request) Successful in 2m5s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 56s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 02:37:54 +00:00
47220929bf Merge pull request 'Add voice-to-draft mobile issue capture' (#930) from timmy/929-voice-to-draft-mobile-capture into main
All checks were successful
CI / lint (push) Successful in 2m8s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 1m3s
CI / release-candidate (push) Successful in 6s
2026-08-16 02:06:46 +00:00
3b4bb618dd feat: add voice-to-draft mobile capture (Closes #929)
All checks were successful
CI / lint (pull_request) Successful in 2m14s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 1m10s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 02:02:24 +00:00
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
All checks were successful
CI / lint (push) Successful in 2m6s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 58s
CI / release-candidate (push) Successful in 6s
2026-08-16 01:39:48 +00:00
64e764b996 fix: flush durable outboxes after reconnect (Closes #927)
All checks were successful
CI / lint (pull_request) Successful in 1m59s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 56s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 01:32:11 +00:00
76eed47487 Merge pull request 'Close completed delegated parent from mobile Filed review' (#926) from timmy/925-close-completed-delegated-parent into main
Some checks failed
CI / lint (push) Successful in 2m5s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Failing after 1m17s
CI / release-candidate (push) Has been skipped
2026-08-16 01:02:34 +00:00
05b2f769e4 feat: close completed delegated parent from Filed review (Closes #925)
All checks were successful
CI / lint (pull_request) Successful in 2m5s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m4s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 00:58:39 +00:00
40b124bc2c Merge pull request #924 from timmy/923-complete-delegated-parent-checklist
All checks were successful
CI / lint (push) Successful in 2m2s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 1m3s
CI / release-candidate (push) Successful in 6s
Complete delegated parent task from mobile Filed review
2026-08-16 00:35:39 +00:00
4ab0ffde58 feat: complete delegated parent task from Filed review (Closes #923)
All checks were successful
CI / lint (pull_request) Successful in 2m4s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 59s
CI / release-candidate (pull_request) Has been skipped
2026-08-16 00:31:37 +00:00
ab77beb3fe Merge pull request 'Promote checklist steps into related issues from mobile detail' (#922) from timmy/921-promote-checklist-related-issue into main
All checks were successful
CI / lint (push) Successful in 1m58s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m5s
CI / release-candidate (push) Successful in 6s
2026-08-15 23:47:02 +00:00
170828b308 feat: promote checklist steps to related issues (Closes #921)
All checks were successful
CI / lint (pull_request) Successful in 1m58s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 58s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 23:40:17 +00:00
08139bc3ee Merge pull request 'Manage checklist steps inline from mobile issue detail' (#920) from timmy/919-manage-checklist-steps into main
All checks were successful
CI / lint (push) Successful in 1m55s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 57s
CI / release-candidate (push) Successful in 6s
2026-08-15 22:42:45 +00:00
4589c46f16 feat: manage mobile checklist steps inline (Closes #919)
All checks were successful
CI / lint (pull_request) Successful in 1m59s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 56s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 22:38:52 +00:00
6b924a4757 Merge pull request #918 from timmy/917-ci-regression-offline-artifact-browser-journey-d
All checks were successful
CI / lint (push) Successful in 1m58s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 56s
CI / release-candidate (push) Successful in 7s
Fix queued issue delivery after offline reload and reconnect.
2026-08-15 21:41:45 +00:00
1602528e47 fix: resume queued delivery on reconnect (Closes #917)
All checks were successful
CI / lint (pull_request) Successful in 1m57s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m0s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 21:31:42 +00:00
d28140bbd1 Merge pull request 'Add checklist steps inline from mobile issue detail' (#916) from timmy/915-mobile-add-checklist-step into main
Some checks failed
CI / lint (push) Successful in 1m58s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Failing after 1m17s
CI / release-candidate (push) Has been skipped
2026-08-15 21:13:17 +00:00
3f559a50ea feat: add mobile checklist steps inline (Closes #915)
All checks were successful
CI / lint (pull_request) Successful in 1m58s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 56s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 21:09:25 +00:00
e7a4eb3331 Merge pull request 'Resolve offline checklist conflicts from mobile Drafts' (#914)
All checks were successful
CI / lint (push) Successful in 2m0s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 56s
CI / release-candidate (push) Successful in 6s
Closes #913
2026-08-15 20:04:12 +00:00
7b27f85b39 feat: resolve mobile checklist conflicts (Closes #913)
All checks were successful
CI / lint (pull_request) Successful in 2m7s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 57s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 20:00:14 +00:00
7204a426af Merge pull request 'Finish a mobile checklist and continue Today' (#912) from timmy/911-checklist-close-next into main
All checks were successful
CI / lint (push) Successful in 1m54s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m1s
CI / release-candidate (push) Successful in 6s
2026-08-15 19:32:07 +00:00
2aad1a9bef feat: finish mobile checklists and continue Today (Closes #911)
All checks were successful
CI / lint (pull_request) Successful in 2m1s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 58s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 19:27:54 +00:00
dbadc73a57 Merge pull request 'Continue mobile checklist progress across offline changes' (#910) from timmy/909-offline-checklist-coalesce into main
All checks were successful
CI / lint (push) Successful in 1m57s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 59s
CI / release-candidate (push) Successful in 6s
2026-08-15 19:05:22 +00:00
59bc13c5b0 test: stabilize transport admission deadline
All checks were successful
CI / lint (pull_request) Successful in 2m3s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m0s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 19:01:24 +00:00
5c370cbe33 feat: continue offline checklist progress (Closes #909)
Some checks failed
CI / lint (pull_request) Failing after 1m57s
CI / build-release (pull_request) Has been skipped
CI / browser-journey (pull_request) Has been skipped
CI / release-candidate (pull_request) Has been skipped
2026-08-15 18:26:34 +00:00
475739328c Merge pull request 'Queue offline issue checklist progress for conflict-safe delivery' (#908)
All checks were successful
CI / lint (push) Successful in 1m58s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 57s
CI / release-candidate (push) Successful in 6s
2026-08-15 17:39:42 +00:00
411e40d117 feat: queue offline checklist progress (Closes #907)
All checks were successful
CI / lint (pull_request) Successful in 1m58s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 59s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 17:35:21 +00:00
88e5f3f33b Merge pull request 'Toggle issue checklist items from mobile detail' (#906) from timmy/905-mobile-checklist-toggle into main
All checks were successful
CI / lint (push) Successful in 1m56s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m1s
CI / release-candidate (push) Successful in 6s
2026-08-15 16:54:47 +00:00
72dcdc6c52 feat: toggle mobile issue checklists (Closes #905)
All checks were successful
CI / lint (pull_request) Successful in 1m59s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 59s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 16:50:48 +00:00
d8f2b803c7 Merge pull request 'Keep mobile share-target content out of navigation URLs' (#904) from timmy/903-private-share-bundle into main
All checks were successful
CI / lint (push) Successful in 1m58s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 56s
CI / release-candidate (push) Successful in 6s
2026-08-15 16:06:28 +00:00
fdd3cf03b4 security: keep mobile share content private (Closes #903)
All checks were successful
CI / lint (pull_request) Successful in 1m56s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 59s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 16:02:35 +00:00
06184f1cb2 Merge pull request 'Continue Prepare Today after Attention clears' (#902) from timmy/901-continue-prepare-today-attention into main
All checks were successful
CI / lint (push) Successful in 1m59s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 58s
CI / release-candidate (push) Successful in 7s
2026-08-15 15:29:33 +00:00
c26f4bdeeb fix: continue Prepare Today after Attention clears (Closes #901)
All checks were successful
CI / lint (pull_request) Successful in 1m53s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 56s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 15:25:44 +00:00
04ae7480cf Merge pull request 'Resume guided Prepare Today across urgent queues' (#900) from timmy/899-resume-prepare-today into main
All checks were successful
CI / lint (push) Successful in 1m49s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 53s
CI / release-candidate (push) Successful in 6s
2026-08-15 15:05:16 +00:00
21705a0859 feat: resume guided Prepare Today passes (Closes #899)
All checks were successful
CI / lint (pull_request) Successful in 1m51s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 54s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 15:01:26 +00:00
a2076cf9a9 Merge pull request 'Guide mobile start-day review across urgent queues' (#898) from timmy/897-mobile-start-day into main
All checks were successful
CI / lint (push) Successful in 1m52s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 55s
CI / release-candidate (push) Successful in 7s
2026-08-15 14:35:40 +00:00
d4aafbf351 feat: guide mobile start-day review (Closes #897)
All checks were successful
CI / lint (pull_request) Successful in 1m54s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 57s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 14:31:37 +00:00
7351363982 Merge pull request 'Reassign open delegated issues from mobile Filed' (#896) from timmy/895-reassign-filed into main
All checks were successful
CI / lint (push) Successful in 1m51s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 52s
CI / release-candidate (push) Successful in 6s
2026-08-15 13:50:01 +00:00
c86a63c54b test: gate offline release journey deterministically
All checks were successful
CI / lint (pull_request) Successful in 1m54s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 56s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 13:46:28 +00:00
3c5c58fc38 feat: reassign delegated filings from mobile Filed (Closes #895)
Some checks failed
CI / lint (pull_request) Successful in 1m49s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 58s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 13:37:10 +00:00
498d7e772a Merge pull request 'Claim and start unowned work from mobile Filed' (#894) from timmy/893-claim-start-filed into main
All checks were successful
CI / lint (push) Successful in 1m43s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 53s
CI / release-candidate (push) Successful in 6s
2026-08-15 13:03:51 +00:00
c594cc20fc feat: claim unowned work from Filed (Closes #893)
All checks were successful
CI / lint (pull_request) Successful in 1m55s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 54s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 12:59:47 +00:00
5105fb40d3 Merge pull request 'Harden private SQLite state filesystem boundaries' (#892) from timmy/891-private-state-boundary into main
All checks were successful
CI / lint (push) Successful in 1m50s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 55s
CI / release-candidate (push) Successful in 6s
2026-08-15 12:33:35 +00:00
b13eeb9cc0 security: harden private SQLite state boundaries (Closes #891)
All checks were successful
CI / lint (pull_request) Successful in 1m57s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 54s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 12:29:44 +00:00
49ef3dad78 Merge pull request 'Purge every private offline store after background session revocation' (#890) from timmy/889-private-store-purge into main
All checks were successful
CI / lint (push) Successful in 1m44s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 52s
CI / release-candidate (push) Successful in 6s
2026-08-15 11:52:38 +00:00
a60f2cfac3 fix: purge every private offline store (Closes #889)
All checks were successful
CI / lint (pull_request) Successful in 1m47s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 54s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 11:48:54 +00:00
2eea42859e Merge pull request 'Revise open delegated issues from mobile Filed' (#888) from timmy/887-revise-filed-issue into main
All checks were successful
CI / lint (push) Successful in 1m48s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 53s
CI / release-candidate (push) Successful in 6s
2026-08-15 11:04:12 +00:00
64f549848d feat: revise delegated filings from mobile Filed (Closes #887)
All checks were successful
CI / lint (pull_request) Successful in 1m50s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 55s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 11:00:18 +00:00
5201b18cae Merge pull request 'Stabilize the event-loop release gate' (#886) from timmy/884-stabilize-release-gate into main
All checks were successful
CI / lint (push) Successful in 1m55s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 56s
CI / release-candidate (push) Successful in 6s
2026-08-15 10:16:39 +00:00
8f92286cce test: make event-loop release gate deterministic (Closes #884)
All checks were successful
CI / lint (pull_request) Successful in 1m53s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 59s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 10:13:05 +00:00
27be5d43a2 Merge pull request 'Withdraw open delegated issues from mobile Filed' (#885) from timmy/884-withdraw-filed-issue into main
Some checks failed
CI / lint (push) Failing after 1m54s
CI / build-release (push) Has been skipped
CI / browser-journey (push) Has been skipped
CI / release-candidate (push) Has been skipped
2026-08-15 10:07:42 +00:00
e64541463c test: cover Filed withdrawal authorization
All checks were successful
CI / lint (pull_request) Successful in 1m55s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 55s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 10:04:10 +00:00
6a03dfd7ac feat: withdraw delegated issues from Filed (Closes #884)
Some checks failed
CI / lint (pull_request) Failing after 1m55s
CI / build-release (pull_request) Has been skipped
CI / browser-journey (pull_request) Has been skipped
CI / release-candidate (pull_request) Has been skipped
2026-08-15 10:00:46 +00:00
f01dfa5f93 Merge pull request 'Keep acknowledged outcomes in mobile Filed history' (#883) from timmy/882-reviewed-filed-history into main
All checks were successful
CI / lint (push) Successful in 1m46s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 54s
CI / release-candidate (push) Successful in 6s
Closes #882
2026-08-15 09:13:43 +00:00
a57aa879e2 feat: keep reviewed Filed history (Closes #882)
All checks were successful
CI / lint (pull_request) Successful in 1m44s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 52s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 09:10:05 +00:00
403190e888 Sync completed Filed acknowledgements across devices (#881)
All checks were successful
CI / lint (push) Successful in 1m52s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 56s
CI / release-candidate (push) Successful in 6s
Closes #880
2026-08-15 08:31:05 +00:00
6acc8866ed feat: sync completed Filed reviews across devices (Closes #880)
All checks were successful
CI / lint (pull_request) Successful in 1m49s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 1m0s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 08:27:19 +00:00
8e7a21f316 Merge pull request 'Review completed delegated issues from mobile Filed' (#879) from timmy/878-completed-filed-review into main
All checks were successful
CI / lint (push) Successful in 1m53s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 55s
CI / release-candidate (push) Successful in 6s
2026-08-15 07:44:21 +00:00
8411e907af feat: review completed mobile Filed outcomes (Closes #878)
All checks were successful
CI / lint (pull_request) Successful in 1m47s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 53s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 07:40:23 +00:00
eb88355229 Merge pull request 'Reply to delegated issues from mobile Filed' (#877) from timmy/876-filed-followup-replies into main
All checks were successful
CI / lint (push) Successful in 1m44s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 58s
CI / release-candidate (push) Successful in 6s
2026-08-15 07:02:51 +00:00
1b68223798 feat: reply from mobile Filed (Closes #876)
All checks were successful
CI / lint (pull_request) Successful in 1m59s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 52s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 06:58:49 +00:00
33d7f4b884 Merge pull request 'Complete the mobile Filed follow-up loop' (#875) from timmy/874-mobile-filed-followup into main
All checks were successful
CI / lint (push) Successful in 1m52s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m1s
CI / release-candidate (push) Successful in 6s
2026-08-15 06:32:56 +00:00
05dd6c1e9c feat: complete mobile Filed follow-up (Closes #874)
All checks were successful
CI / lint (pull_request) Successful in 1m56s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 59s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 06:29:14 +00:00
42f2a077b2 Merge pull request 'Open delegated Filed issues in read-only detail' (#873) from timmy/872-filed-read-only-detail into main
All checks were successful
CI / lint (push) Successful in 1m47s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 54s
CI / release-candidate (push) Successful in 6s
2026-08-15 05:48:39 +00:00
0bb76bdc8b feat: open delegated filings read-only (Closes #872)
All checks were successful
CI / lint (pull_request) Successful in 1m53s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 57s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 05:43:28 +00:00
efafaaddd2 Merge pull request 'Keep delegated filings visible in a mobile Filed queue' (#871) from timmy/870-mobile-filed-queue into main
All checks were successful
CI / lint (push) Successful in 1m43s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 50s
CI / release-candidate (push) Successful in 7s
2026-08-15 05:05:10 +00:00
a9ffc2d879 feat: keep delegated filings in My Work (Closes #870)
All checks were successful
CI / lint (pull_request) Successful in 1m48s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 55s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 05:01:17 +00:00
1fdbba1ca1 Merge pull request 'File related issues from confirmed filing receipts' (#869) from timmy/868-file-related-issue into main
All checks were successful
CI / lint (push) Successful in 1m53s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 1m30s
CI / release-candidate (push) Successful in 6s
2026-08-15 04:16:16 +00:00
4b88e1a533 feat: file related issues from confirmed receipts (Closes #868)
All checks were successful
CI / lint (pull_request) Successful in 1m47s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 59s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 04:12:27 +00:00
7a31ea5cba Merge pull request 'Complete delegated mobile filing with a shareable issue receipt' (#867) from timmy/866-issue-filing-receipt into main
All checks were successful
CI / lint (push) Successful in 1m48s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 50s
CI / release-candidate (push) Successful in 5s
2026-08-15 03:10:27 +00:00
6633ba5244 feat: complete delegated mobile filing handoff (Closes #866)
All checks were successful
CI / lint (pull_request) Successful in 1m44s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 59s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 03:06:32 +00:00
5b954a7a53 Merge pull request 'Isolate the release browser journey from the unit-test job' (#865) from timmy/863-release-e2e-opt-in into main
All checks were successful
CI / lint (push) Successful in 1m52s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 1m1s
CI / release-candidate (push) Successful in 6s
2026-08-15 02:28:01 +00:00
c30206bfb2 fix: isolate release browser journey
All checks were successful
CI / lint (pull_request) Successful in 1m53s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 59s
CI / release-candidate (pull_request) Has been skipped
Refs #863
2026-08-15 02:24:15 +00:00
cf9a02ae38 Merge pull request 'Gate releases on a real mobile offline filing journey' (#864) from timmy/863-mobile-offline-release-gate into main
Some checks failed
CI / lint (push) Failing after 1m52s
CI / build-release (push) Has been skipped
CI / browser-journey (push) Has been skipped
CI / release-candidate (push) Has been skipped
2026-08-15 02:17:33 +00:00
9f5aa33e95 test: gate releases on mobile offline filing
All checks were successful
CI / lint (pull_request) Successful in 1m54s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m4s
CI / release-candidate (pull_request) Has been skipped
Closes #863
2026-08-15 02:13:15 +00:00
ae3749bab9 Merge pull request 'Open ready-to-file mobile Drafts directly in final review' (#862) from timmy/861-review-ready-drafts into main
All checks were successful
CI / lint (push) Successful in 1m49s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-15 01:15:04 +00:00
c1c4ff8e31 feat: review ready mobile drafts in one tap (Closes #861)
All checks were successful
CI / lint (pull_request) Successful in 1m46s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 01:12:10 +00:00
ff41eb1d57 Resume fully planned issue drafts across devices (#860)
All checks were successful
CI / lint (push) Successful in 1m56s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
Closes #859
2026-08-15 00:10:22 +00:00
f23839f2b9 feat: resume fully planned drafts across devices (Closes #859)
All checks were successful
CI / lint (pull_request) Successful in 1m52s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 00:07:32 +00:00
7cab4e8749 Merge pull request 'Automatically reconcile concurrent cross-device Draft saves' (#858) from timmy/857-auto-reconcile-draft-saves into main
All checks were successful
CI / lint (push) Successful in 1m52s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-14 23:31:11 +00:00
8a3316a5c4 fix: reconcile concurrent Draft saves (Closes #857)
All checks were successful
CI / lint (pull_request) Successful in 1m52s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 23:28:12 +00:00
631eb2e584 Merge pull request 'File mobile issues without assigning an owner' (#856) from timmy/855-file-mobile-issues-without-owner into main
All checks were successful
CI / lint (push) Successful in 1m52s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
2026-08-14 23:08:40 +00:00
ed246ce6dd feat: file mobile issues without an owner (Closes #855)
All checks were successful
CI / lint (pull_request) Successful in 1m50s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 23:05:59 +00:00
fc35b17d24 Merge pull request 'Preserve both versions when synchronized Drafts diverge' (#854) from timmy/853-divergent-unfiled-drafts into main
All checks were successful
CI / lint (push) Successful in 1m46s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
2026-08-14 22:35:59 +00:00
cee3d93864 fix: preserve divergent synchronized Drafts (Closes #853)
All checks were successful
CI / lint (pull_request) Successful in 1m53s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 22:29:37 +00:00
fca99fee86 Merge pull request 'Continue unfiled issue drafts across signed-in devices' (#852) from timmy/851-cross-device-unfiled-drafts into main
All checks were successful
CI / lint (push) Successful in 1m49s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-14 21:42:29 +00:00
3dd0ba2024 feat: sync unfiled drafts across devices (Closes #851)
All checks were successful
CI / lint (pull_request) Successful in 1m52s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 21:39:22 +00:00
871e35c10b Require a Today estimate before Create & start
All checks were successful
CI / lint (push) Successful in 1m55s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 8s
2026-08-14 20:44:45 +00:00
5c9420ed9c feat: require estimate before create and start (Closes #849)
All checks were successful
CI / lint (pull_request) Successful in 1m48s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 20:41:57 +00:00
8edc83893f Merge pull request 'Load mobile issue-filing metadata in one resilient request' (#848) from timmy/847-issue-filing-metadata into main
All checks were successful
CI / lint (push) Successful in 1m49s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
2026-08-14 20:02:51 +00:00
a226addcc4 feat: load resilient issue filing metadata
All checks were successful
CI / lint (pull_request) Successful in 1m55s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 19:59:45 +00:00
2402af9e82 Merge pull request 'Select repository issue templates while filing on mobile' (#846) from timmy/845-repository-issue-templates into main
All checks were successful
CI / lint (push) Successful in 1m44s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 8s
2026-08-14 19:18:02 +00:00
31f95589cb feat: apply repository templates during mobile issue filing
All checks were successful
CI / lint (pull_request) Successful in 1m49s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
Closes #845
2026-08-14 19:15:19 +00:00
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
All checks were successful
CI / lint (push) Successful in 1m46s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-14 18:32:28 +00:00
6449ead83d feat: choose initial owner during issue filing
All checks were successful
CI / lint (pull_request) Successful in 1m48s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 18:26:33 +00:00
322f025794 Merge pull request 'Choose blockers while filing a mobile issue' (#842) from timmy/841-capture-blockers into main
All checks were successful
CI / lint (push) Successful in 1m43s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-14 17:36:42 +00:00
9ab22b97f8 feat: capture blockers while filing mobile issues (Closes #841)
All checks were successful
CI / lint (pull_request) Successful in 1m45s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 17:33:45 +00:00
35747cf9fb Merge pull request 'Load comment actions only when a conversation opens' (#840) from timmy/839-lazy-comment-actions into main
All checks were successful
CI / lint (push) Successful in 1m43s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
2026-08-14 17:03:09 +00:00
f094438dab Load comment actions on conversation demand
All checks were successful
CI / lint (pull_request) Successful in 1m53s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 17:00:31 +00:00
04f06042b8 Merge pull request 'Bound decoded screenshot pixels before mobile evidence preview' (#838) from timmy/837-bound-decoded-screenshot-pixels into main
All checks were successful
CI / lint (push) Successful in 1m44s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-14 16:37:55 +00:00
a476170fca feat: bound decoded screenshot pixels (Closes #837)
All checks were successful
CI / lint (pull_request) Successful in 1m46s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 16:35:21 +00:00
5a579362ff Merge pull request 'Visually verify mobile evidence in final filing review' (#836) from timmy/835-visual-mobile-evidence-review into main
All checks were successful
CI / lint (push) Successful in 1m47s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-14 16:01:02 +00:00
d797179844 feat: visually review mobile issue evidence (Closes #835)
All checks were successful
CI / lint (pull_request) Successful in 1m44s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 15:56:08 +00:00
a500915223 Merge pull request 'Review complete mobile issue payload before filing' (#834) from timmy/833-mobile-issue-filing-review into main
All checks were successful
CI / lint (push) Successful in 1m47s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
2026-08-14 15:39:36 +00:00
59a2e4f616 feat: review mobile issue payload before filing (Closes #833)
All checks were successful
CI / lint (pull_request) Successful in 1m42s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 15:36:54 +00:00
e125019f57 Merge pull request 'Annotate mobile evidence before filing' (#832) from timmy/831-annotate-mobile-evidence into main
All checks were successful
CI / lint (push) Successful in 1m55s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
2026-08-14 14:59:50 +00:00
6ba823abd2 feat: annotate mobile evidence before filing (Closes #831)
All checks were successful
CI / lint (pull_request) Successful in 1m43s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 14:57:17 +00:00
f44fa6e579 Crop and redact mobile evidence before filing (#830)
All checks were successful
CI / lint (push) Successful in 1m41s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Closes #829
2026-08-14 14:36:29 +00:00
87ff83bd2d feat: crop and redact mobile evidence (Closes #829)
All checks were successful
CI / lint (pull_request) Successful in 1m49s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 14:33:18 +00:00
7dd396154d Merge pull request 'Explain each mobile evidence screenshot before filing' (#828) from timmy/827-evidence-screenshot-notes into main
All checks were successful
CI / lint (push) Successful in 1m51s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-14 13:44:13 +00:00
af5fe9b69d feat: explain mobile evidence screenshots (Closes #827)
All checks were successful
CI / lint (pull_request) Successful in 1m47s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 13:41:02 +00:00
807364c63b Merge pull request 'Review and reorder mobile evidence before filing' (#826) from timmy/825-review-reorder-mobile-evidence into main
All checks were successful
CI / lint (push) Successful in 1m46s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-14 12:47:07 +00:00
6315323a48 feat: review and reorder mobile evidence (Closes #825)
All checks were successful
CI / lint (pull_request) Successful in 1m46s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 12:43:31 +00:00
6a9271cabb Merge pull request 'Capture mobile evidence bundles with one issue' (#824)
All checks were successful
CI / lint (push) Successful in 1m49s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Closes #823
2026-08-14 11:45:28 +00:00
a1c56bd644 feat: capture mobile evidence bundles (Closes #823)
All checks were successful
CI / lint (pull_request) Successful in 1m42s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 11:43:01 +00:00
ebc3b9ee4d Merge pull request 'Save and reopen synced mobile Search views' (#822)
All checks were successful
CI / lint (push) Successful in 1m48s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Closes #821
2026-08-14 10:49:01 +00:00
19772b5797 feat: sync saved mobile search views (Closes #821)
All checks were successful
CI / lint (pull_request) Successful in 1m46s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 10:46:13 +00:00
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
All checks were successful
CI / lint (push) Successful in 1m44s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-14 10:24:46 +00:00
5cda95eeb2 test: remove scheduler race from snapshot freshness check
All checks were successful
CI / lint (pull_request) Successful in 1m41s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 10:14:09 +00:00
0c71dea00c Merge pull request 'Apply mobile Search release plans atomically' (#818) from timmy/817-atomic-search-release-plan into main
Some checks failed
CI / lint (push) Failing after 1m54s
CI / build-release (push) Has been skipped
CI / release-candidate (push) Has been skipped
2026-08-14 10:00:02 +00:00
53539e0cdf feat: apply search release plans atomically (Closes #817)
All checks were successful
CI / lint (pull_request) Successful in 1m43s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 09:57:15 +00:00
9a24aff537 Merge pull request 'Plan selected mobile Search issues into a release milestone' (#816) from timmy/815-plan-search-release into main
All checks were successful
CI / lint (push) Successful in 1m38s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
Plan selected mobile Search issues into a release milestone (#816)
2026-08-14 09:34:28 +00:00
43769460fc feat: plan search batches into releases (Closes #815)
All checks were successful
CI / lint (pull_request) Successful in 1m37s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 09:30:53 +00:00
2e62eb09f5 Merge pull request 'Batch-schedule selected mobile Search issues into Later' (#814) from timmy/813-batch-defer-search-results into main
All checks were successful
CI / lint (push) Successful in 1m36s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
2026-08-14 09:08:14 +00:00
1ef3e291c2 feat: batch-defer mobile search results (Closes #813)
All checks were successful
CI / lint (pull_request) Successful in 1m36s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 09:05:30 +00:00
3694f2501b Merge pull request 'Fit mobile Search batches to Today time capacity' (#812) from timmy/811-search-batch-time-capacity into main
All checks were successful
CI / lint (push) Successful in 1m38s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-14 08:31:24 +00:00
c3d58d7111 feat: fit search batches to Today capacity (Closes #811)
All checks were successful
CI / lint (pull_request) Successful in 1m43s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 08:28:49 +00:00
da45cc36a3 Batch-plan mobile Search results into Today (#810)
All checks were successful
CI / lint (push) Successful in 1m39s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-14 08:08:45 +00:00
c04cba29d9 feat: batch-plan mobile search results into Today (Closes #809)
All checks were successful
CI / lint (pull_request) Successful in 1m37s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 08:06:03 +00:00
cacecac865 Merge pull request 'Make partial mobile Search pagination retryable and lossless' (#808) from timmy/807-lossless-partial-search into main
All checks were successful
CI / lint (push) Successful in 1m34s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-14 07:35:02 +00:00
10f7cc832a fix: make partial search pagination lossless (Closes #807)
All checks were successful
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 07:32:45 +00:00
e5f37a866c Merge pull request 'Reply and continue from mobile Search' (#806) from timmy/805-reply-continue-mobile-search into main
All checks were successful
CI / lint (push) Successful in 1m39s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-14 06:44:29 +00:00
5a4fbd1564 feat: reply and continue from mobile search (Closes #805)
All checks were successful
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 06:41:34 +00:00
7bcab03b8a Merge pull request 'Read current conversations inside mobile Search preview' (#804)
All checks were successful
CI / lint (push) Successful in 1m37s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Closes #803
2026-08-14 06:07:12 +00:00
51fe9e5af2 feat: show conversations in mobile search preview (Closes #803)
All checks were successful
CI / lint (pull_request) Successful in 1m45s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 06:04:25 +00:00
2406d24cb8 Merge pull request 'Continue through mobile Search results while planning work' (#802) from timmy/801-sequential-mobile-search into main
All checks were successful
CI / lint (push) Successful in 1m33s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-14 05:13:09 +00:00
745d2529e5 feat: continue through mobile search results (Closes #801)
All checks were successful
CI / lint (pull_request) Successful in 1m39s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 05:08:56 +00:00
af9699e362 Merge pull request 'Schedule mobile Search results into Later' (#800)
All checks were successful
CI / lint (push) Successful in 1m38s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
Closes #799
2026-08-14 04:32:45 +00:00
71b38687c4 feat: schedule search results into Later (Closes #799)
All checks were successful
CI / lint (pull_request) Successful in 1m42s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 04:30:02 +00:00
0a8f33dc83 Merge pull request 'Queue mobile Search results into Today' (#798) from timmy/797-queue-mobile-search-results into main
All checks were successful
CI / lint (push) Successful in 1m45s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
2026-08-14 04:08:20 +00:00
0f45b7aead feat: queue mobile search results into Today (Closes #797)
All checks were successful
CI / lint (pull_request) Successful in 1m41s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 04:05:49 +00:00
a44bf46bef Merge pull request 'Scope mobile Search to an accessible repository' (#796) from timmy/795-repository-scoped-mobile-search into main
All checks were successful
CI / lint (push) Successful in 1m38s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
2026-08-14 03:14:05 +00:00
ec86bf55a8 Scope mobile Search by repository
All checks were successful
CI / lint (pull_request) Successful in 1m37s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 03:11:40 +00:00
7636e30482 Merge pull request 'Scope mobile Search by type and status' (#794) from timmy/793-scope-mobile-search into main
All checks were successful
CI / lint (push) Successful in 1m42s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
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
bd054998b2 feat: scope mobile search by type and status (Closes #793)
All checks were successful
CI / lint (pull_request) Successful in 1m45s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 02:34:44 +00:00
3c65bc1b31 Merge pull request 'Open requested pull reviews from mobile Search' (#792) from timmy/791-open-requested-reviews-from-search into main
All checks were successful
CI / lint (push) Successful in 1m36s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
2026-08-14 01:47:39 +00:00
bc082f2bd9 feat: open requested reviews from mobile search (Closes #791)
All checks were successful
CI / lint (pull_request) Successful in 1m45s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 01:44:51 +00:00
9ae0a89970 Merge pull request 'Share resumable mobile Search previews' (#790) from timmy/787-share-resumable-search-previews into main
All checks were successful
CI / lint (push) Successful in 1m37s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-14 01:07:05 +00:00
43163ed926 feat: share resumable mobile search previews (Closes #787)
All checks were successful
CI / lint (pull_request) Successful in 1m41s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 01:04:20 +00:00
66a2b78860 Merge pull request 'Open assigned pull requests from mobile Search' (#786) from timmy/785-open-assigned-pulls-from-search into main
All checks were successful
CI / lint (push) Successful in 1m31s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-14 00:27:40 +00:00
20d4f1ef7e feat: open assigned pulls from mobile search (Closes #785)
All checks were successful
CI / lint (pull_request) Successful in 1m31s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 00:25:07 +00:00
70281bb56a Merge pull request 'Load complete, balanced results in mobile Search' (#784) from timmy/783-paginated-balanced-mobile-search into main
All checks were successful
CI / lint (push) Successful in 1m32s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
2026-08-14 00:06:24 +00:00
7b736bc4c6 feat: paginate balanced mobile search results (Closes #783)
All checks were successful
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 00:04:06 +00:00
fa3e5e3bf0 Merge pull request 'Resume addressable mobile Search sessions' (#782) from timmy/781-resumable-mobile-search into main
All checks were successful
CI / lint (push) Successful in 1m32s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Merge pull request Resume addressable mobile Search sessions (#782)
2026-08-13 23:27:44 +00:00
bc0d47e8e7 feat: resume addressable mobile search sessions (Closes #781)
All checks were successful
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 23:25:35 +00:00
741c27eacf Merge pull request 'Authorize and audit permanent comment deletion' (#780) from timmy/779-authorize-audit-comment-deletion into main
All checks were successful
CI / lint (push) Successful in 1m26s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
2026-08-13 22:58:01 +00:00
3896f17a58 feat: authorize and audit comment deletion (Closes #779)
All checks were successful
CI / lint (pull_request) Successful in 1m28s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 22:55:47 +00:00
9d14fa5ffb Merge pull request 'Reset mobile work details when advancing to another item' (#778) from timmy/777-reset-mobile-work-detail-position into main
All checks were successful
CI / lint (push) Successful in 1m34s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-13 22:30:11 +00:00
83be8fcdbe fix: reset mobile work detail position (Closes #777)
All checks were successful
CI / lint (pull_request) Successful in 1m25s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 22:28:08 +00:00
35f3999787 Merge pull request 'Complete review requests without leaving mobile Updates triage' (#776) from timmy/775-update-review-handoff into main
All checks were successful
CI / lint (push) Successful in 1m35s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-13 22:01:54 +00:00
378291d3c8 feat: review pull requests from update triage (Closes #775)
All checks were successful
CI / lint (pull_request) Successful in 1m42s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 21:59:35 +00:00
584752ed24 Merge pull request 'Prioritize actionable work in mobile Updates triage' (#774) from timmy/773-actionable-update-triage into main
All checks were successful
CI / lint (push) Successful in 1m31s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
2026-08-13 21:26:58 +00:00
d54f58bf36 feat: prioritize actionable update triage (Closes #773)
All checks were successful
CI / lint (pull_request) Successful in 1m37s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 21:24:34 +00:00
84302d6f1e Merge pull request 'Make mobile Update decisions transaction-safe' (#772) from timmy/771-transaction-safe-update-decisions into main
All checks were successful
CI / lint (push) Successful in 1m39s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-13 21:01:00 +00:00
2e0d64907b fix: make update decisions transaction-safe (Closes #771)
All checks were successful
CI / lint (pull_request) Successful in 1m36s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 20:58:43 +00:00
80042ce561 Merge pull request 'Add one-handed swipe decisions to mobile Updates triage' (#770) from timmy/769-mobile-update-swipe-triage into main
All checks were successful
CI / lint (push) Successful in 1m34s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-13 20:32:44 +00:00
931f656f78 feat: add mobile update swipe triage (Closes #769)
All checks were successful
CI / lint (pull_request) Successful in 1m41s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 20:30:15 +00:00
18f56583ba Merge pull request 'Recover mobile sign-in from stalled requests' (#768) from timmy/767-stalled-login-recovery into main
All checks were successful
CI / lint (push) Successful in 1m35s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-13 19:54:59 +00:00
6e2f5d1120 fix: recover stalled mobile sign-in (Closes #767)
All checks were successful
CI / lint (pull_request) Successful in 1m32s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 19:52:44 +00:00
9bd755f521 Merge pull request 'Review kept-unread outcomes after mobile Updates triage' (#766) from timmy/765-update-outcome-review into main
All checks were successful
CI / lint (push) Successful in 1m33s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-13 19:23:52 +00:00
fd2f801f3e feat: review mobile update triage outcomes (Closes #765)
All checks were successful
CI / lint (pull_request) Successful in 1m30s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 19:21:36 +00:00
e21cc58cca Merge pull request 'Keep mobile Update decisions within thumb reach' (#764) from timmy/763-mobile-update-decision-bar into main
All checks were successful
CI / lint (push) Successful in 1m31s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
2026-08-13 18:56:13 +00:00
d6212b2812 feat: keep mobile update decisions within reach (Closes #763)
All checks were successful
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 18:53:50 +00:00
6528b2e73f Merge pull request 'Load mobile update conversations without blocking core detail' (#762) from timmy/761-progressive-update-conversation into main
All checks were successful
CI / lint (push) Successful in 1m36s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-13 18:27:41 +00:00
a9d586e689 feat: load update conversations progressively (Closes #761)
All checks were successful
CI / lint (pull_request) Successful in 1m38s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 18:25:21 +00:00
7c86f28346 Merge pull request 'Open each mobile update at its newest unread activity' (#760) from timmy/759-newest-update-activity into main
All checks were successful
CI / lint (push) Successful in 1m33s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
2026-08-13 17:58:03 +00:00
707ee4c1bc feat: open updates at newest activity (Closes #759)
All checks were successful
CI / lint (pull_request) Successful in 1m41s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 17:55:33 +00:00
82982e5741 Merge pull request 'Prefetch the next mobile update during triage' (#758) from timmy/757-prefetch-next-mobile-update into main
All checks were successful
CI / lint (push) Successful in 1m32s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-13 17:33:37 +00:00
db08c7e75f feat: prefetch the next mobile update (Closes #757)
All checks were successful
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 17:31:20 +00:00
1a3df5d7a7 Merge pull request 'Make the mobile Work action launch the highest-priority queue' (#756) from timmy/755-priority-mobile-work into main
All checks were successful
CI / lint (push) Successful in 1m31s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
2026-08-13 17:02:12 +00:00
fdba0554df feat: prioritize the mobile Work action (Closes #755)
All checks were successful
CI / lint (pull_request) Successful in 1m39s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 16:59:36 +00:00
d2a85cd5ea Merge pull request 'Bound mobile Updates discovery with one atomic snapshot' (#754) from timmy/753-atomic-updates-snapshot into main
All checks were successful
CI / lint (push) Successful in 1m40s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-13 16:27:32 +00:00
1f034a6f64 feat: make Updates discovery atomic (Closes #753)
All checks were successful
CI / lint (pull_request) Successful in 1m43s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 16:25:03 +00:00
de6d6d0e61 Merge pull request 'Mute future updates during mobile triage' (#752) from timmy/751-mute-future-updates into main
All checks were successful
CI / lint (push) Successful in 1m32s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-13 15:57:37 +00:00
8dc3880167 feat: mute future updates during triage (Closes #751)
All checks were successful
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 15:55:02 +00:00
b93e63ab70 Merge pull request 'Complete unread discovery before mobile Updates triage' (#750) from timmy/749-complete-updates-discovery into main
All checks were successful
CI / lint (push) Successful in 1m32s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-13 15:34:15 +00:00
8c82644515 feat: complete Updates discovery before triage (Closes #749)
All checks were successful
CI / lint (pull_request) Successful in 1m37s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 15:28:17 +00:00
6c6cd3c71b Merge pull request 'Resume interrupted mobile Updates triage' (#748) from timmy/747-resumable-updates-triage into main
All checks were successful
CI / lint (push) Successful in 1m40s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-13 15:00:45 +00:00
4f6cebae7c feat: resume mobile Updates triage (Closes #747)
All checks were successful
CI / lint (pull_request) Successful in 1m37s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 14:58:19 +00:00
341a3283ea Merge pull request 'Make live data status truthful and actionable' (#746) from timmy/745-live-data-status into main
All checks were successful
CI / lint (push) Successful in 1m35s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
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
9c276fa251 feat: make live data status actionable (Closes #745)
All checks were successful
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 14:26:09 +00:00
940f7c05d2 Merge pull request 'Make the mobile Queues summary truthful' (#744) from timmy/743-truthful-mobile-queue-summary into main
All checks were successful
CI / lint (push) Successful in 1m36s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-13 13:55:28 +00:00
d34c082da2 fix: make mobile queue summary truthful (Closes #743)
All checks were successful
CI / lint (pull_request) Successful in 1m37s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 13:53:04 +00:00
1c086be09b Merge pull request 'Preserve shared screenshots through operator sign-in' (#742) from timmy/741-preserve-shared-screenshot-signin into main
All checks were successful
CI / lint (push) Successful in 1m31s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-13 13:27:39 +00:00
4cfcb92eec fix: preserve shared screenshots through sign-in (Closes #741)
All checks were successful
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 13:25:26 +00:00
b484bbef03 Merge pull request 'Share screenshots directly into mobile capture' (#740)
All checks were successful
CI / lint (push) Successful in 1m29s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Closes #739
2026-08-13 12:58:07 +00:00
623817eb7d feat: share screenshots into mobile capture (Closes #739)
All checks were successful
CI / lint (pull_request) Successful in 1m27s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 12:55:47 +00:00
11347e5fe0 Merge pull request 'Launch installed mobile work from task-specific app shortcuts' (#738) from timmy/737-pwa-task-shortcuts into main
All checks were successful
CI / lint (push) Successful in 1m35s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-13 12:39:20 +00:00
bb1712279c feat: launch mobile work from PWA shortcuts (Closes #737)
All checks were successful
CI / lint (pull_request) Successful in 1m31s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 12:26:27 +00:00
e7d5196987 Merge pull request 'Wait for complete Agenda discovery before starting a session' (#736)
All checks were successful
CI / lint (push) Successful in 1m26s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 8s
Closes #735
2026-08-13 11:58:30 +00:00
64ff5127d8 fix: wait for complete Agenda discovery (Closes #735)
All checks were successful
CI / lint (pull_request) Successful in 1m26s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 11:56:23 +00:00
4ac5a0f499 Merge pull request 'Make mobile Agenda sessions directly launchable and resumable' (#734) from timmy/733-resumable-mobile-agenda into main
All checks were successful
CI / lint (push) Successful in 1m32s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Merge pull request 'Make mobile Agenda sessions directly launchable and resumable' (#734)
2026-08-13 11:29:15 +00:00
715df380e0 feat: resume mobile Agenda sessions (Closes #733)
All checks were successful
CI / lint (pull_request) Successful in 1m25s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 11:27:13 +00:00
1fc70f6890 Merge pull request 'Run the mobile Agenda as a sequential work session' (#732)
All checks were successful
CI / lint (push) Successful in 1m24s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
Closes #731
2026-08-13 10:57:28 +00:00
523968f5a3 feat: run Agenda as a work session (Closes #731)
All checks were successful
CI / lint (pull_request) Successful in 1m27s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 10:54:58 +00:00
af27bb06e0 Merge pull request 'Bound assigned-issue reminder snapshots' (#730) from timmy/729-bounded-assigned-snapshot into main
All checks were successful
CI / lint (push) Successful in 1m29s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-13 10:26:37 +00:00
4410af09a6 Bound assigned-issue reminder snapshots
All checks were successful
CI / lint (pull_request) Successful in 1m30s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 10:24:23 +00:00
880f60831a Merge pull request 'Configure the deadline reminder horizon per device' (#728) from timmy/727-configurable-deadline-horizon into main
All checks were successful
CI / lint (push) Successful in 1m26s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-13 09:57:25 +00:00
e860d16d29 feat: configure deadline reminder horizon (Closes #727)
All checks were successful
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 09:55:09 +00:00
abf8e23685 Merge pull request 'Bulk-authorize push devices before notification fan-out' (#726) from timmy/725-bulk-push-authorization into main
All checks were successful
CI / lint (push) Successful in 1m27s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-13 09:31:45 +00:00
02a472f841 perf: bulk-authorize push devices (Closes #725)
All checks were successful
CI / lint (pull_request) Successful in 1m35s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 09:29:18 +00:00
c5e6e17fc1 Merge pull request 'Launch Protect Today directly from deadline reminders' (#724) from timmy/723-deadline-protect-today into main
All checks were successful
CI / lint (push) Successful in 1m35s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-13 08:59:34 +00:00
8a12524595 feat: launch Protect Today from deadline reminders (Closes #723)
All checks were successful
CI / lint (pull_request) Successful in 1m22s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 08:57:32 +00:00
cbe4017bdc Merge pull request 'Protect Today by reconciling urgent Agenda deadlines' (#722) from timmy/721-protect-today into main
All checks were successful
CI / lint (push) Successful in 1m30s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-13 08:30:35 +00:00
fcc9ead33e feat: protect Today from urgent deadlines (Closes #721)
All checks were successful
CI / lint (pull_request) Successful in 1m26s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 08:27:01 +00:00
0d29b29bef Merge pull request 'Preserve deadline calendar days across mobile time zones' (#720) from timmy/719-calendar-day-deadlines into main
All checks were successful
CI / lint (push) Successful in 1m21s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
2026-08-13 07:58:32 +00:00
dce9ae04c9 fix: preserve deadline calendar days (Closes #719)
All checks were successful
CI / lint (pull_request) Successful in 1m23s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 07:56:26 +00:00
9ec88179c3 Merge pull request 'Make deadline reminder polling eligibility-aware' (#718) from timmy/717-deadline-eligibility into main
All checks were successful
CI / lint (push) Successful in 1m36s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-13 07:29:20 +00:00
93890e72de test: synchronize deadline fanout assertion
All checks were successful
CI / lint (pull_request) Successful in 1m26s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 07:27:22 +00:00
8f0df3d393 perf: gate deadline reminder polling (Closes #717)
Some checks failed
CI / lint (pull_request) Failing after 1m27s
CI / build-release (pull_request) Has been skipped
CI / release-candidate (pull_request) Has been skipped
2026-08-13 07:23:05 +00:00
fc79e4523d Merge pull request 'Configure deadline reminder time during mobile device setup' (#716) from timmy/configurable-deadline-device-setup into main
All checks were successful
CI / lint (push) Successful in 1m39s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-13 06:59:43 +00:00
741d545199 feat: configure deadline reminder time in device setup (Closes #715)
All checks were successful
CI / lint (pull_request) Successful in 1m26s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 06:57:39 +00:00
9320016b50 Merge pull request 'Run push notification channels on independent fixed cadences' (#714) from timmy/713-independent-push-cadences into main
All checks were successful
CI / lint (push) Successful in 1m32s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-13 06:25:01 +00:00
b069379df0 fix: isolate push channel schedules (Closes #713)
All checks were successful
CI / lint (pull_request) Successful in 1m27s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 06:22:58 +00:00
8387c57ceb Merge pull request 'Isolate and bound deadline reminder delivery' (#712) from timmy/711-isolate-deadline-reminders into main
All checks were successful
CI / lint (push) Successful in 1m29s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-13 05:59:02 +00:00
72842ecbcd fix: isolate deadline reminder dispatch (Closes #711)
All checks were successful
CI / lint (pull_request) Successful in 1m24s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 05:55:50 +00:00
c503994be1 Merge pull request 'Send opt-in mobile deadline reminders into Agenda' (#710) from timmy/709-mobile-deadline-reminders into main
All checks were successful
CI / lint (push) Successful in 1m25s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
2026-08-13 05:11:29 +00:00
5e6328a8d0 feat: send mobile deadline reminders (Closes #709)
All checks were successful
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 05:00:32 +00:00
4209834d6d Merge pull request 'Replan overdue Agenda deadlines in one mobile sweep' (#708) from timmy/707-mobile-agenda-replan into main
All checks were successful
CI / lint (push) Successful in 1m32s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-13 04:29:06 +00:00
46888d2876 feat: replan overdue Agenda deadlines (Closes #707)
All checks were successful
CI / lint (pull_request) Successful in 1m27s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 04:26:53 +00:00
7d7abefb0b Merge pull request 'Surface upcoming deadlines in the mobile task dock' (#706) from timmy/705-mobile-deadline-dock into main
All checks were successful
CI / lint (push) Successful in 1m23s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-13 03:52:58 +00:00
2796808759 feat: surface mobile deadline urgency (Closes #705)
All checks were successful
CI / lint (pull_request) Successful in 1m24s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 03:50:53 +00:00
a2f83b0955 Merge pull request 'Make mobile Agenda discover every assigned deadline' (#704) from timmy/703-complete-mobile-agenda into main
All checks were successful
CI / lint (push) Successful in 1m25s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
2026-08-13 03:27:33 +00:00
6b56188d30 feat: complete mobile Agenda discovery (Closes #703)
All checks were successful
CI / lint (pull_request) Successful in 1m27s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 03:25:25 +00:00
467c8c665d Merge pull request 'Launch fresh mobile work in the Agenda queue' (#702) from timmy/701-mobile-agenda-launch into main
All checks were successful
CI / lint (push) Successful in 1m28s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-13 02:53:37 +00:00
1b311c9cd4 feat: launch fresh mobile work in Agenda (Closes #701)
All checks were successful
CI / lint (pull_request) Successful in 1m27s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 02:51:25 +00:00
71d06347fd Merge pull request 'Keep Today and Later reads available during planning writes' (#700)
All checks were successful
CI / lint (push) Successful in 1m33s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
2026-08-13 02:24:45 +00:00
1de6d73e75 perf: keep planning reads available during writes (Closes #699)
All checks were successful
CI / lint (pull_request) Successful in 1m28s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 02:22:29 +00:00
46c75f0b22 Merge pull request 'Continue cleared mobile queues into Agenda deadlines' (#698) from timmy/697-agenda-queue-continuation into main
All checks were successful
CI / lint (push) Successful in 1m25s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-13 01:55:57 +00:00
5cdb3f7b3e feat: continue cleared queues into Agenda (Closes #697)
All checks were successful
CI / lint (pull_request) Successful in 1m28s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 01:53:36 +00:00
15d197327a Merge pull request 'Keep cold Find Work startup reliable across workers' (#696) from timmy/695-cold-find-work-workers into main
All checks were successful
CI / lint (push) Successful in 1m36s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-13 01:25:01 +00:00
df8385b325 fix: keep cold Find Work reliable across workers (Closes #695)
All checks were successful
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 01:22:42 +00:00
47c6412a12 Merge pull request 'Make the mobile Agenda addressable and Back-safe' (#694) from timmy/693-mobile-agenda-route into main
All checks were successful
CI / lint (push) Successful in 1m27s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-13 00:55:20 +00:00
6cac778acd feat: make mobile Agenda addressable (Closes #693)
All checks were successful
CI / lint (pull_request) Successful in 1m26s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 00:53:09 +00:00
a8a93b2f3b Merge pull request 'Start work from an empty mobile queue' (#692) from timmy/691-empty-mobile-find-start into main
All checks were successful
CI / lint (push) Successful in 1m30s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-13 00:26:37 +00:00
ef37a66fd7 feat: start work from an empty mobile queue (Closes #691)
All checks were successful
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 00:24:04 +00:00
52e2c26304 Merge pull request 'Stabilize recap bounded-concurrency verification' (#690) from timmy/689-stabilize-recap-concurrency-test into main
All checks were successful
CI / lint (push) Successful in 1m30s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 23:55:24 +00:00
3817d2c67b test: stabilize recap concurrency verification (Closes #689)
All checks were successful
CI / lint (pull_request) Successful in 1m26s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 23:52:56 +00:00
ad6c1edf80 Merge pull request 'Isolate readiness probes from live Gitea traffic' (#688) from timmy/687-isolate-readiness into main
All checks were successful
CI / lint (push) Successful in 1m56s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 8s
2026-08-12 23:26:57 +00:00
d2b2c7bada feat: isolate readiness probes from Gitea traffic (Closes #687)
All checks were successful
CI / lint (pull_request) Successful in 2m1s
CI / build-release (pull_request) Successful in 9s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 23:10:29 +00:00
9ba03613d4 Merge pull request 'Plan upcoming deadlines from a mobile Agenda' (#686) from timmy/685-mobile-agenda into main
Some checks failed
CI / lint (push) Failing after 1m40s
CI / build-release (push) Has been skipped
CI / release-candidate (push) Has been skipped
2026-08-12 22:30:56 +00:00
1132075318 feat: add mobile deadline Agenda (Closes #685)
All checks were successful
CI / lint (pull_request) Successful in 1m39s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 22:28:36 +00:00
16b1d8670f Merge pull request 'Continue mobile work across queue boundaries' (#684) from timmy/683-mobile-queue-continuation into main
All checks were successful
CI / lint (push) Successful in 1m38s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 21:59:42 +00:00
e5c5c8ec31 feat: continue mobile work across queues (Closes #683)
All checks were successful
CI / lint (pull_request) Successful in 1m25s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 21:57:34 +00:00
87e0d74414 Merge pull request 'Filter Find Work by repository and label' (#682) from timmy/681-find-work-facets into main
All checks were successful
CI / lint (push) Successful in 1m25s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-12 21:35:47 +00:00
f031a37e8d feat: filter Find Work by repository and label (Closes #681)
All checks were successful
CI / lint (pull_request) Successful in 1m26s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 21:33:27 +00:00
057bec7dc2 Merge pull request 'Resume interrupted Find Work batches without duplicate claims' (#680) from timmy/679-resume-find-work-batches into main
All checks were successful
CI / lint (push) Successful in 1m28s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-12 21:04:39 +00:00
654ae8714e feat: resume interrupted Find Work batches (Closes #679)
All checks were successful
CI / lint (pull_request) Successful in 1m25s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 21:02:25 +00:00
a0dd044995 Merge pull request 'Fill remaining Today slots from Find Work' (#678) from timmy/677-fill-today-from-find-work into main
All checks were successful
CI / lint (push) Successful in 1m27s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
2026-08-12 20:27:50 +00:00
c93a682dfb feat: fill Today from ranked Find Work results (Closes #677)
All checks were successful
CI / lint (pull_request) Successful in 1m31s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 20:25:37 +00:00
5675ca5d41 Merge pull request 'Make Find Work batch claiming respect Today time capacity' (#676) from timmy/675-find-work-time-capacity into main
All checks were successful
CI / lint (push) Successful in 1m26s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-12 19:56:09 +00:00
0767ccaa9d feat: budget Find Work batches by time (Closes #675)
All checks were successful
CI / lint (pull_request) Successful in 1m28s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 19:53:57 +00:00
c6eabb5fc7 Merge pull request 'Search the full Find Work catalog' (#674) from timmy/673-search-find-work into main
All checks were successful
CI / lint (push) Successful in 1m25s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-12 19:27:10 +00:00
77fab85a66 feat: search the full Find Work catalog (Closes #673)
All checks were successful
CI / lint (pull_request) Successful in 1m30s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 19:24:55 +00:00
dcf26c896c Merge pull request 'Batch-claim available work into Today from Find Work' (#672) from timmy/671-batch-claim-find-work into main
All checks were successful
CI / lint (push) Successful in 1m29s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 19:02:54 +00:00
fba1f4238d feat: batch-claim available work into Today (Closes #671)
All checks were successful
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 18:58:26 +00:00
9e301924b4 Merge pull request 'Select all matching work for mobile batch planning' (#670) from timmy/669-select-all-matching-work into main
All checks were successful
CI / lint (push) Successful in 1m31s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
2026-08-12 18:25:41 +00:00
8edd542379 feat: select matching mobile work in one step (Closes #669)
All checks were successful
CI / lint (pull_request) Successful in 1m27s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 18:23:34 +00:00
0fbe047e03 Merge pull request 'Find work within the active mobile queue' (#668) from timmy/667-find-active-queue into main
All checks were successful
CI / lint (push) Successful in 1m27s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 17:58:48 +00:00
995d8f8527 feat: find work within active queues (Closes #667)
All checks were successful
CI / lint (pull_request) Successful in 1m26s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 17:56:42 +00:00
fabd6123f8 Create follow-up and continue the Updates inbox (#666)
All checks were successful
CI / lint (push) Successful in 1m30s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
Closes #665
2026-08-12 17:13:59 +00:00
2553e7121b feat: create follow-up and continue updates (Closes #665)
All checks were successful
CI / lint (pull_request) Successful in 1m25s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 17:10:16 +00:00
681e10c7db Merge pull request 'Create follow-up work from unread updates' (#664) from timmy/663-update-follow-up into main
All checks were successful
CI / lint (push) Successful in 1m32s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 16:35:34 +00:00
d96a77c5d5 feat: create follow-up work from updates (Closes #663)
All checks were successful
CI / lint (pull_request) Successful in 1m32s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 16:32:50 +00:00
97eb928187 Merge pull request 'Batch-plan work directly from mobile queues' (#662) from timmy/661-mobile-batch-planning into main
All checks were successful
CI / lint (push) Successful in 1m37s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
2026-08-12 16:05:43 +00:00
80a4096118 feat: batch-plan mobile work queues (Closes #661)
All checks were successful
CI / lint (pull_request) Successful in 1m42s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 16:03:04 +00:00
f04a547e19 Merge pull request 'Defer selected mobile updates as one unread batch' (#660) from timmy/659-defer-selected-updates into main
All checks were successful
CI / lint (push) Successful in 1m37s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
2026-08-12 15:14:22 +00:00
ea3712ca3e feat: defer selected updates as unread batch (Closes #659)
All checks were successful
CI / lint (pull_request) Successful in 1m36s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 15:11:51 +00:00
7421a8f62d Merge pull request 'Make every mobile Work queue addressable and Back-safe' (#658) from timmy/657-addressable-mobile-work-queues into main
All checks were successful
CI / lint (push) Successful in 1m56s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 8s
2026-08-12 14:31:25 +00:00
982c2a1fb7 feat: make mobile work queues addressable (Closes #657)
All checks were successful
CI / lint (pull_request) Successful in 1m38s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 14:29:07 +00:00
3e7d6f7111 Merge pull request 'Isolate unexpected Web Push device failures' (#656) from timmy/655-isolate-push-device-failures into main
All checks were successful
CI / lint (push) Successful in 1m22s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
2026-08-12 13:55:56 +00:00
32394fcd8f fix: isolate Web Push device failures (Closes #655)
All checks were successful
CI / lint (pull_request) Successful in 1m27s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 13:53:38 +00:00
ea3194cdca Merge pull request 'Restrict Web Push delivery to public endpoints' (#654) from timmy/653-push-endpoint-egress-policy into main
All checks were successful
CI / lint (push) Successful in 1m37s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
Merge pull request 'Restrict Web Push delivery to public endpoints' (#654)
2026-08-12 13:32:32 +00:00
85043b4af6 fix: restrict Web Push endpoint egress (Closes #653)
All checks were successful
CI / lint (pull_request) Successful in 1m46s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 13:29:50 +00:00
dc202bde11 Merge pull request 'Consolidate mobile My Work actions into the thumb dock' (#652) from timmy/651-mobile-command-center into main
All checks were successful
CI / lint (push) Successful in 1m54s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
2026-08-12 13:04:37 +00:00
c4ba78c8cb feat: consolidate mobile work commands (Closes #651)
All checks were successful
CI / lint (pull_request) Successful in 1m51s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 13:01:41 +00:00
e35d7dea35 Merge pull request 'Undo accidental mobile update acknowledgements' (#650) from timmy/649-undo-update-acknowledgement into main
All checks were successful
CI / lint (push) Successful in 1m33s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
2026-08-12 12:37:12 +00:00
b81d695585 feat: undo accidental update acknowledgements (Closes #649)
All checks were successful
CI / lint (pull_request) Successful in 1m36s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 12:34:55 +00:00
75da76c6f5 Merge pull request 'Open Web Push digests in the mobile Updates inbox' (#648) from timmy/647-push-updates-inbox into main
All checks were successful
CI / lint (push) Successful in 1m43s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 12:04:40 +00:00
f78d69fba5 fix: open push digests in Updates inbox (Closes #647)
All checks were successful
CI / lint (pull_request) Successful in 1m38s
CI / build-release (pull_request) Successful in 8s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 11:58:11 +00:00
8ce64d2aa7 Merge pull request 'Launch work directly from mobile queues' (#646) from timmy/645-mobile-queue-launcher into main
All checks were successful
CI / lint (push) Successful in 1m39s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 8s
2026-08-12 11:28:51 +00:00
48f75cff05 feat: launch work directly from mobile queues (Closes #645)
All checks were successful
CI / lint (pull_request) Successful in 1m36s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 11:26:35 +00:00
58260d8a26 Merge pull request 'Keep mobile inline review above the keyboard' (#644) from timmy/mobile-review-keyboard-safe into main
All checks were successful
CI / lint (push) Successful in 1m30s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
2026-08-12 10:56:08 +00:00
54ff44462e fix: keep mobile inline review above keyboard (Closes #643)
All checks were successful
CI / lint (pull_request) Successful in 1m36s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 10:53:56 +00:00
a87a5a4a90 Merge pull request 'Detect and surface atomic passkey counter anomalies' (#642) from timmy/641-passkey-counter-anomalies into main
All checks were successful
CI / lint (push) Successful in 1m32s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 10:29:03 +00:00
c91194c283 fix: detect atomic passkey counter anomalies (Closes #641)
All checks were successful
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 10:26:51 +00:00
5806d47b61 Merge pull request 'Expose unread Updates in the mobile queue switcher' (#640) from timmy/639-mobile-updates-queue into main
All checks were successful
CI / lint (push) Successful in 1m34s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-12 09:54:56 +00:00
95f24a4e90 feat: expose Updates in mobile queues (Closes #639)
All checks were successful
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 09:52:41 +00:00
c016261b1a Merge pull request 'Bound Build my Today readiness checks' (#638)
All checks were successful
CI / lint (push) Successful in 1m40s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
Closes #637
2026-08-12 09:28:47 +00:00
bbc3e10525 perf: bound Build my Today readiness checks (Closes #637)
All checks were successful
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 09:26:31 +00:00
9737300d55 Merge pull request 'Complete Build my Today with inline missing estimates' (#636) from timmy/635-inline-plan-estimates into main
All checks were successful
CI / lint (push) Successful in 1m29s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
2026-08-12 08:58:11 +00:00
a08a51af02 feat: finish Today plans with inline estimates (Closes #635)
All checks were successful
CI / lint (pull_request) Successful in 1m31s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 08:55:52 +00:00
528d721cd3 Merge pull request 'Build my Today from ranked ready work' (#634) from timmy/633-build-my-today into main
All checks were successful
CI / lint (push) Successful in 1m36s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 08:38:49 +00:00
a91788276c feat: build a ready capacity-fit Today plan (Closes #633)
All checks were successful
CI / lint (pull_request) Successful in 1m30s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 08:28:03 +00:00
28a2c9faf1 Merge pull request 'Make Draft filing admission atomic and retry-safe' (#632) from timmy/631-atomic-draft-admission into main
All checks were successful
CI / lint (push) Successful in 1m29s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-12 07:57:53 +00:00
b8befc2041 fix: make Draft admission retry-safe (Closes #631)
All checks were successful
CI / lint (pull_request) Successful in 1m29s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 07:54:12 +00:00
d23c0c894b Merge pull request 'File mobile Drafts in one sequential session' (#630) from timmy/629-sequential-draft-filing into main
All checks were successful
CI / lint (push) Successful in 1m31s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
2026-08-12 07:20:10 +00:00
7948d2e353 feat: file mobile Drafts sequentially (Closes #629)
All checks were successful
CI / lint (pull_request) Successful in 1m29s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 07:18:02 +00:00
1079ec6561 Merge pull request 'Unify mobile work queues in a stable dock switcher' (#628) from timmy/627-mobile-queue-switcher into main
All checks were successful
CI / lint (push) Successful in 1m41s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 06:29:46 +00:00
8d142c808b feat: unify mobile work queues (Closes #627)
All checks were successful
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 06:27:00 +00:00
776ae70019 Merge pull request 'Preserve mobile Drafts when attachment cleanup fails' (#626) from timmy/625-draft-mutation-rollback into main
All checks were successful
CI / lint (push) Successful in 1m22s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 05:56:46 +00:00
285fdff869 fix: preserve Drafts when cleanup fails (Closes #625)
All checks were successful
CI / lint (pull_request) Successful in 1m32s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 05:54:19 +00:00
89d153d6a5 Merge pull request 'Prevent silent mobile Draft loss at capacity' (#624) from timmy/623-draft-capacity-guard into main
All checks were successful
CI / lint (push) Successful in 1m34s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 05:38:48 +00:00
fcb0575e38 feat: guard mobile Draft capacity (Closes #623)
All checks were successful
CI / lint (pull_request) Successful in 1m31s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 05:36:32 +00:00
14ba1bcf14 Merge pull request 'Complete the mobile Save to Drafts handoff' (#622) from timmy/621-mobile-draft-handoff into main
All checks were successful
CI / lint (push) Successful in 1m28s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-12 04:57:56 +00:00
a68639668c feat: complete mobile Draft handoff (Closes #621)
All checks were successful
CI / lint (pull_request) Successful in 1m27s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 04:54:45 +00:00
1f0e9fb59d Merge pull request 'Make PWA updates resilient with progressive feature caching' (#620) from timmy/619-progressive-feature-caching into main
All checks were successful
CI / lint (push) Successful in 1m30s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 04:26:38 +00:00
065275b4ef feat: progressively cache optional PWA features (Closes #619)
All checks were successful
CI / lint (pull_request) Successful in 1m31s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 04:24:18 +00:00
90f1c48565 Merge pull request 'Purge capture-first screenshot Drafts at every private-data boundary' (#618) from timmy/617-purge-capture-drafts into main
All checks were successful
CI / lint (push) Successful in 1m26s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 04:02:55 +00:00
ae07dd185c fix: purge capture Draft screenshots (Closes #617)
All checks were successful
CI / lint (pull_request) Successful in 1m31s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 03:58:10 +00:00
f43f776479 Merge pull request 'Preserve screenshots in capture-first mobile Drafts' (#616) from timmy/615-capture-first-screenshots into main
All checks were successful
CI / lint (push) Successful in 1m25s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-12 03:35:30 +00:00
48e4344f28 feat: preserve capture-first screenshots in Drafts (Closes #615)
All checks were successful
CI / lint (pull_request) Successful in 1m24s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 03:33:19 +00:00
269916486c Merge pull request 'Make mobile New a capture-first flow' (#614) from timmy/613-mobile-capture-first into main
All checks were successful
CI / lint (push) Successful in 1m27s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 02:56:56 +00:00
07235d3870 feat: make mobile New capture-first (Closes #613)
All checks were successful
CI / lint (pull_request) Successful in 1m23s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 02:54:44 +00:00
8c32a9dd81 Merge pull request 'Start an empty Today plan from the mobile Work dock' (#612) from timmy/611-mobile-work-plan-today into main
All checks were successful
CI / lint (push) Successful in 1m27s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
2026-08-12 02:28:30 +00:00
9edd3f6441 feat: start empty Today planning from mobile dock (Closes #611)
All checks were successful
CI / lint (pull_request) Successful in 1m26s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 02:24:10 +00:00
4faf0447be Merge pull request 'Bind operator mode to a canonical public origin' (#610) from timmy/609-canonical-public-origin into main
All checks were successful
CI / lint (push) Successful in 1m27s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-12 01:57:06 +00:00
8ea46974ff feat: bind operator mode to public origin (Closes #609)
All checks were successful
CI / lint (pull_request) Successful in 1m30s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 01:54:51 +00:00
075a277265 Merge pull request 'Log Today recap time concurrently within the foreground deadline' (#608)
All checks were successful
CI / lint (push) Successful in 1m32s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-12 01:24:43 +00:00
a7ec4437d9 feat: bound concurrent recap time logging (Closes #607)
All checks were successful
CI / lint (pull_request) Successful in 1m30s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 01:22:33 +00:00
bf2cf37151 Merge pull request 'Lazy-load active devices and security activity' (#606) from timmy/605-lazy-security-center into main
All checks were successful
CI / lint (push) Successful in 1m30s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 8s
2026-08-12 01:03:13 +00:00
fb58ba30b2 feat: lazy-load security center (Closes #605)
All checks were successful
CI / lint (pull_request) Successful in 1m31s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 01:00:32 +00:00
17823352f2 Merge pull request 'Review unfinished Today work when a new local day begins' (#604) from timmy/603-today-day-rollover into main
All checks were successful
CI / lint (push) Successful in 1m26s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 00:40:49 +00:00
14a3e1ee8d feat: review Today work at day rollover (Closes #603)
All checks were successful
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 00:38:13 +00:00
8dc155e49a Merge pull request 'Complete Today from the mobile session HUD' (#602) from timmy/601-mobile-hud-complete into main
All checks were successful
CI / lint (push) Successful in 1m31s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 7s
2026-08-11 23:58:47 +00:00
7aa25f1d8c feat: complete Today from mobile HUD (Closes #601)
All checks were successful
CI / lint (pull_request) Successful in 1m27s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 23:56:39 +00:00
f023414929 Merge pull request 'Keep the active Today session visible on mobile' (#600) from timmy/599-mobile-today-session-hud into main
All checks were successful
CI / lint (push) Successful in 1m28s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-11 23:36:22 +00:00
96e7b35419 feat: keep active Today visible on mobile (Closes #599)
All checks were successful
CI / lint (pull_request) Successful in 1m28s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 23:29:50 +00:00
4c13e13275 Merge pull request 'Replan Today from live budget risk' (#598) from timmy/597-live-today-budget into main
All checks were successful
CI / lint (push) Successful in 1m27s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-11 23:07:01 +00:00
ebe19f2281 feat: replan Today from live budget risk (Closes #597)
All checks were successful
CI / lint (pull_request) Successful in 1m24s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 23:04:49 +00:00
798790ce1a Merge pull request 'Pause Today while handling Attention' (#596)
All checks were successful
CI / lint (push) Successful in 1m37s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
Closes #595
2026-08-11 22:32:20 +00:00
9629643a63 feat: pause Today while handling Attention (Closes #595)
All checks were successful
CI / lint (pull_request) Successful in 1m31s
CI / build-release (pull_request) Successful in 10s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 22:27:53 +00:00
9f3855d779 Keep mobile Attention actionable alongside Today
All checks were successful
CI / lint (push) Successful in 1m29s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
Keep mobile Attention actionable alongside Today (#594)
2026-08-11 21:41:26 +00:00
c99dd3e3be test: make async deadline checks contention-safe
All checks were successful
CI / lint (pull_request) Successful in 1m39s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 21:39:04 +00:00
7e173640ed refactor: keep mobile work mode update-local
Some checks failed
CI / lint (pull_request) Failing after 1m31s
CI / build-release (pull_request) Has been skipped
CI / release-candidate (pull_request) Has been skipped
2026-08-11 21:34:22 +00:00
87f63370f6 feat: keep mobile Attention actionable alongside Today (Closes #593)
Some checks failed
CI / lint (pull_request) Failing after 1m26s
CI / build-release (pull_request) Has been skipped
CI / release-candidate (pull_request) Has been skipped
2026-08-11 21:25:27 +00:00
614ffae188 Merge pull request 'Reconcile long mobile interruptions before resuming Today time' (#592) from timmy/591-reconcile-today-interruptions into main
All checks were successful
CI / lint (push) Successful in 1m29s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
Merge pull request 'Reconcile long mobile interruptions before resuming Today time' (#592)
2026-08-11 21:07:41 +00:00
aaa9aef78b feat: reconcile long Today interruptions (Closes #591)
All checks were successful
CI / lint (pull_request) Successful in 1m26s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 20:56:40 +00:00
10a8b2738b Merge pull request 'Require fresh authorization and audit recap time logging' (#590) from timmy/589-authorize-audit-recap-time into main
All checks were successful
CI / lint (push) Successful in 1m38s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
2026-08-11 20:29:36 +00:00
379f2b8203 feat: authorize and audit recap time logging (Closes #589)
All checks were successful
CI / lint (pull_request) Successful in 1m29s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 20:26:10 +00:00
53bf89bd47 Merge pull request 'Log corrected Today recap time to Gitea' (#588)
All checks were successful
CI / lint (push) Successful in 1m26s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
Closes #587
2026-08-11 19:43:01 +00:00
697f72da09 feat: log corrected Today recap time (Closes #587)
All checks were successful
CI / lint (pull_request) Successful in 1m25s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 19:40:45 +00:00
c170240057 Merge pull request 'Resume recap-to-plan feedback after interruptions' (#586) from timmy/585-resume-recap-replan into main
All checks were successful
CI / lint (push) Successful in 1m32s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-11 18:59:24 +00:00
3043ccc846 feat: resume recap feedback after interruptions (Closes #585)
All checks were successful
CI / lint (pull_request) Successful in 1m24s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 18:57:11 +00:00
c5ddb1275b Merge pull request 'Turn Today recap feedback into an adjusted plan' (#584) from timmy/583-recap-replan into main
All checks were successful
CI / lint (push) Successful in 1m23s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
2026-08-11 18:36:20 +00:00
1fb26e0a86 feat: turn Today recap feedback into a plan
All checks were successful
CI / lint (pull_request) Successful in 1m24s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
Closes #583
2026-08-11 18:33:59 +00:00
be7bfcaa5a Merge pull request 'Preserve Today recap drafts across offline reloads' (#582)
All checks were successful
CI / lint (push) Successful in 1m40s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 8s
Closes #581
2026-08-11 18:01:33 +00:00
6a3c6c7a9e feat: preserve offline Today recap drafts (Closes #581)
All checks were successful
CI / lint (pull_request) Successful in 1m52s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 17:58:50 +00:00
cab230e3a2 Merge pull request 'Add synced Today recap with estimate-versus-actual history' (#580) from timmy/579-today-recap into main
All checks were successful
CI / lint (push) Successful in 1m22s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-11 17:24:11 +00:00
80ea81ad73 feat: add synced Today recaps (Closes #579)
All checks were successful
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 17:21:22 +00:00
8cd2459468 Merge pull request 'Track interruption-safe actual time through Today' (#578) from timmy/577-today-actual-time into main
All checks were successful
CI / lint (push) Successful in 1m28s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-11 16:17:03 +00:00
7ee10619a4 feat: track actual time through Today (Closes #577)
All checks were successful
CI / lint (pull_request) Successful in 1m27s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 16:14:17 +00:00
a8da5ce949 Merge pull request 'Edit or delete your own conversation comments' (#576) from timmy/575-owned-comment-actions into main
All checks were successful
CI / lint (push) Successful in 1m28s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 8s
Merge pull request 'Edit or delete your own conversation comments' (#576)
2026-08-11 15:23:16 +00:00
8e357a097b feat: edit and delete owned comments (Closes #575)
All checks were successful
CI / lint (pull_request) Successful in 1m28s
CI / build-release (pull_request) Successful in 8s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 15:18:30 +00:00
43f5833f7f Merge pull request 'Make Web Push pagination-complete and reconcile unread delivery state' (#574) from timmy/573-complete-push-snapshot into main
All checks were successful
CI / lint (push) Successful in 1m29s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-11 14:34:39 +00:00
27fff15da4 feat: complete unread push snapshots (Closes #573)
All checks were successful
CI / lint (pull_request) Successful in 1m23s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 14:32:19 +00:00
a2b78710f2 Merge pull request 'Re-alert Web Push when unread threads receive new updates' (#572) from timmy/571-revision-aware-web-push into main
All checks were successful
CI / lint (push) Successful in 1m22s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 8s
Merge pull request 'Re-alert Web Push when unread threads receive new updates' (#572)
2026-08-11 13:54:24 +00:00
e9271f5aab feat: re-alert updated Web Push threads (Closes #571)
All checks were successful
CI / lint (pull_request) Successful in 1m22s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 13:51:58 +00:00
257bc0110e Merge pull request 'Send bounded Web Push digests for update bursts' (#570) from timmy/569-push-burst-digests into main
All checks were successful
CI / lint (push) Successful in 1m33s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
2026-08-11 12:59:31 +00:00
5e0683cd4a feat: send bounded push digests for update bursts (Closes #569)
All checks were successful
CI / lint (pull_request) Successful in 1m19s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 12:57:07 +00:00
c94fc5308f Merge pull request 'Hand off assigned pull requests and continue Today' (#568) from timmy/567-pull-ownership into main
All checks were successful
CI / lint (push) Successful in 1m21s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
feat: hand off assigned pull requests (Closes #567)
2026-08-11 12:40:16 +00:00
0304f5ab2c feat: hand off assigned pull requests (Closes #567)
All checks were successful
CI / lint (pull_request) Successful in 1m19s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 11:49:15 +00:00
50d51ed159 Merge pull request 'Make unread Web Push use the production notification contract' (#566) from timmy/565-production-push-contract into main
All checks were successful
CI / lint (push) Successful in 1m11s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-11 10:57:48 +00:00
8e74f022eb fix: use production IDs for unread Web Push (Closes #565)
All checks were successful
CI / lint (pull_request) Successful in 1m14s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 10:55:38 +00:00
92b0fd56ca Merge pull request 'Defer unread updates until tomorrow from Web Push' (#564) from timmy/563-web-push-tomorrow into main
All checks were successful
CI / lint (push) Successful in 1m13s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Merge pull request #564 from timmy/563-web-push-tomorrow
2026-08-11 10:27:57 +00:00
7ce352553d feat: defer Web Push updates until tomorrow (Closes #563)
All checks were successful
CI / lint (pull_request) Successful in 1m16s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 10:25:56 +00:00
f4a4ed84c3 Merge pull request 'Mark unread updates read from Web Push' (#562) from timmy/561-web-push-mark-read into main
All checks were successful
CI / lint (push) Successful in 1m16s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-11 09:58:56 +00:00
b21888fd7a feat: mark Web Push updates read (Closes #561)
All checks were successful
CI / lint (pull_request) Successful in 1m12s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 09:57:05 +00:00
320d6348ca Merge pull request 'Surface mobile device readiness before My Work' (#560) from timmy/559-mobile-readiness-card into main
All checks were successful
CI / lint (push) Successful in 1m14s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-11 09:35:52 +00:00
7f8ee771dc feat: surface mobile device readiness (Closes #559)
All checks were successful
CI / lint (pull_request) Successful in 1m16s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 09:33:27 +00:00
f446e10670 Merge pull request 'Bound Web Push fan-out latency across devices' (#558) from timmy/557-bounded-push-fanout into main
All checks were successful
CI / lint (push) Successful in 1m15s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-11 08:56:57 +00:00
79309196b8 perf: bound Web Push device fan-out (Closes #557)
All checks were successful
CI / lint (pull_request) Successful in 1m14s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 08:54:48 +00:00
cdf6ecdcb8 Merge pull request 'Guide mobile operators through device readiness setup' (#556)
All checks were successful
CI / lint (push) Successful in 1m14s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Closes #555
2026-08-11 08:35:08 +00:00
797fd0d544 feat: guide mobile device readiness (Closes #555)
All checks were successful
CI / lint (pull_request) Successful in 1m13s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 08:33:01 +00:00
a13e25f6c9 Merge pull request 'Stop Web Push when device sessions expire' (#554)
All checks were successful
CI / lint (push) Successful in 1m9s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Closes #553
2026-08-11 07:57:06 +00:00
91e5b1e0c9 fix: stop push for expired device sessions (Closes #553)
All checks were successful
CI / lint (pull_request) Successful in 1m8s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 07:54:56 +00:00
1f05521094 Merge pull request 'Make unread Web Push delivery multi-worker safe' (#552) from timmy/551-push-delivery-lease into main
All checks were successful
CI / lint (push) Successful in 1m5s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-11 07:27:55 +00:00
59c263fc59 feat: make push delivery multi-worker safe (Closes #551)
All checks were successful
CI / lint (pull_request) Successful in 1m7s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 07:25:55 +00:00
4c64cc4c3e Merge pull request 'Notify mobile operators about new unread updates' (#550) from timmy/549-inbound-web-push into main
All checks were successful
CI / lint (push) Successful in 1m10s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-11 07:10:03 +00:00
6e90e3fa9e feat: notify mobile operators of unread updates (Closes #549)
All checks were successful
CI / lint (pull_request) Successful in 1m15s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 07:07:52 +00:00
83702e75b0 Merge pull request 'Reopen and resume closed issues from mobile Search' (#548) from timmy/547-reopen-resume into main
All checks were successful
CI / lint (push) Successful in 1m9s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-11 06:39:23 +00:00
446281674b feat: reopen and resume closed issues (Closes #547)
All checks were successful
CI / lint (pull_request) Successful in 1m8s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 06:37:19 +00:00
9c5b100d6a Merge pull request 'Select updates to mark read from mobile My Work' (#546) from timmy/545-select-updates into main
All checks were successful
CI / lint (push) Successful in 1m23s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-11 06:03:12 +00:00
163bfad30a feat: select mobile updates to mark read (Closes #545)
All checks were successful
CI / lint (pull_request) Successful in 1m22s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 06:00:48 +00:00
23c8e57a8e Merge pull request 'Surface failed token sign-in alerts in Security activity' (#544)
All checks were successful
CI / lint (push) Successful in 1m7s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-11 05:37:53 +00:00
350bfc067d feat: surface failed token sign-in alerts (Closes #543)
All checks were successful
CI / lint (pull_request) Successful in 1m10s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 05:35:35 +00:00
98aecd8255 Merge pull request 'Lazy-load the complete pull request workspace' (#542) from timmy/541-lazy-pull-workflow into main
All checks were successful
CI / lint (push) Successful in 1m7s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-11 04:55:19 +00:00
7d4ff1f095 perf: lazy-load pull request workspace (Closes #541)
All checks were successful
CI / lint (pull_request) Successful in 1m4s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 04:53:18 +00:00
3605c42651 Merge pull request 'Acknowledge unread updates with a reaction and open the next' (#540) from timmy/539-acknowledge-update-reaction into main
All checks were successful
CI / lint (push) Successful in 1m5s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
2026-08-11 04:12:18 +00:00
d7e2813700 feat: acknowledge unread updates with reactions (Closes #539)
All checks were successful
CI / lint (pull_request) Successful in 1m8s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 04:10:18 +00:00
fb34b5ffda Merge pull request 'Lazy-load the mobile issue capture workflow' (#538) from timmy/537-lazy-issue-capture into main
All checks were successful
CI / lint (push) Successful in 1m8s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-11 03:32:15 +00:00
820da7130d feat: lazy-load issue capture workflow (Closes #537)
All checks were successful
CI / lint (pull_request) Successful in 1m14s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 03:30:07 +00:00
3bad8a0198 Merge pull request 'Keep passkey sign-in available during anonymous challenge floods' (#536) from timmy/535-passkey-flood-protection into main
All checks were successful
CI / lint (push) Successful in 1m11s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-11 03:04:42 +00:00
8bc5f623ac feat: protect passkey sign-in from challenge floods (Closes #535)
All checks were successful
CI / lint (pull_request) Successful in 1m9s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 03:02:42 +00:00
175817e6d8 Merge pull request 'Manage Today blockers offline and sync on reconnect' (#534) from timmy/533-offline-blocker-sync into main
All checks were successful
CI / lint (push) Successful in 1m9s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-11 02:32:31 +00:00
fff7bccf08 feat: sync Today blockers offline (Closes #533)
All checks were successful
CI / lint (pull_request) Successful in 1m14s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 02:29:56 +00:00
ee36b8824a Merge pull request 'Manage issue blockers without leaving Today' (#532) from timmy/531-manage-issue-blockers into main
All checks were successful
CI / lint (push) Successful in 1m15s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
2026-08-11 02:04:33 +00:00
2c8a1225e0 feat: manage issue blockers in app (Closes #531)
All checks were successful
CI / lint (pull_request) Successful in 1m14s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 02:02:23 +00:00
29ceedfc01 Merge pull request 'Keep dashboard live when IndexedDB stalls' (#530) from timmy/529-indexeddb-startup-deadline into main
All checks were successful
CI / lint (push) Successful in 1m13s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-11 01:31:05 +00:00
11a56b21a1 fix: bound IndexedDB dashboard startup (Closes #529)
All checks were successful
CI / lint (pull_request) Successful in 1m16s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 01:28:56 +00:00
3e16dd00d1 Merge pull request 'Attach screenshots to unread update replies' (#528) from timmy/527-update-reply-screenshots into main
All checks were successful
CI / lint (push) Successful in 1m11s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-11 01:10:54 +00:00
31f6097afc feat: attach screenshots to update replies (Closes #527)
All checks were successful
CI / lint (pull_request) Successful in 1m13s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 01:04:18 +00:00
beaf5e0587 Merge pull request 'Move offline work details from localStorage to IndexedDB' (#526) from timmy/525-indexeddb-offline-work into main
All checks were successful
CI / lint (push) Successful in 1m3s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
2026-08-11 00:36:26 +00:00
0a96dfb173 feat: move offline work into IndexedDB (Closes #525)
All checks were successful
CI / lint (pull_request) Successful in 1m8s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 00:34:25 +00:00
7cadb36b41 Merge pull request 'Lock cached dashboard after the confirmed idle deadline' (#524) from timmy/523-confirmed-offline-idle-deadline into main
All checks were successful
CI / lint (push) Successful in 1m12s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-11 00:02:39 +00:00
b1890a29ff feat: enforce confirmed offline idle deadlines (Closes #523)
All checks were successful
CI / lint (pull_request) Successful in 1m15s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 00:00:19 +00:00
100d38cc5c Merge pull request 'Refresh pull request checks without reloading review data' (#522) from timmy/521-status-only-check-refresh into main
All checks were successful
CI / lint (push) Successful in 1m5s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-10 23:28:26 +00:00
e5a352b418 perf: isolate pull check refreshes (Closes #521)
All checks were successful
CI / lint (pull_request) Successful in 1m5s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 23:26:35 +00:00
8907263760 Merge pull request 'Avoid decoding unchanged live snapshots on every poll' (#520) from timmy/519-metadata-only-live-polls into main
All checks were successful
CI / lint (push) Successful in 1m2s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-10 22:56:35 +00:00
fdcd0d31ab perf: reuse unchanged live snapshot values (Closes #519)
All checks were successful
CI / lint (pull_request) Successful in 1m6s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 22:54:08 +00:00
5bef6291e8 Merge pull request 'Show actionable CI checks in mobile pull reviews' (#518) from timmy/517-actionable-ci-checks into main
All checks were successful
CI / lint (push) Successful in 1m4s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
2026-08-10 22:33:52 +00:00
9724358c89 feat: expose actionable pull request checks (Closes #517)
All checks were successful
CI / lint (pull_request) Successful in 1m5s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 22:31:50 +00:00
7555cefccb Merge pull request 'Attach screenshots to assigned pull request comments online and offline' (#516) from timmy/515-pr-comment-screenshots into main
All checks were successful
CI / lint (push) Successful in 1m10s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-10 21:44:58 +00:00
04621223fd feat: attach screenshots to pull comments (Closes #515)
All checks were successful
CI / lint (pull_request) Successful in 1m4s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 21:39:22 +00:00
e5016d8b86 Merge pull request 'Audit passkey enrollment with durable Security activity' (#514) from timmy/513-audit-passkey-enrollment into main
All checks were successful
CI / lint (push) Successful in 59s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
2026-08-10 20:58:07 +00:00
b455dc6286 feat: audit passkey enrollment (Closes #513)
All checks were successful
CI / lint (pull_request) Successful in 59s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 20:56:20 +00:00
338b6ebbd0 Make every PWA shell release content-addressed and atomic
All checks were successful
CI / lint (push) Successful in 1m5s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Closes #511
2026-08-10 20:27:06 +00:00
f18c59c7b3 feat: content-address complete PWA shell (Closes #511)
All checks were successful
CI / lint (pull_request) Successful in 1m7s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 20:25:05 +00:00
e9cab92aff Merge pull request 'Make consequential pull reviews passkey-native and auditable' (#510) from timmy/509-passkey-review-activity into main
All checks were successful
CI / lint (push) Successful in 1m3s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
2026-08-10 19:59:06 +00:00
877dc475dd feat: make consequential reviews passkey-native (Closes #509)
All checks were successful
CI / lint (pull_request) Successful in 1m0s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 19:56:12 +00:00
28e70f6415 Merge pull request 'Require fresh authorization for consequential pull reviews' (#508) from timmy/507-fresh-review-authorization into main
All checks were successful
CI / lint (push) Successful in 1m1s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-10 19:04:01 +00:00
2a6f2ab12d feat: authorize consequential pull reviews (Closes #507)
All checks were successful
CI / lint (pull_request) Successful in 1m3s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 19:02:16 +00:00
c8809a0728 Merge pull request 'Take ownership and start work from an unread update' (#506) from timmy/505-take-ownership-start into main
All checks were successful
CI / lint (push) Successful in 1m7s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-10 18:31:05 +00:00
2717f41957 feat: start work from unread updates (Closes #505)
All checks were successful
CI / lint (pull_request) Successful in 1m9s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 18:29:03 +00:00
b26b57d616 Merge pull request 'Reply, mark read, and continue unread updates' (#504) from timmy/503-reply-read-next into main
All checks were successful
CI / lint (push) Successful in 1m8s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-10 18:04:35 +00:00
1052ce2c84 feat: reply, mark read, and continue updates (Closes #503)
All checks were successful
CI / lint (pull_request) Successful in 1m8s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 18:02:34 +00:00
4f0e6544b3 Merge pull request 'Make access revocation atomic across passkeys and sessions' (#502) from timmy/501-atomic-access-revocation into main
All checks were successful
CI / lint (push) Successful in 1m4s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-10 17:27:01 +00:00
91c59a2953 fix: make access revocation atomic (Closes #501)
All checks were successful
CI / lint (pull_request) Successful in 1m5s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 17:24:54 +00:00
6d1fa3c068 Merge pull request 'Manage and selectively revoke enrolled passkeys' (#500) from timmy/499-manage-enrolled-passkeys into main
All checks were successful
CI / lint (push) Successful in 1m3s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-10 17:08:35 +00:00
21a386b94f feat: manage enrolled passkeys (Closes #499)
All checks were successful
CI / lint (pull_request) Successful in 1m3s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 17:06:36 +00:00
7e5b966f77 Merge pull request 'Make destructive actions truthful when security journaling fails' (#498) from timmy/497-truthful-security-journal into main
All checks were successful
CI / lint (push) Successful in 1m4s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
Merge pull request #498: truthful security-journaled actions
2026-08-10 16:32:39 +00:00
78d044f1af feat: make security-journaled actions outcome truthful (Closes #497)
All checks were successful
CI / lint (pull_request) Successful in 1m7s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 16:30:31 +00:00
814c5726ec Merge pull request 'Optimize oversized screenshots before mobile capture' (#496) from timmy/495-optimize-oversized-screenshots into main
All checks were successful
CI / lint (push) Successful in 1m2s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-10 15:58:35 +00:00
95a14c6de5 feat: optimize oversized mobile screenshots (Closes #495)
All checks were successful
CI / lint (pull_request) Successful in 1m1s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 15:56:35 +00:00
37aa2dd090 Merge pull request 'Add a durable security activity center' (#494) from timmy/493-security-activity-center into main
All checks were successful
CI / lint (push) Successful in 1m6s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-10 15:39:50 +00:00
6565165295 feat: add durable security activity center (Closes #493)
All checks were successful
CI / lint (pull_request) Successful in 1m5s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 15:37:37 +00:00
4749d15d85 Merge pull request 'Use binary screenshot storage and upload end to end' (#492) from timmy/491-binary-screenshot-transport into main
All checks were successful
CI / lint (push) Successful in 1m2s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-10 15:06:07 +00:00
16604d697b feat: use binary screenshot transport (Closes #491)
All checks were successful
CI / lint (pull_request) Successful in 1m6s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 15:03:45 +00:00
59b4d5e6bf Merge pull request 'Keep mobile issue capture visible above the keyboard' (#490) from timmy/489-keyboard-safe-mobile-capture into main
All checks were successful
CI / lint (push) Successful in 1m3s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
2026-08-10 14:28:57 +00:00
83af187c73 feat: keep mobile issue capture above keyboard (Closes #489)
All checks were successful
CI / lint (pull_request) Successful in 1m1s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 14:26:56 +00:00
d4896e10be Merge pull request 'Add an explicit searchable repository picker for mobile capture' (#488) from timmy/487-searchable-repository-picker into main
All checks were successful
CI / lint (push) Successful in 1m1s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Merge pull request 'Add an explicit searchable repository picker for mobile capture' (#488)
2026-08-10 14:03:40 +00:00
81e1af7f16 feat: add explicit searchable repository picker (Closes #487)
All checks were successful
CI / lint (pull_request) Successful in 1m2s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 14:01:49 +00:00
b1b3f758e4 Merge pull request 'Add device passkeys for operator access' (#486)
All checks were successful
CI / lint (push) Successful in 59s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
2026-08-10 13:16:21 +00:00
ced644b275 feat: add device passkeys for operator access (Closes #485)
All checks were successful
CI / lint (pull_request) Successful in 1m3s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 13:14:18 +00:00
131848cc43 Merge pull request 'Require explicit authorization for queued issue closures' (#484) from timmy/483-authorize-queued-closures into main
All checks were successful
CI / lint (push) Successful in 1m1s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-10 12:30:59 +00:00
62a0841eb5 fix: authorize queued issue closures explicitly (Closes #483)
All checks were successful
CI / lint (pull_request) Successful in 1m1s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 12:29:02 +00:00
cc63fc6499 Merge pull request 'Add an actionable offline delivery center' (#482) from timmy/481-offline-delivery-center into main
All checks were successful
CI / lint (push) Successful in 1m5s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
2026-08-10 11:59:24 +00:00
9dede4bf2a feat: add offline delivery center (Closes #481)
All checks were successful
CI / lint (pull_request) Successful in 54s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 11:57:30 +00:00
b667963968 Merge pull request 'Make background outbox claims lease-safe' (#480) from timmy/479-lease-safe-background-claims into main
All checks were successful
CI / lint (push) Successful in 55s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
Merge pull request #480: Make background outbox claims lease-safe
2026-08-10 11:23:39 +00:00
31b0e79aa1 fix: fence background delivery claims (Closes #479)
All checks were successful
CI / lint (pull_request) Successful in 56s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 11:21:53 +00:00
8206856d94 Merge pull request 'Queue screenshot comments for offline delivery' (#478) from timmy/477-offline-screenshot-comments into main
All checks were successful
CI / lint (push) Successful in 58s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-10 10:57:51 +00:00
7ec3330175 feat: queue screenshot comments offline (Closes #477)
All checks were successful
CI / lint (pull_request) Successful in 58s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 10:56:02 +00:00
ec339d242d Merge pull request 'Preserve screenshots while editing queued captures' (#476) from timmy/475-preserve-queued-screenshots into main
All checks were successful
CI / lint (push) Successful in 53s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-10 10:36:15 +00:00
d161cf41c0 fix: preserve screenshots while editing queued captures (Closes #475)
All checks were successful
CI / lint (pull_request) Successful in 56s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 10:34:34 +00:00
535658185a Merge pull request 'Keep offline screenshots outside localStorage quota' (#474) from timmy/473-indexeddb-screenshot-outbox into main
All checks were successful
CI / lint (push) Successful in 57s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-10 10:17:54 +00:00
8b7c56161c fix: move offline screenshots out of localStorage (Closes #473)
All checks were successful
CI / lint (pull_request) Successful in 59s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 10:15:58 +00:00
2b2e2cceb4 Merge pull request 'Make screenshot uploads retry-safe' (#472) from timmy/471-idempotent-attachment-uploads into main
All checks were successful
CI / lint (push) Successful in 53s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-10 09:56:37 +00:00
de596f397f fix: make screenshot uploads retry-safe (#471)
All checks were successful
CI / lint (pull_request) Successful in 56s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 09:54:56 +00:00
af86fc1e98 Merge pull request 'Attach screenshots while capturing mobile issues' (#470) from timmy/469-capture-issue-screenshots into main
All checks were successful
CI / lint (push) Successful in 53s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
2026-08-10 09:36:49 +00:00
5bf491c725 feat: attach screenshots during issue capture (#469)
All checks were successful
CI / lint (pull_request) Successful in 56s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 09:35:09 +00:00
11341ec5d9 Merge pull request 'Attach screenshots to mobile issue comments' (#468) from timmy/467-mobile-comment-screenshots into main
All checks were successful
CI / lint (push) Successful in 54s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-10 08:57:08 +00:00
eefb760169 feat: attach screenshots to issue comments (#467)
All checks were successful
CI / lint (pull_request) Successful in 53s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 08:55:13 +00:00
3f9dd49766 Merge pull request 'Honor server backoff and section freshness in live polling' (#466) from timmy/465-adaptive-live-polling into main
All checks were successful
CI / lint (push) Successful in 58s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Merge pull request 'Honor server backoff and section freshness in live polling' (#466) from timmy/465-adaptive-live-polling into main
2026-08-10 08:19:37 +00:00
72453b76db perf: adapt live polling to server freshness (#465)
All checks were successful
CI / lint (pull_request) Successful in 55s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 08:17:57 +00:00
7639a42d7b Merge pull request 'Lock inactive dashboard sessions without losing drafts' (#464) from timmy/463-idle-session-lock into main
All checks were successful
CI / lint (push) Successful in 54s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-10 07:47:07 +00:00
0b992543f1 feat: lock idle dashboard sessions (#463)
All checks were successful
CI / lint (pull_request) Successful in 56s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 07:45:00 +00:00
9904f17e3d Merge pull request 'Drain authored mutations before shutdown' (#462)
All checks were successful
CI / lint (push) Successful in 53s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
Closes #461
2026-08-10 07:14:28 +00:00
0dd3932d14 fix: drain authored actions on shutdown (#461)
All checks were successful
CI / lint (pull_request) Successful in 51s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 07:12:51 +00:00
8e6ee96ede Merge pull request 'Plan Today by available time and work estimates' (#460) from timmy/459-plan-today-capacity into main
All checks were successful
CI / lint (push) Successful in 52s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-10 06:55:04 +00:00
161977cb67 feat: plan Today by available time (#459)
All checks were successful
CI / lint (pull_request) Successful in 51s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 06:53:25 +00:00
e3c07c2dfb Merge pull request 'Start deferred work directly from Later' (#458) from timmy/457-start-deferred-work into main
All checks were successful
CI / lint (push) Successful in 51s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-10 06:03:32 +00:00
a610708e36 feat: start deferred work directly (#457)
All checks were successful
CI / lint (pull_request) Successful in 52s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 06:00:35 +00:00
f8c5c2b058 Merge pull request 'Defer active Today work and continue to the next ready item' (#456) from timmy/455-defer-today-next into main
All checks were successful
CI / lint (push) Successful in 48s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-10 05:24:08 +00:00
72c470b652 feat: defer Today work and continue (#455)
All checks were successful
CI / lint (pull_request) Successful in 48s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 05:22:01 +00:00
b214a49686 Merge pull request 'Share the Find Work catalog refresh across application workers' (#454) from timmy/453-shared-available-catalog into main
All checks were successful
CI / lint (push) Successful in 50s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-10 04:44:57 +00:00
a9508984e2 fix: release cancelled catalog refresh leases (#453)
All checks were successful
CI / lint (pull_request) Successful in 50s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 04:43:25 +00:00
20d5176713 perf: share Find Work catalog across workers (#453)
Some checks failed
CI / lint (pull_request) Failing after 51s
CI / build-release (pull_request) Has been skipped
CI / release-candidate (pull_request) Has been skipped
2026-08-10 04:40:15 +00:00
c96ed51b0d Merge pull request 'Reply to updates and continue Today in one action' (#452)
All checks were successful
CI / lint (push) Successful in 52s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-10 04:17:31 +00:00
cf2e49300a feat: reply to updates and continue Today (#451)
All checks were successful
CI / lint (pull_request) Successful in 52s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 04:15:53 +00:00
5e49f27142 Merge pull request 'Continue Today after ownership exits' (#450) from timmy/449-ownership-exit-next into main
All checks were successful
CI / lint (push) Successful in 48s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
2026-08-10 03:43:53 +00:00
0280077e45 feat: continue Today after ownership exits (#449)
All checks were successful
CI / lint (pull_request) Successful in 47s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 03:42:01 +00:00
f781410e80 Merge pull request 'Autocomplete teammate mentions in mobile conversations' (#448) from timmy/447-mobile-mentions into main
All checks were successful
CI / lint (push) Successful in 48s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-10 03:04:26 +00:00
0311f85d96 feat: autocomplete mobile teammate mentions (#447)
All checks were successful
CI / lint (pull_request) Successful in 49s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 03:02:35 +00:00
5a6ef62c4c Merge pull request 'Wrap mobile review diffs without horizontal panning' (#446) from timmy/445-wrap-mobile-review-diffs into main
All checks were successful
CI / lint (push) Successful in 52s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-10 02:39:38 +00:00
461c796227 feat: wrap mobile review diffs (#445)
All checks were successful
CI / lint (pull_request) Successful in 51s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 02:37:43 +00:00
6309ed43cd Merge pull request 'Work Today blockers without leaving Stackchain' (#444) from timmy/443-today-blocker-preview into main
All checks were successful
CI / lint (push) Successful in 50s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-10 02:07:06 +00:00
a0aa3c0a22 feat: work Today blockers in-app (#443)
All checks were successful
CI / lint (pull_request) Successful in 52s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 02:05:11 +00:00
60bc7cd686 Merge pull request 'Bound Today readiness latency with demand-driven checks' (#442) from timmy/441-demand-driven-today-readiness into main
All checks were successful
CI / lint (push) Successful in 49s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-10 01:35:45 +00:00
79c8f1626b perf: bound Today readiness latency (#441)
All checks were successful
CI / lint (pull_request) Successful in 49s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 01:33:55 +00:00
cd9f40a1c4 Keep notification acknowledgements responsive under snapshot contention (#440)
All checks were successful
CI / lint (push) Successful in 51s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
Closes #439
2026-08-10 01:16:02 +00:00
0a3fb180ed perf: batch notification snapshot maintenance (#439)
All checks were successful
CI / lint (pull_request) Successful in 49s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 01:14:11 +00:00
74d1435ca7 Merge pull request 'Skip blocked items while running the Today queue' (#438) from timmy/437-today-readiness into main
All checks were successful
CI / lint (push) Successful in 47s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
2026-08-10 00:40:44 +00:00
8989e93c2c feat: skip blocked Today work (#437)
All checks were successful
CI / lint (pull_request) Successful in 48s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 00:39:13 +00:00
7b3b7dadb9 Merge pull request 'Surface blockers while planning Today work' (#436) from timmy/435-dependency-aware-today into main
All checks were successful
CI / lint (push) Successful in 48s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-10 00:02:47 +00:00
05f9e06a10 feat: surface Today issue blockers (#435)
All checks were successful
CI / lint (pull_request) Successful in 53s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 00:00:51 +00:00
e0de45ea90 Merge pull request 'Coalesce live snapshot refreshes across application workers' (#434) from timmy/433-shared-live-snapshot into main
All checks were successful
CI / lint (push) Successful in 49s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 6s
2026-08-09 23:21:58 +00:00
3224389441 perf: share live refreshes across workers (#433)
All checks were successful
CI / lint (pull_request) Successful in 48s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 23:20:07 +00:00
63723e7fa3 Merge pull request 'Precompress immutable runtime and bypass request-time gzip' (#432) from timmy/431-precompress-runtime into main
All checks were successful
CI / lint (push) Successful in 46s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Merge pull request 'Precompress immutable runtime and bypass request-time gzip' (#432) from timmy/431-precompress-runtime into main
2026-08-09 22:22:03 +00:00
740e91a615 perf: precompress immutable dashboard runtime (#431)
All checks were successful
CI / lint (pull_request) Successful in 45s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 22:12:00 +00:00
0363f23cef Merge pull request 'Comment and continue to the next Today item' (#430) from timmy/429-comment-next into main
All checks were successful
CI / lint (push) Successful in 46s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-09 22:02:02 +00:00
2cbeba30fd feat: comment and continue Today work (#429)
All checks were successful
CI / lint (pull_request) Successful in 50s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 22:00:14 +00:00
43595de52f Merge pull request 'Queue offline issue closure and continue Today' (#428) from timmy/427-offline-close-next into main
All checks were successful
CI / lint (push) Successful in 44s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-09 21:27:59 +00:00
c451cd0bcb feat: queue offline issue closure and continue Today (#427)
All checks were successful
CI / lint (pull_request) Successful in 43s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 21:26:23 +00:00
ad27db6846 Merge pull request 'Restore background delivery after sign-in following outbox purge' (#426) from timmy/425-resume-outbox-after-purge into main
All checks were successful
CI / lint (push) Successful in 46s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-09 20:46:12 +00:00
791082bae2 fix: resume background delivery after purge (#425)
All checks were successful
CI / lint (pull_request) Successful in 47s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 20:44:26 +00:00
d3ed0cca0c Merge pull request 'Queue offline Today reviews and continue to the next item' (#424) from timmy/423-queue-offline-review-next into main
All checks were successful
CI / lint (push) Successful in 52s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
2026-08-09 20:06:04 +00:00
ccb595a081 feat: queue offline Today reviews and continue (#423)
All checks were successful
CI / lint (pull_request) Successful in 49s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 20:04:20 +00:00
7b1c9a608c Merge pull request 'Finish Today items without changing Gitea' (#422) from timmy/421-done-today-next into main
All checks were successful
CI / lint (push) Successful in 44s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-09 19:39:15 +00:00
854d040e88 feat: finish Today items without changing Gitea (#421)
All checks were successful
CI / lint (pull_request) Successful in 43s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 19:37:40 +00:00
95baa20eeb Merge pull request 'Queue completed pull request reviews for reconnect delivery' (#420)
All checks were successful
CI / lint (push) Successful in 48s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Closes #419
2026-08-09 19:06:15 +00:00
0cf88d7d7a feat: queue offline pull request reviews (#419)
All checks were successful
CI / lint (pull_request) Successful in 49s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 19:04:28 +00:00
969d5567d4 Merge pull request 'Compress the mobile dashboard shell over HTTP' (#418) from timmy/417-http-compression into main
All checks were successful
CI / lint (push) Successful in 46s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-09 18:35:26 +00:00
679639394f feat: compress dashboard shell over HTTP (#417)
All checks were successful
CI / lint (pull_request) Successful in 47s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 18:33:40 +00:00
3b374cbb50 Merge pull request 'Queue available work in Today without interrupting the current session' (#416)
All checks were successful
CI / lint (push) Successful in 49s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
Closes #415
2026-08-09 18:00:05 +00:00
ad5e50eb48 feat: queue available work in Today (#415)
All checks were successful
CI / lint (pull_request) Successful in 42s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 17:57:48 +00:00
4b8d0c4504 Merge pull request 'Assign and start work from global search' (#414) from timmy/413-search-assign-and-start into main
All checks were successful
CI / lint (push) Successful in 45s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-09 17:08:02 +00:00
6e3b6804c1 feat: assign and start work from search (#413)
All checks were successful
CI / lint (pull_request) Successful in 48s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 17:06:16 +00:00
c31129f1cc Merge pull request 'Assign available work and start it from Find' (#412) from timmy/411-assign-and-start into main
All checks were successful
CI / lint (push) Successful in 44s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-09 16:22:55 +00:00
4afe4dd3b9 feat: assign and start available work (#411)
All checks were successful
CI / lint (pull_request) Successful in 45s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 16:21:20 +00:00
985472eb06 Merge pull request 'Continue Today from the mobile Work dock' (#410) from timmy/409-continue-today-mobile-dock into main
All checks were successful
CI / lint (push) Successful in 47s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-09 15:44:22 +00:00
8b36455c21 feat: continue Today from mobile Work dock (#409)
All checks were successful
CI / lint (pull_request) Successful in 45s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 15:42:42 +00:00
3ba1fe76f0 Merge pull request 'Advance Today after merging a planned pull request' (#408) from timmy/407-merge-pr-and-advance-today into main
All checks were successful
CI / lint (push) Successful in 41s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Advance Today after merging a planned pull request (#408)

Closes #407
2026-08-09 14:56:39 +00:00
321474e8cf feat: advance Today after pull request merge (#407)
All checks were successful
CI / lint (pull_request) Successful in 43s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 14:55:03 +00:00
166d14dd35 Merge pull request 'Preview work before adding it to Today' (#406) from timmy/405-plan-today-preview into main
All checks were successful
CI / lint (push) Successful in 44s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-09 14:24:29 +00:00
7a62f7ead7 feat: preview work before planning (#405)
All checks were successful
CI / lint (pull_request) Successful in 43s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 14:22:37 +00:00
461f90c8ff Merge pull request 'Support issue capture across all accessible repositories' (#404) from timmy/403-issue-capture-repository-pagination into main
All checks were successful
CI / lint (push) Successful in 43s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-09 13:40:58 +00:00
9ea590a65c feat: paginate issue capture repositories (#403)
All checks were successful
CI / lint (pull_request) Successful in 45s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 13:39:27 +00:00
33a6d1bb6d Merge pull request 'Make Plan Today browser-back safe on mobile' (#402) from timmy/401-plan-today-back into main
All checks were successful
CI / lint (push) Successful in 42s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 4s
2026-08-09 12:40:56 +00:00
b9ae9c6c83 feat: make Plan Today browser-back safe (#401)
All checks were successful
CI / lint (pull_request) Successful in 44s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 12:39:19 +00:00
45ceedb679 Merge pull request 'Expire offline private work with operator sessions' (#400) from timmy/399-offline-session-lease into main
All checks were successful
CI / lint (push) Successful in 42s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-09 12:06:15 +00:00
2e2a7821ae feat: expire offline work with sessions (#399)
All checks were successful
CI / lint (pull_request) Successful in 45s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 12:03:55 +00:00
33427d2407 Merge pull request 'Recover foreground dashboard actions from stalled requests' (#398) from timmy/397-foreground-request-deadlines into main
All checks were successful
CI / lint (push) Successful in 42s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
2026-08-09 11:39:22 +00:00
f0b0b703ff feat: bound foreground API requests (#397)
All checks were successful
CI / lint (pull_request) Successful in 41s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 11:37:57 +00:00
055a9dbb2a Merge pull request 'Resume interrupted Today sessions' (#396) from timmy/395-resume-today-session into main
All checks were successful
CI / lint (push) Successful in 43s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 6s
2026-08-09 11:03:45 +00:00
1f7fd093fd feat: resume interrupted Today sessions (#395)
All checks were successful
CI / lint (pull_request) Successful in 43s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 11:02:00 +00:00
ff0e47cbe7 Merge pull request 'Collapse per-card planning actions on mobile' (#394) from timmy/393-mobile-card-planning-disclosure into main
All checks were successful
CI / lint (push) Successful in 40s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
2026-08-09 10:38:25 +00:00
c2da869c47 feat: collapse mobile card planning controls (#393)
All checks were successful
CI / lint (pull_request) Successful in 43s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 10:36:41 +00:00
8ada75ec33 Merge pull request 'Close issues and continue the Today session' (#392) from timmy/391-close-and-next into main
All checks were successful
CI / lint (push) Successful in 42s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-09 10:14:29 +00:00
6dea5fd1ef feat: close issues and continue Today session (#391)
All checks were successful
CI / lint (pull_request) Successful in 42s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 10:12:55 +00:00
bf4ce3adad Merge pull request 'Keep Create & start durable across interruptions' (#390) from timmy/389-durable-create-and-start into main
All checks were successful
CI / lint (push) Successful in 41s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-09 09:42:52 +00:00
34664edb8b feat: keep create and start durable (#389)
All checks were successful
CI / lint (pull_request) Successful in 40s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 09:41:10 +00:00
f92794f571 Merge pull request 'Create and start captured issues' (#388) from timmy/387-create-and-start into main
All checks were successful
CI / lint (push) Successful in 41s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
2026-08-09 09:09:38 +00:00
edbe2d76dd feat: create and start captured issues (#387)
All checks were successful
CI / lint (pull_request) Successful in 40s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 09:08:06 +00:00
cbdddcddbe Merge pull request 'Plan Today in one focused mobile flow' (#386) from timmy/385-plan-today-mobile-flow into main
All checks were successful
CI / lint (push) Successful in 41s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
2026-08-09 08:39:13 +00:00
de6c9c249a feat: add focused mobile Today planner (#385)
All checks were successful
CI / lint (pull_request) Successful in 39s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 08:37:41 +00:00
19122c69e6 Merge pull request 'Make live snapshot revisions safe across workers and restarts' (#384) from timmy/383-multi-worker-live-revisions into main
All checks were successful
CI / lint (push) Successful in 42s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-09 08:15:54 +00:00
2253c40577 fix: scope live revisions to worker generations (#383)
All checks were successful
CI / lint (pull_request) Successful in 41s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 08:14:18 +00:00
5c2c0383df Merge pull request 'Bound planning sync history and reject expired offline edits' (#382) from timmy/381-bound-planning-sync-history into main
All checks were successful
CI / lint (push) Successful in 41s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
Closes #381
2026-08-09 07:45:32 +00:00
4e13f5482b feat: bound planning sync history (#381)
All checks were successful
CI / lint (pull_request) Successful in 39s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 07:43:53 +00:00
51278bc86f Merge pull request 'Bundle and fingerprint the mobile PWA runtime' (#380) from timmy/379-content-hashed-pwa-shell into main
All checks were successful
CI / lint (push) Successful in 43s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-09 07:00:36 +00:00
a919242b8f perf: bundle and fingerprint the PWA runtime (#379)
All checks were successful
CI / lint (pull_request) Successful in 42s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 06:59:05 +00:00
56a9a0cba6 Merge pull request 'Keep Later plans conflict-safe across devices' (#378) from timmy/377-later-conflict-safe into main
All checks were successful
CI / lint (push) Successful in 38s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
2026-08-09 06:39:03 +00:00
27fc74ced6 fix: keep Later plans conflict-safe across devices (#377)
All checks were successful
CI / lint (pull_request) Successful in 41s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 06:37:30 +00:00
f686e2e71e Merge pull request 'Batch queued Today and Later planning edits' (#376)
All checks were successful
CI / lint (push) Successful in 44s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Closes #375
2026-08-09 06:07:19 +00:00
8f7a836d28 feat: batch queued planning edits (#375)
All checks were successful
CI / lint (pull_request) Successful in 44s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 06:05:40 +00:00
5894eafb3f Merge pull request 'Keep Today and Later edits lossless across browser tabs' (#374) from timmy/373-lossless-planning-tabs into main
All checks were successful
CI / lint (push) Successful in 39s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-09 05:38:52 +00:00
639ce911be fix: keep planning edits lossless across tabs (#373)
All checks were successful
CI / lint (pull_request) Successful in 41s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 05:31:14 +00:00
420 changed files with 121221 additions and 2071 deletions

View File

@ -7,21 +7,26 @@ on:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
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:
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Build deterministic release bundle
run: |
SOURCE_DATE_EPOCH="$(git show -s --format=%ct "$GITHUB_SHA")"
@ -31,21 +36,42 @@ jobs:
--commit "$GITHUB_SHA" \
--source-date-epoch "$SOURCE_DATE_EPOCH"
- name: Upload tested release bundle
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
with:
name: release-bundle
path: dist/
browser-journey:
runs-on: ubuntu-latest
needs: build-release
env:
STACKCHAIN_RUN_RELEASE_E2E: "1"
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with: { python-version: "3.11" }
- name: Download assembled release bundle
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2
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 -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
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Download tested release bundle
uses: actions/download-artifact@v3
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2
with:
name: release-bundle
path: dist
@ -59,7 +85,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

617
README.md
View File

@ -18,10 +18,51 @@ token that can read dashboard data, update the authenticated user's notification
threads, create and self-assign issues, discover, claim, and release issue assignments,
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.
requests, merge assigned pull requests, and submit pull-request reviews. For authored
pulls, up to eight non-overlapping one-line reviewer suggestions across bounded UTF-8
files can be staged during the mobile feedback pass, reviewed in a path-grouped manifest,
and committed as one atomic branch update. The batch preserves every original blob
identity, applies same-file replacements from the bottom up, rejects overlapping or stale
lines, and verifies the advanced pull head and every final file before reporting success.
The server uses Gitea's multi-file contents mutation so a cross-file implementation-and-test
fix cannot be partially committed.
After an exact merged commit fails release checks, its mobile release receipt can load the
failed job evidence and prepare a draft rollback pull request. The server revalidates the
operator's participation and push access, reverses only bounded UTF-8 files whose current
default-branch content still matches the failed merge, and creates one atomic commit on a
stable operator-owned branch. Conflicts, renames, binary files, and oversized changes fail
closed; retrying converges on the same draft PR, which opens in the existing review workspace.
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 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; mobile issue capture requires issue
creation and assignment permission. Once a repository and meaningful title are selected,
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
responses cannot replace a newer query, and search failure leaves the draft and browse fallback
intact. Saved drafts restore their exact repository even when it is outside the first page. Once a
repository and meaningful title are selected,
the New issue sheet checks for similar open issues in that repository. Candidate links keep
the draft intact; the first create attempt pauses until the operator reviews them or explicitly
chooses **Create anyway**. This check is advisory and never blocks offline capture or capture
@ -30,17 +71,211 @@ 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,
five-item Today plan syncs across the operator's devices. Server revisions prevent delayed
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.
Eligible open issue previews also expose **Plan ahead**: choose one of the next seven local dates, review
that day's current load and capacity, and enter a required estimate without leaving Search. Existing Week
Ahead placement is moved only after an explicit day change, never duplicated; full days are blocked and
over-capacity admission requires a second confirmation. The action claims unassigned work only after these
checks, stages the canonical issue through the account-bound Week Ahead outbox, and preserves the Search
preview through cancel or browser Back. Offline saves report **sync pending**; if assignment succeeds but
local planning fails, the dashboard reports **Assigned, not planned** and leaves the issue recoverable in My Work.
The live mobile Week Ahead overview also offers **Remove from week** for active planned work. It removes only the
private planning placement—not assignment, issue state, or Gitea content—and immediately recalculates the day's
load. A 10-second **Undo** receipt restores the exact day, list position, and estimate. Both transitions use the
account-bound Week Ahead outbox, remain **sync pending** after delivery failure, and stay unavailable in a read-only
offline snapshot.
Search selection mode extends that flow across several open issues with **Plan Week Ahead**. One phone-safe
review assigns each issue a future day and estimate, validates five-item limits and daily capacity before any
claim, and requires a second confirmation for overload. Confirmed rows are staged into one canonical Week
Ahead update and flushed together; partial assignment or offline sync is reported without discarding the
selection, so claimed work remains recoverable from My Work.
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.
The mobile **Customize routine order** control is also portable across authenticated devices. Reordering
remains immediate when offline and is marked **Sync pending** until connectivity returns. Rapid taps are
coalesced into one latest-order write; transient network or server failures retry automatically with bounded
backoff, and a clean phone refreshes the account order when it returns to the foreground. The complete
routine order is stored as an encrypted, revisioned collection scoped to the confirmed Gitea login; a
concurrent edit shows explicit **Keep this device** and **Use other device** actions instead of silently
losing either order. Account changes discard stale responses and retry work. Resetting publishes the
canonical default order. Delivery, Human Gates, and active Prepare Today precedence are not customizable.
Set `STACKCHAIN_QUEUE_PRIORITY_DB` to override the default `.stackchain-state/queue-priority.sqlite3` path.
Mobile **Recent work** is also portable across signed-in devices. Opening an issue, pull request,
review, Filed item, or update records its canonical detail route locally before navigation and marks
the entry **Sync pending** until the authenticated API confirms it. Reconnect and foreground checks
merge the server list without duplicate routes, while each confirmed account remains bounded to its
five most recent items. A separate **Pin** action keeps up to 20 frequently revisited items above
Recent work even after that five-item window advances; **Unpin** removes only the pin, and both actions
apply offline-first before account-scoped synchronization. Open and Pin/Unpin remain separate touch and
keyboard targets. Titles, repositories, routes, and pins are encrypted at rest with the shared
private-state key; stale responses from a prior account are discarded. Set
`STACKCHAIN_RECENT_WORK_DB` to override `.stackchain-state/recent-work.sqlite3`.
Confirmed **Watch issue** and **Watch pull request** actions on open Search results and assigned My Work
issue/pull-request details feed the mobile **Following** queue, including work already assigned to you or a teammate.
The detail control loads authoritative Gitea state, remains single-flight while changing it, and refreshes Following only
after the account-scoped collection confirms the same mutation. This completes the read → watch → revisit flow without
changing ownership, review assignment, or scheduling work.
Following is a read-first, account-scoped collection: it is encrypted at rest, revisioned, bounded to
50 typed items, and synchronized across signed-in devices. Opening a row reuses the correct issue or
pull-request Search Preview; confirmed **Stop watching** removes an open item. When watched work
closes or merges, the sequential review exposes **Stop watching & next** so the completed item can be
retired without leaving the preview; the next captured change opens immediately, and retiring the
final item completes the Following phase. Changed cards explain whether work closed, reopened, changed title, or
received other activity. During review, conversation messages newer than the prior reviewed revision are highlighted,
and the revision is acknowledged only after both detail and conversation context load; a failed conversation load keeps
that exact change unseen for Retry or **Keep for later & next**. For open work that still needs thought, **Keep for later & next**
restores only the loaded revision to the unseen queue and continues the captured pass without changing
Gitea state, ownership, planning, or watch status. Failed or unconfirmed Gitea mutations leave the collection
and current review position unchanged. Following counts never influence the recommended Work queue. Set
`STACKCHAIN_FOLLOWING_DB` to override `.stackchain-state/following.sqlite3`.
Each device can explicitly opt in to **Notify me when Following changes**. Stackchain polls that
channel independently, validates the device session immediately before delivery, and checkpoints
the exact unseen revision set so unchanged work stays silent across workers. Lock-screen payloads
contain only a bounded count and the `#/my-work/following` route—never repository names, titles,
bodies, or comments. Disabling Following alerts leaves Updates, deadlines, and start-day reminders
unchanged.
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, Filed,
and unseen Following activity, then opens the highest-priority non-empty review queue. Following refreshes only when
Prepare Today is opened; selecting that phase starts its changed-first sequential review directly. Starting the pass
saves a confirmed-account, local-day checkpoint: finishing Agenda, Updates, the final Filed review, or the final
Following revision 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. 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. Rescheduling Today work into
Week Ahead uses a bounded account-scoped FIFO on the device: multiple offline moves survive reload,
deliver oldest-first with stable operation IDs, and rebase each later move on the confirmed plan revisions
without concurrent requests. 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. Cross-device **Continue here** handoff transfers the bounded per-item timing ledger—not only the active item—so the receiving phone keeps the complete recap and optional time-log durations; legacy single-item sessions migrate automatically. **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). 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
for the current unread-update conversation. It deliberately leaves the notification unread;
an open, unassigned issue update also offers **Take ownership & start**, which checks Today capacity
before assignment, preserves the unread update, adds and syncs the owned issue to Today, checkpoints
the session, and opens the issue. The adjacent **Take ownership** action remains available for
claim-only triage, and a local start failure opens the now-owned issue with truthful recovery guidance.
**Mark read & next** remains the explicit acknowledgement path. During an online Updates pass,
Stackchain preloads at most the next surviving conversation from the fixed snapshot while the
current one is being read. Advancing consumes that account-bound result without another detail
request; failures fall back to the normal foreground retry path, and offline triage never speculates.
Delivery or local-admission
failure preserves both the reply draft and checkpoint. Finishing or choosing **End session** clears only the checkpoint and leaves the Today plan
unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
and reconnecting or returning to the dashboard refreshes server truth after replaying queued
offline operations. After a healthy, fully paginated
offline operations. **Plan Tomorrow** remains independent from active Today work and durably admits the exact
ordered plan, capacity, estimates, next local date, timezone, and server revision to account-bound browser
storage before closing on a phone. The Queues summary marks it **sync pending** while offline; reconnect,
foreground, and midnight lifecycle checks share one delivery flight. A successful account receipt removes
the pending copy, while a revision conflict preserves both the phone plan and fresh server snapshot for
review. Unsynced Tomorrow work is never promoted into Today.
**Week Ahead** opens as a read-first seven-day mobile overview, so operators can inspect the next planned day,
work titles and references, load versus capacity, overloads, and pending sync without staging a change. When Today
is empty and no work session is active, **Start this day early** confirms the next planned date, item count, minutes,
and capacity before atomically moving only that day into Today; offline, pending, stale, or non-empty plans remain
unchanged. **Edit day** enters one date and returns to the refreshed overview; **Edit week** starts the continuous
planning pass. Closing an issue from its Week Ahead detail retires that identity and estimate from every future
week day in one durable update while preserving sibling order and daily capacity. The open overview recalculates
immediately; an unavailable save remains visibly **sync pending** and retries through the existing lifecycle.
**Plan Week Ahead** continues through seven local dates and now finishes on a mobile review step instead of
closing after the seventh save. The review shows planned minutes against each days capacity, marks overloads,
and flags work assigned to more than one date. Operators can move an item to another date without copying it;
the estimate follows the item and the server rejects duplicate cross-day assignments without advancing the
week revision. When capacity changes create overload, **Reflow remaining week** previews a deterministic,
order-preserving redistribution across the seven visible dates. No day exceeds its capacity or five-item limit;
work that cannot fit is named and remains in My Work, while missing estimates block apply. Cancel performs no
write, and apply stages one durable whole-week transition through the existing conflict-safe sync path.
Confirmation remains disabled while duplicates exist or the account-bound week is still syncing.
Rapid saves and review moves use one network flight plus a coalesced latest-state delivery, so later staged days
are not stranded behind an earlier request. From the same review, **Refresh from calendar** reads a local
`.ics` file on-device, unions overlapping busy periods within chosen working hours, and compares every current
capacity with refreshed availability and planned load. If refreshed availability creates an overload, the review
previews the deterministic destination of moved work and reports anything that cannot fit. One explicit
**Apply capacities & reflow** action stages the complete seven-day plan once—there is no transient overloaded
save—and preserves consented free windows alongside the redistributed work. A refresh that already fits keeps
the direct one-write capacity path. IANA `TZID` values from Google and Outlook calendars are converted
to the device's local workday, including daylight-saving transitions. Daily and weekly recurrence (`COUNT`,
`UNTIL`, `INTERVAL`, and weekly `BYDAY`), `RDATE`/`EXDATE`, and all-day events are evaluated with work bounded
to the seven review dates; cancelled and transparent events do not consume capacity. If a time zone is unknown,
or a recurring event that could affect the week uses another frequency or unsupported rule part, the review
reports only an affected-event count and disables apply rather than understating busy time. Export a simpler
seven-day calendar to proceed. Raw calendar data and event
metadata are never persisted, rendered in diagnostics, or sent; only the reviewed capacity-minute totals use
the existing encrypted, account-bound Week Ahead sync.
Planning edits can remain offline for up to 30 days. After that, the
expired edit is discarded visibly and the account plan is kept rather than replaying stale
intent. The server retains no more than 4,096 operation receipts per account and removes
receipts older than the same 30-day window; client base revisions keep a pruned replay from
changing a newer Today or Later plan. After a healthy, fully paginated
My Work refresh proves that an item is complete or otherwise no longer eligible, Stackchain
queues an idempotent retirement before removing it locally; partial and degraded refreshes
leave the plan unchanged, and offline retirements replay after reconnect. When
@ -53,20 +288,62 @@ triageable offline: **Queue read & next** writes an account-bound, notification-
deduplicated acknowledgement to the durable background delivery system, removes the
update from the local queue immediately, and opens the next saved conversation. A cold
offline reload suppresses acknowledgements still waiting to sync; reconnect uses the
authenticated notification-read endpoint and keeps transient failures queued. Installed-app
authenticated notification-read endpoint and keeps transient failures queued. Every foreground
same-origin dashboard API request also has a 15-second browser deadline, including fresh-
authorization and step-up retries. Read timeouts settle with retry guidance even if the browser's
fetch ignores abort; mutation timeouts instead tell the operator to refresh and verify the server
outcome before retrying. Caller cancellation still takes precedence, and cross-origin fetches are
not changed by this session boundary. Installed-app
navigations are also deadline-bounded: after four seconds without a network response,
Stackchain aborts the request and opens the cached dashboard shell. If the shell has not
been installed yet, it returns explicit HTTP 504 reconnect guidance instead of hanging.
Live dashboard refreshes use the server's per-section freshness windows: healthy sections
refresh at their earliest deadline while a fully degraded snapshot waits for its reported
cooldown. HTTP `Retry-After` delays are honored up to five minutes, and transport failures
back off from eight seconds to a one-minute cap. Reconnect, foreground return, and explicit
refresh still run immediately; a successful response resets transport backoff.
Closed-app
delivery requests are deadline-bounded: a stalled identity, CSRF, or mutation request is
aborted after 15 seconds, its durable claim returns to the queue, and the next sync retries
with the unchanged idempotency key. Device purge cancels an active drain before closing
private outbox storage. Results are coordinated through a bounded SQLite ledger. Set `STACKCHAIN_STATE_DIR` to a
with the unchanged idempotency key. Each finite, lane-fair drain admits at most 70 records
and runs up to three deliveries concurrently, but claims a record only when a delivery slot
is ready. Claims have unique fencing tokens and are renewed before every network stage;
completion, release, failure, and delivery checkpoints are token-fenced so an expired worker
cannot alter a newer crash-recovery claim. Device purge cancels an active drain before closing
private outbox storage. Results are coordinated through a bounded SQLite ledger. 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, mobile Recent work, and completed Filed review
receipts 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; existing completed Filed receipts migrate transactionally at
startup without changing order or acknowledgement state. Missing, wrong, or modified key material
returns no saved-view or receipt 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
default. Tune it with `STACKCHAIN_IDEMPOTENCY_LOCK_TIMEOUT_SECONDS`; keep the value below
route deadlines. Reservation contention returns retryable HTTP 503 with `Retry-After: 1`.
route deadlines. During a controlled shutdown, the server gives in-flight authored mutations
five seconds to finish and persist their ledger result before it closes the Gitea transport.
Set `STACKCHAIN_AUTHORED_ACTION_SHUTDOWN_GRACE_SECONDS` to match the service manager's
shutdown budget; when that deadline expires, remaining operations are cancelled and their
pending reservations continue to fail closed rather than being retried automatically.
Reservation contention returns retryable HTTP 503 with `Retry-After: 1`.
If contention occurs after the upstream mutation, the dashboard fails closed with
`Retry-After: 5` and asks the caller to verify the result before retrying. Direct API callers
should preserve the `Idempotency-Key` header with the unchanged route and payload until a
@ -75,7 +352,12 @@ Approve, and Request changes reviews, and assigned-PR merge require repository
write permission. Native Comment, Approve, and Request changes reviews support
head-scoped draft comments anchored to changed lines; the dashboard validates each
comment path and submits the summary, decision, and inline comments in one review
request. The dashboard rechecks the current pull-request head, CI success, draft
request. On phone-width review sheets, changed lines wrap inside a stable old/new
line-number gutter by default so long source lines remain readable and commentable
without horizontal panning. **Lines wrapped** toggles back to whitespace-preserving
horizontal inspection, and an explicit choice persists on the device across files,
review-sheet reopen, and reload. Desktop review diffs remain horizontally scrollable
until wrapping is explicitly enabled. The dashboard rechecks the current pull-request head, CI success, draft
state, and mergeability immediately before every merge.
Serve the dashboard only to trusted users on its own origin; cross-origin API
access is intentionally disabled. Authentication defaults to fail-closed
@ -92,28 +374,140 @@ export GITEA_TOKEN='<read-notification-and-issue-write-token>'
export STACKCHAIN_DASHBOARD_AUTH_MODE='operator'
export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='<operator-sign-in-secret>'
export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-secret>'
export STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN='https://forge.example.com'
# Optional; defaults to STACKCHAIN_STATE_DIR/sessions.sqlite3.
export STACKCHAIN_SESSION_DB='/var/lib/stackchain-dashboard/sessions.sqlite3'
# Optional; defaults to STACKCHAIN_STATE_DIR/security-events.sqlite3.
export STACKCHAIN_SECURITY_EVENT_DB='/var/lib/stackchain-dashboard/security-events.sqlite3'
# Optional compatibility overrides; defaults derive from the required public origin.
export STACKCHAIN_PASSKEY_RP_ID='forge.example.com'
export STACKCHAIN_PASSKEY_ORIGIN='https://forge.example.com'
# Optional; defaults to eight hours.
export STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS=28800
# Optional; explicit pointer, keyboard, or touch activity renews this idle window.
# Background polling and queued delivery do not. Defaults to 15 minutes.
export STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS=900
# Optional sign-in throttle: five failures per five minutes, up to 10,000 sources.
export STACKCHAIN_LOGIN_MAX_FAILURES=5
export STACKCHAIN_LOGIN_WINDOW_SECONDS=300
export STACKCHAIN_LOGIN_MAX_ENTRIES=10000
# Public passkey ceremonies share the same window and durable source ledger.
export STACKCHAIN_PASSKEY_OPTIONS_MAX_ATTEMPTS=10
# Bound live one-time challenges even if anonymous clients rotate addresses.
export STACKCHAIN_PASSKEY_MAX_CHALLENGES_PER_SOURCE=10
export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000
# Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3.
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
# Required for worker-shared live/Find Work snapshots, synchronized Today/Later
# planning, Saved Search state, completed Filed review receipts, 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.
# The single-key setting remains the compatible first deployment and writes v1 envelopes.
export STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY='<base64-encoded-32-byte-key>'
# After all workers run keyring-capable code, configure at most four named keys and
# select the active write key. Keep the old key present during the rolling deployment.
# Key IDs use 1-32 letters, digits, underscores, or hyphens.
export STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEYS='{"legacy":"<old-base64-key>","2026-08":"<new-base64-key>"}'
export STACKCHAIN_PRIVATE_STATE_ACTIVE_KEY_ID='2026-08'
# New writes use authenticated v2 envelopes carrying the active key ID. Rewrap every
# shared store with the restart-safe command; output contains store-level counts only.
# Run it again until every store reports failed=0, migrated=0, and current=total,
# then remove the old key from every worker. Retain all keys and investigate if it exits 1.
python3 scripts/rotate_private_state.py
# 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.
# The feature stays disabled unless all three values are present. Privacy-safe update
# alerts offer Mark read and Tomorrow; Tomorrow syncs the unread item to Later at
# 09:00 in the device's local timezone without opening the dashboard. Bursts send
# three individual alerts followed by one private digest that opens Updates. A later
# comment on an already-delivered thread triggers a fresh alert when Gitea advances
# that thread's updated_at revision; unchanged and older snapshots remain silent. Opt-in
# Following alerts use generic copy and deep-link to the changed-first Following review.
# Browser push services must resolve exclusively to public IP addresses. Stackchain
# validates endpoints at enrollment and again before delivery, rejects redirects,
# and removes legacy subscriptions that resolve to private or reserved networks.
export STACKCHAIN_VAPID_PUBLIC_KEY='<url-safe-public-key>'
export STACKCHAIN_VAPID_PRIVATE_KEY='<private-key-from-secret-manager>'
export STACKCHAIN_VAPID_SUBJECT='mailto:ops@example.com'
# 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.
export STACKCHAIN_PUSH_MAX_INDIVIDUAL_NOTIFICATIONS=3
export STACKCHAIN_PUSH_LEASE_SECONDS=60
export STACKCHAIN_PUSH_DB='/var/lib/stackchain-dashboard/push-subscriptions.sqlite3'
uvicorn src.main:app --host 127.0.0.1 --port 8000
```
Sign-in failures are scoped to a hashed canonical client address and persisted across
workers and restarts. Once the budget is exhausted, the server returns `429` with
`Retry-After`; the mobile login form disables retries for that interval. Expired
source records are pruned and the ledger is size-bounded. Keep its SQLite file on
shared writable storage. `X-Forwarded-For` is ignored unless the immediate peer is
inside `STACKCHAIN_TRUSTED_PROXY_CIDRS`; list only networks you operate. Without
that setting, a reverse proxy is safely treated as one shared source.
`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
source and globally in the session registry. Once either sign-in budget is exhausted,
the server returns `429` with `Retry-After`; a successful sign-in clears that source's
failure state. Expired source records and challenges are pruned, and both ledgers are
size-bounded. Keep their SQLite files on shared writable storage. `X-Forwarded-For` is
ignored unless the immediate peer is inside `STACKCHAIN_TRUSTED_PROXY_CIDRS`; list only
networks you operate. Without that setting, a reverse proxy is safely treated as one
shared source.
Inbound API mutations are admitted through a body-size boundary before FastAPI
parses JSON: sign-in is capped at 16 KiB and other `POST`, `PUT`, and `PATCH`
@ -129,18 +523,64 @@ storage shared by all dashboard workers. Operators name a device at sign-in and
open **Active devices** to review creation/expiry times, identify the current device,
and revoke one remote session without interrupting other trusted devices. The API
exposes only independent management IDs and bounded labels—never cookie values,
session hashes, CSRF proofs, or source addresses. Existing two-column registries are
migrated in place and their live sessions remain valid.
session hashes, CSRF proofs, or source addresses. The registry also stores each
session's last explicit activity. Existing two-column registries are migrated in
place, their live sessions remain valid, and their idle clock starts at migration.
High-impact actions—merging a pull request, closing an assigned issue, revoking a
remote device, or signing out every device—require the operator access token again.
The same sheet includes **Security activity**, a reverse-chronological journal of
successful token/passkey sign-ins, passkey enrollments, sign-outs, remote device
revocations, issue closures, and pull-request merges. The separate SQLite journal
retains at most 10,000 events for 90 days. 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
revocation, or issue closure, the journal durably reserves a pending event; if that
reservation fails, the consequential action does not begin. A successful action
remains truthfully reported even if its event cannot immediately be finalized, and
the activity sheet marks that durable record as **Outcome confirmation pending**.
After token bootstrap, **Active devices → Add a passkey for this device** enrolls a
WebAuthn credential with required user verification. That device can then sign in
and authorize high-impact actions with its biometric/PIN gesture. The access token
remains the recovery fallback for browsers without WebAuthn or devices without an
enrolled credential. Registration and authentication challenges are exact-purpose,
single-use, and short-lived. **Enrolled passkeys** lists every durable credential,
including those whose original session has expired or signed out, using only its
bounded label, enrollment time, and active/current status. Removing one requires a
single-use fresh authorization bound to that exact management ID. A remote active
session linked to the credential is revoked atomically; unrelated credentials and
sessions remain valid. Removing the current device's passkey keeps its current
session active, so the sheet warns that the recovery token will be required after
sign-out. Remotely revoking an enrolled device also deletes its passkey; signing out
normally keeps the passkey available for the next sign-in. Passkey assertion counters
advance with an atomic compare-and-swap across workers: stale or equal nonzero counters
fail before a session or authorization grant is issued, while counterless authenticators
remain compatible. The bounded **Passkey counter anomaly** Security activity entry names
only the device label and attempted action; repeated alerts should prompt removal and
re-enrollment of that passkey.
High-impact actions—merging a pull request, closing an assigned issue, permanently
deleting an authored comment, revoking a remote device, or signing out every device—
require a passkey assertion or the
operator access token again.
The server issues a random 90-second grant bound to the active session, exact action,
and exact target. Only its digest is stored, and the grant is consumed atomically on
first use. Expired, replayed, cross-session, and target-substituted grants fail before
Gitea or session state is changed. The browser preserves the pending request and
retries it once after the built-in mobile/keyboard-accessible authorization prompt.
If an active session expires, the first authenticated API rejection replaces the
After 15 minutes without pointer, keyboard, or touch activity (configurable through
`STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS`), the server rejects the session even if
polling or background delivery continued. The dashboard locks and background outbox
delivery pauses, but drafts, Today/Later state, queued mutations, and caches remain on
the device; signing in resumes the existing account-bound work. If an active session
expires, the first authenticated API rejection replaces the
dashboard with sign-in and explains that private drafts remain on the device; signing
in again resumes account-bound queued delivery. Expiry recovery does not clear offline
state. A selectively or globally revoked device instead receives a bounded revocation
@ -160,7 +600,15 @@ Every response also defines the browser execution boundary with a Content Securi
Policy that allows scripts only from the dashboard origin, denies framing and
plugins, and blocks unused browser capabilities. Keep these response headers when
proxying; do not add inline scripts or broaden `script-src`. The dashboard bootstrap
and stylesheet are same-origin static assets included in the offline PWA shell.
is assembled in source order into one content-addressed JavaScript response. Dashboard
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
@ -178,12 +626,14 @@ successful response is JSON containing
to search commands plus issues and pull requests across every repository visible to
the configured Gitea token. Remote search starts after two characters, is debounced,
and keeps local commands usable if Gitea search is unavailable. Selecting a remote
result opens a mobile-safe, read-only preview without discarding the search query;
open unassigned issues can be claimed in place and handed into My Work after Gitea
confirms the assignment. Closed work and pull requests remain read-only with a safe
canonical Gitea link. The bounded APIs are available at
`GET /api/v1/search?q=<query>&limit=<1-25>` and
`GET /api/v1/repos/<owner>/<repo>/issues/<number>/preview?kind=issue|pull`. Never commit the token
result opens a mobile-safe preview without discarding the search query. Open
unassigned issues can be claimed in place and handed into My Work after Gitea
confirms the assignment. A closed issue can be reopened, self-assigned, added to
Today, and resumed through the same capacity-guarded flow; pull requests remain
read-only with a safe canonical Gitea link. The bounded APIs are available at
`GET /api/v1/search?q=<query>&limit=<1-25>`,
`GET /api/v1/repos/<owner>/<repo>/issues/<number>/preview?kind=issue|pull`, and
`PATCH /api/v1/repos/<owner>/<repo>/issues/<number>/reopen`. Never commit the token
or place it in a tracked configuration file.
For service monitoring, GET `/healthz` is a liveness check that confirms the
@ -202,10 +652,34 @@ below the route deadlines. Streaming diff reads share the same read capacity,
while POST, PATCH, PUT, and DELETE requests are never coalesced.
Live snapshots expose independent `context`, `events`, and `notifications`
revisions. The browser sends its known revisions on later polls, so `/api/v1/live`
can omit unchanged section bodies while still returning current freshness and
retry metadata. The client retains omitted data and only rebuilds or persists the
sections that changed.
revision tokens. Snapshot content, freshness/backoff metadata, and revisions are published
atomically through a private SQLite store shared by all application workers. An expiring
refresh lease ensures only one worker loads currently due sections; other workers serve the
same stale snapshot while that refresh runs, and can recover an abandoned lease after expiry.
Set `STACKCHAIN_LIVE_SNAPSHOT_DB` to override the default
`STACKCHAIN_STATE_DIR/live-snapshot.sqlite3`; keep the containing directory on private,
worker-shared writable storage. The database and directory are restricted to the service
account and never contain the Gitea token. Find Work uses the same worker-shared pattern:
`STACKCHAIN_AVAILABLE_ISSUE_SNAPSHOT_DB` overrides
`STACKCHAIN_STATE_DIR/available-issue-snapshot.sqlite3`. One expiring lease bounds each
catalog scan across the deployment, shared retry metadata prevents worker-by-worker retry
bursts, and confirmed claims are removed from every worker's retained catalog. On a cold
catalog, non-owner workers wait within the bounded foreground deadline for the lease owner
to publish; if that owner releases or abandons the lease, a waiter can take over the scan
within its remaining request budget. Releasing
an assignment invalidates the shared catalog so the newly available issue can be discovered
by the next authoritative scan. Each bounded opaque revision token includes the
store generation, so a token from a different deployment or before replacement of the store
cannot suppress different content. The browser sends its known tokens on later polls, so
`/api/v1/live` can omit unchanged section bodies while still returning current freshness and
retry metadata. The client retains omitted data and only rebuilds or persists the sections
that changed. Malformed or oversized tokens are rejected before any upstream work.
Confirmed notification acknowledgements update this worker's live view immediately, then
maintain the shared snapshot outside the async request loop. Bulk acknowledgements remove
all successful notification IDs in one SQLite transaction and advance the notification
revision once. If shared-store lock admission fails after Gitea confirms the mutation, the
API keeps the confirmed result and process-local filtering rather than falsely reporting the
upstream write as failed; a later shared refresh reconciles the cache.
## Offline mobile shell
@ -215,17 +689,31 @@ filter, preserves the selected release lane, shows the current draft count, resp
the device safe area, and moves out of the way while a full-screen task is open.
Desktop layout is unchanged.
Mobile **Search** provides touch-sized **Repository**, **Type**, and **Status** controls.
Operators can find and select a repository visible to their Gitea account, then scope results
to that exact repository, issues, pull requests, open work, closed work, or all accessible work;
the server applies every scope before pagination. Clearing Repository returns to organization-wide
Search. The selected scope is bounded and addressable, survives preview/back, reload, sharing,
and sign-in continuation, and a scope change cancels obsolete requests before restarting at the
first page. Repository lookup failure leaves organization-wide Search usable.
My Work also has an account-synced **Later** queue. **Later today** defers an item for four
hours, while **Tomorrow** returns it at 09:00 in the device's local timezone. **Choose date & time**
accepts a valid future local date and time and returns the item at that exact instant; the picker
shows the device timezone and rejects empty, normalized, invalid, or past values before saving.
Deferred items leave normal and Attention queues without marking notifications read
or changing any Gitea issue or pull request. They automatically return to their
existing priority position at the wake time, and **Bring back now** restores them
early. Later wake times are scoped to the confirmed Gitea login and synchronize
existing priority position at the wake time. **Start now** atomically admits a deferred
item to Today, removes its Later record, and opens that exact item in a resumable Today
session; full or unavailable Today storage leaves the deferral intact for retry. Items
already in Today are opened without duplication. **Bring back now** still restores an
item early without starting it. Later wake times are scoped to the confirmed Gitea login and synchronize
across signed-in tabs and devices. Offline changes apply immediately, survive reload,
and replay after reconnect; `STACKCHAIN_LATER_DB` can override the default durable
store at `.stackchain-state/later.sqlite3`.
and replay after reconnect. Each queued edit carries the account revision it was based
on; if another device has since changed the same item, Stackchain keeps the newer
account plan and reports the conflict instead of silently overwriting it. Unrelated
items continue syncing in the same batch. `STACKCHAIN_LATER_DB` can override the
default durable store at `.stackchain-state/later.sqlite3`.
After one successful online load, the installed dashboard precaches a versioned,
subpath-scoped application shell. During a network outage or a dashboard HTTP
@ -243,28 +731,35 @@ newest 20 comments. Requested-review records also retain the head SHA, CI state,
newest 20 prior reviews, and at most 50 sanitized file previews with 400 diff lines
per file. A previously opened unread update is retained by notification
identity with allowlisted subject context and its newest 20 conversation messages.
This account-bound cache is limited to ten records across all detail kinds; credentials,
repository catalogs, events, raw patches, and complete API responses are excluded.
The snapshot and each account-bound detail are committed as separate IndexedDB records,
so large review previews do not consume the small synchronous localStorage quota or require
rewriting the whole cache. Only the opt-in preference remains in localStorage. Existing v1
localStorage payloads migrate after a successful IndexedDB commit. The detail cache remains
limited to ten records across all detail kinds; credentials, repository catalogs, events, raw
patches, and complete API responses are excluded.
A cold offline launch labels the saved time. Cached Today details open in the existing
phone sheet, where comments can enter the account-bound durable outbox. For issue and
non-review pull details, planning, assignment, review, merge, and close controls remain disabled
until reconnection. Cached
requested reviews open in the existing review sheet so file progress, notes, summary,
decision, and inline-comment drafts remain usable under the saved head SHA. Review
submission is never queued: reconnect reloads the current head before enabling submit;
a changed head starts clean SHA-scoped progress while retaining the prior-head draft.
until reconnection. Cached requested reviews open in the existing review sheet so file progress, notes,
summary, decision, and inline-comment drafts remain usable under the saved head SHA.
**Queue review for reconnect** durably stores that complete SHA-bound review with one
idempotency key. Reconnect or Background Sync submits it exactly once; transient failures
retain the queue entry, while stale-head and invalid-inline-comment responses move it to
Drafts as **Needs attention** without deleting feedback. Confirmed delivery removes the
queued operation and its matching SHA-scoped draft and progress.
Cached unread updates use the same phone conversation sheet and replies enter the
account-bound durable outbox, while mark read, ownership, deferral, and older-message loading remain disabled until reconnection.
Cards without a saved detail explain that reconnection is required. **Clear offline
work data** deletes both stores, and opting out or seven-day expiry deletes them
automatically.
work data**, opt-out, sign-out, remote revocation, and absolute session expiry delete the
IndexedDB records; seven-day expiry removes stale records automatically.
API responses and mutations are never cached by the service worker. New issue captures,
issue comments, pull-request comments, and unread-update replies use bounded local
outboxes when connectivity or a retryable server failure prevents delivery. Issue
captures and authored messages are also mirrored into account-bound IndexedDB lanes
and registered with Background Sync, so a supporting installed browser can deliver
new issues, issue comments, pull-request comments, and unread-update replies after
new issues, issue comments, pull-request comments, unread-update replies, and completed
pull-request reviews after
every dashboard client has closed. The worker verifies the current Gitea login, shares
an atomic delivery claim with the foreground path, and preserves the original
idempotency key. Installed browsers can explicitly enable **Notify me when queued
@ -377,3 +872,23 @@ Checkpoint replacement uses writer-unique, flushed temporary files and an
atomic rename, preventing concurrent writers from colliding or exposing partial
JSON. Full behavior and safety gates are documented in
[`docs/release-engine-spec.md`](docs/release-engine-spec.md).
## Human Gates inbox
Authenticated release producers use `POST /api/v1/human-gates/intake` with an
`Idempotency-Key` and an immutable `"candidate_hash"`; durable account-bound
SQLite storage is configured by `STACKCHAIN_HUMAN_GATE_DB`. Account isolation
binds each queue to the upstream principal ID and login, including its offline
browser cache. Review decisions carry `expected_revision`, a stable idempotency
key across network retries and mobile app restarts, and return durable receipts.
An interrupted decision response is restored as **Decision outcome unknown**;
the operator verifies the exact persisted operation instead of creating a second
decision. The client clears that operation only after recovering a receipt. If producer evidence
changes after a release or hold, the update reopens the exact hash for a new
revision-checked decision instead of silently retaining the old outcome.
New hashes mark older pending candidates `superseded` without removing their audit
history. See [`docs/human-gates.md`](docs/human-gates.md) for the complete
producer body, decision API, and **Telegram coalescing contract**. Lock-screen
and Telegram notifications expose count and route only, using
`#/my-work/human-gates`; they never expose project, title, candidate hash,
artifacts, checks, provenance, reasons, or receipts.

View File

@ -6,4 +6,5 @@ def explicit_local_dashboard_auth_mode(monkeypatch):
"""Keep tests intentional now that deployed authentication fails closed."""
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "insecure-local")
monkeypatch.delenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", raising=False)
monkeypatch.delenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", raising=False)
monkeypatch.delenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", raising=False)
monkeypatch.setenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "https://test")

45
docs/human-gates.md Normal file
View File

@ -0,0 +1,45 @@
# Human Gates producer and notification contract
Human Gates is an account-bound release-candidate inbox. The canonical mobile route is `#/my-work/human-gates`. Reads may use the last account-scoped browser cache, but Release/Hold decisions require a live authenticated identity and an online server round trip.
A cold open of the canonical route confirms the account and opens Human Gates from the core browser runtime while optional Today and Planning bundles continue hydrating or recovering. The full workspace adopts that controller and fixed review snapshot without a second queue request or duplicate decision handlers.
## Producer intake
Authenticated producers submit `POST /api/v1/human-gates/intake` with a unique `Idempotency-Key` header and JSON such as:
```json
{
"source": "release-bot",
"project": "stackchain/dashboard",
"candidate_hash": "abc123",
"title": "Dashboard candidate",
"priority": 7,
"artifacts": [{"name": "manifest", "url": "https://forge.example/artifacts/manifest.json"}],
"links": [{"label": "change", "url": "https://forge.example/pulls/1415"}],
"checks": [{"name": "browser", "state": "success", "required": true}],
"score": {"value": 92, "provenance": "release-evaluator/v2"},
"provenance": {"producer": "release-bot", "run_id": "run-9"}
}
```
The immutable identity is authenticated account + `source` + `project` + `candidate_hash`. Retrying the same key and body returns the same gate. Reusing a key for different facts, or redefining an existing candidate hash, returns 409. A newer hash from the same source/project atomically marks older pending candidates `superseded`; detail history remains available for audit. Configure durable storage with `STACKCHAIN_HUMAN_GATE_DB` (default: `$STACKCHAIN_STATE_DIR/human-gates.sqlite3`).
Consumers list `GET /api/v1/human-gates`, inspect `GET /api/v1/human-gates/{id}`, and submit `POST /api/v1/human-gates/{id}/decision` with a new `Idempotency-Key`, `expected_revision`, and either `release` or `hold`. Hold requires a reason. Release requires all three checklist confirmations; if any required check is not successful it also requires an explicit override reason. Durable receipts are available at `GET /api/v1/human-gate-receipts/{receipt_id}`. All endpoints are authenticated, account-bound, and `Cache-Control: no-store`.
Before a mobile client submits Release or Hold, it stores one account-, gate-, and revision-bound pending operation with the exact payload and idempotency key. A transport interruption changes the decision tray to **Decision outcome unknown** and blocks replacement decisions. **Verify decision** replays that exact operation; server-side idempotency returns the original receipt if the decision committed, or executes it once if the first request never arrived. The pending operation and saved review progress are removed only after a receipt is confirmed. Reloading or restarting the installed app restores this verification flow even when the committed gate is no longer present in the live pending queue. Pending decision data never crosses the immutable account cache boundary.
## Privacy-safe notification contract
Web Push is opt-in per authenticated device under **My Work → Settings → Notify me when release decisions are waiting**. The preference is stored with that device's push subscription; revoked sessions are removed before delivery. The poller honors the device's routine-alert quiet hours, coalesces unchanged pending counts, and routes a notification tap to `#/my-work/human-gates`.
Web Push, Telegram, and other lock-screen adapters MUST expose **count and route only**:
```json
{
"pending_count": 3,
"route": "#/my-work/human-gates"
}
```
The notification text may say “3 Human Gates pending” and provide the route. It MUST NOT include project names, candidate hash values, titles, artifact/link URLs, checks, scores, provenance, decision history, superseded candidate facts, reasons, or receipts. Multiple intake/supersession events before delivery replace the pending notification with the latest count rather than emitting one message per candidate. A transition to zero clears the outstanding notification; it does not send candidate detail. Producers and adapters must fetch current state after authenticated route open rather than treating notification delivery as an action authorization.

View File

@ -23,3 +23,20 @@
- API contracts + versioning
- independent deployability
- observability: metrics, traces, structured logs
# Disk capacity incident
Root filesystem usage at or above 85% is an operations incident.
1. Capture the initial state with `df -h / /var/lib/gitea`.
2. Inspect directory sizes and active processes before cleanup. Reclaim only
disposable caches and abandoned temporary environments. Responders must not
delete Gitea data, repositories, databases, secrets, or active release
artifacts.
3. Re-run `df -h / /var/lib/gitea`; usage must be below 85%.
4. Confirm the release path with `systemctl is-active gitea act_runner`, then run
the repository test suite.
Record the before and after usage in the incident ticket. If safe cleanup cannot
restore capacity below 85%, keep the incident open and escalate storage
expansion rather than removing durable data.

180
frontend/agenda-calendar.js Normal file
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
frontend/agenda-replan.js Normal file
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;

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};
});

View File

@ -0,0 +1,49 @@
function createAssignAndStart({ available, claim, start, queue, recover, announce }) {
let request = null;
function run(item, { alreadyOwned = false, destination = 'start' } = {}) {
if (request) return request;
if (!available()) {
announce('Today is full—remove an item before assigning this issue.');
return Promise.resolve('full');
}
request = Promise.resolve()
.then(() => alreadyOwned ? item : claim(item))
.then(confirmed => {
const outcome = destination === 'queue' ? queue(confirmed) : start(confirmed);
if (outcome === 'queued') {
announce(alreadyOwned ? 'Queued in Today. Keep finding work when ready.' :
'Assigned and queued in Today. Keep finding work when ready.');
return outcome;
}
if (outcome === 'started') {
announce(alreadyOwned ? 'Added to Today and ready to work.' :
'Assigned, added to Today, and ready to work.');
return outcome;
}
if (destination === 'queue') {
if (outcome === 'sync-unavailable') {
announce(alreadyOwned ?
'Saved in Today on this device, but account sync is unavailable. The issue is open so you can recover.' :
'Assigned and saved in Today on this device, but account sync is unavailable. The issue is open so you can recover.');
} else {
announce(alreadyOwned ?
'Today could not be queued. The issue is open so you can recover.' :
'Assigned to you, but Today could not be queued. The issue is open so you can recover.');
}
} else {
announce(alreadyOwned ?
'Today could not start. The issue is open so you can recover.' :
'Assigned to you, but Today could not start. The issue is open so you can recover.');
}
recover(confirmed);
return 'recovery';
})
.finally(() => { request = null; });
return request;
}
return { run };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createAssignAndStart;

View File

@ -1,10 +1,83 @@
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']);
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({
body: String(message.body || ''),
decision: String(message.decision || 'comment'),
expectedHeadSha: String(message.expectedHeadSha || ''),
comments: Array.isArray(message.comments) ? message.comments : [],
});
}
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 {
if (item.draftKey && item.draftFingerprint &&
storage?.getItem(item.draftKey) === item.draftFingerprint) storage.removeItem(item.draftKey);
if (item.progressKey && item.progressFingerprint &&
storage?.getItem(item.progressKey) === item.progressFingerprint) storage.removeItem(item.progressKey);
} catch (_error) { /* Delivery is confirmed even when local cleanup is unavailable. */ }
}
function read() {
try {
@ -26,14 +99,50 @@ 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 || '') &&
item.number === Number(message.number || 0) &&
item.expectedHeadSha === String(message.expectedHeadSha || ''));
if (queuedReview) {
if (reviewFingerprint(queuedReview) === reviewFingerprint(message)) return { ...queuedReview };
throw new Error('A review for this saved head is already queued. Open Drafts to inspect or discard it first.');
}
}
if (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,
@ -45,6 +154,33 @@ 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', '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 || ''),
comments: Array.isArray(message.comments) ? message.comments.map(comment => ({ ...comment })) : [],
draftKey: String(message.draftKey || ''),
progressKey: String(message.progressKey || ''),
draftFingerprint: String(message.draftFingerprint || ''),
progressFingerprint: String(message.progressFingerprint || ''),
} : {}),
...(message.kind === 'issue-blocker' ? {
blockerRepository: String(message.blockerRepository || ''),
blockerNumber: Number(message.blockerNumber || 0),
present: message.present === true,
} : {}),
...(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);
@ -52,12 +188,29 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
}
async function enqueueDurably(message) {
const previousItems = read();
const item = enqueue(message, false);
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.');
}
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
return { item, background: false, durability: 'foreground-only' };
}
const durableItems = read().map(candidate => candidate.id === item.id && attachments.length ? {
...candidate,
...(attachments.length === 1 ? { attachment:durableAttachment(attachments[0]) } :
{ attachments:attachments.map(durableAttachment) }),
} : candidate);
try {
await backgroundSync.reconcile(durableItems, 'authored');
} catch (error) {
write(previousItems, false);
throw error;
}
try {
await backgroundSync.reconcile(read(), 'authored');
await backgroundSync.requestSync();
return { item, background: true, durability: 'background' };
} catch (error) {
@ -95,6 +248,22 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
return 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply';
}
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
if (item.kind === 'issue-close') {
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close';
}
if (item.kind === 'issue-blocker') {
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers';
}
if (item.kind === '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';
}
@ -102,6 +271,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
async function sendItem(item, currentLogin) {
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
if (pending.has(item.id)) return pending.get(item.id);
const attemptAt = Number(now());
write(read().map(candidate => candidate.id === item.id ? {
...candidate, status:'sending', lastAttemptAt:attemptAt,
} : candidate), false);
const request = (async () => {
try {
let result;
@ -114,29 +287,94 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
}
result = delivery.message;
} else {
result = await fetchJson(endpoint(item), {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({ body: item.body }),
});
if (item.kind === 'update-reply-read') {
if (!item.replyConfirmed) {
await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', {
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({ body: item.body }),
});
write(read().map(candidate => candidate.id === item.id ? {
...candidate, replyConfirmed: true, status: 'sending', lastAttemptAt: attemptAt,
} : candidate), false);
}
result = await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read', {
method: 'PATCH', headers: { Accept: 'application/json' },
});
} else if (item.kind === 'issue-close') {
result = await fetchJson(endpoint(item), {
method: 'PATCH',
headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
});
if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.');
} else if (item.kind === 'issue-blocker') {
result = await fetchJson(endpoint(item), {
method: 'PATCH',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({
repository: item.blockerRepository,
number: item.blockerNumber,
present: item.present,
}),
});
const dependencies = Array.isArray(result?.dependencies) ? result.dependencies : [];
const present = dependencies.some(candidate => candidate?.repository === item.blockerRepository &&
Number(candidate?.number) === item.blockerNumber);
if (result?.number !== item.number || result?.dependencies_available !== true || present !== item.present) {
throw new Error('Blocker change was not confirmed.');
}
} else {
const body = item.kind === 'pull-review' ? {
body: item.body,
decision: item.decision,
expected_head_sha: item.expectedHeadSha,
comments: item.comments,
} : item.kind === 'issue-content' ? {
title:item.title, body:item.body, expected_updated_at:item.expectedUpdatedAt,
} : { body: item.body };
result = await fetchJson(endpoint(item), {
method: item.kind === 'issue-content' ? 'PATCH' : 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
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 };
clearConfirmedReviewState(item);
discard(item.id);
return { result };
} catch (error) {
const status = Number(error?.status || 0);
const permanent = status >= 400 && status < 500;
const attemptError = String(error.message || 'Delivery failed').slice(0, 240);
if (permanent) {
write(read().map(candidate => candidate.id === item.id ? {
...candidate,
status: 'attention',
error: String(error.message || 'Message needs attention').slice(0, 240),
lastAttemptAt: attemptAt,
lastAttemptError: attemptError,
...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}),
} : candidate));
} else {
write(read().map(candidate => candidate.id === item.id ? {
...candidate, status:'queued', lastAttemptAt:attemptAt, lastAttemptError:attemptError,
} : candidate));
}
return { error, transient: !permanent };
}
@ -151,7 +389,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
let blocked = 0;
currentLogin = String(currentLogin || '').trim();
for (const item of read()) {
if (item.status === 'attention') continue;
if (item.status === 'attention' || item.kind === 'issue-close') continue;
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
const outcome = await sendItem(item, currentLogin);
if (outcome.result) confirmed.push(outcome.result);
@ -192,24 +430,78 @@ 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 => {
const background = statuses.get(item.id);
if (background?.status === 'sent') return [];
if (background?.status === 'sent') {
clearConfirmedReviewState(item);
return [];
}
if (background?.status === 'attention') return [{
...item,
status: 'attention',
error: String(background.error || 'Message needs attention').slice(0, 240),
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
}];
if (background?.status === 'authorization') return [{
...item,
status: 'authorization',
error: String(background.error || 'Fresh authorization required').slice(0, 240),
}];
return [item];
});
write(items);
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;

View File

@ -35,6 +35,7 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error || new Error('Issue outbox transaction aborted'));
Promise.resolve(work({
get: id => requested(objectStore.get(id)),
getAll: () => requested(objectStore.getAll()),
put: value => requested(objectStore.put(value)),
delete: id => requested(objectStore.delete(id)),
@ -54,7 +55,25 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
return transact;
}
function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000 } = {}) {
function createUnfiledAttachmentStore(indexedDB = globalThis.indexedDB) {
const transact = createIndexedDbTransaction(indexedDB, 'stackchain-unfiled-captures-v1');
return {
put: (id, value) => transact(records => records.put({id, ...value})),
get: id => transact(async records => {
const value = await records.get(id);
if (!value) return null;
const {id: _id, ...attachment} = value;
return attachment;
}),
delete: id => transact(records => records.delete(id)),
};
}
function createIssueSyncStore({
transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000,
createToken = () => globalThis.crypto?.randomUUID?.() ||
(Date.now().toString(36) + '-' + Math.random().toString(36).slice(2)),
} = {}) {
const transact = transaction || createIndexedDbTransaction(indexedDB);
async function reconcile(items, outboxLane = 'issue') {
@ -65,15 +84,35 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
if (current.recordType === 'receipt-preference') continue;
const currentLane = current.outboxLane || 'issue';
if (currentLane !== outboxLane) continue;
const replacement = incoming.get(current.id);
let replacement = incoming.get(current.id);
if (!replacement) {
if (current.status !== 'sending' || Number(current.claimUntil) <= Number(now())) {
await records.delete(current.id);
}
continue;
}
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 || 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())) ||
(current.status === 'attention' && replacement.status === 'attention') ||
(['attention', 'authorization'].includes(current.status) &&
replacement.status === current.status) ||
current.status === 'sent') {
incoming.set(current.id, current);
}
@ -87,21 +126,25 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
const timestamp = Number(now());
const items = await records.getAll();
const item = items.find(candidate => candidate.ownerLogin === ownerLogin &&
candidate.kind !== 'issue-close' &&
(candidate.status === 'queued' || candidate.status === 'sending') &&
(candidate.status !== 'sending' || Number(candidate.claimUntil) <= timestamp));
if (!item) return null;
const claimed = { ...item, status: 'sending', claimUntil: timestamp + claimMs };
const claimed = {
...item, status: 'sending', claimUntil: timestamp + claimMs, claimToken: createToken(),
};
await records.put(claimed);
return claimed;
});
}
async function claimBatch(ownerLogin, limit = 70) {
async function planBatch(ownerLogin, limit = 70) {
return transact(async records => {
const timestamp = Number(now());
const eligible = (await records.getAll()).filter(candidate =>
candidate.recordType !== 'receipt-preference' &&
candidate.ownerLogin === ownerLogin &&
candidate.kind !== 'issue-close' &&
(candidate.status === 'queued' || candidate.status === 'sending') &&
(candidate.status !== 'sending' || Number(candidate.claimUntil) <= timestamp));
const lanes = {
@ -111,14 +154,10 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
const selected = [];
const maximum = Math.max(0, Number(limit) || 0);
while (selected.length < maximum && (lanes.issue.length || lanes.authored.length)) {
if (lanes.issue.length) selected.push(lanes.issue.shift());
if (selected.length < maximum && lanes.authored.length) selected.push(lanes.authored.shift());
if (lanes.issue.length) selected.push(lanes.issue.shift().id);
if (selected.length < maximum && lanes.authored.length) selected.push(lanes.authored.shift().id);
}
const claimed = selected.map(item => ({
...item, status: 'sending', claimUntil: timestamp + claimMs,
}));
for (const item of claimed) await records.put(item);
return claimed;
return selected;
});
}
@ -129,7 +168,9 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
if (!item || item.ownerLogin !== ownerLogin ||
!['queued', 'sending'].includes(item.status) ||
(item.status === 'sending' && Number(item.claimUntil) > timestamp)) return null;
const claimed = { ...item, status: 'sending', claimUntil: timestamp + claimMs };
const claimed = {
...item, status: 'sending', claimUntil: timestamp + claimMs, claimToken: createToken(),
};
await records.put(claimed);
return claimed;
});
@ -139,10 +180,24 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
return transact(async records => {
const current = (await records.getAll()).find(candidate => candidate.id === item.id);
if (current && ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
(current.status === 'attention' && item.status === 'attention') ||
(['attention', 'authorization'].includes(current.status) &&
item.status === current.status) ||
current.status === 'sent')) return current;
await records.put({ ...item });
return item;
const preservedAttachment = (current?.attachment?.data || current?.attachment?.blob) &&
item?.attachment?.stored && !item.attachment.data && !item.attachment.blob
? { attachment: current.attachment } : {};
const 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, ...preservedAttachments,
...(current.deliveredIssue ? { deliveredIssue: current.deliveredIssue } : {}),
...(current.attachmentMarkdown ? { attachmentMarkdown: current.attachmentMarkdown } : {}),
...(current.attachmentMarkdowns ? { attachmentMarkdowns:current.attachmentMarkdowns } : {}),
} : { ...item };
await records.put(next);
return next;
});
}
@ -153,6 +208,32 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
});
}
async function updateClaim(id, claimToken, transform) {
return transact(async records => {
const item = records.get ? await records.get(id) :
(await records.getAll()).find(candidate => candidate.id === id);
if (!item || item.status !== 'sending' || !claimToken || item.claimToken !== claimToken) {
return false;
}
await records.put(transform(item));
return true;
});
}
function clearClaim(item, changes) {
const { claimToken: _claimToken, ...unclaimed } = item;
return { ...unclaimed, ...changes, claimUntil: 0 };
}
async function renew(id, claimToken) {
let renewed = null;
await updateClaim(id, claimToken, item => {
renewed = { ...item, claimUntil: Number(now()) + claimMs };
return renewed;
});
return renewed;
}
function preferenceId(ownerLogin) {
return 'receipt-preference:' + String(ownerLogin || '').trim();
}
@ -175,17 +256,29 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
}
return {
get: id => transact(records => records.get(id)),
reconcile,
upsert,
update,
claim,
claimNext,
claimBatch,
complete: id => update(id, item => ({ ...item, status: 'sent', claimUntil: 0 })),
release: id => update(id, item => ({ ...item, status: 'queued', claimUntil: 0 })),
fail: (id, error, deliveryState) => update(id, item => ({
...item, status: 'attention', claimUntil: 0, error,
planBatch,
supportsClaimTokens: true,
renew,
checkpoint: updateClaim,
complete: (id, claimToken, deliveredIssue) => updateClaim(id, claimToken, item => clearClaim(item, {
status: 'sent',
...(item.completionIntent === 'create-and-start' && deliveredIssue ? { deliveredIssue } : {}),
})),
release: (id, claimToken) => updateClaim(id, claimToken,
item => clearClaim(item, { status: 'queued' })),
fail: (id, claimToken, error, deliveryState) => updateClaim(id, claimToken, item => clearClaim(item, {
status: 'attention', error,
...(deliveryState ? { deliveryState } : {}),
})),
authorization: (id, claimToken, error) => updateClaim(
id, claimToken, item => clearClaim(item, { status: 'authorization', error })
),
snapshot: () => transact(async records =>
(await records.getAll()).filter(item => item.recordType !== 'receipt-preference')),
countBlocked: ownerLogin => transact(async records =>
@ -202,10 +295,32 @@ function createBackgroundIssueSync({
batch = work => work(), requestTimeoutMs = 15000,
}) {
let purgeRequested = false;
let activePurge = null;
let activeFlush = null;
const activeRequests = new Set();
const timeoutMs = Math.max(1, Number(requestTimeoutMs) || 15000);
const completeClaim = (item, delivered) => store.supportsClaimTokens
? store.complete(item.id, item.claimToken, delivered) : store.complete(item.id, delivered);
const releaseClaim = item => store.supportsClaimTokens
? store.release(item.id, item.claimToken) : store.release(item.id);
const failClaim = (item, error, deliveryState) => store.supportsClaimTokens
? store.fail(item.id, item.claimToken, error, deliveryState)
: store.fail(item.id, error, deliveryState);
const requireAuthorization = (item, error) => store.supportsClaimTokens
? store.authorization(item.id, item.claimToken, error)
: store.authorization(item.id, error);
const checkpointClaim = (item, transform) => store.supportsClaimTokens
? store.checkpoint(item.id, item.claimToken, transform) : store.update?.(item.id, transform);
async function requestStage(item, url, options) {
if (store.renew) {
const renewed = await store.renew(item.id, item.claimToken);
if (!renewed) throw new Error('Background delivery claim was lost.');
}
return requestJson(url, options);
}
async function requestJson(url, options = {}) {
const controller = new AbortController();
activeRequests.add(controller);
@ -235,14 +350,29 @@ function createBackgroundIssueSync({
if (item.kind === 'notification-read') {
return { id: item.id, status, kind: 'notification-read', route: '#/my-work/updates' };
}
if (item.kind === 'update-reply') {
if (item.kind === 'update-reply' || item.kind === 'update-reply-read') {
return { id: item.id, status, kind: 'message', route: '#/my-work/update/' + encodeURIComponent(item.notificationId) };
}
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
if (item.kind === 'pull-review') {
return {
id: item.id, status, kind: 'message',
route: '#/my-work/review/' + repository + '/' + encodeURIComponent(item.number),
};
}
if (item.kind === 'issue-close') {
return {
id: item.id, status, kind: 'message',
route: '#/my-work/issue/' + repository + '/' + encodeURIComponent(item.number),
};
}
if (item.kind === 'issue-comment' || item.kind === 'pull-comment') {
const resource = item.kind === 'pull-comment' ? 'pull' : 'issue';
return { id: item.id, status, kind: 'message', route: '#/my-work/' + resource + '/' + repository + '/' + encodeURIComponent(item.number) };
}
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) };
}
@ -256,7 +386,77 @@ function createBackgroundIssueSync({
if (item.kind === 'update-reply') {
return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item);
}
if (item.kind === 'update-reply-read') {
return {
url: base + 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read',
options: { method: 'PATCH', headers: { Accept: 'application/json' } },
};
}
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
if (item.kind === 'issue-close') {
return {
url: base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close',
options: {
method: 'PATCH',
headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
},
};
}
if (item.kind === 'issue-blocker') {
return {
url: base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers',
options: {
method: 'PATCH',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({
repository: item.blockerRepository,
number: item.blockerNumber,
present: item.present === true,
}),
},
};
}
if (item.kind === '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',
options: {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({
body: item.body,
decision: item.decision,
expected_head_sha: item.expectedHeadSha,
comments: item.comments || [],
}),
},
};
}
if (item.kind === 'issue-comment' || item.kind === 'pull-comment') {
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
return authoredRequest(
@ -264,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: {
@ -277,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' } : {}),
}),
@ -299,28 +508,300 @@ function createBackgroundIssueSync({
};
}
function stageOperationId(operationId, stage) {
const suffix = ':' + stage;
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 = '';
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 (conversationAttachments(current).length) options.headers['Idempotency-Key'] = stageOperationId(current.operationId, 'reply');
await requestStage(
current,
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply',
options,
);
const checkpointed = await checkpointClaim(current, stored => ({ ...stored, replyConfirmed: true }));
if (checkpointed === false) throw new Error('Background delivery claim was lost.');
current = { ...current, replyConfirmed: true };
}
const request = deliveryRequest(current);
return requestStage(current, request.url, request.options);
}
function attachmentMultipart(attachment) {
let blob = attachment?.blob;
if (!blob && attachment?.data) {
const binary = atob(String(attachment.data));
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
blob = new Blob([bytes], { type: String(attachment.contentType || '') });
}
if (!blob) throw new Error('The saved screenshot is unavailable. Retry before sending.');
const form = new FormData();
form.append('file', blob, String(attachment.filename || 'screenshot'));
return form;
}
async function deliverIssueCapture(item) {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
let deliveredIssue = item.deliveredIssue;
if (!deliveredIssue) {
const request = deliveryRequest(item);
deliveredIssue = await requestStage(item, request.url, request.options);
await checkpointClaim(item, current => ({ ...current, deliveredIssue }));
}
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',
{
method: 'POST',
headers: {
Accept: 'application/json',
'Idempotency-Key': stageOperationId(
item.operationId, attachments.length === 1 ? 'attachment' : 'attachment-' + index,
),
},
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;
}
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',
{
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'attachment-comment'),
},
body: JSON.stringify({ body: attachmentMarkdown }),
},
);
return deliveredIssue;
}
async function deliverScreenshotComment(item) {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
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,
base + 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments',
{
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'comment'),
},
body: JSON.stringify({ body: text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown }),
},
);
}
async function deliverSearchReply(item) {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
let current = item;
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',
headers:{ Accept:'application/json', 'Content-Type':'application/json',
'Idempotency-Key':stageOperationId(current.operationId, 'reply') },
body:JSON.stringify({ body:text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown }),
});
}
async function deliver(item) {
const request = deliveryRequest(item);
try {
const delivered = await requestJson(request.url, request.options);
await store.complete(item.id);
const delivered = item.kind === 'update-reply-read' ? await deliverReplyRead(item) :
item.kind === 'search-reply' ? await deliverSearchReply(item) :
item.kind === 'update-reply' && conversationAttachments(item).length ? await deliverUpdateScreenshotReply(item) :
conversationAttachments(item).length && ['issue-comment', 'pull-comment'].includes(item.kind) ?
await deliverScreenshotComment(item) : item.attachment && !item.kind ?
await deliverIssueCapture(item) : item.attachments?.length && !item.kind ?
await deliverIssueCapture(item) : item.blockers?.length && !item.kind ?
await deliverIssueCapture(item) : await requestStage(item, request.url, request.options);
if (item.kind === 'issue-close' && delivered?.state !== 'closed') {
const error = new Error('Issue closure was not confirmed.');
error.status = 422;
throw error;
}
if (item.kind === 'issue-blocker') {
const dependencies = Array.isArray(delivered?.dependencies) ? delivered.dependencies : [];
const present = dependencies.some(candidate => candidate?.repository === item.blockerRepository &&
Number(candidate?.number) === item.blockerNumber);
if (delivered?.number !== item.number || delivered?.dependencies_available !== true || present !== item.present) {
const error = new Error('Blocker change was not confirmed.');
error.status = 422;
throw error;
}
}
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 };
} catch (error) {
const status = Number(error?.status || 0);
if (status === 401) {
await store.release(item.id);
await releaseClaim(item);
throw error;
}
if (status === 428 && item.kind === 'pull-review') {
const message = String(error?.message || 'Fresh authorization required').slice(0, 240);
await requireAuthorization(item, message);
return {
authorization: true,
error,
receipt: receiptFor(item, 'authorization'),
};
}
if (status >= 400 && status < 500) {
await store.fail(
item.id,
await failClaim(
item,
String(error?.message || 'Issue needs attention').slice(0, 240),
error?.code === 'delivery_uncertain' ? 'uncertain' : undefined,
);
return { attention: true, error, receipt: receiptFor(item, 'attention') };
}
await store.release(item.id);
await releaseClaim(item);
throw error;
}
}
@ -342,14 +823,39 @@ function createBackgroundIssueSync({
const confirmed = [];
const receipts = [];
let attention = 0;
if (!login) return { confirmed, blocked: 0, attention, login, receipts };
let authorization = 0;
if (!login) return { confirmed, blocked: 0, attention, authorization, login, receipts };
const collect = result => {
if (result.issue) confirmed.push(result.issue);
if (result.message) confirmed.push(result.message);
if (result.attention) attention += 1;
if (result.authorization) authorization += 1;
if (result.receipt) receipts.push(result.receipt);
};
if (store.claimBatch) {
if (store.planBatch) {
const planned = await store.planBatch(login, batchSize);
let next = 0;
let authenticationError = null;
let transientError = null;
const worker = async () => {
while (!purgeRequested && !authenticationError && next < planned.length) {
const id = planned[next++];
const item = await store.claim(id, login);
if (!item) continue;
try {
collect(await deliver(item));
} catch (error) {
if (Number(error?.status || 0) === 401) authenticationError = error;
else if (!transientError) transientError = error;
}
}
};
const concurrency = Math.max(1, Math.min(Number(maxConcurrency) || 1, planned.length));
await Promise.all(Array.from({ length: concurrency }, worker));
if (purgeRequested) throw new Error('Background delivery canceled.');
if (authenticationError) throw authenticationError;
if (transientError) throw transientError;
} else if (store.claimBatch) {
const claimed = await store.claimBatch(login, batchSize);
let next = 0;
let authenticationError = null;
@ -384,7 +890,7 @@ function createBackgroundIssueSync({
}
}
const blocked = store.countBlocked ? await store.countBlocked(login) : 0;
return { confirmed, blocked, attention, login, receipts };
return { confirmed, blocked, attention, authorization, login, receipts };
}
function flush() {
@ -394,15 +900,27 @@ function createBackgroundIssueSync({
return activeFlush;
}
async function purge() {
function purge() {
if (activePurge) return activePurge;
purgeRequested = true;
activeRequests.forEach(controller => controller.abort());
if (activeFlush) await activeFlush.catch(() => {});
await store.close?.();
activePurge = (async () => {
if (activeFlush) await activeFlush.catch(() => {});
await store.close?.();
})();
return activePurge;
}
async function resume() {
const pendingPurge = activePurge;
if (pendingPurge) await pendingPurge;
if (activePurge === pendingPurge) activePurge = null;
purgeRequested = false;
}
return {
flush, send, purge,
flush, send, purge, resume,
get: id => store.get(id),
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
snapshot: () => store.snapshot(),
setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled),
@ -415,4 +933,5 @@ if (typeof module !== 'undefined' && module.exports) module.exports = createBack
if (typeof globalThis !== 'undefined') {
globalThis.createBackgroundIssueSync = createBackgroundIssueSync;
globalThis.createIssueSyncStore = createIssueSyncStore;
globalThis.createUnfiledAttachmentStore = createUnfiledAttachmentStore;
}

162
frontend/batch-find-work.js Normal file
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;

39
frontend/card-planning.js Normal file
View File

@ -0,0 +1,39 @@
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
else root.createCardPlanning = factory;
})(typeof self !== 'undefined' ? self : this, function createCardPlanning(root) {
function sync(disclosure) {
disclosure.querySelector('summary')?.setAttribute(
'aria-expanded', String(disclosure.open)
);
}
function wire() {
const disclosures = Array.from(root.querySelectorAll('[data-card-planning]'));
disclosures.forEach(disclosure => {
if (disclosure.dataset.cardPlanningBound === 'true') return;
disclosure.dataset.cardPlanningBound = 'true';
sync(disclosure);
disclosure.addEventListener('toggle', () => {
if (disclosure.open) {
disclosures.forEach(other => {
if (other === disclosure || !other.open) return;
other.open = false;
sync(other);
});
}
sync(disclosure);
});
disclosure.addEventListener('keydown', event => {
if (event.key !== 'Escape' || !disclosure.open) return;
disclosure.open = false;
sync(disclosure);
disclosure.querySelector('summary')?.focus();
event.preventDefault();
event.stopPropagation();
});
});
}
return { wire };
});

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;

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);
},
};
};

196
frontend/comment-actions.js Normal file
View File

@ -0,0 +1,196 @@
function createCommentActions({ fetchJson, getLogin, confirmDelete = () => false }) {
const reactionLabels = {
'+1': 'Thumbs up', '-1': 'Thumbs down', laugh: 'Laugh', hooray: 'Hooray',
confused: 'Confused', heart: 'Heart', rocket: 'Rocket', eyes: 'Eyes',
};
const encodedRepository = repository => String(repository || '').split('/')
.map(encodeURIComponent).join('/');
function pathFor(context, commentId) {
const item = context?.item || {};
if (context?.kind === 'update') {
return 'api/v1/notifications/' + encodeURIComponent(item.notification_id) +
'/comments/' + encodeURIComponent(commentId);
}
if (!['issue', 'pull'].includes(context?.kind)) throw new Error('Comment conversation is unavailable.');
return 'api/v1/repos/' + encodedRepository(item.repository) + '/' +
(context.kind === 'pull' ? 'pulls/' : 'issues/') + encodeURIComponent(item.number) +
'/comments/' + encodeURIComponent(commentId);
}
function reactionPath(context, commentId, content = '') {
return pathFor(context, commentId) + '/reactions' +
(content ? '/' + encodeURIComponent(content) : '');
}
const pendingReactions = new Set();
const controller = {
isOwned(comment) {
const login = String(getLogin() || '').trim();
return Boolean(login && comment && comment.author === login);
},
async edit(context, pager, commentId, body) {
const draft = String(body || '').trim();
if (!draft) throw new Error('Comment must not be blank.');
const comment = await fetchJson(pathFor(context, commentId), {
method: 'PATCH',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ body: draft }),
});
pager.replace(comment);
return pager.snapshot();
},
async remove(context, pager, commentId) {
if (!confirmDelete('Delete this comment permanently?')) return null;
const result = await fetchJson(pathFor(context, commentId), {
method: 'DELETE', headers: { Accept: 'application/json' },
});
if (!result?.deleted || Number(result.id) !== Number(commentId)) {
throw new Error('Comment deletion was not confirmed.');
}
pager.remove(commentId);
return pager.snapshot();
},
async loadReactions(context, commentId) {
return fetchJson(reactionPath(context, commentId), {
headers: { Accept: 'application/json' },
});
},
async setReaction(context, commentId, content, active) {
return fetchJson(reactionPath(context, commentId, content), {
method: 'PUT',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ active: Boolean(active) }),
});
},
reactionHtml(state) {
const current = new Map((state?.reactions || []).map(item => [item.content, item]));
return Object.entries(reactionLabels).map(([content, label]) => {
const item = current.get(content) || {};
const count = Number.isInteger(item.count) && item.count > 0 ? item.count : 0;
const selected = item.selected === true;
return '<button type="button" data-comment-reaction="' + content + '" ' +
'aria-pressed="' + selected + '" aria-label="' + label + ' ' + count + '">' +
label + ' ' + count + '</button>';
}).join('') + '<button type="button" data-comment-reactions-close>Cancel</button>';
},
actionHtml(comment) {
const owned = controller.isOwned(comment) ?
'<div class="comment-owned-actions" aria-label="Your comment actions">' +
'<button type="button" data-comment-action="edit">Edit</button>' +
'<button type="button" data-comment-action="delete">Delete</button></div>' : '';
return owned + '<div class="comment-reactions" aria-label="Comment reactions">' +
'<button type="button" data-comment-reactions-open aria-expanded="false" ' +
'aria-label="React to this comment">React</button>' +
'<div class="comment-reaction-menu" data-comment-reaction-menu hidden></div></div>';
},
wire({ root, getSurface, isOffline, escapeHtml }) {
root.addEventListener('click', async event => {
const actionButton = event.target.closest('[data-comment-action]');
const reactionClose = actionButton ? null : event.target.closest('[data-comment-reactions-close]');
const reactionButton = actionButton ? null : event.target.closest('[data-comment-reaction]');
const reactionTrigger = actionButton ? null : event.target.closest('[data-comment-reactions-open]');
if (reactionClose) {
const card = reactionClose.closest('.issue-comment');
const menu = card?.querySelector('[data-comment-reaction-menu]');
const trigger = card?.querySelector('[data-comment-reactions-open]');
if (menu && trigger) {
menu.hidden = true;
trigger.setAttribute('aria-expanded', 'false');
trigger.focus();
}
return;
}
if (reactionButton || reactionTrigger) {
const control = reactionButton || reactionTrigger;
const card = control.closest('.issue-comment');
const commentId = Number(card?.dataset.commentId);
const surface = getSurface();
const menu = card?.querySelector('[data-comment-reaction-menu]');
const trigger = card?.querySelector('[data-comment-reactions-open]');
if (!commentId || !menu || !trigger) return;
if (isOffline()) {
surface.status.textContent = 'Reconnect to react.';
return;
}
if (pendingReactions.has(commentId)) return;
pendingReactions.add(commentId);
control.disabled = true;
try {
if (reactionButton) {
const content = reactionButton.dataset.commentReaction;
const active = reactionButton.getAttribute('aria-pressed') !== 'true';
const state = await controller.setReaction(surface.context, commentId, content, active);
menu.innerHTML = controller.reactionHtml(state);
menu.hidden = true;
trigger.setAttribute('aria-expanded', 'false');
trigger.focus();
surface.status.textContent = 'Reaction updated.';
} else {
surface.status.textContent = 'Loading reactions…';
const state = await controller.loadReactions(surface.context, commentId);
menu.innerHTML = controller.reactionHtml(state);
menu.hidden = false;
trigger.setAttribute('aria-expanded', 'true');
surface.status.textContent = '';
}
} catch (error) {
surface.status.textContent = error.message || 'Reactions are unavailable. Please retry.';
} finally {
pendingReactions.delete(commentId);
control.disabled = false;
}
return;
}
const button = actionButton;
if (!button) return;
const card = button.closest('.issue-comment');
const commentId = Number(card?.dataset.commentId);
const surface = getSurface();
const comment = surface.pager?.snapshot().comments.find(item => item.id === commentId);
if (!comment || !controller.isOwned(comment)) return;
if (isOffline()) {
surface.status.textContent = 'Reconnect to edit or delete this comment.';
return;
}
if (button.dataset.commentAction === 'delete') {
try {
const state = await controller.remove(surface.context, surface.pager, commentId);
if (state) {
surface.render(state);
surface.status.textContent = 'Comment deleted.';
}
} catch (error) {
surface.status.textContent = error.message || 'Comment deletion failed. Retry or open it in Gitea.';
}
return;
}
card.innerHTML = '<div class="small">Editing your comment</div>' +
'<textarea class="comment-edit-textarea" maxlength="10000" aria-label="Edit comment">' +
escapeHtml(comment.body || '') + '</textarea>' +
'<div class="comment-owned-actions"><button type="button" data-comment-edit-save>Save</button>' +
'<button type="button" data-comment-edit-cancel>Cancel</button></div>';
const textarea = card.querySelector('.comment-edit-textarea');
textarea.focus();
card.querySelector('[data-comment-edit-cancel]').addEventListener('click', () => surface.render(surface.pager.snapshot()));
card.querySelector('[data-comment-edit-save]').addEventListener('click', async saveEvent => {
const save = saveEvent.currentTarget;
save.disabled = true;
surface.status.textContent = 'Saving comment…';
try {
const state = await controller.edit(surface.context, surface.pager, commentId, textarea.value);
surface.render(state);
surface.status.textContent = 'Comment updated.';
} catch (error) {
save.disabled = false;
surface.status.textContent = error.message || 'Comment update failed. Your edit is safe; retry or open it in Gitea.';
textarea.focus();
}
});
});
},
};
return controller;
}
if (typeof module !== 'undefined' && module.exports) module.exports = createCommentActions;

61
frontend/comment-next.js Normal file
View File

@ -0,0 +1,61 @@
function createCommentNext({ post, queue, queueKind = '', canQueue, accept = () => undefined, complete }) {
let inFlight = null;
async function admitQueued(item, message) {
const admission = await queue(message);
if (!admission || (!admission.item && admission.durable !== true)) {
throw new Error('Comment was not saved for delivery.');
}
accept(item, { delivery: admission.background ? 'queued' : 'saved' });
return {
accepted: true,
delivery: admission.background ? 'queued' : 'saved',
background: Boolean(admission.background),
completed: Boolean(complete(item)),
};
}
function submit(item, body, operationId = '') {
if (inFlight) return inFlight;
inFlight = (async () => {
try {
let comment;
try {
comment = await post(item, body, typeof operationId === 'function' ? operationId() : operationId);
} catch (error) {
if (!canQueue(error)) throw error;
const identity = queueKind === 'update-reply' ? {
kind: 'update-reply', notificationId: item.notification_id,
} : {
kind: item.kind === 'pull' ? 'pull-comment' : 'issue-comment',
repository: item.repository, number: item.number,
};
return admitQueued(item, {
...identity, body,
operationId: typeof operationId === 'function' ? operationId() : operationId,
});
}
accept(item, { delivery: 'posted', comment });
return {
accepted: true,
delivery: 'posted',
comment,
completed: Boolean(complete(item)),
};
} finally {
inFlight = null;
}
})();
return inFlight;
}
function admit(item, message) {
if (inFlight) return inFlight;
inFlight = admitQueued(item, message).finally(() => { inFlight = null; });
return inFlight;
}
return { submit, admit, busy: () => Boolean(inFlight) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createCommentNext;

View File

@ -1,3 +1,22 @@
function buildLiveRevisionQuery(revisions = {}) {
const params = new URLSearchParams();
const tokenPattern = /^[0-9a-f]{16}\.[0-9]{1,20}$/;
['context', 'events', 'notifications'].forEach((section) => {
const revision = revisions[section];
if (typeof revision === 'string' && tokenPattern.test(revision)) {
params.set(section + '_revision', revision);
}
});
return params.toString();
}
function retryAfterMs(value) {
if (typeof value !== 'string' || !/^\d+$/.test(value)) return null;
const seconds = Number(value);
if (!Number.isFinite(seconds) || seconds <= 0) return null;
return Math.min(seconds * 1000, 300000);
}
function createContextPoller({
fetchContext,
onSnapshot,
@ -9,25 +28,60 @@ function createContextPoller({
clearDeadlineTimer = clearTimeout,
intervalMs = 8000,
timeoutMs = 12000,
maxBackoffMs = 60000,
}) {
let activeRequest = null;
let timer = null;
let stopped = false;
let revisions = {};
let retainedSnapshot = null;
let nextDelayMs = intervalMs;
let failureStreak = 0;
let nextRetryAt = null;
let lastSuccessAt = null;
function snapshotDelay(snapshot) {
const freshness = snapshot && snapshot.freshness;
const sections = freshness && freshness.sections;
const sectionValues = sections && Object.values(sections);
const degradedRetrySeconds = (sectionValues || [])
.filter(section => section.degraded)
.map(section => Number(section.retry_in_seconds ?? freshness.retry_in_seconds))
.filter(seconds => Number.isFinite(seconds) && seconds > 0);
const failedSectionDelay = degradedRetrySeconds.length ?
Math.min(...degradedRetrySeconds) * 1000 : null;
if (sectionValues && sectionValues.length && sectionValues.every(section => section.degraded)) {
return failedSectionDelay || intervalMs;
}
const freshForSeconds = Number(freshness && freshness.fresh_for_seconds);
const healthyDeadlines = (sectionValues || [])
.filter(section => !section.degraded)
.map(section => freshForSeconds - Number(section.age_seconds))
.filter(seconds => Number.isFinite(seconds));
if (Number.isFinite(freshForSeconds) && freshForSeconds > 0 && healthyDeadlines.length) {
const earliestDeadline = Math.min(...healthyDeadlines);
const healthyDelay = earliestDeadline <= 0 &&
sectionValues.some(section => !section.degraded && section.revalidating) ?
intervalMs : Math.max(0, earliestDeadline) * 1000;
return failedSectionDelay === null ? healthyDelay : Math.min(healthyDelay, failedSectionDelay);
}
return intervalMs;
}
function cancelTimer() {
if (timer !== null) clearTimer(timer);
timer = null;
}
function schedule() {
function schedule(delayMs = intervalMs) {
cancelTimer();
if (stopped || isHidden()) return;
nextRetryAt = Date.now() + delayMs;
timer = setTimer(() => {
timer = null;
nextRetryAt = null;
refresh();
}, intervalMs);
}, delayMs);
}
function abortError() {
@ -72,13 +126,16 @@ function createContextPoller({
let request;
try {
request = fetchContext({ ...revisions }, { signal: controller.signal });
request = fetchContext(options.full ? {} : { ...revisions }, { signal: controller.signal });
} catch (error) {
request = Promise.reject(error);
}
requestState.promise = Promise.race([Promise.resolve(request), deadlinePromise])
.then((snapshot) => {
if (activeRequest !== requestState) return retainedSnapshot;
failureStreak = 0;
lastSuccessAt = Date.now();
nextDelayMs = snapshotDelay(snapshot);
const changedSections = ['context', 'events', 'notifications'].filter(
(section) => Object.prototype.hasOwnProperty.call(snapshot, section)
);
@ -88,7 +145,14 @@ function createContextPoller({
return retainedSnapshot;
})
.catch((error) => {
if (activeRequest === requestState && !stopped) onError(error);
if (activeRequest === requestState && !stopped) {
const retryAfterMs = Number(error && error.retryAfterMs);
failureStreak += 1;
nextDelayMs = Number.isFinite(retryAfterMs) && retryAfterMs > 0
? retryAfterMs
: Math.min(intervalMs * (2 ** (failureStreak - 1)), maxBackoffMs);
onError(error);
}
return null;
})
.finally(() => {
@ -96,7 +160,7 @@ function createContextPoller({
clearDeadlineTimer(requestState.deadline);
if (activeRequest !== requestState) return;
activeRequest = null;
schedule();
schedule(nextDelayMs);
});
return requestState.promise;
}
@ -107,10 +171,56 @@ function createContextPoller({
return refresh({ force: true });
}
function adopt(snapshot) {
if (
stopped || activeRequest || retainedSnapshot || !snapshot ||
typeof snapshot !== 'object' ||
!Object.prototype.hasOwnProperty.call(snapshot, 'context')
) return false;
retainedSnapshot = { ...snapshot };
revisions = { ...(snapshot.revisions || {}) };
failureStreak = 0;
lastSuccessAt = Date.now();
nextDelayMs = snapshotDelay(snapshot);
const changedSections = ['context', 'events', 'notifications'].filter(
section => Object.prototype.hasOwnProperty.call(snapshot, section)
);
onSnapshot(retainedSnapshot, changedSections);
schedule(nextDelayMs);
return true;
}
async function adoptPending(snapshotPromise) {
if (!snapshotPromise || typeof snapshotPromise.then !== 'function') return false;
let deadline = null;
const timeout = new Promise(resolve => {
deadline = setDeadlineTimer(() => resolve(null), timeoutMs);
});
try {
const snapshot = await Promise.race([
Promise.resolve(snapshotPromise).catch(() => null),
timeout,
]);
return adopt(snapshot);
} finally {
clearDeadlineTimer(deadline);
}
}
return {
start: refresh,
refresh,
adopt,
adoptPending,
setVisible,
getState() {
return {
refreshing: Boolean(activeRequest),
nextRetryAt,
lastSuccessAt,
failureStreak,
};
},
stop() {
stopped = true;
cancelTimer();
@ -123,6 +233,9 @@ function createContextPoller({
};
}
createContextPoller.buildRevisionQuery = buildLiveRevisionQuery;
createContextPoller.retryAfterMs = retryAfterMs;
if (typeof module !== 'undefined' && module.exports) {
module.exports = createContextPoller;
}

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;

View File

@ -0,0 +1,93 @@
(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, onChange = () => {} }) {
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 {
const saved = await save;
onChange();
return saved;
}
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);
onChange();
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 };
};
});

View File

@ -0,0 +1,163 @@
(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, ['get', 'list'].includes(operation) ? 'readonly' : 'readwrite');
const records = transaction.objectStore(storeName);
if (operation === 'put') return requestResult(records.put(value));
if (operation === 'delete') return requestResult(records.delete(key));
if (operation === 'list') return requestResult(records.getAll());
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,
updatedAt:Date.now(), 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;
}
async function list() {
if (!transact || draftScope !== 'conversation') return [];
const ownerLogin = String(getOwnerLogin() || '').trim();
if (!ownerLogin) return [];
const records = await transact('list');
return (Array.isArray(records) ? records : []).flatMap(record => {
if (record?.version !== 1 || record.ownerLogin !== ownerLogin ||
(record.scope && record.scope !== 'conversation') ||
!Array.isArray(record.attachments) || !record.attachments.length) return [];
const updatedAt = Number(record.updatedAt || 0);
if (record.kind === 'update') {
const notificationId = Number(record.notificationId || 0);
if (!Number.isInteger(notificationId) || notificationId < 1) return [];
return [{
id:record.id, kind:'photo-reply', label:'Update photos', title:'Update #' + notificationId,
photo_count:record.attachments.length, updated_at:updatedAt,
route:{ kind:'update', notification_id:notificationId }, photo_store:'conversation',
}];
}
const repository = String(record.repository || '');
const number = Number(record.number || 0);
if (!['issue', 'pull'].includes(record.kind) || !repository ||
!Number.isInteger(number) || number < 1) return [];
const label = record.kind === 'issue' ? 'Issue photos' : 'PR photos';
return [{
id:record.id, kind:'photo-reply', label, repository, number,
title:repository + '#' + number, photo_count:record.attachments.length, updated_at:updatedAt,
route:{ kind:record.kind, repository, number }, photo_store:'conversation',
}];
}).sort((left, right) => Number(right.updated_at) - Number(left.updated_at) || left.id.localeCompare(right.id));
}
return { save, load, remove, list };
};
});

View File

@ -63,6 +63,54 @@ function createConversationPager({ loadPage }) {
}
return snapshot();
},
replace(comment) {
if (!comment || !Number.isInteger(comment.id)) return snapshot();
state = {
...state,
comments: state.comments.map(existing => existing.id === comment.id ? { ...comment } : existing),
};
return snapshot();
},
remove(commentId) {
const comments = state.comments.filter(comment => comment.id !== commentId);
if (comments.length !== state.comments.length) {
state = { ...state, comments, total: Math.max(0, state.total - 1) };
}
return snapshot();
},
};
}
function renderConversationComment(comment, controller, escapeHtml, formatTime, renderMarkdown) {
const actions = controller?.actionHtml?.(comment) || '';
return '<div class="issue-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>' + actions + '<div class="issue-sheet-content markdown-content">' +
renderMarkdown(comment.body || 'No comment body provided.') + '</div></div>';
}
function createConversationRenderers({qs,renderComment,updateReadPosition,getSelectedUpdate}) {
function status(kind, comments, total) {
qs('#' + kind + '-conversation-status').textContent = comments.length ?
comments.length + ' of ' + Math.max(total || 0, comments.length) + ' messages loaded.' : 'No comments yet.';
}
function paint(kind, state, controller) {
const comments = state?.comments || [];
qs('#' + kind + '-comments').innerHTML = comments.length ? comments.map(comment =>
kind === 'pull' ? '<div class="pull-comment-card">' + renderComment(comment,controller) + '</div>' :
renderComment(comment,controller)).join('') : '<div class="muted">No comments yet.</div>';
qs('#load-older-' + kind + '-comments').hidden = !Number.isInteger(state?.older_page);
status(kind,comments,state?.total);
if (kind === 'update') {
const newest = qs('#update-comments .issue-comment:last-child');
updateReadPosition.ready(String(getSelectedUpdate()?.notification_id || ''),newest);
}
}
return {
paintIssueConversation:(state,controller)=>paint('issue',state,controller),
paintPullConversation:(state,controller)=>paint('pull',state,controller),
paintUpdateConversation:(state,controller)=>paint('update',state,controller),
};
}

View File

@ -0,0 +1,84 @@
function createCreateAndStart({ todayWork, todaySync, refresh, warm, start, announce }) {
const completed = new Set();
function available() {
return todayWork.read().length < todayWork.limit;
}
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);
if (added === 'full') {
announce('Created, but Today changed—remove an item, then add this issue.');
return 'full';
}
if (added !== 'added' && added !== 'exists') {
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();
warm();
start(issue);
announce('Created, added to Today, and ready to work.');
return 'started';
}
return { available, capacity, capacityMessage, complete };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createCreateAndStart;

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,19 +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 {
@ -44,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: ''};
@ -67,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;
}
@ -124,6 +440,42 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
return {status: 'ready'};
}
function pendingFollowUp() {
try {
const parsed = JSON.parse(storage.getItem(followUpStorageKey) || 'null');
if (!parsed || typeof parsed !== 'object') return null;
const repository = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(String(parsed.repository || '')) ? String(parsed.repository) : '';
const title = String(parsed.title || '').trim().slice(0, 240);
const body = String(parsed.body || '').trim().slice(0, 9500);
return title || body ? {repository, title, body, labelIds: []} : null;
} catch (_error) { return null; }
}
function acceptFollowUp() {
const followUp = pendingFollowUp();
if (!followUp) return loadDraft();
const accepted = saveDraft(followUp);
try { storage.removeItem(followUpStorageKey); }
catch (_error) { /* Accepted content is already persisted as the issue draft. */ }
return accepted;
}
function discardFollowUp() {
try { storage.removeItem(followUpStorageKey); }
catch (_error) { /* The existing capture remains authoritative. */ }
}
function stageFollowUp(value) {
const followUp = {repository: String(value?.repository || ''), title: String(value?.title || ''), body: String(value?.body || '')};
if (!followUp.title && !followUp.body) return {status: 'empty'};
try { storage.setItem(followUpStorageKey, JSON.stringify(followUp)); }
catch (_error) { /* The in-page flow can still continue. */ }
const existing = loadDraft();
if (existing.title || existing.body) return {status: 'conflict'};
acceptFollowUp();
return {status: 'ready'};
}
function loadLabels(repository) {
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
const priorities = new Set(['p0', 'priority-high', 'critical']);
@ -143,6 +495,315 @@ 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);
const request = fetchJson('api/v1/repositories?page=' + safePage + '&limit=50')
.then(payload => ({
items: Array.isArray(payload?.items) ? payload.items : [],
page: Number(payload?.page) || safePage,
total: Math.max(0, Number(payload?.total) || 0),
has_more: payload?.has_more === true,
}))
.finally(() => repositoryPageRequests.delete(safePage));
repositoryPageRequests.set(safePage, request);
return request;
}
async function searchRepositories(value) {
const query = String(value || '').trim().slice(0, 80);
const request = ++repositorySearchRequest;
if (query.length < 2) return {status: 'idle', items: []};
try {
const payload = await fetchJson(
'api/v1/repositories/search?q=' + encodeURIComponent(query) + '&limit=20'
);
if (request !== repositorySearchRequest) return {status: 'stale', items: []};
return {
status: 'ready',
items: Array.isArray(payload?.items) ? payload.items : [],
};
} catch (error) {
if (request !== repositorySearchRequest) return {status: 'stale', items: []};
return {status: 'failed', items: [], error};
}
}
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();
@ -203,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'} : {}),
}),
@ -214,12 +877,22 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
}
return {
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, 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,
};
}
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;

View File

@ -0,0 +1,203 @@
function createController({request, storage, login, createId = () => globalThis.crypto?.randomUUID?.() || String(Date.now())}) {
const storageKey = 'stackchain.create-pull.v1.' + String(login || '').toLowerCase();
let operationId = createId();
let current = {
repository:'', branches:[], head:'', base:'', expected_head_sha:'',
title:'', body:'', draft:true,
};
try {
const saved = JSON.parse(storage?.getItem?.(storageKey) || 'null');
if (saved && typeof saved === 'object') {
for (const field of ['repository', 'head', 'base', 'title', 'body']) {
if (typeof saved[field] === 'string') current[field] = saved[field];
}
if (typeof saved.draft === 'boolean') current.draft = saved.draft;
}
} catch (_) {}
function snapshot() {
return JSON.parse(JSON.stringify(current));
}
function persist() {
storage?.setItem?.(storageKey, JSON.stringify({
repository:current.repository, head:current.head, base:current.base,
title:current.title, body:current.body, draft:current.draft,
}));
}
async function selectRepository(repository) {
const options = await request('api/v1/repos/' + repository + '/pull-creation-options');
const branches = Array.isArray(options?.branches) ? options.branches : [];
const base = branches.some(branch => branch.name === options.default_branch) ? options.default_branch : (branches[0]?.name || '');
const headBranch = branches.find(branch => branch.name !== base) || branches[0] || {};
current = {
...current, repository, branches, base,
head:headBranch.name || '', expected_head_sha:headBranch.sha || '',
};
persist();
return snapshot();
}
function update(values) {
current = {...current, ...values};
if (Object.prototype.hasOwnProperty.call(values, 'head')) {
current.expected_head_sha = current.branches.find(branch => branch.name === values.head)?.sha || '';
}
persist();
return snapshot();
}
async function submit() {
const result = await request('api/v1/repos/' + current.repository + '/pulls', {
method:'POST',
headers:{'Idempotency-Key':operationId},
body:{
head:current.head, base:current.base, title:current.title, body:current.body,
draft:current.draft, expected_head_sha:current.expected_head_sha,
},
});
operationId = createId();
return result;
}
return {
state:snapshot,
selectRepository,
update,
submit,
};
}
function createBinding({controller, render, status, onCreated}) {
return {
async repositoryChanged(repository) {
status('Loading branches…');
try {
const state = await controller.selectRepository(repository);
render(state);
status(state.branches.length ? 'Choose the source and base branches.' : 'No branches are available.');
return state;
} catch (error) {
status(error.message || 'Branches could not be loaded.');
throw error;
}
},
changed(values) {
const state = controller.update(values);
render(state);
return state;
},
async submit() {
status('Creating pull request…');
try {
const result = await controller.submit();
status(result.existing ? `Pull request #${result.number} is already open.` : `Pull request #${result.number} created.`);
onCreated(result);
return result;
} catch (error) {
status(error.message || 'The pull request could not be created. Your draft is safe.');
throw error;
}
},
};
}
async function bindDashboard() {
const qs = selector => document.querySelector(selector);
const switchButton = qs('#switch-to-create-pull');
switchButton.disabled = true;
const identityResponse = await fetch('api/v1/background-identity').catch(()=>null);
const identity = await identityResponse?.json().catch(()=>({}));
if (!identityResponse?.ok || !identity?.login) {
switchButton.title = 'Account identity is unavailable.';
return null;
}
const login = identity.login;
const root = qs('#create-pull-sheet');
const repository = qs('#create-pull-repository');
const head = qs('#create-pull-head');
const base = qs('#create-pull-base');
const title = qs('#create-pull-title');
const body = qs('#create-pull-body');
const submit = qs('#submit-create-pull');
const request = async (url, options) => {
const response = await fetch(url, options ? {
method:options.method,
headers:{'Content-Type':'application/json', ...options.headers},
body:JSON.stringify(options.body),
} : undefined);
const payload = await response.json().catch(()=>({}));
if (!response.ok) throw new Error(payload.error || payload.detail || 'Request failed.');
return payload;
};
const controller = createController({request, storage:localStorage, login});
const setOptions = (select, branches) => {
select.replaceChildren(...branches.map(branch => {
const option = document.createElement('option');
option.value = option.textContent = branch.name;
return option;
}));
};
const render = state => {
setOptions(head, state.branches);
setOptions(base, state.branches);
head.value = state.head;
base.value = state.base;
head.disabled = base.disabled = !state.branches.length;
qs('#create-pull-head-receipt').textContent = state.expected_head_sha ? 'Source at ' + state.expected_head_sha.slice(0, 12) : '';
submit.disabled = !(state.repository && state.head && state.base && state.head !== state.base && title.value.trim());
};
const binding = createBinding({
controller, render,
status:message=>{ qs('#create-pull-status').textContent = message; },
onCreated:result=>{
root.hidden = true;
qs('main').inert = false;
location.hash = '#/my-work/pull/' + result.repository + '/' + result.number;
location.reload();
},
});
const update = () => binding.changed({
head:head.value, base:base.value, title:title.value, body:body.value,
draft:document.querySelector('input[name="create-pull-mode"]:checked')?.value !== 'ready',
});
repository.addEventListener('change', () => binding.repositoryChanged(repository.value).catch(()=>{}));
[head,base,title,body].forEach(field => field.addEventListener('input', update));
document.querySelectorAll('input[name="create-pull-mode"]').forEach(field => field.addEventListener('change', () => {
update();
submit.textContent = field.value === 'ready' && field.checked ? 'Create ready pull request' : 'Create draft pull request';
}));
qs('#create-pull-form').addEventListener('submit', event => {
event.preventDefault();
update();
binding.submit().catch(()=>{});
});
const close = () => {
root.hidden = true;
qs('main').inert = false;
qs('#new-issue').focus();
};
qs('#cancel-create-pull').addEventListener('click', close);
switchButton.addEventListener('click', () => {
qs('#cancel-new-issue').click();
repository.replaceChildren(...Array.from(qs('#create-issue-repository').options).map(source => {
const option = document.createElement('option');
option.value = source.value;
option.textContent = source.textContent;
return option;
}));
root.hidden = false;
qs('main').inert = true;
qs('#cancel-create-pull').focus();
});
root.addEventListener('keydown', event => {
if (event.key === 'Escape') { event.preventDefault(); close(); }
});
switchButton.disabled = false;
return binding;
}
const cp = {createController, createBinding, b:bindDashboard};
if (typeof module !== 'undefined') module.exports = cp;
else cp.b();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
function createDetailDefer({
laterWork,
session,
continueSession = null,
close,
refresh,
focus,
@ -16,6 +17,13 @@ function createDetailDefer({
return false;
}
const inSession = session.active();
if (continueSession && session.checkpointed?.(item)) {
if (continueSession(item) === true) return true;
laterWork.restore(item);
refresh();
announce('Could not remove this item from Today, so it was restored from Later.');
return false;
}
if (!inSession && closeSheet) close();
refresh();
announce('Deferred until ' + formatTime(until) + '. It stays unread and unchanged in Gitea.');

142
frontend/device-storage.js Normal file
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 };
});

View File

@ -0,0 +1,68 @@
function showDraftCapacityDialog(captures, qs) {
const state = captures.capacity();
if (!state.full) return false;
const oldest = state.oldest;
qs('#draft-capacity-oldest').innerHTML = '<strong>' + escapeHtml(oldest.title) + '</strong><div class="small">' +
escapeHtml(oldest.body || 'No note') + (oldest.hasAttachment ? ' · Screenshot attached' : '') + '</div>';
qs('#draft-capacity-sheet').dataset.oldestId = oldest.id;
qs('#draft-capacity-status').textContent = '';
qs('#draft-capacity-sheet').hidden = false;
qs('#keep-editing-draft').focus();
return true;
}
function bindDraftCapacityDialog({
qs, 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();
});
qs('#review-full-drafts').addEventListener('click', () => {
const oldestId = qs('#draft-capacity-sheet').dataset.oldestId;
saveIssueCaptureDraft();
qs('#draft-capacity-sheet').hidden = true;
closeCreateIssueSheet(true, false);
qs('[data-work-filter="draft"]').click();
mobileTaskDock.select('queues');
refreshMyWorkView();
requestAnimationFrame(() => {
const card = qs('[data-capture-id="' + CSS.escape(oldestId) + '"]');
card?.scrollIntoView({block:'nearest'});
card?.focus({preventScroll:true});
});
});
qs('#replace-oldest-draft').addEventListener('click', async () => {
const oldest = unfiledCaptures.capacity().oldest;
if (!oldest || oldest.id !== qs('#draft-capacity-sheet').dataset.oldestId) {
qs('#draft-capacity-status').textContent = 'Drafts changed. Review them before replacing anything.';
return;
}
if (!window.confirm('Replace “' + oldest.title + '” and permanently delete its saved contents?')) return;
const button = qs('#replace-oldest-draft');
button.disabled = true;
try {
const draft = 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();
qs('#create-issue-title').value = '';
qs('#create-issue-body').value = '';
createIssueAttachmentController.clear();
closeCreateIssueSheet(true, false);
qs('[data-work-filter="draft"]').click();
mobileTaskDock.select('queues');
refreshMyWorkView();
requestAnimationFrame(() => qs('[data-capture-id="' + CSS.escape(saved.id) + '"]')?.focus());
qs('#my-work-action-status').textContent = 'Oldest Draft replaced. New work saved to Drafts.';
} catch (error) {
qs('#draft-capacity-status').textContent = error.message + ' Nothing was replaced.';
} finally { button.disabled = false; }
});
}
if (typeof module !== 'undefined' && module.exports) module.exports = {showDraftCapacityDialog, bindDraftCapacityDialog};

View File

@ -0,0 +1,118 @@
function createDraftFilingSession({list}) {
let orderedIds = [];
let currentId = '';
let reviewTrigger = null;
function visibleIds() {
const available = new Set(list().map(item => item.id));
orderedIds = orderedIds.filter(id => available.has(id));
for (const item of list()) {
if (!orderedIds.includes(item.id)) orderedIds.push(item.id);
}
return orderedIds;
}
function state() {
const ids = visibleIds();
if (!ids.length) {
currentId = '';
return null;
}
if (!ids.includes(currentId)) currentId = ids[0];
const index = ids.indexOf(currentId);
return {id:currentId, position:index + 1, total:ids.length, remaining:ids.length - index - 1};
}
function start(id) {
orderedIds = list().map(item => item.id);
if (!orderedIds.includes(id)) throw new Error('This capture is no longer available.');
currentId = id;
return state();
}
function move(offset) {
const ids = visibleIds();
if (!ids.length) return state();
const index = Math.max(0, ids.indexOf(currentId));
currentId = ids[(index + offset + ids.length) % ids.length];
return state();
}
function removeCurrentAndNext(removedId) {
const previousIds = orderedIds.slice();
const removedIndex = Math.max(0, previousIds.indexOf(removedId));
orderedIds = previousIds.filter(id => id !== removedId);
const ids = visibleIds();
if (!ids.length) return state();
currentId = ids[Math.min(removedIndex, ids.length - 1)];
return state();
}
const session = {start, current:state, next:()=>move(1), previous:()=>move(-1), removeCurrentAndNext};
session.attach = (qs, dependencies) => {
const openCapture = async (captureId, trigger = null) => {
const resumed = await dependencies.captures.resume(captureId, dependencies.getLogin());
const issueCapture = dependencies.capture?.() || dependencies.issueCapture;
issueCapture.saveDraft(resumed);
dependencies.setResumedId(captureId);
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;
if (!state) return;
qs('#draft-filing-progress').textContent = `Draft ${state.position} of ${state.total}`;
qs('#draft-filing-remaining').textContent = state.remaining ? `${state.remaining} remaining` : 'Last Draft';
qs('#previous-draft').disabled = qs('#skip-draft').disabled = state.total < 2;
qs('#submit-new-issue').textContent = state.remaining ? 'File & next' : 'File final Draft';
},
bind() {
[['#previous-draft','previous'],['#skip-draft','next']].forEach(([selector, method]) =>
qs(selector).addEventListener('click', async () => {
const state = session[method]();
if (state) await openCapture(state.id);
})
);
},
async advance(completedId) {
const state = session.removeCurrentAndNext(completedId);
if (state) { await openCapture(state.id); return true; }
qs('#draft-filing-session').hidden = true;
qs('#submit-new-issue').textContent = 'Create & assign to me';
return false;
},
});
};
return session;
}
if (typeof module !== 'undefined' && module.exports) module.exports = createDraftFilingSession;

View File

@ -101,7 +101,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
function parseOutbox(raw) {
try {
const record = JSON.parse(raw);
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
if (![1, 2, 3].includes(record?.version) || !Array.isArray(record.items)) return [];
const currentLogin = String(getCurrentLogin() || '').trim();
return record.items.filter(item =>
item && typeof item.id === 'string' && typeof item.repository === 'string' && typeof item.title === 'string'
@ -112,9 +112,12 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
id: 'stackchain.issue-outbox.v1:' + item.id,
outbox_id: item.id,
kind: 'issue-outbox',
status: item.status === 'attention' ? 'attention' : 'queued',
status: item.status === 'completion' ? 'completion' :
(item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' : 'queued')),
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
(item.status === 'attention' ? 'Needs attention' : 'Queued issue'),
(item.status === 'completion' ? 'Created · ready to start' :
(item.status === 'attention' ? 'Needs attention' : 'Queued issue')),
continuation: item.status === 'completion' && item.completionIntent === 'create-and-start',
delivery_state: item.deliveryState,
repository: item.repository,
title: textPreview(item.title) || 'Untitled queued issue',
@ -123,6 +126,8 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
quarantined,
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
last_attempt_at: Number(item.lastAttemptAt || 0),
last_attempt_error: textPreview(item.lastAttemptError),
updated_at: Number(item.queuedAt || 0),
};
});
@ -136,26 +141,46 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
const currentLogin = String(getCurrentLogin() || '').trim();
return record.items.filter(item => item && typeof item.id === 'string' && typeof item.body === 'string')
.map(item => {
const isUpdate = item.kind === 'update-reply';
const routeKind = item.kind === 'pull-comment' ? 'pull' : 'issue';
const isUpdate = item.kind === 'update-reply' || item.kind === 'update-reply-read';
const isReview = item.kind === 'pull-review';
const isClosure = item.kind === 'issue-close';
const routeKind = isReview ? 'review' : (item.kind === 'pull-comment' ? 'pull' : 'issue');
const target = isUpdate ? 'Update #' + item.notificationId : item.repository + '#' + item.number;
const inlineFeedback = isReview && Array.isArray(item.comments) ? item.comments.map(comment =>
String(comment.path || 'File') + ' · ' +
(comment.new_position ? 'new line ' + comment.new_position : 'old line ' + comment.old_position) +
': ' + String(comment.body || '')
).filter(Boolean) : [];
const copyText = [item.body, inlineFeedback.length ? 'Inline feedback\n' + inlineFeedback.join('\n') : '']
.filter(Boolean).join('\n\n');
const ownerLogin = String(item.ownerLogin || '').trim();
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
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,
outbox_kind: item.kind,
kind: 'authored-outbox',
status: item.status === 'attention' ? 'attention' : 'queued',
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
(item.status === 'attention' ? 'Needs attention' : 'Queued message'),
status: item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' :
(item.status === 'authorization' || isClosure ? 'authorization' : 'queued')),
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'))),
authorization_required: isClosure || (isReview && item.status === 'authorization'),
delivery_state: item.deliveryState,
repository: isUpdate ? '' : item.repository,
title: target,
preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')),
copy_text: item.body,
copy_text: copyText,
checklist_conflict: checklistConflict,
...(isReview ? { head_sha: item.expectedHeadSha } : {}),
quarantined,
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
last_attempt_at: Number(item.lastAttemptAt || 0),
last_attempt_error: textPreview(item.lastAttemptError),
updated_at: Number(item.queuedAt || 0),
route: isUpdate ? { kind:'update', notification_id:item.notificationId } :
{ kind:routeKind, repository:item.repository, number:item.number },
@ -214,7 +239,38 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
} catch (_error) { return false; }
}
return { list, discard };
function partition(items = list()) {
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' :
(item.status === 'authorization' ? 'authorization' : 'waiting'));
summary[state] += 1;
return summary;
}, { waiting:0, sending:0, attention:0, authorization:0 });
const retryable = deliveries.filter(item =>
item.status === 'queued' && !item.authorization_required && !item.quarantined && item.delivery_state !== 'uncertain'
);
return { drafts, deliveries, counts, retryable, actionable:deliveries.filter(needsAction).length };
}
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;

View File

@ -0,0 +1,63 @@
function createFeatureLoader({ document, urls, timeoutMs = 10000 }) {
const pending = new Map();
const loaded = new Set();
function load(name) {
if (loaded.has(name)) return Promise.resolve();
if (pending.has(name)) return pending.get(name);
const url = urls[name];
if (!url) return Promise.reject(new Error('Unknown feature: ' + name));
const request = new Promise((resolve, reject) => {
const script = document.createElement('script');
let timeout;
const fail = () => {
clearTimeout(timeout);
script.remove();
pending.delete(name);
reject(new Error('Could not load ' + name.replace(/-/g, ' ') + '.'));
};
script.src = url;
script.async = true;
script.onload = () => {
clearTimeout(timeout);
pending.delete(name);
loaded.add(name);
resolve();
};
script.onerror = fail;
timeout = setTimeout(fail, timeoutMs);
document.head.appendChild(script);
});
pending.set(name, request);
return request;
}
async function run(name, elements, callback) {
const trigger = elements?.trigger;
const status = elements?.status;
const retryLabel = elements?.retryLabel || 'Tap New issue to retry.';
if (trigger) trigger.disabled = true;
if (status) status.textContent = 'Loading ' + name.replace(/-/g, ' ') + '…';
try {
await load(name);
callback();
if (status) status.textContent = '';
return true;
} catch (_error) {
if (status) {
const label = name === 'issue-capture' ? 'Issue capture' :
name === 'pull-workflow' ? 'Pull workspace' :
name === 'security-center' ? 'Security center' : name.replace(/-/g, ' ');
status.textContent = label + ' could not load. ' + retryLabel;
}
return false;
} finally {
if (trigger) trigger.disabled = false;
}
}
return { load, run, ready: name => loaded.has(name) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createFeatureLoader;

9
frontend/filed-claim.js Normal file
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;

292
frontend/following.js Normal file
View File

@ -0,0 +1,292 @@
(function (root, factory) {
const exports = factory();
if (typeof module === 'object' && module.exports) {
module.exports = exports.createFollowing;
module.exports.attachFollowing = exports.attachFollowing;
module.exports.planningDisposition = exports.planningDisposition;
} else {
root.createFollowing = exports.createFollowing;
root.attachFollowing = exports.attachFollowing;
root.followingPlanningDisposition = exports.planningDisposition;
}
})(typeof self !== 'undefined' ? self : this, function () {
function planningDisposition(detail, controls) {
const active = detail?.following === true && detail.kind === 'issue' && detail.state === 'open' &&
Boolean(detail.claimable || detail.assigned_to_me);
const result = active ? {active:true,today:'Add to Today & next',later:'Later & next',week:'Week Ahead & next'} : {active:false};
if (!controls && globalThis.document) {
const query = selector => globalThis.document.querySelector(selector);
controls = {root:query('.search-preview-primary-actions'),claim:query('#claim-search-result'),
today:query('#queue-search-result'),later:query('#defer-search-result'),week:query('#plan-search-result'),
start:query('#start-search-result'),watch:query('#watch-search-result')};
}
controls?.root?.classList.toggle('following-disposition-mode', active);
if (active && controls) {
controls.claim.hidden = controls.start.hidden = controls.watch.hidden = true;
controls.today.textContent = result.today;
controls.later.textContent = result.later;
controls.week.textContent = result.week;
}
return result;
}
function createFollowing(options) {
let generation = 0;
let snapshot = {revision:0, items:[]};
let review = null;
function sameItem(left, right) {
return (left?.kind || 'issue') === (right?.kind || 'issue') &&
left?.repository === right?.repository && Number(left?.number) === Number(right?.number);
}
function publish(status, error) {
const state = {status, revision:snapshot.revision, items:[...snapshot.items]};
state.degraded = snapshot.degraded === true;
state.refreshFailures = Number(snapshot.refreshFailures) || 0;
if (review?.acknowledged.size) state.reviewSummary = {
reviewed:review.acknowledged.size,
remaining:snapshot.items.filter(item => item.has_unseen_change === true).length,
};
if (error) state.error = error;
options.render?.(state);
if (status === 'ready') options.onCount?.(
snapshot.items.filter(item => item.has_unseen_change === true).length);
return state;
}
async function load() {
const requestGeneration = ++generation;
publish('loading');
try {
const result = await options.fetchJson('api/v1/following', {headers:{Accept:'application/json'}});
if (requestGeneration !== generation) return snapshot;
snapshot = {
revision:Number(result?.revision) || 0,
items:Array.isArray(result?.items) ? result.items.slice(0, 50)
.map(item => ({...item, kind:item.kind === 'pull' ? 'pull' : 'issue'})) : [],
degraded:result?.degraded === true,
refreshFailures:Number(result?.refresh_failures) || 0,
};
publish('ready');
return snapshot;
} catch (error) {
if (requestGeneration === generation) publish('error', error);
throw error;
}
}
async function acknowledge(item) {
const current = snapshot.items.find(candidate => sameItem(candidate, item) &&
candidate.updated_at === item?.updated_at);
if (!current || current.has_unseen_change !== true || typeof options.onAcknowledge !== 'function') return false;
await options.onAcknowledge(current);
current.has_unseen_change = false;
review?.acknowledged.add(current.kind + ':' + current.repository + '#' + current.number + '@' + current.updated_at);
publish('ready');
return true;
}
async function keepForLater(item) {
const exact = candidate => sameItem(candidate, item) && candidate.updated_at === item?.updated_at;
const current = snapshot.items.find(exact);
const index = review?.active ? review.items.findIndex(exact) : -1;
if (!current || index < 0 || typeof options.onKeep !== 'function') return null;
await options.onKeep(current);
current.has_unseen_change = true;
review.acknowledged.delete(current.kind + ':' + current.repository + '#' +
current.number + '@' + current.updated_at);
review.items.splice(index, 1);
const next = review.items[index] || null;
if (!next) review.active = false;
publish('ready');
if (next) await open(snapshot.items.findIndex(candidate => sameItem(candidate, next)));
return next;
}
async function open(index) {
const item = snapshot.items[Number(index)];
if (!item) return false;
await options.onOpen?.({...item, following:true});
await acknowledge(item);
return true;
}
async function startReview() {
const items = snapshot.items
.filter(item => item.has_unseen_change === true)
.map(item => ({...item, following:true}));
if (!items.length) return false;
review = {items, more:false, active:true, acknowledged:new Set()};
await open(snapshot.items.indexOf(snapshot.items.find(item => sameItem(item, items[0]))));
return true;
}
function finishReview() {
const completed = Boolean(review?.completed || (review?.active &&
!snapshot.items.some(item => item.has_unseen_change === true)));
if (review) review.active = false;
if (completed && !review.completed) {
review.completed = true;
options.onReviewComplete?.();
}
publish('ready');
return completed;
}
function retire(item) {
const same = candidate => sameItem(candidate, item);
const index = review?.active ? review.items.findIndex(same) : -1;
snapshot.items = snapshot.items.filter(candidate => !same(candidate));
if (index < 0) {
publish('ready');
return null;
}
review.items.splice(index, 1);
const next = review.items[index] || null;
if (!next) {
review.active = false;
if (!review.completed) {
review.completed = true;
options.onReviewComplete?.();
}
}
publish('ready');
return next ? {...next} : null;
}
return {
load, open, startReview, previewLoaded:acknowledge, keepForLater, finishReview, retire,
session:() => review?.active ? {items:[...review.items], more:false} : null,
items:() => snapshot.items.map(item => ({...item})),
count:() => snapshot.items.length,
};
}
function attachFollowing(onOpen, hooks = {}) {
const document = globalThis.document;
const query = selector => document.querySelector(selector);
const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, character =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[character]);
const formatTime = value => new Date(value).toLocaleString();
const fetchJson = async (url, options) => {
const response = await fetch(url, options);
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.detail || payload.error || 'Following is temporarily unavailable.');
return payload;
};
const changeRevision = (item, action) => {
const [owner, repo] = item.repository.split('/');
return fetchJson('api/v1/following/' + encodeURIComponent(owner) + '/' +
encodeURIComponent(repo) + '/issues/' + item.number + '/' + action +
'?kind=' + encodeURIComponent(item.kind), {
method:'PUT', headers:{'Content-Type':'application/json', Accept:'application/json'},
body:JSON.stringify({updated_at:item.updated_at}),
});
};
let feature;
let activeReviewItem = null;
const show = () => query('#following-sheet').open || query('#following-sheet').showModal();
function render(state) {
hooks.onStatus?.(state.status);
const list = query('#following-list');
const status = query('#following-status');
const reviewButton = query('#review-following');
query('#retry-following').hidden = state.status !== 'error';
if (state.status === 'loading') return void (status.textContent = 'Loading watched items…');
if (state.status === 'error') return void (status.textContent = state.error?.message || 'Following is temporarily unavailable.');
const unseen = state.items.filter(item => item.has_unseen_change === true).length;
reviewButton.hidden = unseen === 0;
reviewButton.textContent = unseen === 1 ? 'Review new activity' : 'Review ' + unseen + ' new changes';
status.textContent = (state.reviewSummary
? 'Reviewed ' + state.reviewSummary.reviewed + ' changes · ' + state.reviewSummary.remaining + ' still need review. '
: '') + (state.degraded ? 'Some watched items could not be refreshed. Showing last known details. ' : '') + (state.items.length
? state.items.length + (state.items.length === 1 ? ' watched item.' : ' watched items.')
: 'No watched items yet. Watch an issue or pull request from Search to keep it here.');
list.innerHTML = state.items.map((item, index) =>
'<button class="following-card' + (item.has_unseen_change ? ' has-unseen-change' : '') +
'" type="button" data-following-index="' + index + '"><span>' +
(item.has_unseen_change ? '<em>' + escapeHtml(item.change_summary || 'New activity') + '</em>' : '') + '<strong>' +
escapeHtml(item.title) + '</strong><small>' + escapeHtml(
(item.kind === 'pull' ? 'Pull request' : 'Issue') + ' · ' + item.repository + ' #' + item.number +
' · ' + item.state + ' · ' + formatTime(item.updated_at)) +
'</small></span><span aria-hidden="true"></span></button>').join('');
list.querySelectorAll('[data-following-index]').forEach(button => button.addEventListener('click', () => {
query('#following-sheet').close();
feature.open(Number(button.dataset.followingIndex)).catch(() => {});
}));
}
feature = createFollowing({
fetchJson, render,
onCount:count => {
const value = query('[data-mobile-queue-count="following"]');
value.textContent = count;
value.closest('button').setAttribute('aria-label', 'Following, ' + count +
(count === 1 ? ' unseen change' : ' unseen changes'));
hooks.onCount?.(count, feature.items());
},
onOpen:item => {
activeReviewItem = item;
return onOpen(item);
},
onReviewComplete:hooks.onReviewComplete,
onAcknowledge:item => changeRevision(item, 'seen'),
onKeep:item => changeRevision(item, 'keep'),
});
query('#close-following').addEventListener('click', () => query('#following-sheet').close());
query('#review-following').addEventListener('click', () => {
query('#following-sheet').close();
feature.startReview().catch(() => {
show();
});
});
query('#retry-following').addEventListener('click', () => feature.load().catch(() => {}));
const keepButton = query('#keep-following-for-later');
keepButton.addEventListener('click', async () => {
const status = query('#keep-following-status');
keepButton.disabled = true;
status.textContent = 'Keeping this change for later…';
try {
if (!await feature.keepForLater(activeReviewItem)) query('#close-search-preview').click();
status.textContent = 'Kept for later.';
} catch (_) {
status.textContent = 'Could not keep this change for later. Retry when ready.';
keepButton.disabled = false;
}
});
return {
load:feature.load,
review:feature.startReview,
async route() {
await feature.load().then(feature.startReview).catch(() => false) || show();
},
open() {
show();
feature.load().catch(() => {});
return 'opened-following';
},
session:feature.session,
previewLoaded:feature.previewLoaded,
keepForLater:feature.keepForLater,
retire:feature.retire,
preview(state) {
const disposition = planningDisposition(state?.detail || state?.item);
keepButton.hidden = state?.item?.following !== true || disposition.active;
keepButton.disabled = false;
if (keepButton.hidden || state.status === 'loading') query('#keep-following-status').textContent = '';
},
returnToFollowing() {
const completed = feature.finishReview();
if (completed) return 'completed-following';
show();
globalThis.requestAnimationFrame?.(() => {
const target = query('#review-following:not([hidden])') || query('.following-card') || query('#close-following');
target?.focus();
});
return 'returned-following';
},
};
}
return {createFollowing, attachFollowing, planningDisposition};
});

510
frontend/human-gates.js Normal file
View File

@ -0,0 +1,510 @@
function createHumanGates(options = {}) {
const storage = options.storage || window.localStorage;
const getLogin = options.getLogin || (() => '');
const getAccountKey = options.getAccountKey || getLogin;
const isOnline = options.isOnline || (() => navigator.onLine);
const location = options.location || window.location;
const fetchJson = options.fetchJson;
const nodes = options.nodes || {};
let queue = { pending_count: 0, items: [] };
let reviewSnapshot = [];
let reviewIndex = -1;
let loadedAccountKey = '';
let loadEpoch = 0;
const decisionKeys = new Map();
let decisionFlight = null;
let openFlight = null;
let onChange = options.onChange;
let historyItems = [];
let historyNextCursor = null;
let historyLoadedMore = false;
let historyLoadError = '';
const escape = value => String(value ?? '').replace(/[&<>"']/g, character => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
})[character]);
const cacheKey = () => 'stackchain.human-gates.v1:' + String(getAccountKey() || '').trim().toLowerCase();
const progressKey = item => 'stackchain.human-gate-review.v1:' +
String(getAccountKey() || '').trim().toLowerCase() + ':' + item.id + ':' + item.revision;
const decisionStorageKey = () => 'stackchain.human-gate-decision.v1:' +
String(getAccountKey() || '').trim().toLowerCase();
const setText = (node, value) => { if (node) node.textContent = value; };
const setHtml = (node, value) => { if (node) node.innerHTML = value; };
const publish = state => onChange?.(JSON.parse(JSON.stringify(queue)), state);
function validSnapshot(value) {
return value && Number.isInteger(value.pending_count) && Array.isArray(value.items) ? value : null;
}
function restore() {
if (!getLogin()) return null;
try { return validSnapshot(JSON.parse(storage.getItem(cacheKey()) || 'null')); } catch (_) { return null; }
}
function save(value) {
if (!getLogin()) return;
try { storage.setItem(cacheKey(), JSON.stringify(value)); } catch (_) {}
}
function restoreProgress(item) {
if (!getLogin() || !item) return null;
try {
const value = JSON.parse(storage.getItem(progressKey(item)) || 'null');
if (!value || value.gate_id !== item.id || value.revision !== item.revision) return null;
return value;
} catch (_) { return null; }
}
function saveProgress(values = {}) {
const item = current();
if (!getLogin() || !item) return false;
const checklist = values.checklist || {};
const progress = {
gate_id:item.id, revision:item.revision,
checklist:Object.fromEntries(['exact_hash', 'artifacts_reviewed', 'provenance_reviewed'].map(key => [key, checklist[key] === true])),
reason:String(values.reason || ''), override_reason:String(values.override_reason || ''),
};
try { storage.setItem(progressKey(item), JSON.stringify(progress)); return true; } catch (_) { return false; }
}
function restorePendingDecision(item = null) {
if (!getLogin()) return null;
try {
const value = JSON.parse(storage.getItem(decisionStorageKey()) || 'null');
if (!value || typeof value !== 'object' || !value.idempotency_key || !value.operation ||
!value.payload || !value.gate_id || !Number.isInteger(value.revision)) return null;
if (item && (value.gate_id !== item.id || value.revision !== item.revision)) return null;
return value;
} catch (_) { return null; }
}
function savePendingDecision(value) {
storage.setItem(decisionStorageKey(), JSON.stringify(value));
}
function clearPendingDecision(item) {
if (!restorePendingDecision(item)) return;
try { storage.removeItem?.(decisionStorageKey()); } catch (_) {}
}
function valuesFromDetail() {
const checklist = Object.fromEntries(Array.from(nodes.detail?.querySelectorAll?.('[data-gate-checklist]') || []).map(input => [input.dataset.gateChecklist, input.checked]));
return {
checklist,
reason:nodes.detail?.querySelector?.('[data-gate-reason]')?.value || '',
override_reason:nodes.detail?.querySelector?.('[data-gate-override]')?.value || '',
};
}
function updateReadiness(values = valuesFromDetail()) {
const completed = ['exact_hash', 'artifacts_reviewed', 'provenance_reviewed']
.filter(key => values.checklist?.[key] === true).length;
setText(nodes.detail?.querySelector?.('[data-gate-readiness]'), completed + ' of 3 confirmations complete');
}
function captureProgress() {
if (!nodes.detail?.querySelectorAll) return false;
const values = valuesFromDetail();
updateReadiness(values);
return saveProgress(values);
}
nodes.detail?.addEventListener?.('input', captureProgress);
nodes.detail?.addEventListener?.('change', captureProgress);
function render() {
setText(nodes.count, String(queue.pending_count));
if (!queue.pending_count) {
setHtml(nodes.list, '<div class="human-gates-zero"><strong>Inbox zero</strong><span>No release candidates need your decision.</span></div>');
setText(nodes.status, 'Human Gates inbox zero.');
return;
}
setText(nodes.status, queue.pending_count + (queue.pending_count === 1 ? ' gate pending.' : ' gates pending.'));
setHtml(nodes.list, queue.items.map(item =>
'<button class="human-gate-card" type="button" data-human-gate-id="' + escape(item.id) + '">' +
'<strong>' + escape(item.title) + '</strong><code>' + escape(item.candidate_hash) + '</code>' +
'<span>Priority ' + escape(item.priority ?? 0) + '</span></button>'
).join(''));
}
function setView(view) {
nodes.pendingTab?.setAttribute?.('aria-pressed', view === 'pending' ? 'true' : 'false');
nodes.historyTab?.setAttribute?.('aria-pressed', view === 'history' ? 'true' : 'false');
}
function renderHistory(preserveDetail = false) {
setView('history');
if (historyNextCursor) {
setText(nodes.status, historyItems.length + ' Human Gate decisions loaded. Older decisions are available.');
} else if (historyLoadedMore) {
setText(nodes.status, 'All ' + historyItems.length + ' Human Gate decisions loaded.');
} else {
setText(nodes.status, historyItems.length + (historyItems.length === 1 ? ' past Human Gate decision.' : ' past Human Gate decisions.'));
}
if (!historyItems.length) {
setHtml(nodes.list, '<div class="human-gates-zero"><strong>No decision history</strong><span>Released and held candidates will appear here.</span></div>');
setHtml(nodes.detail, '');
return;
}
setHtml(nodes.list, historyItems.map(item =>
'<button class="human-gate-card human-gate-history-card" type="button" data-human-gate-history-id="' + escape(item.id) + '">' +
'<strong>' + escape(item.title) + '</strong><span class="human-gate-state human-gate-state-' + escape(item.state) + '">' +
escape(item.state.charAt(0).toUpperCase() + item.state.slice(1)) + '</span>' +
'<code>' + escape(item.candidate_hash) + '</code><time>' + escape(new Date(Number(item.updated_at) * 1000).toLocaleString()) + '</time></button>'
).join('') + (historyNextCursor ? '<button class="human-gate-history-more" type="button" data-human-gate-history-more>' +
(historyLoadError ? 'Retry older decisions' : 'Load older decisions') + '</button>' : ''));
Array.from(nodes.list?.querySelectorAll?.('[data-human-gate-history-id]') || []).forEach(card => {
card.addEventListener('click', () => selectHistory(card.dataset.humanGateHistoryId).catch(error => {
setText(nodes.status, error.message || 'Human Gate history is unavailable.');
}));
});
nodes.list?.querySelector?.('[data-human-gate-history-more]')?.addEventListener?.('click', () => {
loadMoreHistory().catch(error => setText(nodes.status, error.message || 'Older Human Gate decisions are unavailable.'));
});
if (!preserveDetail) setHtml(nodes.detail, '<div class="human-gates-zero"><strong>Decision history</strong><span>Open a candidate to review its durable receipt.</span></div>');
}
async function showHistory() {
if (!isOnline()) throw new Error('Human Gate history requires an online connection.');
if (!String(getLogin() || '').trim()) throw new Error('Authenticated account identity is required.');
const result = validSnapshot(await fetchJson('api/v1/human-gates?state=history&limit=20'));
if (!result) throw new Error('Human Gate history response is invalid.');
historyItems = result.items;
historyNextCursor = result.next_cursor || null;
historyLoadedMore = false;
historyLoadError = '';
renderHistory();
return JSON.parse(JSON.stringify(historyItems));
}
async function loadMoreHistory() {
if (!historyNextCursor) return JSON.parse(JSON.stringify(historyItems));
if (!isOnline()) throw new Error('Human Gate history requires an online connection.');
const cursor = historyNextCursor;
let result;
try {
result = validSnapshot(await fetchJson(
'api/v1/human-gates?state=history&limit=20&cursor=' + encodeURIComponent(cursor)
));
if (!result) throw new Error('Human Gate history response is invalid.');
} catch (error) {
historyLoadError = error?.message || 'Older Human Gate decisions are unavailable.';
renderHistory(true);
setText(nodes.status, historyLoadError + ' Loaded decisions are still available.');
throw error;
}
const known = new Set(historyItems.map(item => item.id));
historyItems.push(...result.items.filter(item => !known.has(item.id)));
historyNextCursor = result.next_cursor || null;
historyLoadedMore = true;
historyLoadError = '';
renderHistory(true);
return JSON.parse(JSON.stringify(historyItems));
}
async function selectHistory(gateId) {
const summary = historyItems.find(item => item.id === gateId);
if (!summary) throw new Error('Gate is not in the current history.');
const detail = await fetchJson('api/v1/human-gates/' + encodeURIComponent(gateId));
if (!detail || detail.id !== gateId) throw new Error('Gate history detail is invalid.');
let receipt = null;
if (detail.receipt_id) receipt = await fetchJson('api/v1/human-gate-receipts/' + encodeURIComponent(detail.receipt_id));
const checklist = receipt?.checklist || {};
const confirmations = ['exact_hash', 'artifacts_reviewed', 'provenance_reviewed']
.filter(key => checklist[key] === true).map(key => '<li>' + escape(key.replaceAll('_', ' ')) + '</li>').join('');
setHtml(nodes.detail,
'<article class="human-gate-detail human-gate-receipt"><p class="small">' + escape(detail.state.toUpperCase()) + '</p>' +
'<h3>' + escape(detail.title) + '</h3><p>Project <strong>' + escape(detail.project) + '</strong></p>' +
'<p>Exact candidate <code>' + escape(detail.candidate_hash) + '</code></p>' +
(receipt ? '<p>Decision receipt <code>' + escape(receipt.receipt_id) + '</code></p>' +
'<p>Decided ' + escape(new Date(Number(receipt.decided_at) * 1000).toLocaleString()) + '</p>' +
(receipt.reason ? '<h4>Reason</h4><p>' + escape(receipt.reason) + '</p>' : '') +
(receipt.override_reason ? '<h4>Override</h4><p>' + escape(receipt.override_reason) + '</p>' : '') +
(confirmations ? '<h4>Confirmed</h4><ul>' + confirmations + '</ul>' : '') :
'<p>No decision receipt exists because this candidate was superseded.</p>') + '</article>');
return {detail, receipt};
}
function showPending() {
setView('pending');
render();
renderDetail(current());
return JSON.parse(JSON.stringify(queue));
}
function renderDetail(item) {
if (!item) {
setHtml(nodes.detail, '<div class="human-gates-zero"><strong>Inbox zero</strong><span>Fixed review snapshot complete.</span></div>');
return;
}
const progress = restoreProgress(item) || {checklist:{}, reason:'', override_reason:''};
const pendingDecision = restorePendingDecision(item);
const checked = key => progress.checklist?.[key] === true ? ' checked' : '';
const checks = (item.checks || []).map(check =>
'<li class="gate-check gate-check-' + escape(check.state) + '"><strong>' + escape(check.name) + '</strong> · ' + escape(check.state) + (check.required ? ' · required' : '') + '</li>'
).join('');
const artifacts = (item.artifacts || []).map(artifact => '<li><a href="' + escape(artifact.url) + '" target="_blank" rel="noreferrer noopener">' + escape(artifact.name) + '</a></li>').join('');
const links = (item.links || []).map(link => '<li><a href="' + escape(link.url) + '" target="_blank" rel="noreferrer noopener">' + escape(link.label) + '</a></li>').join('');
const provenance = Object.entries(item.provenance || {}).map(([key, value]) => '<li><strong>' + escape(key) + '</strong> · ' + escape(value) + '</li>').join('');
const history = (item.history || []).map(event => '<li><strong>' + escape(event.action) + '</strong> · ' + escape(event.at) + '</li>').join('');
setHtml(nodes.detail,
'<article class="human-gate-detail"><h3>' + escape(item.title) + '</h3>' +
'<p>Project <strong>' + escape(item.project) + '</strong></p>' +
'<p>Exact candidate <code>' + escape(item.candidate_hash) + '</code></p>' +
'<p>Score ' + escape(item.score?.value ?? 'not supplied') + ' · ' + escape(item.score?.provenance || '') + '</p>' +
'<h4>Artifacts</h4><ul>' + artifacts + '</ul><h4>Links</h4><ul>' + links + '</ul>' +
'<h4>Checks</h4><ul>' + checks + '</ul><h4>Provenance</h4><ul>' + provenance + '</ul>' +
'<h4>History</h4><ul>' + history + '</ul>' +
'<label><input type="checkbox" data-gate-checklist="exact_hash"' + checked('exact_hash') + '> Exact hash reviewed</label>' +
'<label><input type="checkbox" data-gate-checklist="artifacts_reviewed"' + checked('artifacts_reviewed') + '> Artifacts reviewed</label>' +
'<label><input type="checkbox" data-gate-checklist="provenance_reviewed"' + checked('provenance_reviewed') + '> Provenance reviewed</label>' +
'<label>Hold reason<textarea data-gate-reason>' + escape(progress.reason) + '</textarea></label>' +
'<label>Override reason<textarea data-gate-override>' + escape(progress.override_reason) + '</textarea></label>' +
'<div class="human-gate-decision-tray' + (pendingDecision ? ' human-gate-decision-recovery' : '') + '"><div class="human-gate-decision-state">' +
(pendingDecision ? '<strong>Decision outcome unknown</strong><span role="status">The previous ' + escape(pendingDecision.decision) +
' response was interrupted. Verify it before making another decision.</span>' :
'<strong data-gate-readiness>0 of 3 confirmations complete</strong><span data-gate-error role="alert" hidden></span>') + '</div>' +
'<div class="human-gate-decision-actions">' + (pendingDecision ?
'<button type="button" data-gate-recover>Verify decision</button>' :
'<button type="button" data-gate-decision="hold">Hold &amp; next</button><button type="button" data-gate-decision="release">Release &amp; next</button>') +
'</div></div></article>'
);
updateReadiness();
}
async function load() {
const epoch = ++loadEpoch;
const accountKey = String(getAccountKey() || '').trim().toLowerCase();
if (accountKey !== loadedAccountKey) {
loadedAccountKey = accountKey;
queue = { pending_count: 0, items: [] };
reviewSnapshot = [];
reviewIndex = -1;
render();
}
const cached = restore();
if (cached) {
queue = cached;
render();
publish({available:true, authoritative:false, cached:true});
}
try {
const live = validSnapshot(await fetchJson('api/v1/human-gates'));
if (epoch !== loadEpoch || accountKey !== loadedAccountKey) return queue;
if (!live) throw new Error('Human Gates response is invalid.');
queue = { pending_count: live.pending_count, items: live.items.slice() };
const interrupted = restorePendingDecision();
if (interrupted && interrupted.item?.id === interrupted.gate_id && interrupted.item.revision === interrupted.revision) {
const recoveryItem = {...interrupted.item, decision_recovery:true};
const existingIndex = queue.items.findIndex(candidate => candidate.id === interrupted.gate_id);
if (existingIndex >= 0) queue.items[existingIndex] = recoveryItem;
else {
queue.items.unshift(recoveryItem);
queue.pending_count += 1;
}
}
save(queue);
render();
publish({available:true, authoritative:true});
return queue;
} catch (error) {
if (epoch !== loadEpoch || accountKey !== loadedAccountKey) return queue;
if (!cached) {
publish({available:false, authoritative:false});
throw error;
}
setText(nodes.status, 'Offline cached gate list · reconnect before deciding.');
return queue;
}
}
function reviewNext() {
if (reviewIndex < 0) {
reviewSnapshot = queue.items.slice();
reviewIndex = 0;
}
const item = reviewSnapshot[reviewIndex] || null;
renderDetail(item);
if (item) {
const index = reviewIndex;
fetchJson('api/v1/human-gates/' + encodeURIComponent(item.id)).then(detail => {
if (!detail || detail.id !== item.id) throw new Error('Gate detail is invalid.');
if (reviewIndex !== index || reviewSnapshot[index]?.id !== item.id) return;
reviewSnapshot[index] = detail;
renderDetail(detail);
}).catch(() => { setText(nodes.status, 'Gate detail is unavailable. Retry while online.'); });
}
return item;
}
function select(gateId) {
if (reviewIndex < 0) reviewSnapshot = queue.items.slice();
const index = reviewSnapshot.findIndex(item => item.id === gateId);
if (index < 0) throw new Error('Gate is not in the current review snapshot.');
reviewIndex = index;
return reviewNext();
}
function current() { return reviewIndex < 0 ? null : (reviewSnapshot[reviewIndex] || null); }
function idempotencyKey(item, decision, payload) {
const operation = item.id + ':' + item.revision + ':' + decision + ':' + JSON.stringify(payload);
const pending = restorePendingDecision(item);
if (pending?.operation === operation) return { operation, key: pending.idempotency_key };
if (decisionKeys.has(operation)) return { operation, key: decisionKeys.get(operation) };
const nonce = globalThis.crypto?.randomUUID?.() || (Date.now().toString(36) + '-' + Math.random().toString(36).slice(2));
const key = 'human-gate:' + item.id + ':' + item.revision + ':' + decision + ':' + nonce;
decisionKeys.set(operation, key);
return { operation, key };
}
function completeDecision(item, decision, operation, receipt) {
decisionKeys.delete(operation);
clearPendingDecision(item);
try { storage.removeItem?.(progressKey(item)); } catch (_) {}
queue.items = queue.items.filter(candidate => candidate.id !== item.id);
queue.pending_count = Math.max(0, queue.pending_count - 1);
save(queue); render();
publish({available:true, authoritative:true, decision:true});
reviewIndex += 1;
const next = current();
if (next) reviewNext(); else renderDetail(null);
setText(nodes.status, next ? (decision === 'release' ? 'Released. Reviewing next gate.' : 'Held. Reviewing next gate.') : 'Decision saved. Human Gates review snapshot complete.');
return { receipt, next };
}
async function postDecision(item, pending) {
const receipt = await fetchJson('api/v1/human-gates/' + encodeURIComponent(item.id) + '/decision', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': pending.idempotency_key },
body: JSON.stringify(pending.payload),
});
return completeDecision(item, pending.decision, pending.operation, receipt);
}
function decisionError(message, selector) {
const error = new Error(message);
error.targetSelector = selector;
return error;
}
async function decideAndNext(decision, values = {}) {
const item = current();
if (!item) throw new Error('No gate is selected.');
if (!isOnline()) throw new Error('Human Gate decisions require an online connection.');
if (!String(getLogin() || '').trim()) throw new Error('Authenticated account identity is required.');
const checklist = values.checklist || {};
const complete = ['exact_hash', 'artifacts_reviewed', 'provenance_reviewed'].every(key => checklist[key] === true);
if (decision === 'release' && !complete) {
const firstMissing = ['exact_hash', 'artifacts_reviewed', 'provenance_reviewed'].find(key => checklist[key] !== true);
throw decisionError('Complete the release checklist before deciding.', '[data-gate-checklist="' + firstMissing + '"]');
}
const unmet = (item.checks || []).filter(check => check.required && check.state !== 'success');
if (decision === 'release' && unmet.length && !String(values.override_reason || '').trim()) {
const names = unmet.map(check => String(check.name || 'Unnamed check')).join(', ');
throw decisionError('An explicit override reason is required for unmet required checks: ' + names + '.', '[data-gate-override]');
}
if (decision === 'hold' && !String(values.reason || '').trim()) {
throw decisionError('A hold reason is required.', '[data-gate-reason]');
}
const payload = {
expected_revision: item.revision, decision,
reason: String(values.reason || '').trim(),
override_reason: String(values.override_reason || '').trim(), checklist,
};
if (decisionFlight) return decisionFlight;
const operation = (async () => {
const decisionKey = idempotencyKey(item, decision, payload);
const pending = {
gate_id:item.id, revision:item.revision, decision, payload,
operation:decisionKey.operation, idempotency_key:decisionKey.key,
item:JSON.parse(JSON.stringify(item)),
};
savePendingDecision(pending);
try {
return await postDecision(item, pending);
} catch (error) {
renderDetail(item);
setText(nodes.status, 'Decision outcome unknown. Verify the interrupted decision while online.');
throw error;
}
})();
decisionFlight = operation;
try {
return await operation;
} finally {
if (decisionFlight === operation) decisionFlight = null;
}
}
async function recoverDecision() {
const item = current();
if (!item) throw new Error('No gate is selected.');
if (!isOnline()) throw new Error('Decision verification requires an online connection.');
if (!String(getLogin() || '').trim()) throw new Error('Authenticated account identity is required.');
const pending = restorePendingDecision(item);
if (!pending) throw new Error('No interrupted decision exists for this gate revision.');
if (decisionFlight) return decisionFlight;
const operation = postDecision(item, pending);
decisionFlight = operation;
try { return await operation; }
finally { if (decisionFlight === operation) decisionFlight = null; }
}
async function submitDecision(decision) {
const values = valuesFromDetail();
updateReadiness(values);
const errorNode = nodes.detail?.querySelector?.('[data-gate-error]');
if (errorNode) { errorNode.textContent = ''; errorNode.hidden = true; }
const buttons = Array.from(nodes.detail?.querySelectorAll?.('[data-gate-decision]') || []);
buttons.forEach(button => { button.disabled = true; });
try {
return await decideAndNext(decision, values);
} catch (error) {
if (errorNode) {
errorNode.textContent = error?.message || 'The decision could not be saved.';
errorNode.hidden = false;
}
let target = error?.targetSelector && nodes.detail?.querySelector?.(error.targetSelector);
if (!target && error?.targetSelector?.startsWith('[data-gate-checklist=')) {
const key = error.targetSelector.match(/"([^"]+)"/)?.[1];
target = Array.from(nodes.detail?.querySelectorAll?.('[data-gate-checklist]') || [])
.find(input => input.dataset.gateChecklist === key);
}
target?.scrollIntoView?.({block:'center', behavior:'smooth'});
target?.focus?.({preventScroll:true});
throw error;
} finally {
buttons.forEach(button => { button.disabled = false; });
}
}
function open() {
location.hash = '#/my-work/human-gates';
if (nodes.panel) nodes.panel.hidden = false;
if (openFlight) return openFlight;
const operation = (async () => {
setView('pending');
await load();
reviewSnapshot = queue.items.slice();
reviewIndex = 0;
return reviewNext();
})();
openFlight = operation;
const clearFlight = () => {
if (openFlight === operation) openFlight = null;
};
operation.then(clearFlight, clearFlight);
return operation;
}
return {
load, open, reviewNext, select, showHistory, loadMoreHistory, selectHistory, showPending,
decideAndNext, recoverDecision, submitDecision, current, saveProgress,
setOnChange(callback) { onChange = callback; },
restoreCached: restore,
snapshot: () => JSON.parse(JSON.stringify(queue)),
route: () => location.hash,
};
}
if (typeof module !== 'undefined') module.exports = createHumanGates;
if (typeof window !== 'undefined') window.createHumanGates = createHumanGates;

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,8 @@
else root.createInstallApp = factory;
})(typeof self !== 'undefined' ? self : this, function createInstallApp(options) {
const dismissedKey = 'stackchain.install.dismissed.v1';
let deferredPrompt = null;
let deferredPrompt = options.initialPrompt || null;
let installed = false;
function hide() {
options.card.hidden = true;
@ -25,6 +26,7 @@
await prompt.prompt();
const choice = await prompt.userChoice;
if (choice.outcome === 'accepted') {
installed = true;
hide();
options.status.textContent = 'Stackchain was added to your device.';
} else {
@ -44,6 +46,7 @@
hide();
return;
}
if (deferredPrompt) showNativePrompt(deferredPrompt);
if (options.isIosSafari()) {
options.installButton.hidden = true;
options.guidance.hidden = false;
@ -51,6 +54,7 @@
}
options.window.addEventListener('beforeinstallprompt', showNativePrompt);
options.window.addEventListener('appinstalled', () => {
installed = true;
deferredPrompt = null;
hide();
});
@ -58,5 +62,18 @@
options.dismissButton.addEventListener('click', dismiss);
}
return {start};
function state() {
if (installed || options.isStandalone()) {
return {state:'complete', detail:'Stackchain is installed on this device.'};
}
if (options.isIosSafari()) {
return {state:'incomplete', detail:'On Safari, tap Share, then Add to Home Screen.'};
}
if (deferredPrompt) {
return {state:'incomplete', detail:'Stackchain is ready to install.'};
}
return {state:'unavailable', detail:'Installation is available from a supported browser.'};
}
return {start, install, state};
});

View File

@ -0,0 +1,418 @@
(function(root, 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(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 normalizeNote(value) {
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 240);
}
function escapeMarkdown(value) {
return value.replace(/([\\`*_[\]{}()<>#+\-.!|])/g, '\\$1');
}
const optimizeImage = issueEvidenceReview.optimizeImage;
function multipart(attachment) {
let blob = attachment?.blob;
if (!blob && attachment?.data) {
const binary = atob(String(attachment.data));
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
blob = new Blob([bytes], { type: String(attachment.contentType || '') });
}
if (!blob) throw new Error('The saved screenshot is unavailable. Retry before sending.');
const form = new FormData();
form.append('file', blob, String(attachment.filename || 'screenshot'));
return form;
}
function create(options) {
const upload = options.upload;
const 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 = [];
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.');
}
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.');
}
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 || inspectPixels) {
return Promise.resolve(optimizeSelectedImage(file)).then(optimized => {
if (generation !== selectionGeneration) return state();
return commitSelection(optimized);
});
}
return commitSelection(file);
}
function clear() {
selectionGeneration += 1;
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) {
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);
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() {
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.length) return null;
const values = selected.map(serializeItem);
return values.length > 1 ? values : values[0];
}
async function prepareComment(target, body) {
const text = String(body || '').trim();
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);
}
const evidence = markdown.join('\n\n');
return text ? text + '\n\n' + evidence : evidence;
}
return { select, restore, remove, move, replace, setNote, note, clear, state, serialize, prepareComment };
}
function mount(options) {
const inputs = Array.from(new Set((options.inputs || [options.input]).filter(Boolean)));
if (!inputs.includes(options.input)) inputs.push(options.input);
const controller = create({
...options, inspectPixels:true,
maxFiles:options.maxFiles || (options.input?.multiple ? MAX_FILES : 1),
});
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(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;
inputs.forEach(input => {
input.value = '';
input.disabled = false;
});
clearSelection();
options.onChange?.(controller.state());
}
function showPreview(file, optimized) {
if (previewUrl) options.revokeObjectURL(previewUrl);
previewUrl = options.createObjectURL(file);
options.image.src = previewUrl;
options.meta.textContent = file.name + ' · ' + Math.ceil(file.size / 1024) + ' KB';
options.preview.hidden = false;
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.');
}
function selectFromInput(event) {
const sourceInput = event.target;
const files = Array.from(sourceInput.files || []);
const sequence = ++selectionSequence;
if (!files.length) return;
let first;
try { first = controller.select(files[0]); }
catch (error) {
options.status.textContent = error.message;
sourceInput.value = '';
return;
}
if (!reviewEnabled && files.length === 1 && (!first || typeof first.then !== 'function')) {
showPreview(files[0], false);
return;
}
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;
sourceInput.value = '';
}
}).finally(() => {
if (sequence === selectionSequence) inputs.forEach(input => { input.disabled = false; });
});
}
inputs.forEach(input => input.addEventListener('change', selectFromInput));
options.remove.addEventListener('click', () => {
const current = controller.state();
const count = Array.isArray(current) ? current.length : (current ? 1 : 0);
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 = 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 = 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 = 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, MAX_PIXELS };
});

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 };
});

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 };
});

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;
});

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;
});

View File

@ -5,16 +5,98 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
);
const pending = new Map();
function captureAttachment(value) {
const contentType = String(value?.contentType || '');
const filename = String(value?.filename || '').slice(0, 255);
const blob = value?.blob;
const data = String(value?.data || '');
if (!filename || !['image/png', 'image/jpeg', 'image/webp'].includes(contentType)) return undefined;
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, ...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() {
try {
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
if (![1, 2, 3].includes(record?.version) || !Array.isArray(record.items)) return [];
return record.items.filter(item => item && typeof item === 'object');
} catch (_error) { return []; }
}
function write(items, mirror = true) {
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
storage?.setItem(storageKey, JSON.stringify({ version: 3, items }));
coordinator?.notify('issue');
if (mirror && backgroundSync?.reconcile) {
Promise.resolve(backgroundSync.reconcile(items))
@ -23,7 +105,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
}
}
function enqueue(draft, mirror = true) {
function prepareItem(draft) {
const ownerLogin = String(getOwnerLogin() || '').trim();
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing an issue.');
const items = read();
@ -39,73 +121,213 @@ 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' && !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);
}
if (/^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))) item.dueDate = String(draft.dueDate);
return item;
}
function localIndexItem(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,
...(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} : {}),
}))} : {}),
};
}
function enqueue(draft, mirror = true) {
const items = read();
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
const existing = sourceCaptureId && items.find(item => item.sourceCaptureId === sourceCaptureId);
if (existing) return { ...existing };
const item = prepareItem(draft);
items.push(item);
write(items, mirror);
return item;
}
async function enqueueDurably(draft) {
const item = enqueue(draft, false);
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
return { item, background: false, durability: 'foreground-only' };
}
try {
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
const existing = sourceCaptureId && read().find(item => item.sourceCaptureId === sourceCaptureId);
if (existing) {
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
return { item: { ...existing }, background: false, durability: 'foreground-only', reused: true };
}
await backgroundSync.reconcile(read());
await backgroundSync.requestSync();
return { item, background: true, durability: 'background' };
return { item: { ...existing }, background: true, durability: 'background', reused: true };
}
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
const item = enqueue(draft, false);
return { item, background: false, durability: 'foreground-only' };
}
const item = prepareItem(draft);
const current = read();
try {
await backgroundSync.reconcile([...current, item]);
const localItem = localIndexItem(item);
write([...current, item].map(localIndexItem), false);
await backgroundSync.requestSync();
return { item: localItem, background: true, durability: 'background' };
} catch (error) {
return { item, background: false, durability: 'foreground-only', error };
const persisted = read().find(candidate => candidate.id === item.id);
if (!persisted) throw error;
return { item: persisted, background: false, durability: 'foreground-only', error };
}
}
function update(id, draft, mirror = true) {
async function hydrateForEdit(id) {
const item = read().find(candidate => candidate.id === id);
if (!item) return null;
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);
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 ? {attachment} : {}),
...(attachments ? {attachments} : {}),
};
}
function prepareUpdate(id, draft) {
let updated = null;
write(read().map(item => {
const items = read().map(item => {
if (item.id !== id) return item;
const nextRepository = String(draft?.repository || '');
const nextTitle = String(draft?.title || '');
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 changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate;
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,
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 (nextMilestoneId === undefined) delete updated.milestoneId;
if (nextDueDate === undefined) delete updated.dueDate;
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;
}), mirror);
});
return { items, updated };
}
function update(id, draft, mirror = true) {
const { items, updated } = prepareUpdate(id, draft);
write(items, mirror);
return updated;
}
async function updateDurably(id, draft) {
const item = update(id, draft, false);
if (!item || !backgroundSync?.reconcile || !backgroundSync?.requestSync) {
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
const item = update(id, draft, false);
return { item, background: false, durability: 'foreground-only' };
}
const { items, updated: item } = prepareUpdate(id, draft);
if (!item) return { item, background: false, durability: 'foreground-only' };
try {
await backgroundSync.reconcile(read());
await backgroundSync.reconcile(items);
const localItems = items.map(localIndexItem);
write(localItems, false);
await backgroundSync.requestSync();
return { item, background: true, durability: 'background' };
return { item: localIndexItem(item), background: true, durability: 'background' };
} catch (error) {
return { item, background: false, durability: 'foreground-only', error };
const persisted = read().find(candidate => candidate.id === id);
if (!persisted || persisted.operationId !== item.operationId) throw error;
return { item: persisted, background: false, durability: 'foreground-only', error };
}
}
@ -116,9 +338,129 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
return true;
}
function persistDeliveryStage(id, stage) {
write(read().map(item => item.id === id ? { ...item, ...stage } : item));
}
function stageOperationId(operationId, stage) {
const suffix = ':' + stage;
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
}
function 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) {
const binary = atob(String(attachment.data));
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
blob = new Blob([bytes], { type: String(attachment.contentType || '') });
}
if (!blob) throw new Error('The saved screenshot is unavailable. Retry before sending.');
const form = new FormData();
form.append('file', blob, String(attachment.filename || 'screenshot'));
return form;
}
async function sendDirect(item, repository) {
let issue = item.deliveredIssue;
if (!issue) {
issue = await fetchJson('api/v1/repos/' + repository + '/issues', {
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({
title: item.title, body: item.body, label_ids: item.labelIds,
...(item.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 || item.attachments || item.blockers?.length) {
persistDeliveryStage(item.id, { deliveredIssue: issue });
}
}
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, attachments.length === 1 ? 'attachment' : 'attachment-' + index,
),
},
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);
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',
{
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'attachment-comment'),
},
body: JSON.stringify({ body: markdown }),
},
);
return issue;
}
async function sendItem(item, currentLogin) {
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
if (pending.has(item.id)) return pending.get(item.id);
const attemptAt = Number(now());
write(read().map(candidate => candidate.id === item.id ? {
...candidate, status:'sending', lastAttemptAt:attemptAt,
} : candidate), false);
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
const request = (async () => {
try {
@ -132,29 +474,24 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
}
issue = delivery.issue;
} else {
issue = await fetchJson('api/v1/repos/' + repository + '/issues', {
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({
title: item.title, body: item.body, label_ids: item.labelIds,
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
}),
});
issue = await sendDirect(item, repository);
}
if (!issue) return { blocked: true };
discard(item.id);
return { issue };
return { issue, item };
} catch (error) {
const status = Number(error?.status || 0);
const attemptError = String(error.message || 'Delivery failed').slice(0, 240);
if (status >= 400 && status < 500) {
write(read().map(candidate => candidate.id === item.id ? {
...candidate, status: 'attention', error: String(error.message || 'Issue needs attention').slice(0, 240),
lastAttemptAt: attemptAt, lastAttemptError: attemptError,
...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}),
} : candidate));
} else {
write(read().map(candidate => candidate.id === item.id ? {
...candidate, status:'queued', lastAttemptAt:attemptAt, lastAttemptError:attemptError,
} : candidate));
}
return { error, transient: !(status >= 400 && status < 500) };
}
@ -166,16 +503,31 @@ 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()) {
if (item.status === 'attention') continue;
if (item.status === 'attention' || item.status === 'completion') continue;
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
const result = await sendItem(item, currentLogin);
if (result.issue) confirmed.push(result.issue);
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, remaining: read(), blocked };
return { confirmed, completions, filings, remaining: read(), blocked };
}
async function flush(currentLogin) {
@ -202,7 +554,20 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
write(read().map(candidate => candidate.id === id ? queued : candidate));
}
const result = await sendItem(queued, currentLogin);
return { confirmed: result.issue ? [result.issue] : [], remaining: read(), blocked: 0 };
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,
};
}
async function retry(id, currentLogin) {
@ -214,18 +579,53 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
const statuses = new Map((records || []).map(item => [item.id, item]));
const items = read().flatMap(item => {
const background = statuses.get(item.id);
if (background?.status === 'sent') return [];
const deliveryStage = {
...(background?.deliveredIssue ? { deliveredIssue: background.deliveredIssue } : {}),
...(background?.attachmentMarkdown ? { attachmentMarkdown: background.attachmentMarkdown } : {}),
};
if (background?.status === 'sent') {
if (item.completionIntent === 'create-and-start' && background.deliveredIssue) return [{
...item, status: 'completion', deliveredIssue: background.deliveredIssue,
}];
return [];
}
if (background?.status === 'attention') return [{
...item, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240),
...localIndexItem(item), ...deliveryStage, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240),
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
}];
return [item];
return [{ ...localIndexItem(item), ...deliveryStage }];
});
write(items);
return items;
}
return { enqueue, enqueueDurably, update, updateDurably, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
function pendingCompletions(currentLogin) {
const login = String(currentLogin || '').trim();
if (!login) return [];
return read().filter(item => item.status === 'completion' && item.ownerLogin === login &&
item.completionIntent === 'create-and-start' && item.deliveredIssue).map(item => ({
id: item.id,
intent: item.completionIntent,
ownerLogin: item.ownerLogin,
operationId: item.operationId,
estimateMinutes: item.estimateMinutes,
issue: item.deliveredIssue,
}));
}
function completeIntent(id, currentLogin) {
const login = String(currentLogin || '').trim();
const items = read();
const matched = items.some(item => item.id === id && item.status === 'completion' && item.ownerLogin === login);
if (!matched) return false;
write(items.filter(item => item.id !== id));
return true;
}
return {
enqueue, enqueueDurably, hydrateForEdit, update, updateDurably, discard, flush, retry, reconcileBackground,
pendingCompletions, completeIntent, list: () => read().map(item => ({ ...item })),
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueOutbox;

View File

@ -18,15 +18,151 @@ 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;
let milestoneRequest = null;
let blockerRequest = null;
const issuePath = item => 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
'/issues/' + encodeURIComponent(item.number);
const draftKey = item => 'stackchain.issue-comment.v1:' + item.repository + '#' + item.number;
@ -37,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;
@ -63,10 +208,29 @@ 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' },
});
},
updateBlocker(item, blocker, remove = false) {
if (blockerRequest) return blockerRequest;
blockerRequest = fetchJson(issuePath(item) + '/blockers', {
method: remove ? 'DELETE' : 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ repository: blocker.repository, number: blocker.number }),
}).then(result => {
const dependencies = Array.isArray(result?.dependencies) ? result.dependencies : [];
const present = dependencies.some(candidate =>
candidate?.repository === blocker.repository && Number(candidate?.number) === Number(blocker.number)
);
if (result?.number !== item.number || result?.dependencies_available !== true || present === remove) {
throw new Error('Blocker change was not confirmed.');
}
return result;
}).finally(() => { blockerRequest = null; });
return blockerRequest;
},
loadDraft(item) {
try { return storage?.getItem(draftKey(item)) || ''; }
catch (_error) { return ''; }
@ -92,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({
@ -110,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));
@ -189,11 +566,13 @@ 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 => {
if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.');
globalThis.dispatchEvent?.(new CustomEvent('stackchain:issue-closed',{detail:item}));
return result;
}).finally(() => { closeRequest = null; });
return closeRequest;
@ -235,6 +614,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);
@ -243,7 +644,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 }),
@ -261,4 +663,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;

View File

@ -0,0 +1,51 @@
function createLaterAndStart({ todayWork, todaySync, laterWork, refresh, warm, start, announce }) {
const pending = new Map();
async function run(item, identity) {
const added = todayWork.add(item);
if (added === 'full') {
announce('Today is limited to 5 items. Remove one, then try Start now again.');
return 'full';
}
if (added !== 'added' && added !== 'exists') {
announce('Could not save Today on this device. The item remains in Later; try again.');
return added;
}
if (added === 'added' && !todaySync.enqueue('add', identity)) {
todayWork.remove(item);
announce('Today sync is unavailable. The item remains in Later; try again.');
return 'sync-unavailable';
}
if (!laterWork.restore(item)) {
if (added === 'added') {
todayWork.remove(item);
todaySync.enqueue('remove', identity);
todaySync.flush();
}
announce('Could not remove this item from Later. Nothing was started; try again.');
return 'later-unavailable';
}
refresh();
if (added === 'added') todaySync.flush();
warm();
const outcome = await start(item);
if (outcome === 'gated') {
announce('Moved to Today. Choose how to handle its blocker before starting.');
return 'gated';
}
announce(added === 'exists' ? 'Opened the existing Today item.' : 'Moved to Today and opened.');
return 'started';
}
function startDeferred(item) {
const identity = todayWork.identity(item);
if (pending.has(identity)) return pending.get(identity);
const operation = run(item, identity).finally(() => pending.delete(identity));
pending.set(identity, operation);
return operation;
}
return { start: startDeferred };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterAndStart;

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;

View File

@ -1,5 +1,6 @@
function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStatus, createOperationId, createChannel,
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000 }) {
function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStatus, createOperationId, createChannel, coordinator,
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000,
now = Date.now, maxOfflineMs = 30 * 24 * 60 * 60 * 1000 }) {
const prefix = 'stackchain.later-sync.v1.';
const migrationPrefix = 'stackchain.later-sync-migrated.v1.';
const snapshotPrefix = 'stackchain.later-sync-snapshot.v1.';
@ -8,6 +9,8 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
let channelKey = '';
let retryTimer = null;
let retryAttempt = 0;
let expiredCount = 0;
const knownOperationKeys = new Set();
function cancelRetry() {
if (retryTimer !== null) clearTimer?.(retryTimer);
@ -84,25 +87,67 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
function pending() {
const storageKey = key();
if (!storageKey || !storage) return [];
const recordPrefix = storageKey + '.operation.';
try {
const value = JSON.parse(storage.getItem(storageKey) || '[]');
return Array.isArray(value) ? value.filter(operation =>
operation && typeof operation.operation_id === 'string' &&
['defer', 'restore'].includes(operation.action) &&
typeof operation.item_id === 'string' &&
(operation.action === 'restore' || typeof operation.wake_at === 'string')
) : [];
const legacy = JSON.parse(storage.getItem(storageKey) || '[]');
if (Array.isArray(legacy)) {
legacy.forEach((operation, index) => {
if (!operation?.operation_id) return;
const recordKey = recordPrefix + encodeURIComponent(operation.operation_id);
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: index }));
knownOperationKeys.add(recordKey);
});
if (legacy.length) storage.removeItem(storageKey);
}
const keys = new Set([...knownOperationKeys].filter(candidate => candidate.startsWith(recordPrefix)));
for (let index = 0; index < Number(storage.length || 0); index += 1) {
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 expired = records.filter(record => Number(record.queued_at) >= 1_000_000_000_000 &&
now() - Number(record.queued_at) > maxOfflineMs);
expired.forEach(record => {
storage.removeItem(record.recordKey);
knownOperationKeys.delete(record.recordKey);
});
if (expired.length) {
expiredCount += expired.length;
onStatus?.('expired', { count: expiredCount });
}
const activeRecords = records.filter(record => !expired.includes(record))
.sort((left, right) => Number(left.queued_at || 0) - Number(right.queued_at || 0) ||
left.operation.operation_id.localeCompare(right.operation.operation_id));
const latestByItem = new Map();
activeRecords.forEach(record => latestByItem.set(record.operation.item_id, record));
activeRecords.filter(record => latestByItem.get(record.operation.item_id) !== record).forEach(record => {
storage.removeItem(record.recordKey);
knownOperationKeys.delete(record.recordKey);
});
return activeRecords.filter(record => latestByItem.get(record.operation.item_id) === record)
.map(record => ({
...record.operation,
base_revision: Number.isInteger(record.operation.base_revision)
? record.operation.base_revision : 0,
})).filter(operation =>
operation && typeof operation.operation_id === 'string' &&
['defer', 'restore'].includes(operation.action) && typeof operation.item_id === 'string' &&
(operation.action === 'restore' || typeof operation.wake_at === 'string'));
} catch (_error) {
return [];
}
}
function save(operations) {
function removeOperation(operationId) {
const storageKey = key();
if (!storageKey || !storage) return false;
const recordKey = storageKey + '.operation.' + encodeURIComponent(operationId);
try {
if (operations.length) storage.setItem(storageKey, JSON.stringify(operations));
else storage.removeItem(storageKey);
storage.removeItem(recordKey);
knownOperationKeys.delete(recordKey);
return true;
} catch (_error) {
return false;
@ -115,12 +160,27 @@ 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;
const operations = pending().filter(operation => operation.item_id !== itemId);
operations.push({ operation_id: operationId(), action, item_id: itemId, wake_at: wakeAt });
const saved = save(operations);
(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);
let saved = false;
try {
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
knownOperationKeys.add(recordKey);
saved = true;
coordinator?.notify('later');
} catch (_error) { /* Report the persistence failure below. */ }
onStatus?.(saved ? 'pending' : 'error');
return saved;
}
@ -131,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) {
@ -145,26 +207,41 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
const ownerKey = key();
if (!ownerKey) return false;
ensureChannel();
expiredCount = 0;
try {
let plan = await fetchJson('api/v1/later');
let operations = pending();
let plan;
let conflictCount = 0;
if (!operations.length) plan = await fetchJson('api/v1/later');
while (operations.length) {
if (key() !== ownerKey) return false;
const operation = operations[0];
const batch = operations.slice(0, 50);
plan = await fetchJson('api/v1/later', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(operation),
body: JSON.stringify({ operations: batch }),
});
const remaining = pending();
const delivered = remaining.findIndex(candidate => candidate.operation_id === operation.operation_id);
if (delivered >= 0 && !save(remaining.filter((_, index) => index !== delivered))) {
throw new Error('Could not persist Later delivery receipt');
const hasReceipts = Array.isArray(plan.accepted_operation_ids) ||
Array.isArray(plan.duplicate_operation_ids) || Array.isArray(plan.rejected_operations);
const received = hasReceipts ? [
...(plan.accepted_operation_ids || []),
...(plan.duplicate_operation_ids || []),
...(plan.rejected_operations || []).map(item => item.operation_id),
] : batch.map(item => item.operation_id);
conflictCount += (plan.rejected_operations || [])
.filter(item => item.reason === 'stale_intent').length;
for (const operationId of received) {
if (pending().some(candidate => candidate.operation_id === operationId) &&
!removeOperation(operationId)) {
throw new Error('Could not persist Later delivery receipt');
}
}
operations = pending();
}
adopt(plan);
onStatus?.(pending().length ? 'pending' : 'saved');
onStatus?.(pending().length ? 'pending' :
(conflictCount ? 'conflict' : expiredCount ? 'expired' : 'saved'),
conflictCount ? { count: conflictCount } : expiredCount ? { count: expiredCount } : {});
retryAttempt = 0;
cancelRetry();
return true;
@ -176,7 +253,10 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
}
function flush() {
if (!flushing) flushing = run().finally(() => { flushing = null; });
if (!flushing) {
const delivery = coordinator ? coordinator.runExclusive('later', run) : run();
flushing = Promise.resolve(delivery).finally(() => { flushing = null; });
}
return flushing;
}
@ -187,6 +267,10 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
);
}
coordinator?.subscribe(change => {
if (change.queue === 'later' && pending().length) flush();
});
return { enqueue, migrate, flush, pending, startLifecycle };
}

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,26 @@ 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';
}
function deferMany(items, until) {
const key = storageKey();
const wake = new Date(until);
const entries = (items || []).map(item => [identity(item), item]);
if (!key) return 'unavailable';
if (!entries.length || entries.some(([id]) => !id) || Number.isNaN(wake.getTime()) || wake <= now()) {
return 'invalid';
}
const records = read();
const wakeAt = wake.toISOString();
entries.forEach(([id]) => { records[id] = wakeAt; });
if (!write(records)) return 'unavailable';
entries.forEach(([id]) => onChange('defer', id, wakeAt));
return 'deferred';
}
@ -77,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);
}
@ -103,31 +124,38 @@ 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 };
}
return { identity, read, adopt, defer, restore, presetUntil, partition };
return { identity, read, adopt, defer, deferMany, restore, presetUntil, partition };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterWork;

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 };
});

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,27 +15,113 @@
|| 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;
}
const form = options.form;
const status = options.status;
const button = options.button;
const passkeyButton = options.passkeyButton;
const credentials = options.credentials;
const fetchImpl = options.fetchImpl;
const location = options.location;
const clearPrivateDeviceData = options.clearPrivateDeviceData;
const continuation = validShareContinuation(options.continuation);
const setIntervalImpl = options.setIntervalImpl || setInterval;
const clearIntervalImpl = options.clearIntervalImpl || clearInterval;
const setTimeoutImpl = options.setTimeoutImpl || setTimeout;
const clearTimeoutImpl = options.clearTimeoutImpl || clearTimeout;
const requestTimeoutMs = options.requestTimeoutMs || 15000;
let timer = null;
let activeAttempt = false;
if (continuation !== './') status.textContent = 'Sign in to continue your shared capture.';
function setAttemptActive(active) {
activeAttempt = active;
button.disabled = active;
if (passkeyButton) passkeyButton.disabled = active || !credentials?.get;
}
async function requestWithDeadline(url, init) {
const controller = new AbortController();
const timeout = setTimeoutImpl(() => {
const error = new Error('Sign-in request timed out');
error.name = 'TimeoutError';
controller.abort(error);
}, requestTimeoutMs);
try {
return await fetchImpl(url, { ...init, signal: controller.signal });
} finally {
clearTimeoutImpl(timeout);
}
}
function decodeBase64Url(value) {
const padded = String(value).replaceAll('-', '+').replaceAll('_', '/')
+ '='.repeat((4 - String(value).length % 4) % 4);
return Uint8Array.from(atob(padded), character => character.charCodeAt(0));
}
function encodeBase64Url(value) {
if (value === null || value === undefined) return null;
const bytes = new Uint8Array(value);
let binary = '';
bytes.forEach(byte => { binary += String.fromCharCode(byte); });
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
}
function credentialJSON(credential) {
return {
id: credential.id,
type: credential.type,
rawId: encodeBase64Url(credential.rawId),
response: {
authenticatorData: encodeBase64Url(credential.response.authenticatorData),
clientDataJSON: encodeBase64Url(credential.response.clientDataJSON),
signature: encodeBase64Url(credential.response.signature),
userHandle: encodeBase64Url(credential.response.userHandle),
},
};
}
if (continuation !== './') {
const continuationParams = new URLSearchParams(continuation.slice(3));
status.textContent = continuationParams.has('preview')
? 'Sign in to open the shared Search result.'
: 'Sign in to continue your shared capture.';
}
async function showReason(reason) {
if (reason === 'session-expired') {
status.textContent = 'Your session expired. Private drafts remain on this device. Sign in to continue.';
return;
}
if (reason === 'session-idle') {
status.textContent = 'Stackchain locked. Your drafts and queued work are still on this device. Sign in to resume.';
return;
}
if (reason !== 'session-revoked') return;
button.disabled = true;
status.textContent = 'This device was remotely signed out. Clearing Stackchain private data…';
@ -66,18 +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) {
@ -91,18 +184,66 @@
status.textContent = 'Sign-in failed. Check the token and try again.';
}
return { submit, showReason };
async function signInWithPasskey(deviceLabel = 'This device') {
if (!credentials?.get) {
status.textContent = 'Passkeys are not supported in this browser. Use the access token.';
return false;
}
if (activeAttempt) return false;
setAttemptActive(true);
status.textContent = 'Waiting for your passkey…';
try {
const optionsResponse = await requestWithDeadline('api/v1/passkeys/authentication/options', {
method: 'POST', headers: { Accept: 'application/json' },
});
if (!optionsResponse.ok) throw new Error('No enrolled passkey');
const publicKey = await optionsResponse.json();
const challenge = publicKey.challenge;
publicKey.challenge = decodeBase64Url(publicKey.challenge);
publicKey.allowCredentials = (publicKey.allowCredentials || []).map(item => ({
...item, id: decodeBase64Url(item.id),
}));
const credential = await credentials.get({ publicKey });
const response = await requestWithDeadline('api/v1/passkeys/authentication/verify', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge,
credential: credentialJSON(credential),
device_label: deviceLabel,
action: 'sign_in',
target: 'dashboard',
}),
});
if (!response.ok) throw new Error('Passkey verification failed');
location.replace(continuation);
return true;
} catch (error) {
status.textContent = error?.name === 'TimeoutError'
? 'Passkey sign-in timed out. Check your connection and try again.'
: 'Passkey sign-in was not completed. Try again or use the access token.';
return false;
} finally {
setAttemptActive(false);
}
}
if (passkeyButton) passkeyButton.disabled = !credentials?.get;
return { submit, signInWithPasskey, showReason };
}));
if (typeof document !== 'undefined') {
const form = document.getElementById('sign-in');
const status = document.getElementById('status');
const button = document.getElementById('submit-sign-in');
const passkeyButton = document.getElementById('passkey-sign-in');
const loginParams = new URLSearchParams(window.location.search);
const controller = createLoginController({
form,
status,
button,
passkeyButton,
credentials: window.navigator?.credentials,
fetchImpl: fetch.bind(window),
location: window.location,
continuation: loginParams.get('continue'),
@ -116,4 +257,8 @@ if (typeof document !== 'undefined') {
const deviceLabel = data.get('device_label');
controller.submit(accessToken, deviceLabel);
});
passkeyButton?.addEventListener('click', () => {
const data = new FormData(form);
controller.signInWithPasskey(data.get('device_label'));
});
}

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"]}]
}
}
}

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;
});

View File

@ -0,0 +1,147 @@
(function(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.createMentionComposer = api.create;
})(typeof globalThis !== 'undefined' ? globalThis : this, function() {
'use strict';
function activeMention(value, caret) {
const before = String(value || '').slice(0, Math.max(0, caret));
const match = before.match(/(^|\s)@([A-Za-z0-9_.-]{2,39})$/);
if (!match) return null;
const start = before.length - match[2].length - 1;
return { start, end:before.length, query:match[2] };
}
function insertMention(value, mention, login) {
const text = String(value || '');
const suffixStart = mention.end < text.length && /\s/.test(text[mention.end]) ? mention.end + 1 : mention.end;
const inserted = '@' + login + ' ';
return {
value:text.slice(0, mention.start) + inserted + text.slice(suffixStart),
caret:mention.start + inserted.length,
};
}
function create(options) {
const textarea = options.textarea;
const listbox = options.listbox;
const status = options.status;
const setTimer = options.setTimer || setTimeout;
const clearTimer = options.clearTimer || clearTimeout;
const createOption = options.createOption || (() => document.createElement('button'));
let timer = null;
let requestId = 0;
let mention = null;
let candidates = [];
let activeIndex = -1;
function dismiss() {
requestId += 1;
candidates = [];
activeIndex = -1;
listbox.replaceChildren();
listbox.hidden = true;
textarea.setAttribute('aria-expanded', 'false');
textarea.removeAttribute('aria-activedescendant');
status.textContent = '';
}
function activate(index) {
if (!candidates.length) return;
activeIndex = (index + candidates.length) % candidates.length;
listbox.children.forEach((option, optionIndex) => {
option.setAttribute('aria-selected', optionIndex === activeIndex ? 'true' : 'false');
});
textarea.setAttribute('aria-activedescendant', listbox.children[activeIndex].id);
}
function select(index) {
if (!mention || !candidates[index]) return;
const result = insertMention(textarea.value, mention, candidates[index].login);
textarea.value = result.value;
textarea.setSelectionRange(result.caret, result.caret);
dismiss();
textarea.focus();
textarea.dispatchEvent?.(new Event('input', { bubbles:true }));
}
function render(items) {
candidates = Array.isArray(items) ? items : [];
activeIndex = -1;
listbox.replaceChildren();
candidates.forEach((candidate, index) => {
const option = createOption();
option.type = 'button';
option.id = listbox.id + '-option-' + index;
option.className = 'mention-option';
option.dataset.login = candidate.login;
option.setAttribute('role', 'option');
option.setAttribute('aria-selected', 'false');
option.textContent = '@' + candidate.login + (candidate.name !== candidate.login ? ' · ' + candidate.name : '');
option.addEventListener('pointerdown', event => {
event.preventDefault();
select(index);
});
listbox.appendChild(option);
});
listbox.hidden = !candidates.length;
textarea.setAttribute('aria-expanded', candidates.length ? 'true' : 'false');
status.textContent = candidates.length ? candidates.length + ' teammates found.' : 'No matching teammates.';
}
function schedule() {
if (timer !== null) clearTimer(timer);
mention = activeMention(textarea.value, textarea.selectionStart);
const repository = options.getRepository();
if (!mention || !repository) {
dismiss();
return;
}
const currentRequest = ++requestId;
timer = setTimer(async () => {
try {
const items = await options.loadCandidates(repository, mention.query);
if (currentRequest === requestId && repository === options.getRepository()) render(items);
} catch (_error) {
if (currentRequest === requestId) {
listbox.hidden = true;
textarea.setAttribute('aria-expanded', 'false');
status.textContent = 'Teammate suggestions unavailable; you can keep typing.';
}
}
}, options.delayMs ?? 180);
}
function keydown(event) {
if (event.key === 'Escape' && !listbox.hidden) {
event.preventDefault();
dismiss();
} else if (event.key === 'ArrowDown' && candidates.length) {
event.preventDefault();
activate(activeIndex + 1);
} else if (event.key === 'ArrowUp' && candidates.length) {
event.preventDefault();
activate(activeIndex < 0 ? candidates.length - 1 : activeIndex - 1);
} else if (event.key === 'Enter' && activeIndex >= 0) {
event.preventDefault();
select(activeIndex);
}
}
return {
start() {
textarea.setAttribute('autocomplete', 'off');
textarea.setAttribute('aria-autocomplete', 'list');
textarea.setAttribute('aria-controls', listbox.id);
textarea.setAttribute('aria-expanded', 'false');
textarea.addEventListener('input', schedule);
textarea.addEventListener('click', schedule);
textarea.addEventListener('keydown', keydown);
},
dismiss,
};
}
return { activeMention, insertMention, create };
});

View File

@ -0,0 +1,125 @@
(function(root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileAppBadge = factory;
})(typeof self !== 'undefined' ? self : this, function createMobileAppBadge({
control,
status,
container,
navigator,
storage,
serviceWorker,
}) {
const ENABLED_KEY = 'stackchain.app-badge.enabled.v1';
let enabled = false;
const confirmedCounts = {updates:0, following:0, 'human-gates':0};
let renderedCount = null;
function available() {
return typeof navigator?.setAppBadge === 'function'
&& typeof navigator?.clearAppBadge === 'function';
}
async function syncPreference() {
try {
const registration = await serviceWorker?.ready;
registration?.active?.postMessage({type:'stackchain-app-badge-preference', enabled});
return Boolean(registration?.active);
} catch (_error) {
return false;
}
}
async function syncCount(channel, count) {
if (!enabled) return;
try {
const registration = await serviceWorker?.ready;
registration?.active?.postMessage({type:'stackchain-app-badge-count', channel, count});
} catch (_error) { /* A later refresh can retry. */ }
}
async function render() {
const confirmedCount = Math.min(
9999, confirmedCounts.updates + confirmedCounts.following + confirmedCounts['human-gates']
);
if (!enabled || !available() || renderedCount === confirmedCount) return true;
try {
if (confirmedCount > 0) await navigator.setAppBadge(confirmedCount);
else await navigator.clearAppBadge();
renderedCount = confirmedCount;
if (status) status.textContent = 'App icon badge is on and up to date.';
return true;
} catch (_error) {
if (status) status.textContent = 'Could not update the app icon badge. Stackchain still has the confirmed count.';
return false;
}
}
async function change() {
enabled = Boolean(control?.checked);
if (enabled) storage?.setItem(ENABLED_KEY, 'true');
else storage?.removeItem(ENABLED_KEY);
await syncPreference();
if (enabled) {
if (confirmedCounts.updates > 0) await syncCount('updates', confirmedCounts.updates);
if (confirmedCounts.following > 0) await syncCount('following', confirmedCounts.following);
if (confirmedCounts['human-gates'] > 0) await syncCount('human-gates', confirmedCounts['human-gates']);
}
if (!enabled && available()) {
confirmedCounts.updates = 0;
confirmedCounts.following = 0;
confirmedCounts['human-gates'] = 0;
try {
await navigator.clearAppBadge();
renderedCount = null;
if (status) status.textContent = 'App icon badge is off.';
return true;
} catch (_error) {
if (status) status.textContent = 'App icon badge is off, but the browser could not clear the old count.';
return false;
}
}
return render();
}
function start() {
if (!available()) {
if (container) container.hidden = true;
if (control) control.disabled = true;
if (status) status.textContent = 'App icon badges are unavailable in this browser.';
return false;
}
enabled = storage?.getItem(ENABLED_KEY) === 'true';
if (enabled) void syncPreference();
if (control) {
control.checked = enabled;
control.addEventListener('change', change);
}
if (status) status.textContent = enabled ? 'App icon badge is on.' : 'App icon badge is off.';
return true;
}
function readiness() {
if (!available()) return {state:'unavailable', detail:'App icon badges are unavailable in this browser.'};
return enabled
? {state:'complete', detail:'The app icon shows review attention.'}
: {state:'incomplete', detail:'Show review attention without opening Stackchain.'};
}
async function enable() {
if (!available()) return false;
if (control) control.checked = true;
await change();
return true;
}
async function reconcile(channel, count, authoritative = false) {
if (!Object.hasOwn(confirmedCounts, channel)
|| authoritative !== true || !Number.isSafeInteger(count) || count < 0 || count > 9999) return false;
confirmedCounts[channel] = count;
await syncCount(channel, count);
return render();
}
return {start, change, reconcile, readiness, enable};
});

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};
});

View File

@ -14,7 +14,9 @@
function reveal() {
if (!active) return;
active.workspace.scrollIntoView({ block:'end', inline:'nearest' });
const focused = documentRef.activeElement;
const target = active.focusWithin && active.workspace.contains(focused) ? focused : active.workspace;
target.scrollIntoView({ block:active.focusWithin ? 'nearest' : 'end', inline:'nearest' });
}
function applyGeometry() {
@ -59,12 +61,14 @@
entry.onFocusOut = () => schedule(() => {
if (!entry.workspace.contains(documentRef.activeElement)) deactivate(entry);
});
entry.composer.addEventListener('focus', entry.onFocus);
entry.focusTarget = entry.focusWithin ? entry.workspace : entry.composer;
entry.focusEvent = entry.focusWithin ? 'focusin' : 'focus';
entry.focusTarget.addEventListener(entry.focusEvent, entry.onFocus);
entry.workspace.addEventListener('focusout', entry.onFocusOut);
}
function unbind(entry) {
entry.composer.removeEventListener('focus', entry.onFocus);
entry.focusTarget.removeEventListener(entry.focusEvent, entry.onFocus);
entry.workspace.removeEventListener('focusout', entry.onFocusOut);
}

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});

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};
});

View File

@ -0,0 +1,209 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileDeviceSetup = factory;
})(typeof self !== 'undefined' ? self : this, function createMobileDeviceSetup(options) {
const promptDismissKey = 'stackchain.device-setup-prompt-dismissed-until';
const promptDismissMs = 7 * 24 * 60 * 60 * 1000;
const steps = [
['install', options.installButton, options.installStatus, options.install, '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'],
['appBadge', options.appBadgeButton, options.appBadgeStatus, options.enableAppBadge, 'Enable'],
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline, 'Enable'],
].filter(([_name, button, status, action]) => button && status && action);
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;
const complete = steps.filter(([name]) => readiness[name].state === 'complete').length;
return {available, complete};
}
function renderPrompt(readiness) {
if (!options.promptCard) return;
const {available, complete} = readinessCounts(readiness);
let dismissed = false;
try {
dismissed = Number(options.promptStorage?.getItem(promptDismissKey) || 0) > (options.now?.() ?? Date.now());
} catch (_error) {}
options.promptSummary.textContent = `${complete} of ${available} steps complete`;
options.promptCard.hidden = dismissed || available === 0 || complete === available;
}
async function render() {
const readiness = await options.getReadiness();
let available = 0;
let complete = 0;
steps.forEach(([name, button, status, _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;
if (step.state === 'complete') complete += 1;
});
options.readyStatus.textContent = complete === available
? 'This device is ready.'
: `${complete} of ${available} available steps ready.`;
renderPrompt(readiness);
return readiness;
}
async function open(event) {
trigger = event?.currentTarget || options.launcher;
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 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);
options.promptDismiss?.addEventListener('click', () => {
try {
options.promptStorage?.setItem(promptDismissKey, String((options.now?.() ?? Date.now()) + promptDismissMs));
} catch (_error) {}
options.promptCard.hidden = true;
});
options.closeButton.addEventListener('click', close);
options.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) {
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 () => {
button.disabled = true;
try { await action(); }
finally { await render(); }
});
});
renderPrompt(await options.getReadiness());
}
return {start, open, close, render};
});
if (typeof module === 'object' && module.exports) {
module.exports.mount = mountMobileDeviceSetup;
} else {
self.createMobileDeviceSetup.mount = mountMobileDeviceSetup;
}
function mountMobileDeviceSetup(options) {
const qs = selector => options.document.querySelector(selector);
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'),
appBadgeButton:qs('#device-setup-app-badge'),
deadlineButton:qs('#device-setup-deadline'),
installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'),
protectionStatus:qs('#device-setup-protection-status'),
pushStatus:qs('#device-setup-push-status'), deadlineStatus:qs('#device-setup-deadline-status'),
appBadgeStatus:qs('#device-setup-app-badge-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()
? {state:'unavailable', detail:'Offline saving is unavailable in this browser.'}
: options.offlineEnabled()
? {state:'complete', detail:qs('#offline-work-status').textContent || 'Offline work is saved.'}
: {state:'incomplete', detail:'Private My Work and Today data are not saved offline.'},
protection:options.storageProtectionReadiness(),
push:options.notificationReadiness(),
appBadge:options.appBadgeReadiness(),
deadline:options.deadlineReadiness(),
}),
install:() => options.installApp.install(),
enableOffline:options.enableOffline,
protectStorage:options.protectStorage,
enablePush:options.enablePush,
enableAppBadge:options.enableAppBadge,
enableDeadline:options.enableDeadline,
});
}

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,
};
};
});

View File

@ -0,0 +1,136 @@
(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,
setTimer: (...args) => globalThis.setTimeout(...args),
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'),
coach: doc?.querySelector('[data-mobile-first-task-coach]'),
receipt: doc?.querySelector('#mobile-first-task-receipt'),
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 state() {
const currentKey = key();
if (!currentKey) return '';
try { return options.storage.getItem(currentKey) || ''; }
catch (_error) { return ''; }
}
function store(value) {
const currentKey = key();
const current = state();
if (!currentKey || current === value || current === 'complete') return false;
try {
options.storage.setItem(currentKey, value);
return true;
} catch (_error) { return false; }
}
function adoptRemote(value) {
return ['coaching', 'complete'].includes(value) && store(value);
}
function required() {
return Boolean(options.mediaQuery.matches && account() && !options.isTodayActive() && !state());
}
function renderCoach() {
if (!options.coach) return;
const visible = options.mediaQuery.matches && state() === 'coaching' && options.isTodayActive();
options.coach.hidden = !visible;
const host = options.coach.closest?.('[data-mobile-today-hud]');
if (visible && host) host.hidden = false;
}
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() && state() !== 'complete') {
store('coaching');
if (options.sheet.open) options.sheet.close();
renderCoach();
return 'coaching';
}
renderCoach();
if (options.sheet.open) render();
if (required() && options.hasWork()) return 'awaiting-start';
return required() ? 'required' : 'inactive';
}
function completeOutcome() {
if (state() !== 'coaching') return false;
store('complete');
options.eventTarget?.dispatchEvent?.(new CustomEvent('stackchain:first-task-complete'));
renderCoach();
if (options.receipt) {
options.receipt.hidden = false;
options.setTimer?.(() => { options.receipt.hidden = true; }, 6000);
}
return true;
}
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);
}
globalThis.stackchainFirstTaskCompleting = () => state() === 'coaching';
return {required, open, refresh, render, start, completeOutcome, adoptRemote};
});

110
frontend/mobile-insights.js Normal file
View File

@ -0,0 +1,110 @@
(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;
let backgroundInert = null;
function attr(element, name, value) {
if (value === null) element.removeAttribute(name);
else element.setAttribute(name, value);
}
function containBackground() {
if (backgroundInert) return;
backgroundInert = new Map(o.backgrounds.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 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;
if (active) containBackground();
else releaseBackground();
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 };
});

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;

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';
}

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});

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();
},
};
});

View File

@ -0,0 +1,144 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileQueueLauncher = factory;
})(typeof self !== 'undefined' ? self : this, function createMobileQueueLauncher(options) {
const emptyMessages = {
attention: 'No items need attention.',
update: 'No unread updates are ready to open.',
later: 'No deferred work is ready to open.',
draft: 'No drafts are ready to open.',
};
const labels = {
delivery: 'Recover Delivery', gate: 'Review Human Gates', attention: 'Start Attention',
today: 'Continue Today', update: 'Resume Updates', agenda: 'Open Agenda',
following: 'Review Following', authored: 'Open My PRs', filed: 'Review Filed',
later: 'Start Later', draft: 'Open Drafts',
};
const criticalQueueNames = ['delivery', 'gate'];
const defaultRoutineOrder = [
'attention', 'today', 'update', 'agenda', 'following', 'authored', 'filed', 'later', 'draft',
];
const onlineOnlyQueues = new Set(['delivery', 'gate']);
const allQueues = [
'today', 'tomorrow', 'week', 'agenda', 'delivery', 'gate', 'attention',
'update', 'following', 'filed', 'authored', 'later', 'draft', 'find', 'recaps',
];
function routineOrder() {
const proposed = options.getRoutineOrder ? options.getRoutineOrder() : defaultRoutineOrder;
if (!Array.isArray(proposed) || proposed.length !== defaultRoutineOrder.length ||
new Set(proposed).size !== defaultRoutineOrder.length ||
proposed.some(name => !defaultRoutineOrder.includes(name))) return defaultRoutineOrder.slice();
return proposed.slice();
}
function activeQueueNames() {
return criticalQueueNames.concat(routineOrder());
}
function recommend() {
const counts = options.getCounts ? options.getCounts() : {};
const online = options.isOnline ? options.isOnline() : true;
const match = activeQueueNames().map(name => [name, labels[name]]).find(([name]) =>
Number(counts[name]) > 0 && (online || !onlineOnlyQueues.has(name))
);
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 adaptiveRecommendation() {
const preparation = options.getPreparation ? options.getPreparation() : null;
if (preparation && (preparation.active || Number(preparation.total) > 0)) {
const prefix = preparation.active ? 'Resume preparation · ' : 'Start day · ';
return {name: 'prepare', count: Math.max(0, Number(preparation.total) || 0), label: prefix + preparation.label};
}
return recommend();
}
function presentation() {
const counts = options.getCounts ? options.getCounts() : {};
const names = activeQueueNames();
const active = names
.map(name => ({name, count: Math.max(0, Number(counts[name]) || 0)}))
.filter(item => item.count > 0);
names.forEach(name => {
if (counts[name + 'Unavailable'] && !active.some(item => item.name === name)) {
active.push({name, unavailable: true});
}
});
return {
nextUp: adaptiveRecommendation(),
active,
planning: ['today', 'tomorrow', 'week'],
all: allQueues.slice(),
};
}
function renderPresentation() {
const view = presentation();
const planning = new Set(view.planning);
const active = view.active.filter(item => !planning.has(item.name));
const activeNames = new Set(active.map(item => item.name));
if (options.nextAction) {
options.nextAction.textContent = view.nextUp.label;
options.nextAction.dataset.queue = view.nextUp.name;
const prefix = view.nextUp.name === 'prepare' ? 'Start or continue: ' : 'Next up: ';
options.nextAction.setAttribute('aria-label', prefix + view.nextUp.label);
}
active.forEach(item => {
const row = options.rows?.[item.name];
if (!row) return;
if (item.unavailable) row.setAttribute('data-unavailable', 'true');
else row.removeAttribute('data-unavailable');
options.activeList?.append(row);
});
view.planning.forEach(name => {
const row = options.rows?.[name];
if (row) options.planningList?.append(row);
});
view.all.forEach(name => {
if (planning.has(name) || activeNames.has(name)) return;
const row = options.rows?.[name];
if (!row) return;
row.removeAttribute('data-unavailable');
options.allList?.append(row);
});
if (options.activeSection) options.activeSection.hidden = active.length === 0;
return view;
}
function open(name) {
if (name === 'delivery' && options.openDelivery) return options.openDelivery();
if (name === 'gate' && options.openHumanGates) return options.openHumanGates();
if (name === 'today') return options.openToday();
if (name === 'agenda') return options.openAgenda();
if (name === 'update' && options.openUpdates) return options.openUpdates();
if (name === 'following' && options.openFollowing) return options.openFollowing();
if (name === 'filed' && options.openFiled) return options.openFiled();
options.selectFilter(name);
const action = options.firstAction(name);
if (!action) {
options.announce(emptyMessages[name] || 'No work is ready to open.');
return 'empty';
}
action.click();
return 'opened';
}
function continueWork() {
const next = adaptiveRecommendation();
if (next.name === 'prepare') {
options.openPreparation();
return 'prepare';
}
if (next.name === 'find') {
options.openFindWork();
return 'find';
}
return open(next.name);
}
return { open, recommend, adaptiveRecommendation, presentation, renderPresentation, continueWork };
});

View File

@ -0,0 +1,340 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileQueuePriority = factory;
})(typeof self !== 'undefined' ? self : this, function createMobileQueuePriority(options = {}) {
const DEFAULT_ORDER = [
'attention', 'today', 'update', 'agenda', 'following', 'authored', 'filed', 'later', 'draft',
];
const storage = options.storage;
const getLogin = options.getLogin || (() => '');
const fetchJson = options.fetchJson;
const prefix = 'stackchain-mobile-queue-priority-v1:';
const labels = options.labels || {};
const documentRef = options.document || (typeof document !== 'undefined' ? document : null);
const setTimer = options.setTimeout || setTimeout;
const clearTimer = options.clearTimeout || clearTimeout;
const debounceMs = Number.isFinite(options.debounceMs) ? Math.max(0, options.debounceMs) : 150;
const retryBaseMs = Number.isFinite(options.retryBaseMs) ? Math.max(1, options.retryBaseMs) : 1000;
const retryMaxMs = Number.isFinite(options.retryMaxMs) ? Math.max(retryBaseMs, options.retryMaxMs) : 30000;
let memory = null;
const syncFlights = new Map();
let debounceTimer = null;
let retryTimer = null;
let retryAccount = '';
let retryAttempts = 0;
function key() {
const login = String(getLogin() || '').trim().toLowerCase();
return login ? prefix + encodeURIComponent(login) : '';
}
function valid(order) {
return Array.isArray(order) && order.length === DEFAULT_ORDER.length &&
new Set(order).size === DEFAULT_ORDER.length &&
order.every(name => DEFAULT_ORDER.includes(name) && typeof name === 'string');
}
function fresh() {
return {revision:0, order:DEFAULT_ORDER.slice(), pending:false, status:'ready', remote:null};
}
function read() {
const accountKey = key();
if (!accountKey || !storage) return fresh();
if (memory?.key === accountKey) return memory.value;
let value = fresh();
try {
const saved = JSON.parse(storage.getItem(accountKey) || 'null');
if (valid(saved)) value = {revision:0, order:saved.slice(), pending:true, status:'pending', remote:null};
else if (saved && valid(saved.order) && Number.isInteger(saved.revision) && saved.revision >= 0) {
value = {
revision:saved.revision, order:saved.order.slice(), pending:Boolean(saved.pending),
status:saved.status === 'conflict' ? 'conflict' : (saved.pending ? 'pending' : 'ready'),
remote:saved.remote && valid(saved.remote.order) ? {
revision:Number(saved.remote.revision) || 0, order:saved.remote.order.slice(),
} : null,
};
}
} catch (_error) {}
memory = {key:accountKey, value};
return value;
}
function persist(value) {
const accountKey = key();
if (!accountKey || !storage) return false;
memory = {key:accountKey, value};
try {
storage.setItem(accountKey, JSON.stringify(value));
return true;
} catch (_error) {
return false;
}
}
function snapshot() {
const value = read();
return {
revision:value.revision, order:value.order.slice(), pending:value.pending,
status:value.status, remote:value.remote ? {revision:value.remote.revision, order:value.remote.order.slice()} : null,
};
}
function announce(value) {
if (options.status) {
options.status.textContent = ({pending:'Sync pending.', conflict:'Routine order changed on another device.',
syncing:'Syncing routine order…', error:'Routine order could not sync.', ready:''})[value.status] || '';
}
options.onState?.(snapshot());
}
function getOrder() {
return read().order.slice();
}
function save(order) {
if (!key() || !valid(order)) return false;
const current = read();
const value = {revision:current.revision, order:order.slice(), pending:true, status:'pending', remote:null};
if (!persist(value)) return false;
options.onChange?.(order.slice());
announce(value);
scheduleSync();
return true;
}
function scheduleSync() {
if (!fetchJson || !key()) return false;
if (debounceTimer) clearTimer(debounceTimer);
debounceTimer = setTimer(() => {
debounceTimer = null;
void sync();
}, debounceMs);
return true;
}
function clearRetry() {
if (retryTimer) clearTimer(retryTimer);
retryTimer = null;
retryAccount = '';
retryAttempts = 0;
}
function transient(error) {
const status = Number(error?.status) || 0;
return status === 0 || status === 408 || status === 425 || status === 429 || status >= 500;
}
function scheduleRetry(accountKey) {
if (retryTimer && retryAccount !== accountKey) clearRetry();
if (retryTimer || key() !== accountKey) return false;
const delay = Math.min(retryMaxMs, retryBaseMs * (2 ** retryAttempts));
retryAttempts += 1;
retryAccount = accountKey;
retryTimer = setTimer(() => {
retryTimer = null;
retryAccount = '';
const current = read();
if (key() !== accountKey || !current.pending || current.status === 'conflict') return;
void sync();
}, delay);
return true;
}
function move(name, delta) {
const order = getOrder();
const index = order.indexOf(name);
const next = index + (Number(delta) < 0 ? -1 : 1);
if (index < 0 || next < 0 || next >= order.length) return order;
[order[index], order[next]] = [order[next], order[index]];
save(order);
return order.slice();
}
function reset() {
const order = DEFAULT_ORDER.slice();
if (fetchJson && key()) save(order);
else {
const accountKey = key();
if (accountKey && storage) {
try { storage.removeItem(accountKey); } catch (_error) {}
}
memory = null;
options.onChange?.(order.slice());
}
return order;
}
function adopt(snapshotValue, accountKey = key()) {
if (!accountKey || key() !== accountKey) return snapshot();
if (!snapshotValue || !Number.isInteger(snapshotValue.revision) || snapshotValue.revision < 0 || !valid(snapshotValue.order)) {
throw new Error('Queue priority response is invalid.');
}
const value = {revision:snapshotValue.revision, order:snapshotValue.order.slice(), pending:false, status:'ready', remote:null};
clearRetry();
persist(value);
options.onChange?.(value.order.slice());
announce(value);
render();
return snapshot();
}
async function load() {
const accountKey = key();
if (!fetchJson || !accountKey) return snapshot();
try {
const remote = await fetchJson('api/v1/queue-priority');
if (key() !== accountKey) return snapshot();
const local = read();
if (local.pending) {
local.remote = valid(remote?.order) ? {revision:remote.revision, order:remote.order.slice()} : null;
persist(local);
return sync();
}
return adopt(remote, accountKey);
} catch (_error) {
if (key() !== accountKey) return snapshot();
const current = read();
current.status = current.pending ? 'pending' : 'error';
persist(current); announce(current);
return snapshot();
}
}
async function drain(accountKey) {
while (key() === accountKey) {
const current = read();
if (!current.pending) return snapshot();
const sent = {revision:current.revision, order:current.order.slice()};
current.status = 'syncing'; persist(current); announce(current);
try {
const saved = await fetchJson('api/v1/queue-priority', {
method:'PUT', headers:{'Content-Type':'application/json'},
body:JSON.stringify(sent),
});
if (key() !== accountKey) return snapshot();
if (!saved || !Number.isInteger(saved.revision) || !valid(saved.order)) {
throw new Error('Queue priority response is invalid.');
}
const latest = read();
if (latest.order.some((name, index) => name !== sent.order[index])) {
latest.revision = saved.revision; latest.pending = true;
latest.status = 'pending'; latest.remote = null;
persist(latest); announce(latest);
continue;
}
return adopt(saved, accountKey);
} catch (error) {
if (key() !== accountKey) return snapshot();
const latest = read();
const remote = error?.status === 409 && error?.payload?.detail?.snapshot;
if (remote && valid(remote.order) && Number.isInteger(remote.revision)) {
clearRetry();
latest.status = 'conflict'; latest.pending = true;
latest.remote = {revision:remote.revision, order:remote.order.slice()};
} else {
latest.status = 'pending'; latest.pending = true;
if (transient(error)) scheduleRetry(accountKey);
}
persist(latest); announce(latest); render();
return snapshot();
}
}
return snapshot();
}
function sync() {
if (debounceTimer) {
clearTimer(debounceTimer);
debounceTimer = null;
}
const accountKey = key();
const current = read();
if (!fetchJson || !accountKey || !current.pending) return Promise.resolve(snapshot());
if (syncFlights.has(accountKey)) return syncFlights.get(accountKey);
const flight = drain(accountKey).finally(() => {
if (syncFlights.get(accountKey) === flight) syncFlights.delete(accountKey);
});
syncFlights.set(accountKey, flight);
return flight;
}
async function useLocal() {
const current = read();
if (!current.remote) return snapshot();
current.revision = current.remote.revision;
current.remote = null; current.pending = true; current.status = 'pending';
persist(current);
return sync();
}
function useRemote() {
const current = read();
return current.remote ? adopt(current.remote) : snapshot();
}
function displayName(name) {
return labels[name] || name.charAt(0).toUpperCase() + name.slice(1);
}
function render() {
if (!options.list || !documentRef) return getOrder();
const order = getOrder();
const signedIn = Boolean(key());
const rows = order.map((name, index) => {
const row = documentRef.createElement('div');
row.setAttribute('data-queue-priority', name);
row.setAttribute('class', 'mobile-queue-priority-row');
const label = documentRef.createElement('span');
label.textContent = displayName(name);
const controls = documentRef.createElement('span');
controls.setAttribute('class', 'mobile-queue-priority-controls');
const earlier = documentRef.createElement('button');
earlier.textContent = 'Earlier'; earlier.setAttribute('type', 'button');
earlier.setAttribute('aria-label', 'Move ' + displayName(name) + ' earlier');
earlier.disabled = !signedIn || index === 0;
earlier.addEventListener('click', () => {
move(name, -1); render();
});
const later = documentRef.createElement('button');
later.textContent = 'Later'; later.setAttribute('type', 'button');
later.setAttribute('aria-label', 'Move ' + displayName(name) + ' later');
later.disabled = !signedIn || index === order.length - 1;
later.addEventListener('click', () => {
move(name, 1); render();
});
controls.append(earlier, later); row.append(label, controls);
return row;
});
options.list.replaceChildren(...rows);
if (options.resetButton) options.resetButton.disabled = !signedIn;
if (options.conflict) options.conflict.hidden = read().status !== 'conflict';
return order;
}
function start() {
options.resetButton?.addEventListener('click', () => {
reset(); render();
});
options.keepLocalButton?.addEventListener('click', () => { void useLocal(); });
options.useRemoteButton?.addEventListener('click', () => { useRemote(); });
return render();
}
function startLifecycle(lifecycle = {}) {
const windowObject = lifecycle.window;
const lifecycleDocument = lifecycle.document;
const reconcile = () => {
if (!key()) return Promise.resolve(snapshot());
return read().pending ? sync() : load();
};
windowObject?.addEventListener?.('online', () => { void reconcile(); });
lifecycleDocument?.addEventListener?.('visibilitychange', () => {
if (!lifecycleDocument.hidden) void reconcile();
});
return reconcile;
}
return {getOrder, move, reset, render, start, startLifecycle, load, sync, scheduleSync, useLocal, useRemote,
state:snapshot, defaultOrder:() => DEFAULT_ORDER.slice()};
});

View File

@ -0,0 +1,455 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileRecentWork = factory;
})(typeof globalThis !== 'undefined' ? globalThis : this, function createMobileRecentWork(options) {
'use strict';
const storage = options.storage;
const getLogin = options.getLogin;
const fetchJson = options.fetchJson;
const limit = Math.max(1, Number(options.limit) || 5);
const pinnedLimit = Math.max(1, Number(options.pinnedLimit) || 20);
const prefix = 'stackchain.mobile-recent-work.v1.';
const repositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
const kinds = new Set(['issue', 'filed', 'pull', 'review', 'update']);
const setTimer = options.setTimeout || setTimeout;
const clearTimer = options.clearTimeout || clearTimeout;
const debounceMs = Number.isFinite(options.debounceMs) ? Math.max(0, options.debounceMs) : 150;
const retryMs = Number.isFinite(options.retryMs) ? Math.max(1, options.retryMs) : 1000;
const retryMaxMs = Number.isFinite(options.retryMaxMs) ? Math.max(retryMs, options.retryMaxMs) : 30000;
let syncFlight = null;
let syncAccount = '';
let debounceTimer = null;
let retryTimer = null;
let retryAccount = '';
let retryAttempt = 0;
let operationSequence = 0;
let pinsExpanded = false;
let currentItem = null;
const detailPins = Array.from(options.detailPins || []);
detailPins.forEach(button => button.addEventListener?.('click', () => {
if (!currentItem) return;
const isPinned = pinned().some(item => item.route === currentItem.route);
if (isPinned) unpin(currentItem.route);
else pin(currentItem);
}));
options.pinnedToggle?.addEventListener?.('click', () => {
pinsExpanded = !pinsExpanded;
render();
});
function operationId() {
operationSequence += 1;
return Date.now().toString(36) + '-' + operationSequence.toString(36);
}
function clearRetry(resetAttempt = false) {
if (retryTimer) clearTimer(retryTimer);
retryTimer = null;
retryAccount = '';
if (resetAttempt) retryAttempt = 0;
}
function scheduleRetry(accountKey) {
if (retryTimer || key() !== accountKey || !hasPending(read())) return false;
retryAccount = accountKey;
const delay = Math.min(retryMaxMs, retryMs * (2 ** retryAttempt));
retryAttempt += 1;
retryTimer = setTimer(() => {
const timer = retryTimer;
retryTimer = null;
retryAccount = '';
if (timer) clearTimer(timer);
if (key() === accountKey && hasPending(read())) void sync();
}, delay);
return true;
}
function login() {
return String(getLogin?.() || '').trim().toLowerCase();
}
function key() {
const owner = login();
return owner ? prefix + owner : '';
}
function normalize(item) {
const kind = String(item?.kind || '');
const number = Number(item?.number ?? item?.notification_id);
if (!kinds.has(kind) || !Number.isSafeInteger(number) || number < 1) return null;
let route = '';
let repository = '';
if (kind === 'update') {
route = '#/my-work/update/' + number;
} else {
repository = String(item?.repository || '');
if (!repositoryPattern.test(repository)) return null;
route = '#/my-work/' + kind + '/' + repository + '/' + number;
}
const title = String(item?.title || item?.subject?.title || '').trim().slice(0, 180);
if (!title) return null;
return {kind, ...(repository ? {repository} : {}), number, title, route};
}
function normalizeList(value, maximum = limit) {
if (!Array.isArray(value)) return [];
const unique = [];
for (const candidate of value) {
const item = normalize(candidate);
if (item && !unique.some(existing => existing.route === item.route)) unique.push(item);
if (unique.length === maximum) break;
}
return unique;
}
function normalizePinOps(value) {
if (!Array.isArray(value)) return [];
const unique = [];
for (const candidate of value) {
const action = candidate?.action;
const item = action === 'pin' ? normalize(candidate.item) : null;
const route = action === 'pin' ? item?.route : String(candidate?.route || '');
if ((action !== 'pin' && action !== 'unpin') || !route || (action === 'pin' && !item)) continue;
if (!unique.some(existing => (existing.item?.route || existing.route) === route)) {
const normalized = action === 'pin' ? {action, item} : {action, route};
if (typeof candidate.operationId === 'string' && candidate.operationId) normalized.operationId = candidate.operationId;
unique.push(normalized);
}
if (unique.length === pinnedLimit) break;
}
return unique;
}
function normalizePending(value) {
if (!Array.isArray(value)) return [];
const unique = [];
for (const candidate of value) {
const item = normalize(candidate);
if (!item || unique.some(existing => existing.route === item.route)) continue;
if (typeof candidate.operationId === 'string' && candidate.operationId) item.operationId = candidate.operationId;
unique.push(item);
if (unique.length === limit) break;
}
return unique;
}
function empty() {
return {items:[], pinned:[], pending:[], pinOps:[]};
}
function read() {
const storageKey = key();
if (!storageKey) return empty();
try {
const parsed = JSON.parse(storage.getItem(storageKey) || 'null');
if (Array.isArray(parsed)) return {...empty(), items:normalizeList(parsed)};
return {
items:normalizeList(parsed?.items),
pinned:normalizeList(parsed?.pinned, pinnedLimit),
pending:normalizePending(parsed?.pending),
pinOps:normalizePinOps(parsed?.pinOps),
};
} catch (_) {
return empty();
}
}
function persist(value, accountKey = key()) {
if (!accountKey || accountKey !== key()) return false;
try {
storage.setItem(accountKey, JSON.stringify({
items:normalizeList(value.items),
pinned:normalizeList(value.pinned, pinnedLimit),
pending:normalizePending(value.pending),
pinOps:normalizePinOps(value.pinOps),
}));
return true;
} catch (_) {
return false;
}
}
function hasPending(value) {
return value.pending.length > 0 || value.pinOps.length > 0;
}
function announce(value = read(), status = null) {
if (!options.status) return;
options.status.textContent = status || (hasPending(value) ? 'Sync pending.' : '');
}
function items() {
return read().items;
}
function pinned() {
return read().pinned;
}
function state() {
const value = read();
const pendingCount = value.pending.length + value.pinOps.length;
return {pending:pendingCount > 0, pendingCount};
}
function scheduleSync() {
if (!fetchJson || !key()) return false;
if (debounceTimer) clearTimer(debounceTimer);
debounceTimer = setTimer(() => {
debounceTimer = null;
void sync();
}, debounceMs);
return true;
}
function record(item) {
const accountKey = key();
const normalized = normalize(item);
if (!accountKey || !normalized) return false;
const current = read();
current.items = [normalized, ...current.items.filter(existing => existing.route !== normalized.route)].slice(0, limit);
current.pinned = current.pinned.some(existing => existing.route === normalized.route)
? [normalized, ...current.pinned.filter(existing => existing.route !== normalized.route)]
: current.pinned;
current.pending = [{...normalized, operationId:operationId()}, ...current.pending.filter(existing => existing.route !== normalized.route)].slice(0, limit);
if (!persist(current, accountKey)) return false;
announce(current);
render();
scheduleSync();
return true;
}
function queuePinOp(current, operation) {
const route = operation.item?.route || operation.route;
current.pinOps = [{...operation, operationId:operationId()}, ...current.pinOps.filter(existing => (existing.item?.route || existing.route) !== route)];
}
function pin(item) {
const accountKey = key();
const normalized = normalize(item);
if (!accountKey || !normalized) return false;
const current = read();
current.pinned = [normalized, ...current.pinned.filter(existing => existing.route !== normalized.route)].slice(0, pinnedLimit);
queuePinOp(current, {action:'pin', item:normalized});
if (!persist(current, accountKey)) return false;
announce(current);
render();
scheduleSync();
return true;
}
function unpin(route) {
const accountKey = key();
route = String(route || '');
if (!accountKey || !route) return false;
const current = read();
if (!current.pinned.some(item => item.route === route)) return false;
current.pinned = current.pinned.filter(item => item.route !== route);
queuePinOp(current, {action:'unpin', route});
if (!persist(current, accountKey)) return false;
announce(current);
render();
scheduleSync();
return true;
}
function applyPinOps(remote, operations) {
let result = normalizeList(remote, pinnedLimit);
for (const operation of [...normalizePinOps(operations)].reverse()) {
const route = operation.item?.route || operation.route;
result = operation.action === 'pin'
? [operation.item, ...result.filter(item => item.route !== route)].slice(0, pinnedLimit)
: result.filter(item => item.route !== route);
}
return result;
}
function adopt(snapshot, accountKey, pending = [], pinOps = []) {
if (key() !== accountKey || !snapshot || !Array.isArray(snapshot.items)) return false;
const remote = normalizeList(snapshot.items);
const remotePinned = normalizeList(snapshot.pinned, pinnedLimit);
const unsent = normalizePending(pending);
const unsentPinOps = normalizePinOps(pinOps);
const value = {
items:normalizeList([...unsent, ...remote]),
pinned:applyPinOps(remotePinned, unsentPinOps),
pending:unsent,
pinOps:unsentPinOps,
};
persist(value, accountKey);
announce(value);
render();
return true;
}
async function drain(accountKey) {
while (key() === accountKey) {
const current = read();
if (!hasPending(current)) return current;
const sending = current.pending[current.pending.length - 1];
const pinOperation = sending ? null : current.pinOps[current.pinOps.length - 1];
announce(current, 'Syncing recent work…');
try {
let snapshot;
if (sending) {
snapshot = await fetchJson('api/v1/recent-work', {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(normalize(sending)),
});
} else {
const isPin = pinOperation.action === 'pin';
snapshot = await fetchJson('api/v1/recent-work/pin', {
method:isPin ? 'PUT' : 'DELETE',
headers:{'Content-Type':'application/json'},
body:JSON.stringify(isPin ? pinOperation.item : {route:pinOperation.route}),
});
}
if (key() !== accountKey) return read();
const latest = read();
const pending = sending
? latest.pending.filter(item => item.operationId !== sending.operationId)
: latest.pending;
const pinOps = pinOperation
? latest.pinOps.filter(operation => {
const sameRoute = (operation.item?.route || operation.route) === (pinOperation.item?.route || pinOperation.route);
return !sameRoute || operation.action !== pinOperation.action || operation.operationId !== pinOperation.operationId;
})
: latest.pinOps;
if (!adopt(snapshot, accountKey, pending, pinOps)) throw new Error('Recent work response is invalid.');
retryAttempt = 0;
} catch (_error) {
if (key() === accountKey) {
announce(read());
scheduleRetry(accountKey);
}
return read();
}
}
return read();
}
function sync() {
if (debounceTimer) { clearTimer(debounceTimer); debounceTimer = null; }
const accountKey = key();
if (retryTimer && retryAccount !== accountKey) clearRetry(true);
else if (retryTimer) clearRetry(false);
if (!fetchJson || !accountKey || !hasPending(read())) return Promise.resolve(read());
if (syncFlight && syncAccount === accountKey) return syncFlight;
syncAccount = accountKey;
syncFlight = drain(accountKey).finally(() => {
if (syncAccount === accountKey) { syncFlight = null; syncAccount = ''; }
});
return syncFlight;
}
async function load() {
const accountKey = key();
if (!fetchJson || !accountKey) return read();
try {
const snapshot = await fetchJson('api/v1/recent-work');
if (key() !== accountKey) return read();
const current = read();
adopt(snapshot, accountKey, current.pending, current.pinOps);
return hasPending(current) ? sync() : read();
} catch (_error) {
if (key() === accountKey) announce(read(), hasPending(read()) ? null : 'Recent work could not sync.');
return read();
}
}
function startLifecycle(lifecycle = {}) {
const reconcile = () => hasPending(read()) ? sync() : load();
lifecycle.window?.addEventListener?.('online', () => { void reconcile(); });
lifecycle.document?.addEventListener?.('visibilitychange', () => {
if (!lifecycle.document.hidden) void reconcile();
});
return reconcile;
}
function detail(item) {
return item.kind === 'update'
? 'Update · #' + item.number
: item.kind.charAt(0).toUpperCase() + item.kind.slice(1) + ' · ' + item.repository + ' #' + item.number;
}
function row(item, isPinned) {
const itemDetail = detail(item);
const wrapper = options.document.createElement('div');
const button = options.document.createElement('button');
const action = options.document.createElement('button');
const copy = options.document.createElement('span');
const primary = options.document.createElement('strong');
const secondary = options.document.createElement('small');
wrapper.setAttribute('class', 'mobile-recent-work-row');
primary.textContent = item.title;
secondary.textContent = itemDetail;
copy.appendChild(primary);
copy.appendChild(secondary);
button.appendChild(copy);
button.setAttribute('type', 'button');
button.setAttribute('data-recent-work-route', item.route);
button.setAttribute('aria-label', 'Open ' + item.title + ', ' + itemDetail.toLowerCase().replace(' · ', ' '));
button.addEventListener('click', () => {
if (isPinned) record(item);
options.openRoute?.(item.route);
});
action.textContent = isPinned ? 'Unpin' : 'Pin';
action.setAttribute('type', 'button');
action.setAttribute('data-recent-work-pin', isPinned ? 'unpin' : 'pin');
action.setAttribute('aria-label', (isPinned ? 'Unpin ' : 'Pin ') + item.title);
action.addEventListener('click', () => isPinned ? unpin(item.route) : pin(item));
wrapper.appendChild(button);
wrapper.appendChild(action);
return wrapper;
}
function renderCurrent() {
const isPinned = currentItem && pinned().some(item => item.route === currentItem.route);
detailPins.forEach(button => {
button.hidden = !currentItem;
if (!currentItem) return;
button.textContent = isPinned ? 'Pinned' : 'Pin';
button.setAttribute('aria-pressed', isPinned ? 'true' : 'false');
button.setAttribute('aria-label', (isPinned ? 'Unpin ' : 'Pin ') + currentItem.title);
button.setAttribute('data-current-work-pin', isPinned ? 'unpin' : 'pin');
});
}
function setCurrent(item) {
currentItem = normalize(item);
renderCurrent();
return Boolean(currentItem);
}
function render() {
const recent = items();
const fixed = pinned();
const fixedRoutes = new Set(fixed.map(item => item.route));
const visibleRecent = recent.filter(item => !fixedRoutes.has(item.route));
const list = options.list;
const section = options.section;
if (list && section && options.document) {
const rows = visibleRecent.map(item => row(item, false));
list.replaceChildren(...rows);
section.hidden = rows.length === 0;
}
if (options.pinnedList && options.pinnedSection && options.document) {
const visiblePins = pinsExpanded ? fixed : fixed.slice(0, 3);
const rows = visiblePins.map(item => row(item, true));
options.pinnedList.replaceChildren(...rows);
options.pinnedSection.hidden = rows.length === 0;
}
if (options.pinnedToggle) {
options.pinnedToggle.hidden = fixed.length <= 3;
options.pinnedToggle.textContent = pinsExpanded ? 'Show fewer' : 'Show all ' + fixed.length;
options.pinnedToggle.setAttribute('aria-expanded', pinsExpanded ? 'true' : 'false');
options.pinnedToggle.setAttribute('aria-controls', 'mobile-pinned-work-list');
}
renderCurrent();
return visibleRecent.length + fixed.length;
}
return {items, pinned, record, pin, unpin, setCurrent, render, load, sync, startLifecycle, state};
});

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;

View File

@ -0,0 +1,83 @@
(function (root, factory) {
const createMobileSearchModal = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = createMobileSearchModal;
else root.createMobileSearchModal = createMobileSearchModal;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
const FOCUSABLE = [
'a[href]', 'button', 'input', 'select', 'textarea',
'[tabindex]:not([tabindex="-1"])', '[contenteditable="true"]',
].join(',');
return function createMobileSearchModal(options) {
const documentRef = options.document;
const backgrounds = Array.from(options.backgrounds || [
documentRef.querySelector?.('body > header'),
documentRef.querySelector?.('main'),
documentRef.querySelector?.('#mobile-task-dock'),
]).filter(Boolean);
let surface = null;
let opener = null;
let priorInert = null;
function focusable() {
if (!surface || typeof surface.querySelectorAll !== 'function') return [];
return Array.from(surface.querySelectorAll(FOCUSABLE)).filter(element =>
!element.disabled && !element.hidden && element.getAttribute?.('aria-hidden') !== 'true' &&
(typeof element.matches !== 'function' || element.matches(':not([hidden])')) &&
(typeof element.getClientRects !== 'function' || element.getClientRects().length > 0)
);
}
function focus(element) {
if (element?.isConnected !== false && typeof element?.focus === 'function') element.focus();
}
function onKeydown(event) {
if (!surface || event.key !== 'Tab') return;
const controls = focusable();
if (!controls.length) {
event.preventDefault();
return;
}
const first = controls[0];
const last = controls[controls.length - 1];
if (event.shiftKey && documentRef.activeElement === first) {
event.preventDefault();
focus(last);
} else if (!event.shiftKey && documentRef.activeElement === last) {
event.preventDefault();
focus(first);
} else if (!controls.includes(documentRef.activeElement)) {
event.preventDefault();
focus(event.shiftKey ? last : first);
}
}
function transition(nextSurface, transitionOptions = {}) {
surface = nextSurface;
focus(transitionOptions.initialFocus || focusable()[0]);
}
function activate(nextSurface, activateOptions = {}) {
if (!surface) {
opener = activateOptions.opener || documentRef.activeElement || null;
priorInert = backgrounds.map(element => element.inert === true);
backgrounds.forEach(element => { element.inert = true; });
}
transition(nextSurface, activateOptions);
}
function deactivate() {
if (!surface) return;
backgrounds.forEach((element, index) => { element.inert = priorInert[index]; });
surface = null;
priorInert = null;
const restore = opener;
opener = null;
focus(restore);
}
documentRef.addEventListener('keydown', onKeydown);
return { activate, transition, deactivate, current: () => surface };
};
});

View File

@ -0,0 +1,132 @@
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'),
changes:bySection('changes'),
reply:bySection('reply'),
actions:bySection('actions'),
},
targets:{
overview:document.getElementById('search-preview-overview'),
conversation:document.getElementById('search-preview-conversation'),
changes:document.getElementById('search-preview-review'),
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});

View File

@ -0,0 +1,201 @@
(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'],
['gate', 'Human Gates'],
['agenda', 'Agenda'],
['attention', 'Attention'],
['update', 'Updates'],
['filed', 'Filed'],
['following', 'Following'],
];
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() : {};
let phases = reviewPhases(counts);
const gateUnavailable = counts.gateUnavailable === true;
if (gateUnavailable) {
phases = phases.filter(phase => phase.name !== 'gate');
const afterDelivery = phases[0]?.name === 'delivery' ? 1 : 0;
phases.splice(afterDelivery, 0, {name:'gate', label:'Human Gates unavailable · retry', count:count(counts.gate)});
}
const followingUnavailable = counts.followingUnavailable === true;
if (followingUnavailable) {
phases = phases.filter(phase => phase.name !== 'following');
phases.push({name:'following', label:'Following unavailable', count:count(counts.following)});
}
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' :
(next === 'gate' && gateUnavailable ? 'Retry Human Gates' :
(next === 'following' && followingUnavailable ? 'Retry Following' :
(phases.length ? 'Review ' + phases[0].label : (today ? 'Continue Today' : 'Find Work'))));
const gateRetry = next === 'gate' && gateUnavailable;
const followingRetry = next === 'following' && followingUnavailable;
return {
total,
next,
label: nextLabel,
summary: gateRetry ? 'Human Gates need retry before Today · ' + today + ' planned' :
followingRetry ? 'Following needs retry before Today · ' + today + ' planned' :
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';
if (options.elements.action) {
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?.action) options.elements.action.addEventListener('click', startNext);
}
return {briefing, completePhase, finish, reconcile, render, start, startNext, state};
});

View File

@ -9,45 +9,136 @@
let wasHidden = false;
function select(name) {
if (!buttons[name]) return false;
Object.entries(buttons).forEach(([key, button]) => {
if (key === name) button.setAttribute('aria-current', 'page');
else button.removeAttribute('aria-current');
});
return true;
}
function refreshVisibility() {
const hidden = overlays.some(overlay => overlay.classList.contains('open'));
const hidden = overlays.some(overlay => overlay.open || overlay.classList.contains('open'));
nav.hidden = hidden;
if (options.sessionHud) {
if (hidden) options.sessionHud.setAttribute('data-overlay-hidden', 'true');
else options.sessionHud.removeAttribute('data-overlay-hidden');
}
if (wasHidden && !hidden && launcher) launcher.focus();
wasHidden = hidden;
}
function openQueues() {
if (!options.queueSheet || options.queueSheet.open) return;
options.queueSheet.showModal();
}
function closeQueues(returnToToday = false) {
options.queueSheet?.open && options.queueSheet.close();
if (returnToToday) options.detour?.()?.finishDetour();
}
function start() {
Object.entries(buttons).forEach(([name, button]) => {
button.addEventListener('click', event => {
launcher = event.currentTarget;
select(name);
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(true));
options.queueSheet.addEventListener('cancel', event => {
event.preventDefault();
closeQueues(true);
});
options.queueSheet.addEventListener('close', () => buttons.queues?.focus());
Object.entries(options.queueRows || {}).forEach(([name, row]) => {
row.addEventListener('click', () => {
closeQueues();
options.onSelectQueue?.(name, row);
});
});
}
options.observe(refreshVisibility, overlays);
refreshVisibility();
}
function updateAttention(count) {
const total = Math.max(0, Number(count) || 0);
const badge = options.attentionBadge;
if (badge) {
badge.textContent = String(total);
badge.hidden = total === 0;
}
if (buttons.work) {
buttons.work.setAttribute(
'aria-label',
total ? 'Work, ' + total + ' items need attention' : 'Work'
);
}
function updateWork(mode) {
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',
following:'Following', authored:'My PRs', filed:'Filed', later:'Later', draft:'Drafts',
}[mode] || 'Work';
const queue = ['Delivery', 'Attention', 'Updates', 'Agenda', 'Following', 'My PRs', '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);
}
return {start, refreshVisibility, updateAttention};
function updateAttention(count) {
const total = Math.max(0, Number(count) || 0);
const visible = total > 0;
if (options.attentionBadge) {
options.attentionBadge.textContent = String(total);
options.attentionBadge.hidden = !visible;
}
if (buttons.attention) {
buttons.attention.hidden = !visible;
buttons.attention.setAttribute('aria-label', 'Attention, ' + total + ' items');
}
if (visible) nav.setAttribute('data-attention', 'true');
else nav.removeAttribute('data-attention');
}
function updateQueues(counts) {
const names = 'today agenda delivery gate attention update following filed authored later draft'.split(' ');
const normalized = Object.fromEntries(names.map(name => [name, Math.max(0, Number(counts?.[name]) || 0)]));
const actionableNames = ['today', 'delivery', 'gate', 'attention', 'update', 'following', 'filed', 'authored', 'later', 'draft'];
const active = actionableNames.reduce((total, name) => total + (normalized[name] > 0 ? 1 : 0), 0);
Object.entries(options.queueCounts || {}).forEach(([name, element]) => {
element.textContent = String(normalized[name] || 0);
});
options.queueRows?.update?.setAttribute(
'aria-label', 'Updates, ' + normalized.update + ' unread conversations'
);
options.queueRows?.following?.setAttribute(
'aria-label', 'Following, ' + normalized.following + ' unseen changes'
);
if (options.queueBadge) {
options.queueBadge.textContent = active + ' active';
options.queueBadge.hidden = active === 0;
}
if (options.deadlineBadge) {
options.deadlineBadge.textContent = normalized.agenda + ' due';
options.deadlineBadge.hidden = normalized.agenda === 0;
}
if (options.queueRows?.agenda) {
if (normalized.agenda > 0) options.queueRows.agenda.setAttribute('data-deadlines', 'true');
else options.queueRows.agenda.removeAttribute('data-deadlines');
}
const label = active === 0 && normalized.agenda === 0
? 'Queues: no active queues; no upcoming deadlines'
: 'Queues: Today ' + normalized.today
+ ', Agenda ' + normalized.agenda + ' due'
+ ', Delivery ' + normalized.delivery
+ ', Human Gates ' + normalized.gate
+ ', Attention ' + normalized.attention
+ ', Updates ' + normalized.update
+ ', Following ' + normalized.following
+ ', Filed ' + normalized.filed
+ ', My PRs ' + normalized.authored
+ ', 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};
});

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() };
});

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;

View File

@ -0,0 +1,33 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileWorkEntry = factory;
})(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';
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 if (current === 'activate') options.openActivation();
else if (current === 'find') options.findWork();
else options.queueLauncher.open(current);
return current;
}
return {mode, open};
});

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,27 @@ 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 isAuthored = (item.work_reasons || []).includes('authored_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_authored: isAuthored,
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' :
(isAuthored ? 'Authored 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 +103,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 +126,8 @@ function buildMyWork(data, now = new Date()) {
attention_reason: 'Unread update',
notification_id: update.id,
reason: 'Unread update',
_priority: 1,
update_reason: '',
_priority: 4,
};
work.push(item);
byKey.set(subjectKind + ':' + key, item);
@ -178,10 +213,67 @@ function createBulkNotificationAcknowledger({ markRead, onItems, onStatus }) {
};
}
function createNotificationSelection({ limit = 50, onChange = () => {} } = {}) {
const maximum = Number.isInteger(limit) && limit > 0 ? limit : 50;
let active = false;
const selected = new Set();
const snapshot = () => ({
active,
ids: Array.from(selected),
count: selected.size,
limit: maximum,
at_limit: selected.size >= maximum,
});
const publish = () => onChange(snapshot());
const select = notificationId => {
if (!active || !Number.isInteger(notificationId)) return 'inactive';
if (selected.has(notificationId)) return 'already-selected';
if (selected.size >= maximum) return 'limit';
selected.add(notificationId);
publish();
return 'selected';
};
return {
start() {
if (active) return snapshot();
active = true;
publish();
return snapshot();
},
select,
toggle(notificationId) {
if (!selected.has(notificationId)) return select(notificationId);
selected.delete(notificationId);
publish();
return 'deselected';
},
retain(notificationIds) {
const allowed = new Set((notificationIds || []).filter(Number.isInteger));
let changed = false;
selected.forEach(id => {
if (!allowed.has(id)) {
selected.delete(id);
changed = true;
}
});
if (changed) publish();
return snapshot();
},
cancel() {
active = false;
selected.clear();
publish();
return snapshot();
},
snapshot,
};
}
function createNotificationPager({ load, onNotifications, onPagination, onStatus }) {
let pagination = { page: 1, total: 0, has_more: false };
let pending = false;
return {
let completing = null;
const pager = {
reset(next) {
pagination = { ...pagination, ...(next || {}) };
onPagination(pagination);
@ -215,18 +307,31 @@ function createNotificationPager({ load, onNotifications, onPagination, onStatus
pending = false;
}
},
loadAll(getExisting) {
if (completing) return completing;
completing = (async () => {
while (pagination.has_more) {
const loaded = await pager.loadMore(getExisting());
if (!loaded) return false;
}
return true;
})().finally(() => { completing = null; });
return completing;
},
};
return pager;
}
function createWorkPager({ load, onItems, onPagination, onStatus }) {
let pagination = {};
const pending = new Set();
const completing = new Map();
const labels = {
issue: 'issues',
pull: 'pull requests',
review: 'review requests',
};
return {
const pager = {
reset(next) {
Object.entries(next || {}).forEach(([stream, value]) => {
const current = pagination[stream];
@ -275,31 +380,109 @@ function createWorkPager({ load, onItems, onPagination, onStatus }) {
pending.delete(stream);
}
},
loadAll(stream, getExisting) {
if (completing.has(stream)) return completing.get(stream);
const completion = (async () => {
while (pagination[stream]?.has_more) {
const loaded = await pager.loadMore(stream, getExisting());
if (!loaded) return false;
}
return true;
})().finally(() => completing.delete(stream));
completing.set(stream, completion);
return completion;
},
};
return pager;
}
function createNotificationReader({
load, markRead, onOpen, onDetail, onItems, onStatus, onClose,
acknowledge = null,
queueRead = null,
loadSaved = () => null,
loadConversation = null,
onConversation = () => {},
onConversationStatus = () => {},
createPager = typeof createConversationPager === 'function' ? createConversationPager : null,
getScope = () => '',
}) {
let selected = null;
let loadVersion = 0;
let marking = false;
let conversationPager = null;
let offlineHydrated = false;
let prefetched = null;
let prefetchKey = '';
async function hydrateConversation(item, version) {
if (!loadConversation || !createPager || !item || offlineHydrated) return false;
onConversationStatus('Loading conversation…');
try {
const initial = await loadConversation(item.notification_id, null);
if (selected !== item || version !== loadVersion) return false;
conversationPager = createPager({
loadPage: page => loadConversation(item.notification_id, page),
});
const state = conversationPager.reset(initial);
onConversation(state);
onConversationStatus(state.comments.length + ' of ' +
Math.max(state.total || 0, state.comments.length) + ' messages loaded.');
return true;
} catch (_error) {
if (selected === item && version === loadVersion) {
conversationPager = null;
onConversationStatus('Conversation temporarily unavailable. Retry.');
}
return false;
}
}
function prefetch(item) {
if (!item) { prefetched = null; prefetchKey = ''; return false; }
const notificationId = item?.notification_id;
if (!Number.isInteger(notificationId) || offlineHydrated) return false;
const key = getScope() + notificationId;
if (prefetchKey === key) return prefetched;
if (prefetched) return false;
prefetchKey = key;
prefetched = load(notificationId);
return prefetched;
}
async function advanceAfterRead(items, current, queueing = false) {
const updated = acknowledgeNotification(items, current.notification_id);
onItems(updated);
const currentIndex = items.indexOf(current);
const remaining = items.slice(currentIndex + 1).concat(items.slice(0, currentIndex));
const next = remaining.find(item => item && item.has_update &&
Number.isInteger(item.notification_id) && (!queueing || loadSaved(item)));
if (next) await open(next, queueing ? loadSaved(next) : null);
else {
selected = null;
onClose();
onStatus('Inbox cleared.');
}
return { items: updated, item: current, next: next || null };
}
async function open(item, savedDetail = null) {
selected = item;
offlineHydrated = Boolean(savedDetail && !Array.isArray(savedDetail));
const version = ++loadVersion;
const preload = !offlineHydrated && prefetchKey === getScope() + item.notification_id ? prefetched : null;
prefetched = null;
prefetchKey = '';
onOpen(item);
onStatus('Loading update…');
if (!preload) onStatus('Loading update…');
try {
const detail = offlineHydrated ? savedDetail : await load(item.notification_id);
let detail;
if (offlineHydrated) detail = savedDetail;
else if (preload) detail = await preload.catch(() => {
onStatus('Loading update…');
return load(item.notification_id);
});
else detail = await load(item.notification_id);
if (selected !== item || version !== loadVersion) return false;
onDetail(detail);
if (createPager && loadConversation && detail.conversation) {
@ -311,6 +494,9 @@ function createNotificationReader({
conversationPager = null;
}
onStatus('Update ready.');
if (!offlineHydrated && detail.conversation_available) {
void hydrateConversation(item, version);
}
return true;
} catch (_error) {
if (selected === item && version === loadVersion) {
@ -322,11 +508,24 @@ function createNotificationReader({
return {
open,
prefetch,
retryConversation() {
if (!selected || offlineHydrated) return Promise.resolve(false);
return hydrateConversation(selected, loadVersion);
},
commentPager() {
return conversationPager;
},
appendReply(comment) {
if (!conversationPager) return false;
onConversation(conversationPager.append(comment));
return true;
},
acceptReadAndNext(items, item = selected) {
if (!item || (selected && selected !== item)) return false;
selected = item;
return advanceAfterRead(items, item);
},
async loadOlder() {
if (!conversationPager || !selected || offlineHydrated) return false;
const pager = conversationPager;
@ -354,19 +553,7 @@ function createNotificationReader({
try {
if (queueing) await queueRead(current.notification_id);
else await markRead(current.notification_id);
const updated = acknowledgeNotification(items, current.notification_id);
onItems(updated);
const currentIndex = items.indexOf(current);
const remaining = items.slice(currentIndex + 1).concat(items.slice(0, currentIndex));
const next = remaining.find(item => item && item.has_update &&
Number.isInteger(item.notification_id) && (!queueing || loadSaved(item)));
if (next) await open(next, queueing ? loadSaved(next) : null);
else {
selected = null;
onClose();
onStatus('Inbox cleared.');
}
return { items: updated, next: next || null };
return await advanceAfterRead(items, current, queueing);
} catch (_error) {
onStatus(queueing ? 'Could not queue update read. Retry.' : 'Could not mark update read. Retry.');
return false;
@ -374,6 +561,21 @@ function createNotificationReader({
marking = false;
}
},
async acknowledgeAndNext(items) {
if (!selected || marking || offlineHydrated || !acknowledge) return false;
const current = selected;
marking = true;
onStatus('Adding reaction and marking read…');
try {
await acknowledge(current.notification_id);
return await advanceAfterRead(items, current);
} catch (_error) {
onStatus('Could not acknowledge update. Retry.');
return false;
} finally {
marking = false;
}
},
};
}
@ -394,9 +596,9 @@ function createNotificationReplier({
if ((storage.getItem(keyFor(item)) || '') !== body) storage.removeItem(operationKeyFor(item));
storage.setItem(keyFor(item), body);
}
catch (_error) { /* Keep the editable textarea as the fallback. */ }
catch (_error) {}
},
async submit(item, body) {
async submit(item, body, attachment = null) {
if (pending) return false;
pending = true;
this.saveDraft(item, body);
@ -407,11 +609,28 @@ function createNotificationReplier({
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
onStatus('Sending reply…');
try {
if (attachment) {
onStatus('Saving screenshot for durable delivery…');
const admission = await authoredOutbox.enqueueDurably({
kind:'update-reply', notificationId:item.notification_id, body, operationId, attachment,
});
const delivery = await authoredOutbox.retry(admission.item.id, admission.item.ownerLogin);
if (!delivery.confirmed?.length) {
const remaining = delivery.remaining?.find(candidate => candidate.id === admission.item.id);
if (remaining?.status === 'attention') return false;
onStatus('Queued for sync when the connection returns.');
return { queued:true };
}
try { storage.removeItem(keyFor(item)); storage.removeItem(operationKeyFor(item)); }
catch (_error) {}
onStatus('Reply posted. You can mark this update read when ready.');
return delivery.confirmed[0];
}
const result = await post(item.notification_id, body, operationId);
try { storage.removeItem(keyFor(item)); }
catch (_error) { /* The posted reply is still authoritative. */ }
catch (_error) {}
try { storage.removeItem(operationKeyFor(item)); }
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
catch (_error) {}
onStatus('Reply posted. You can mark this update read when ready.');
return result;
} catch (error) {
@ -440,6 +659,8 @@ 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 === 'authored') filtered = items.filter((item) => item.is_authored);
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);
@ -452,6 +673,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 => {
@ -472,10 +821,68 @@ function workIdentity(item) {
return [kind, repository, number, notification].join(':');
}
function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', onOpen, onProgress, onFinish }) {
function createWorkSessionCheckpoint({
storage,
getLogin,
onError = () => {},
key = 'stackchain.today-session.v1',
}) {
const login = () => String(getLogin() || '').trim();
let errorReported = false;
const reportError = error => {
if (errorReported) return;
errorReported = true;
onError(error);
};
const read = () => {
const owner = login();
if (!owner) return null;
try {
const value = JSON.parse(storage.getItem(key) || 'null');
if (value?.version !== 1 || value.login !== owner || typeof value.identity !== 'string' ||
!Number.isInteger(value.index) || value.index < 0) return null;
return value;
} catch (error) {
reportError(error);
return null;
}
};
return {
read,
save(identity, index) {
const owner = login();
if (!owner) return false;
try {
storage.setItem(key, JSON.stringify({ version:1, login:owner, identity, index }));
return true;
} catch (error) {
reportError(error);
return false;
}
},
clear() {
if (!read()) return false;
try {
storage.removeItem(key);
return true;
} catch (error) {
reportError(error);
return false;
}
},
};
}
function createWorkSession({
getItems, getFilter, getMilestone = () => 'all', checkpoint = null,
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({
@ -485,9 +892,12 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
can_next: index < items.length - 1,
});
const finish = () => {
const clearCheckpoint = durable;
running = false;
durable = false;
currentIdentity = '';
currentIndex = -1;
if (clearCheckpoint) activeCheckpoint()?.clear();
onFinish();
return false;
};
@ -495,6 +905,7 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
if (!items.length || index < 0 || index >= items.length) return finish();
currentIndex = index;
currentIdentity = workIdentity(items[index]);
if (durable) activeCheckpoint()?.save(currentIdentity, currentIndex);
report(items, index);
onOpen(items[index]);
return true;
@ -502,11 +913,40 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
return {
active: () => running,
start() {
checkpointed: item => running && durable && checkpointedEnabled() && (!item || workIdentity(item) === currentIdentity),
end: () => finish(),
resumable: () => Boolean(activeCheckpoint()?.read()),
reopen(requested = null) {
if (!running) return false;
const items = queue();
const requestedIdentity = requested ? workIdentity(requested) : currentIdentity;
const index = items.findIndex(item => workIdentity(item) === requestedIdentity);
if (index < 0) return false;
currentIdentity = requestedIdentity;
currentIndex = index;
if (durable && requested) activeCheckpoint()?.save(currentIdentity, currentIndex);
report(items, index);
onOpen(items[index]);
return true;
},
resume(requested = null) {
const saved = activeCheckpoint()?.read();
if (!saved) return false;
durable = true;
const items = queue();
if (!items.length) return finish();
running = true;
return openAt(items, 0);
const exact = items.findIndex(item => workIdentity(item) ===
(requested ? workIdentity(requested) : saved.identity));
return openAt(items, exact >= 0 ? exact : Math.min(saved.index, items.length - 1));
},
start(item = null) {
const items = queue();
if (!items.length) return finish();
running = true;
durable = Boolean(activeCheckpoint() && checkpointEnabled());
const requested = item ? items.findIndex(candidate => workIdentity(candidate) === workIdentity(item)) : 0;
return openAt(items, requested >= 0 ? requested : 0);
},
reconcile() {
if (!running) return false;
@ -523,21 +963,48 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
return index > 0 ? openAt(items, index - 1) : false;
},
next() {
next(requested = null) {
if (!running) return false;
const items = queue();
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
if (requested) {
const requestedIndex = items.findIndex(item => workIdentity(item) === workIdentity(requested));
return requestedIndex >= 0 ? openAt(items, requestedIndex) : false;
}
return index >= 0 && index < items.length - 1 ? openAt(items, index + 1) : finish();
},
complete() {
complete(requested = null) {
if (!running) return false;
const items = queue();
if (requested) {
const requestedIndex = items.findIndex(item => workIdentity(item) === workIdentity(requested));
return requestedIndex >= 0 ? openAt(items, requestedIndex) : false;
}
const stillPresent = items.findIndex(item => workIdentity(item) === currentIdentity);
if (stillPresent >= 0) {
return stillPresent < items.length - 1 ? openAt(items, stillPresent + 1) : finish();
}
return items.length ? openAt(items, Math.min(currentIndex, items.length - 1)) : finish();
},
items: () => queue().slice(),
target(action) {
const items = queue();
if (!items.length) return null;
if (action === 'start') return items[0];
if (action === 'resume') {
const saved = activeCheckpoint()?.read();
if (!saved) return null;
const exact = items.findIndex(item => workIdentity(item) === saved.identity);
return items[exact >= 0 ? exact : Math.min(saved.index, items.length - 1)];
}
const current = items.findIndex(item => workIdentity(item) === currentIdentity);
if (action === 'continue') return current >= 0 ? items[current] : null;
if (action === 'next') return current >= 0 && current < items.length - 1 ? items[current + 1] : null;
if (action === 'complete') {
return items[current >= 0 ? Math.min(current + 1, items.length - 1) : Math.min(currentIndex, items.length - 1)];
}
return null;
},
};
}
@ -596,10 +1063,28 @@ function summarizeMyWork(items) {
return (updates ? updateLabel + ' · ' : '') + reviewLabel + ' · ' + assignedLabel;
}
function findQueueItems(items, query) {
const needle = String(query || '').trim().toLowerCase();
if (!needle) return (items || []).slice();
return (items || []).filter(item => {
const number = Number.isInteger(item?.number) ? '#' + item.number : '';
return [item?.repository, item?.key, number, item?.title]
.some(value => String(value || '').toLowerCase().includes(needle));
});
}
function 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,
authored: items.filter((item) => item.is_authored).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,
@ -609,8 +1094,12 @@ 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;
buildMyWork.workIdentity = workIdentity;
buildMyWork.replaceIssueLabels = replaceIssueLabels;
buildMyWork.replaceIssueContent = replaceIssueContent;
@ -618,11 +1107,14 @@ if (typeof module !== 'undefined' && module.exports) {
buildMyWork.replaceIssueMilestone = replaceIssueMilestone;
buildMyWork.removeIssue = removeIssue;
buildMyWork.summarizeMyWork = summarizeMyWork;
buildMyWork.findQueueItems = findQueueItems;
buildMyWork.filedFollowUpTarget = filedFollowUpTarget;
buildMyWork.countMyWork = countMyWork;
buildMyWork.acknowledgeNotification = acknowledgeNotification;
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
buildMyWork.notificationIds = notificationIds;
buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger;
buildMyWork.createNotificationSelection = createNotificationSelection;
buildMyWork.createNotificationPager = createNotificationPager;
buildMyWork.createWorkPager = createWorkPager;
buildMyWork.createNotificationReader = createNotificationReader;

View File

@ -0,0 +1,85 @@
function createNotificationUndo({ restore, onItems, onStatus, onOffer = () => {}, onClear = () => {} }) {
let offered = null;
let pending = false;
return {
offer(item, items) {
if (!item || !Number.isInteger(item.notification_id)) return false;
offered = { item, items: Array.isArray(items) ? [...items] : [] };
const label = item.key || 'Update';
onStatus(label + ' marked read. Undo?');
onOffer(item);
return true;
},
async run() {
if (!offered || pending) return false;
const current = offered;
const label = current.item.key || 'Update';
pending = true;
onStatus('Restoring ' + label + '…');
try {
await restore(current.item.notification_id);
if (offered !== current) return false;
const restored = [current.item].concat(current.items.filter(
item => item?.notification_id !== current.item.notification_id
));
offered = null;
onItems(restored, current.item);
onStatus(label + ' is unread again.');
onClear();
return true;
} catch (_error) {
onStatus('Could not restore ' + label + '. Retry Undo.');
return false;
} finally {
pending = false;
}
},
clear() {
offered = null;
onClear();
},
};
}
async function requestNotificationUnread(notificationId) {
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/unread', {
method: 'PATCH', headers: { Accept: 'application/json' },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Restore unread failed.');
return payload;
}
function createDashboardNotificationUndo({ restore, getItems, setItems, getNotifications, setNotifications, refresh, select }) {
let timer = null;
const controller = createNotificationUndo({
restore,
onItems: (items, restored) => {
setItems(items);
const notifications = getNotifications();
if (!notifications.some(item => item.id === restored.notification_id)) {
setNotifications([{ id:restored.notification_id }].concat(notifications));
}
refresh();
},
onStatus: message => {
select('#notification-undo-status').textContent = message;
select('#my-work-action-status').textContent = message;
},
onOffer: item => {
clearTimeout(timer);
select('#notification-undo').hidden = false;
select('#undo-notification').disabled = false;
select('#undo-notification').setAttribute('aria-label', 'Undo marking ' + (item.key || 'update') + ' read');
timer = setTimeout(() => controller.clear(), 10000);
},
onClear: () => { select('#notification-undo').hidden = true; },
});
return controller;
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = createNotificationUndo;
module.exports.createDashboardNotificationUndo = createDashboardNotificationUndo;
}

View File

@ -0,0 +1,21 @@
function createOfflineIssueBlocker({
enqueueDurably,
onQueued = () => {},
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
}) {
return async function queueOfflineIssueBlocker(item, blocker, present) {
const admission = await enqueueDurably({
kind: 'issue-blocker',
repository: String(item.repository || ''),
number: Number(item.number || 0),
blockerRepository: String(blocker.repository || ''),
blockerNumber: Number(blocker.number || 0),
present: present === true,
operationId: String(createOperationId()).slice(0, 128),
});
onQueued(item, blocker, present === true, admission);
return { admission };
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createOfflineIssueBlocker;

View File

@ -0,0 +1,19 @@
function createOfflineIssueClose({ enqueueDurably, completeToday, createOperationId = () =>
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2) }) {
return async function closeOfflineIssue(item) {
const admission = await enqueueDurably({
kind: 'issue-close',
repository: String(item.repository || ''),
number: Number(item.number || 0),
body: '',
operationId: String(createOperationId()).slice(0, 128),
});
const advanced = Boolean(completeToday(item, {
successMessage: 'Issue closure queued. Next Today item opened.',
failureMessage: 'Issue closure queued, but Today still needs completion.',
}));
return { admission, advanced };
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createOfflineIssueClose;

View File

@ -31,14 +31,18 @@
}
const previousFailed = failed;
const readyKeys = new Set(items.filter(item => loadSavedDetail(login, item)).map(itemKey));
const savedDetails = new Map();
await Promise.all(items.map(async item => {
savedDetails.set(itemKey(item), await loadSavedDetail(login, item));
}));
const readyKeys = new Set(items.filter(item => savedDetails.get(itemKey(item))).map(itemKey));
const snapshot = pending => ({
total: items.length, ready: readyKeys.size, failed: failed.size, pending,
});
const candidates = items.filter(item => {
const key = itemKey(item);
if (onlyFailed && !previousFailed.has(key)) return false;
const saved = loadSavedDetail(login, item);
const saved = savedDetails.get(key);
return onlyFailed || !saved || saved.source_updated_at !== item.updated_at;
});
failed = new Set();
@ -51,8 +55,9 @@
try {
const detail = await loadDetail(item);
if (runGeneration !== generation) return;
const saved = saveDetail(login, item, { ...detail, source_updated_at: item.updated_at });
if (saved !== false) readyKeys.add(itemKey(item));
const saved = await saveDetail(login, item, { ...detail, source_updated_at: item.updated_at });
if (saved === false) throw new Error('Offline detail was not durably admitted.');
readyKeys.add(itemKey(item));
} catch (_error) {
if (runGeneration === generation) failed.add(itemKey(item));
}

View File

@ -27,6 +27,54 @@
];
const REVIEW_FIELDS = ['id', 'state', 'body', 'submitted_at'];
function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-offline-work-v2') {
if (!indexedDB) return null;
let databasePromise;
function database() {
if (!databasePromise) databasePromise = new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, 1);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains('work')) {
request.result.createObjectStore('work', { keyPath: 'id' });
}
};
request.onsuccess = () => {
const db = request.result;
db.onversionchange = () => { db.close(); databasePromise = undefined; };
resolve(db);
};
request.onerror = () => reject(request.error || new Error('Offline work database failed to open.'));
});
return databasePromise;
}
const requested = request => new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
return async work => {
const db = await database();
return new Promise((resolve, reject) => {
const transaction = db.transaction('work', 'readwrite');
const store = transaction.objectStore('work');
let result;
let workError;
transaction.oncomplete = () => workError ? undefined : resolve(result);
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(workError || transaction.error || new Error('Offline work transaction aborted.'));
Promise.resolve(work({
get: key => requested(store.get(key)),
getAll: () => requested(store.getAll()),
put: value => requested(store.put(value)),
delete: key => requested(store.delete(key)),
clear: () => requested(store.clear()),
})).then(value => { result = value; }).catch(error => {
workError = error;
try { transaction.abort(); } catch (_) { reject(error); }
});
});
};
}
function pick(source, fields) {
const output = {};
fields.forEach(field => {
@ -36,14 +84,155 @@
}
function createOfflineWorkStore({
storage, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS, maxDetails = 10,
storage, indexedDB, transaction, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS,
maxDetails = 10, initializationTimeoutMs = 1500,
}) {
const transact = transaction || createIndexedDbTransaction(indexedDB);
let migrationPromise;
let readinessPromise;
let readinessGeneration = 0;
let readinessState = transact ? { state:'initializing', reason:'' } : { state:'ready', reason:'' };
let recordCache = null;
let pendingDurableClear = false;
function hydrateLegacyCache() {
const cache = new Map();
try {
const snapshot = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null');
if (snapshot) cache.set('snapshot', { ...snapshot, id:'snapshot' });
const details = JSON.parse(storage.getItem(DETAILS_KEY) || '[]');
if (Array.isArray(details)) details.forEach(record => {
if (!record?.key || !record?.user_login || !record?.data) return;
const id = 'detail:' + record.user_login + ':' + record.key;
cache.set(id, { ...record, id });
});
} catch (_) { cache.clear(); }
recordCache = cache;
}
function migrateLegacy(generation) {
if (!transact) return Promise.resolve(true);
if (!migrationPromise) migrationPromise = (async () => {
let snapshot = null;
let details = [];
try {
snapshot = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null');
const parsedDetails = JSON.parse(storage.getItem(DETAILS_KEY) || '[]');
details = Array.isArray(parsedDetails) ? parsedDetails : [];
} catch (_) {
return false;
}
if (!snapshot && !details.length) return true;
await transact(async records => {
if (snapshot && !(await records.get('snapshot'))) {
await records.put({ ...snapshot, id:'snapshot' });
}
for (const record of details.slice(-Math.max(1, maxDetails))) {
if (!record?.key || !record?.user_login || !record?.data) continue;
const id = 'detail:' + record.user_login + ':' + record.key;
if (!(await records.get(id))) await records.put({ ...record, id });
}
});
if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
storage.removeItem(SNAPSHOT_KEY);
storage.removeItem(DETAILS_KEY);
return true;
})().catch(() => false);
return migrationPromise;
}
async function initialize(generation) {
const migrated = await migrateLegacy(generation);
if (!migrated || generation !== readinessGeneration || readinessState.state === 'degraded') return false;
if (pendingDurableClear) {
await transact(async records => { await records.clear(); });
if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
pendingDurableClear = false;
recordCache = new Map();
return true;
}
return transact(async records => {
const currentTime = now().getTime();
const valid = [];
for (const record of await records.getAll()) {
const savedAt = Date.parse(record?.saved_at || '');
const validShape = record?.id === 'snapshot' ?
record.version === VERSION && record.user_login && record.data :
record?.id?.startsWith('detail:') && record.user_login && record.key && record.data;
if (!validShape || !Number.isFinite(savedAt) || currentTime - savedAt > maxAgeMs) {
if (record?.id) await records.delete(record.id);
} else valid.push(record);
}
if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
recordCache = new Map(valid.map(record => [record.id, record]));
return true;
});
}
function startReady() {
const generation = ++readinessGeneration;
readinessState = { state:'initializing', reason:'' };
const operation = initialize(generation).catch(() => false);
readinessPromise = new Promise(resolve => {
const timer = setTimeout(() => {
if (generation !== readinessGeneration || readinessState.state !== 'initializing') return;
hydrateLegacyCache();
readinessState = { state:'degraded', reason:'deadline' };
resolve(false);
}, Math.max(1, Number(initializationTimeoutMs) || 1500));
operation.then(available => {
if (generation !== readinessGeneration || readinessState.state === 'degraded') return;
clearTimeout(timer);
if (available) readinessState = { state:'ready', reason:'' };
else {
hydrateLegacyCache();
readinessState = { state:'degraded', reason:'unavailable' };
}
resolve(Boolean(available));
});
});
return readinessPromise;
}
function ready() {
if (!transact || readinessState.state === 'ready') return Promise.resolve(true);
if (readinessState.state === 'degraded') return Promise.resolve(false);
return readinessPromise || startReady();
}
function status() {
return { ...readinessState };
}
function retry() {
if (!transact) return Promise.resolve(true);
migrationPromise = undefined;
readinessPromise = undefined;
return startReady();
}
function enabled() {
try { return storage.getItem(ENABLED_KEY) === 'true'; }
catch (_) { return false; }
}
function clear() {
if (transact) {
try {
storage.removeItem(SNAPSHOT_KEY);
storage.removeItem(DETAILS_KEY);
} catch (_) { /* IndexedDB remains the source of truth. */ }
if (readinessState.state === 'degraded') {
pendingDurableClear = true;
recordCache = new Map();
return Promise.resolve(true);
}
migrationPromise = Promise.resolve(true);
return transact(async records => {
await records.clear();
recordCache = new Map();
return true;
}).catch(() => false);
}
try {
storage.removeItem(SNAPSHOT_KEY);
storage.removeItem(DETAILS_KEY);
@ -104,6 +293,22 @@
saved_at: now().toISOString(),
data,
};
if (transact) {
record.id = 'detail:' + login + ':' + key;
return ready().then(available => available ? transact(async records => {
const existing = (await records.getAll()).filter(candidate =>
candidate?.id?.startsWith('detail:') && candidate.id !== record.id
);
await records.put(record);
const overflow = existing.concat(record).sort((a, b) =>
String(a.saved_at).localeCompare(String(b.saved_at))
).slice(0, -Math.max(1, maxDetails));
for (const candidate of overflow) await records.delete(candidate.id);
overflow.forEach(candidate => recordCache.delete(candidate.id));
recordCache.set(record.id, record);
return true;
}) : false).catch(() => false);
}
const records = readDetails().filter(candidate =>
!(candidate?.key === key && candidate?.user_login === login)
);
@ -117,6 +322,19 @@
const key = detailKey(item);
login = String(login || '').trim();
if (!login || !key) return null;
if (transact) {
const id = 'detail:' + login + ':' + key;
if (!recordCache) return ready().then(() => loadDetail(login, item));
const record = recordCache.get(id);
if (!record) return null;
const savedAt = Date.parse(record.saved_at || '');
if (!record.data || !Number.isFinite(savedAt) || now().getTime() - savedAt > maxAgeMs) {
recordCache.delete(id);
transact(async records => { await records.delete(id); }).catch(() => {});
return null;
}
return { ...record.data, saved_at: record.saved_at };
}
const records = readDetails();
const currentTime = now().getTime();
const valid = records.filter(record => {
@ -142,6 +360,7 @@
function save(snapshot) {
if (!enabled() || !snapshot?.user?.login) return false;
const record = {
id: 'snapshot',
version: VERSION,
user_login: String(snapshot.user.login),
saved_at: now().toISOString(),
@ -154,12 +373,36 @@
notification_pagination: snapshot.notification_pagination || {},
},
};
if (transact) {
return ready().then(available => available ? transact(async records => {
await records.put(record);
recordCache.set(record.id, record);
return true;
}) : false).catch(() => false);
}
try { storage.setItem(SNAPSHOT_KEY, JSON.stringify(record)); }
catch (_) { return false; }
return true;
}
function load(expectedLogin) {
if (transact) {
if (!recordCache) return ready().then(() => load(expectedLogin));
const record = recordCache.get('snapshot');
const savedAt = Date.parse(record?.saved_at || '');
const invalid = record?.version !== VERSION || !record?.user_login || !record?.data ||
!Number.isFinite(savedAt);
const expired = Number.isFinite(savedAt) && now().getTime() - savedAt > maxAgeMs;
if (invalid || expired) {
if (record) {
recordCache.delete('snapshot');
transact(async records => { await records.delete('snapshot'); }).catch(() => {});
}
return null;
}
if (expectedLogin && record.user_login !== expectedLogin) return null;
return { ...record.data, saved_at: record.saved_at };
}
let record;
try { record = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null'); }
catch (_) { clear(); return null; }
@ -175,7 +418,7 @@
return { ...record.data, saved_at: record.saved_at };
}
return { enabled, setEnabled, save, load, saveDetail, loadDetail, clear };
return { enabled, setEnabled, ready, retry, status, save, load, saveDetail, loadDetail, clear };
}
return createOfflineWorkStore;

View File

@ -15,7 +15,7 @@ function createOutboxCoordinator({
let sequence = 0;
function validChange(value) {
return value && (value.queue === 'issue' || value.queue === 'authored') ? value : null;
return value && ['issue', 'authored', 'today', 'later'].includes(value.queue) ? value : null;
}
function publish(change) {

View File

@ -0,0 +1,80 @@
(function (root, factory) {
const createPhotoDraftInbox = factory();
if (typeof module === 'object' && module.exports) module.exports = createPhotoDraftInbox;
else root.createPhotoDraftInbox = createPhotoDraftInbox;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
function targetKey(route) {
if (route?.kind === 'update') {
const notificationId = Number(route.notification_id || 0);
return notificationId > 0 ? 'update:' + notificationId : '';
}
const kind = route?.kind === 'search' ? route.target_kind : route?.kind;
const repository = String(route?.repository || '');
const number = Number(route?.number || 0);
return ['issue', 'pull'].includes(kind) && repository && number > 0 ?
kind + ':' + repository.toLowerCase() + '#' + number : '';
}
function removeTarget(item) {
const route = item?.route || {};
if (route.kind === 'update') return { kind:'update', notificationId:Number(route.notification_id) };
return {
kind:route.kind === 'search' ? route.target_kind : route.kind,
repository:route.repository,
number:Number(route.number),
};
}
return function createPhotoDraftInbox({ conversation, search }) {
let items = [];
async function refresh(existing = []) {
const occupied = new Set(existing.map(item => targetKey(item?.route)).filter(Boolean));
const results = await Promise.allSettled([
conversation?.list?.() || [],
search?.list?.() || [],
]);
items = results.flatMap(result => result.status === 'fulfilled' && Array.isArray(result.value) ? result.value : [])
.filter(item => {
const key = targetKey(item?.route);
if (!key || occupied.has(key)) return false;
occupied.add(key);
return true;
})
.sort((left, right) => Number(right.updated_at || 0) - Number(left.updated_at || 0) ||
String(left.id || '').localeCompare(String(right.id || '')));
return items.slice();
}
function list() { return items.slice(); }
async function discard(item) {
if (!item || !items.some(candidate => candidate.id === item.id && candidate.photo_store === item.photo_store)) {
return false;
}
const store = item.photo_store === 'search' ? search : conversation;
if (!store?.remove) return false;
await store.remove(removeTarget(item));
items = items.filter(candidate => !(candidate.id === item.id && candidate.photo_store === item.photo_store));
return true;
}
function description(item) {
return item?.kind === 'photo-reply' ?
Number(item.photo_count || 0) + (Number(item.photo_count) === 1 ? ' saved photo' : ' saved photos') :
(item?.preview || 'Unfinished draft');
}
function searchTarget(item) {
const route = item?.route || {};
return {
kind:route.target_kind, repository:route.repository,
number:route.number, title:item?.title,
};
}
return { refresh, list, discard, description, searchTarget };
};
});

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;

View File

@ -0,0 +1,63 @@
(function (root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.createPlanTodayPreview = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
return function createPlanTodayPreview({
planner,
identity,
getScroll = () => 0,
setScroll = () => {},
onOpen = () => {},
onClose = () => {},
}) {
let current = null;
function open(item, trigger = null) {
if (!item || !identity?.(item) || !planner?.snapshot().open) return false;
current = { item, trigger, scroll:Number(getScroll()) || 0 };
onOpen(item, trigger);
return true;
}
function setDependencies({ available, dependencies } = {}) {
if (!current) return false;
current.dependencies_available = available === true;
current.dependencies = Array.isArray(dependencies) ? dependencies.filter(item => item?.state === 'open') : [];
current.requires_override = !current.dependencies_available || current.dependencies.length > 0;
return true;
}
function close({ add = false, override = false } = {}) {
if (!current) return 'closed';
if (add && current.requires_override && !override) {
return current.dependencies_available ? 'blocked' : 'dependencies-unavailable';
}
const { item, trigger, scroll } = current;
let result = 'returned';
if (add) {
const id = identity(item);
result = planner.snapshot().ids.includes(id) ? 'already-added' : planner.toggle(item);
}
current = null;
setScroll(scroll);
onClose(item, trigger);
return result;
}
function snapshot() {
if (!current) return { open:false };
const state = { open:true, item:current.item, trigger:current.trigger, scroll:current.scroll };
if (Object.prototype.hasOwnProperty.call(current, 'dependencies_available')) {
state.dependencies_available = current.dependencies_available;
state.dependencies = current.dependencies;
state.requires_override = current.requires_override;
}
return state;
}
return { open, close, setDependencies, snapshot };
};
});

View File

@ -0,0 +1,41 @@
function createPlanTodayReadiness({ identity, inspect, concurrency = 3 }) {
const workerCount = Math.max(1, Math.floor(Number(concurrency) || 1));
let generation = 0;
async function run(items) {
const runGeneration = ++generation;
const candidates = Array.from(items || []);
const results = new Array(candidates.length);
let next = 0;
async function worker() {
while (next < candidates.length) {
const index = next;
next += 1;
const item = candidates[index];
let result;
try {
result = await inspect(item);
} catch (_error) {
result = { status:'unverified' };
}
results[index] = [identity(item), result];
}
}
await Promise.all(Array.from(
{ length:Math.min(workerCount, candidates.length) },
() => worker(),
));
if (runGeneration !== generation) return null;
return Object.fromEntries(results);
}
function cancel() {
generation += 1;
}
return { run, cancel };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createPlanTodayReadiness;

199
frontend/plan-today.js Normal file
View File

@ -0,0 +1,199 @@
function createPlanToday({ identity, save, start, limit = 5 }) {
let openState = false;
let draftIds = [];
let itemsById = new Map();
let capacityMinutes = null;
let estimates = {};
let recommendations = {};
let recommendationAware = false;
let capacityAware = false;
let buildState = null;
let buildReadiness = null;
function cleanItems(items) {
const unique = new Map();
for (const item of items || []) {
const id = identity?.(item);
if (id && !unique.has(id)) unique.set(id, item);
}
return unique;
}
function open(selectedItems, candidates, planning = null, actualMinutes = null) {
itemsById = cleanItems([...(selectedItems || []), ...(candidates || [])]);
draftIds = [];
for (const item of selectedItems || []) {
const id = identity?.(item);
if (id && itemsById.has(id) && !draftIds.includes(id) && draftIds.length < limit) draftIds.push(id);
}
capacityAware = Boolean(planning && ('capacity_minutes' in planning || 'estimates' in planning));
capacityMinutes = Number.isInteger(planning?.capacity_minutes) && planning.capacity_minutes > 0
? planning.capacity_minutes : null;
estimates = {};
for (const [id, minutes] of Object.entries(planning?.estimates || {})) {
if (Number.isInteger(minutes) && minutes > 0) estimates[id] = minutes;
}
recommendations = {};
recommendationAware = actualMinutes !== null;
buildState = null;
buildReadiness = null;
for (const [id, minutes] of Object.entries(actualMinutes || {})) {
if (draftIds.includes(id) && Number.isInteger(minutes) && minutes >= 5 && minutes <= 1440 &&
estimates[id] !== minutes) recommendations[id] = minutes;
}
if (Object.keys(recommendations).length) capacityAware = true;
openState = true;
return snapshot();
}
function toggle(item) {
if (!openState) return 'closed';
const id = identity?.(item);
if (!id) return 'unavailable';
itemsById.set(id, item);
const index = draftIds.indexOf(id);
if (index >= 0) {
draftIds.splice(index, 1);
return 'removed';
}
if (draftIds.length >= limit) return 'full';
draftIds.push(id);
return 'added';
}
function move(id, direction) {
const index = draftIds.indexOf(id);
const target = direction === 'up' ? index - 1 : direction === 'down' ? index + 1 : -1;
if (!openState || index < 0 || target < 0 || target >= draftIds.length) return false;
[draftIds[index], draftIds[target]] = [draftIds[target], draftIds[index]];
return true;
}
function close() {
openState = false;
draftIds = [];
itemsById = new Map();
capacityMinutes = null;
estimates = {};
recommendations = {};
recommendationAware = false;
capacityAware = false;
buildState = null;
buildReadiness = null;
}
function cancel() {
close();
return true;
}
function setCapacity(minutes) {
capacityAware = true;
capacityMinutes = Number.isInteger(minutes) && minutes > 0 ? minutes : null;
return snapshot();
}
function setEstimate(id, minutes) {
const resolvingBuildEstimate = buildState?.needs_estimate?.includes(id);
if (!openState || (!draftIds.includes(id) && !resolvingBuildEstimate)) return false;
capacityAware = true;
if (!Number.isInteger(minutes) || minutes < 5 || minutes > 1440) return false;
estimates[id] = minutes;
if (resolvingBuildEstimate && buildReadiness) buildRecommendation(buildReadiness);
return true;
}
function applyRecommendation(id) {
const minutes = recommendations[id];
if (!openState || !draftIds.includes(id) || !Number.isInteger(minutes)) return false;
estimates[id] = minutes;
delete recommendations[id];
capacityAware = true;
return true;
}
function buildRecommendation(readiness = {}) {
if (!openState) return null;
buildReadiness = { ...readiness };
const selected = [];
const reasons = {};
const skipped = [];
const needsEstimate = [];
let used = 0;
for (const [id, item] of itemsById) {
if (selected.length >= limit) break;
const check = readiness[id] || { status:'unverified' };
if (check.status !== 'ready') {
skipped.push({
id,
status:check.status === 'blocked' ? 'blocked' : 'unverified',
reason:check.reason || (check.status === 'blocked' ? 'Blocked' : 'Could not verify readiness'),
});
continue;
}
const minutes = estimates[id];
if (!Number.isInteger(minutes)) {
needsEstimate.push(id);
continue;
}
if (capacityMinutes !== null && used + minutes > capacityMinutes) continue;
selected.push(id);
reasons[id] = item.reason || item.attention_reason || 'Ranked My Work';
used += minutes;
}
draftIds = selected;
capacityAware = true;
buildState = { selected:[...selected], reasons, skipped, needs_estimate:needsEstimate };
return buildState;
}
function commit({ start: startAfterSave = false, confirmOverCapacity = false } = {}) {
if (!openState) return 'closed';
const ids = [...draftIds];
const state = snapshot();
if (state.over_capacity && !confirmOverCapacity) return 'confirm-over-capacity';
const payload = capacityAware ? {
ids,
capacity_minutes: capacityMinutes,
estimates: Object.fromEntries(ids.filter(id => estimates[id]).map(id => [id, estimates[id]])),
} : ids;
if (save?.(payload) === false) return 'unavailable';
const first = ids.length ? itemsById.get(ids[0]) : null;
close();
if (startAfterSave && first) start?.(first);
return 'saved';
}
function snapshot() {
const basic = { open: openState, ids: [...draftIds], count: draftIds.length, limit };
if (!capacityAware) return basic;
const selectedEstimates = Object.fromEntries(draftIds.filter(id => estimates[id]).map(id => [id, estimates[id]]));
const plannedMinutes = Object.values(selectedEstimates).reduce((total, value) => total + value, 0);
const remainingMinutes = capacityMinutes === null ? null : capacityMinutes - plannedMinutes;
return {
...basic,
capacity_minutes: capacityMinutes,
estimates: selectedEstimates,
...(recommendationAware ? { recommendations:Object.fromEntries(
draftIds.filter(id => recommendations[id]).map(id => [id, recommendations[id]])
) } : {}),
planned_minutes: plannedMinutes,
remaining_minutes: remainingMinutes,
unestimated_count: draftIds.length - Object.keys(selectedEstimates).length,
over_capacity: remainingMinutes !== null && remainingMinutes < 0,
...(buildState ? { build:buildState } : {}),
};
}
function item(id) {
return itemsById.get(id) || null;
}
function candidates() {
return [...itemsById.entries()].filter(([id]) => !draftIds.includes(id)).map(([, value]) => value);
}
return { open, toggle, move, cancel, setCapacity, setEstimate, applyRecommendation, buildRecommendation, commit, snapshot, item, candidates };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createPlanToday;

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 };
}
};
});

View File

@ -0,0 +1,14 @@
(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',
'stackchain-today-action-mailbox-v1',
'stackchain-app-badge-preference-v1',
]);
if (typeof module !== 'undefined' && module.exports) module.exports = databases;
else root.stackchainPrivateDatabases = databases;
})(typeof globalThis !== 'undefined' ? globalThis : this);

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;
@ -36,11 +42,11 @@
});
}
function deletePrivateOutbox() {
function deletePrivateDatabase(name) {
if (!indexedDB) return Promise.resolve();
return new Promise((resolve, reject) => {
let request;
try { request = indexedDB.deleteDatabase('stackchain-background-outbox-v1'); }
try { request = indexedDB.deleteDatabase(name); }
catch (error) { reject(error); return; }
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
@ -49,10 +55,10 @@
}
return async function clearPrivateDeviceData() {
await stopWorkerOutbox();
removeOwnedStorage(localStorage);
if (sessionStorage !== localStorage) removeOwnedStorage(sessionStorage);
await stopWorkerOutbox();
await deletePrivateOutbox();
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))

View File

@ -0,0 +1,182 @@
function createProgressiveCapture({
document,
storage,
getLogin = () => '',
createCaptures = globalThis.createUnfiledCaptures,
requestFullWorkspace = async () => {
const lifecycle = await globalThis.stackchainWorkspaceLifecycle;
return lifecycle?.optionalReady;
},
createId,
now,
}) {
const button = document.querySelector('[data-mobile-task="new"]');
const root = document.querySelector('#create-issue-sheet');
const title = document.querySelector('#create-issue-title');
const body = document.querySelector('#create-issue-body');
const heading = document.querySelector('#create-issue-heading');
const save = document.querySelector('#save-unfiled-issue');
const file = document.querySelector('#file-new-issue');
const cancel = document.querySelector('#cancel-new-issue');
const status = document.querySelector('#my-work-action-status');
const captures = createCaptures({
storage,
getCaptureLogin:getLogin,
getCurrentLogin:getLogin,
...(createId ? {createId} : {}),
...(now ? {now} : {}),
});
const listeners = [];
let started = false;
let saving = null;
let saved = false;
let handedOff = false;
const checkpointKey = 'stackchain.progressive-capture.v1';
function listen(element, name, callback) {
element?.addEventListener?.(name, callback);
listeners.push([element, name, callback]);
}
function close() {
root?.classList.remove('open');
}
function readCheckpoints() {
try {
const record = JSON.parse(storage?.getItem(checkpointKey) || 'null');
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
return record.items.filter(item => item && typeof item.ownerLogin === 'string' &&
typeof item.title === 'string' && typeof item.body === 'string');
} catch (_error) { return []; }
}
function readCheckpoint() {
const login = String(getLogin() || '').trim();
if (!login) return null;
return readCheckpoints().find(item => item.ownerLogin === login) || null;
}
function checkpoint() {
const ownerLogin = String(getLogin() || '').trim();
if (!ownerLogin) return false;
try {
const items = readCheckpoints().filter(item => item.ownerLogin !== ownerLogin);
items.push({
ownerLogin,
title:String(title?.value || '').slice(0, 255),
body:String(body?.value || '').slice(0, 10000),
});
storage?.setItem(checkpointKey, JSON.stringify({
version:1, items,
}));
return true;
} catch (_error) { return false; }
}
function clearCheckpoint(expected) {
const current = readCheckpoint();
if (!current || current.title !== expected.title || current.body !== expected.body) return false;
try {
const ownerLogin = String(getLogin() || '').trim();
const items = readCheckpoints().filter(item => item.ownerLogin !== ownerLogin);
if (items.length) storage?.setItem(checkpointKey, JSON.stringify({version:1, items}));
else storage?.removeItem(checkpointKey);
return true;
} catch (_error) { return false; }
}
function open(event) {
saved = false;
const recovered = readCheckpoint();
if (recovered) {
if (title) title.value = recovered.title;
if (body) body.value = recovered.body;
if (status) status.textContent = 'Unfinished capture restored from this phone.';
}
if (heading) heading.textContent = 'Capture work';
root?.classList.add('open');
title?.focus?.();
}
async function saveDraft() {
if (saved || saving) return saving;
save.disabled = true;
const draft = {title:title?.value || '', body:body?.value || ''};
saving = Promise.resolve().then(() => captures.save(draft)).then(() => {
saved = true;
clearCheckpoint(draft);
if (title) title.value = '';
if (body) body.value = '';
close();
if (status) status.textContent = 'Saved to Drafts. Choose a repository when youre ready to file it.';
return true;
}).catch(error => {
if (status) status.textContent = error.message;
title?.focus?.();
return false;
}).finally(() => {
save.disabled = false;
saving = null;
});
return saving;
}
async function fileNow() {
if (file.disabled) return false;
file.disabled = true;
if (status) status.textContent = 'Loading filing tools…';
try {
await requestFullWorkspace();
return true;
} catch (_error) {
file.disabled = false;
if (status) status.textContent = 'Filing tools unavailable. Your capture is still editable; retry when connected.';
title?.focus?.();
return false;
}
}
function start() {
if (started) return false;
started = true;
root?.classList.add('progressive-capture');
listen(button, 'click', open);
listen(save, 'click', saveDraft);
listen(file, 'click', fileNow);
listen(cancel, 'click', close);
listen(title, 'input', checkpoint);
listen(body, 'input', checkpoint);
return true;
}
function handoff() {
if (handedOff) return null;
handedOff = true;
const state = {
open:Boolean(root?.classList.contains('open')),
title:title?.value || '', body:body?.value || '',
};
state.complete = () => clearCheckpoint(state);
root?.classList.remove('progressive-capture');
file.disabled = false;
listeners.forEach(([element, name, callback]) => element?.removeEventListener?.(name, callback));
listeners.length = 0;
return state;
}
return {start, handoff};
}
if (typeof window !== 'undefined' && typeof document !== 'undefined' &&
typeof createUnfiledCaptures === 'function' &&
window.matchMedia?.('(max-width: 600px)').matches === true) {
window.stackchainProgressiveCapture = createProgressiveCapture({
document,
storage:window.localStorage,
getLogin:() => window.stackchainProgressiveMyWork?.login?.() || '',
});
window.stackchainProgressiveCapture.start();
}
if (typeof module !== 'undefined' && module.exports) module.exports = createProgressiveCapture;

View File

@ -0,0 +1,110 @@
function createProgressiveHumanGates(options = {}) {
const document = options.document || globalThis.document;
const location = options.location || globalThis.location;
const history = options.history || globalThis.history;
const storage = options.storage || globalThis.localStorage;
const isOnline = options.isOnline || (() => globalThis.navigator?.onLine !== false);
const fetchJson = options.fetchJson || (async (path, requestOptions = {}) => {
const response = await fetch(path, requestOptions);
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || payload.detail?.message || payload.detail || 'Review request failed.');
return payload;
});
const liveSnapshot = options.liveSnapshot || null;
const getIdentity = options.getIdentity || (async () => {
const response = liveSnapshot ? await liveSnapshot.acquire({requireIdentity:true}) :
await fetchJson('api/v1/live', {headers:{Accept:'application/json'}});
const user = response?.context?.user || {};
const login = String(user.login || '').trim();
return {login, accountKey:login && user.id ? String(user.id) + ':' + login : login};
});
const query = selector => document.querySelector(selector);
const nodes = {
count:query('#human-gates-count'), list:query('#human-gates-list'),
status:query('#human-gates-status'), panel:query('#human-gates'),
detail:query('#human-gate-detail'),
pendingTab:query('#human-gates-pending'), historyTab:query('#human-gates-history'),
};
let login = '';
let accountKey = '';
let started = false;
let startFlight = null;
const controller = createHumanGates({
storage, isOnline, location, fetchJson,
getLogin:() => login, getAccountKey:() => accountKey, nodes,
});
const showError = error => {
if (nodes.status) nodes.status.textContent = error?.message || 'Human Gates are unavailable.';
};
const open = () => start(true).catch(showError);
query('#open-human-gates')?.addEventListener?.('click', open);
query('#close-human-gates')?.addEventListener?.('click', () => {
if (nodes.panel) nodes.panel.hidden = true;
if (location.hash === '#/my-work/human-gates') history.replaceState({}, '', '#/my-work');
});
nodes.list?.addEventListener?.('click', event => {
const card = event.target?.closest?.('[data-human-gate-id]');
if (card) controller.select(card.dataset.humanGateId);
});
nodes.detail?.addEventListener?.('click', event => {
const recovery = event.target?.closest?.('[data-gate-recover]');
if (recovery) {
recovery.disabled = true;
controller.recoverDecision().catch(error => {
showError(error);
recovery.disabled = false;
});
return;
}
const decision = event.target?.closest?.('[data-gate-decision]')?.dataset.gateDecision;
if (!decision) return;
controller.submitDecision(decision).catch(error => {
if (!error?.targetSelector) showError(error);
});
});
nodes.pendingTab?.addEventListener?.('click', () => controller.showPending());
nodes.historyTab?.addEventListener?.('click', () => controller.showHistory().catch(showError));
async function start(force = false) {
if (!force && location.hash !== '#/my-work/human-gates') return false;
if (started) return controller.open().then(() => true);
if (startFlight) return startFlight;
startFlight = (async () => {
const identity = await getIdentity();
login = String(identity?.login || '').trim();
accountKey = String(identity?.accountKey || login).trim();
if (!login) throw new Error('Authenticated account identity is required.');
await controller.open();
started = true;
return true;
})();
try { return await startFlight; }
finally { startFlight = null; }
}
return {
start,
handoff() {
return {
controller, started,
adoptIdentity(nextLogin, nextAccountKey) {
login = String(nextLogin || '').trim();
accountKey = String(nextAccountKey || login).trim();
},
};
},
};
}
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
window.stackchainProgressiveHumanGates = createProgressiveHumanGates({
document, liveSnapshot:window.stackchainProgressiveLiveSnapshot,
});
void window.stackchainProgressiveHumanGates.start().catch(() => {});
}
if (typeof module !== 'undefined' && module.exports) {
globalThis.createHumanGates = globalThis.createHumanGates || require('./human-gates.js');
module.exports = createProgressiveHumanGates;
}

View File

@ -0,0 +1,49 @@
function createProgressiveLiveSnapshot({fetchSnapshot}) {
let value = null;
let flight = null;
const hasIdentity = snapshot => {
const user = snapshot?.context?.user || {};
return Boolean(String(user.login || '').trim());
};
const requireUsable = (snapshot, options) => {
if (!options?.requireIdentity || hasIdentity(snapshot)) return snapshot;
if (value === snapshot) value = null;
throw new Error('Authenticated account identity is unavailable.');
};
const acquire = (options = {}) => {
if (value) return Promise.resolve().then(() => requireUsable(value, options));
if (flight) return flight.then(snapshot => requireUsable(snapshot, options));
flight = Promise.resolve().then(() => fetchSnapshot()).then(snapshot => {
value = snapshot;
flight = null;
return snapshot;
}, error => {
flight = null;
throw error;
});
return flight.then(snapshot => requireUsable(snapshot, options));
};
return {
acquire,
snapshot:() => value,
pending:() => flight,
identity() {
const user = value?.context?.user || {};
const login = String(user.login || '').trim();
return {login, accountKey:login && user.id ? String(user.id) + ':' + login : login};
},
};
}
if (typeof window !== 'undefined') {
window.stackchainProgressiveLiveSnapshot = createProgressiveLiveSnapshot({
fetchSnapshot:async () => {
const response = await fetch('api/v1/live', {headers:{Accept:'application/json'}});
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.json();
},
});
}
if (typeof module !== 'undefined' && module.exports) module.exports = createProgressiveLiveSnapshot;

View File

@ -0,0 +1,119 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createProgressiveMobileDock = factory;
})(typeof self !== 'undefined' ? self : this, function createProgressiveMobileDock(options) {
const document = options.document;
const myWork = options.myWork;
const work = document.querySelector('[data-mobile-task="work"]');
const queues = document.querySelector('[data-mobile-task="queues"]');
const sheet = document.querySelector('#mobile-queue-sheet');
const close = document.querySelector('#close-mobile-queues');
const next = document.querySelector('#mobile-queue-next-action');
const rows = Object.fromEntries(Array.from(document.querySelectorAll('[data-mobile-queue]'))
.map(row => [row.dataset.mobileQueue, row]));
const countElements = Object.fromEntries(Array.from(document.querySelectorAll('[data-mobile-queue-count]'))
.map(element => [element.dataset.mobileQueueCount, element]));
const originalDescriptions = Object.fromEntries(Object.entries(rows)
.map(([name, row]) => [name, row.querySelector?.('small')?.textContent || '']));
const originalCounts = Object.fromEntries(Object.entries(countElements)
.map(([name, element]) => [name, element.textContent]));
const supported = new Set(['all', 'attention', 'filed', 'authored', 'issue', 'pull', 'review', 'update']);
const listeners = [];
let started = false;
let lastTask = null;
let unsubscribe = null;
function listen(element, name, listener) {
if (!element) return;
element.addEventListener(name, listener);
listeners.push([element, name, listener]);
}
function render() {
const counts = myWork?.counts?.() || {};
Object.entries(rows).forEach(([name, row]) => {
const count = Math.max(0, Number(counts[name]) || 0);
const countElement = countElements[name];
if (supported.has(name)) {
row.removeAttribute('data-unavailable');
row.removeAttribute('data-progressive-loading');
if (countElement) countElement.textContent = String(count);
return;
}
row.setAttribute('data-unavailable', 'true');
row.setAttribute('data-progressive-loading', 'true');
const description = row.querySelector?.('small');
if (description) description.textContent = 'Still loading';
if (countElement) countElement.textContent = '…';
});
const actionable = ['attention', 'filed', 'authored', 'review', 'update']
.find(name => Number(counts[name]) > 0);
if (next) {
if (actionable) {
next.textContent = 'Open ' + (actionable === 'authored' ? 'My PRs' : actionable[0].toUpperCase() + actionable.slice(1))
+ ' (' + counts[actionable] + ')';
next.dataset.queue = actionable;
next.removeAttribute('data-unavailable');
} else {
next.textContent = Number(counts.all) > 0 ? 'Open assigned work (' + counts.all + ')' : 'Assigned work is still loading';
next.dataset.queue = 'all';
if (!Number(counts.all)) next.setAttribute('data-unavailable', 'true');
}
}
}
function openSheet() {
lastTask = 'queues';
render();
if (sheet && !sheet.open) sheet.showModal();
}
function openQueue(name) {
if (!supported.has(name)) return false;
lastTask = 'work';
if (sheet?.open) sheet.close();
myWork?.selectQueue?.(name, {openFirst:true});
return true;
}
function start() {
if (started) return false;
started = true;
listen(work, 'click', () => { lastTask = 'work'; myWork?.openFirst?.(); });
listen(queues, 'click', openSheet);
listen(close, 'click', () => sheet?.open && sheet.close());
Object.entries(rows).forEach(([name, row]) => listen(row, 'click', () => openQueue(name)));
listen(next, 'click', () => openQueue(next.dataset.queue || 'all'));
unsubscribe = myWork?.subscribe?.(render) || null;
render();
return true;
}
function handoff() {
return {queueSheetOpen:Boolean(sheet?.open), lastTask};
}
function stop() {
listeners.splice(0).forEach(([element, name, listener]) => element.removeEventListener(name, listener));
unsubscribe?.();
unsubscribe = null;
Object.entries(rows).forEach(([name, row]) => {
row.removeAttribute('data-unavailable');
row.removeAttribute('data-progressive-loading');
const description = row.querySelector?.('small');
if (description) description.textContent = originalDescriptions[name];
if (countElements[name]) countElements[name].textContent = originalCounts[name];
});
started = false;
}
return {start, stop, handoff, render, openQueue};
});
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
window.stackchainProgressiveMobileDock = createProgressiveMobileDock({
document,
myWork:window.stackchainProgressiveMyWork,
});
window.stackchainProgressiveMobileDock.start();
}

View File

@ -0,0 +1,278 @@
function createProgressiveMyWork({
document, fetchSnapshot, liveSnapshot: snapshotBroker = null,
pollerOptions = {}, lifecycleTarget = globalThis,
}) {
const list = document.querySelector('#my-work-list');
const status = document.querySelector('#my-work-status');
const detail = document.querySelector('#progressive-work-detail');
const detailTitle = document.querySelector('#progressive-work-detail-title');
const detailMeta = document.querySelector('#progressive-work-detail-meta');
const detailReason = document.querySelector('#progressive-work-detail-reason');
const closeDetail = document.querySelector('#close-progressive-work-detail');
const openGitea = document.querySelector('#open-progressive-work-gitea');
const filters = Array.from(document.querySelectorAll('[data-work-filter]'));
const listeners = [];
const lifecycleListeners = [];
const subscribers = new Set();
let items = [];
let active = 'all';
let stopped = false;
let selectedByUser = false;
let liveSnapshot = null;
let liveSnapshotPromise = null;
let confirmedLogin = '';
let poller = null;
let detailTrigger = null;
let openWork = null;
let contextUnavailable = false;
const deferredQueues = {
today:'Today', agenda:'Agenda', later:'Later', draft:'Drafts',
};
const fetchProgressiveSnapshot = (revisions = {}, options = {}) =>
snapshotBroker && Object.keys(revisions || {}).length === 0 ?
snapshotBroker.acquire() : fetchSnapshot(revisions, options);
const escapeHtml = value => String(value || '').replace(/[&<>"']/g, character => ({
'&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;',
})[character]);
const safeUrl = value => {
try {
const url = new URL(String(value || ''), globalThis.location?.href || 'https://invalid.example/');
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
} catch (_error) { return ''; }
};
const matches = (item, filter) =>
filter === 'review' ? item.is_review :
filter === 'update' ? item.has_update :
filter === 'attention' ? item.needs_attention :
filter === 'filed' ? item.is_filed :
filter === 'authored' ? item.is_authored :
filter === 'pull' ? item.kind === 'pull' && !item.is_review : item.kind === filter;
const visibleItems = () => active === 'all' ? items : items.filter(item => matches(item, active));
const notify = () => subscribers.forEach(subscriber => subscriber());
const queueCounts = () => ({
all:items.length,
filed:items.filter(item => matches(item, 'filed')).length,
authored:items.filter(item => matches(item, 'authored')).length,
attention:items.filter(item => matches(item, 'attention')).length,
update:items.filter(item => matches(item, 'update')).length,
review:items.filter(item => matches(item, 'review')).length,
});
const closeProgressiveDetail = ({ restoreFocus = true } = {}) => {
if (!detail || detail.hidden) return;
detail.hidden = true;
if (restoreFocus) detailTrigger?.focus?.();
detailTrigger = null;
openWork = null;
};
const openProgressiveDetail = (item, trigger) => {
if (!detail || !item) return;
detailTrigger = trigger;
openWork = item;
detailTitle.textContent = item.title || item.key || 'Untitled work';
detailMeta.textContent = (item.key || 'Unknown work item') + ' · ' +
(item.kind === 'pull' ? 'Pull request' : 'Issue');
detailReason.textContent = item.reason || 'Assigned to you';
const href = safeUrl(item.url);
openGitea.hidden = !href;
if (href) openGitea.href = href;
else openGitea.removeAttribute?.('href');
detail.hidden = false;
closeDetail?.focus?.();
};
const updateCounts = () => {
['all','attention','filed','authored','issue','pull','review','update'].forEach(filter => {
const count = filter === 'all' ? items.length : items.filter(item => matches(item, filter)).length;
const element = document.querySelector('[data-work-count="' + filter + '"]');
if (element) element.textContent = String(count);
});
};
const render = () => {
if (stopped || !list) return;
const visible = visibleItems();
list.innerHTML = contextUnavailable && !items.length ?
'<div class="muted">Assigned work is reconnecting…</div>' : deferredQueues[active] ?
'<div class="muted">' + deferredQueues[active] + ' is still loading…</div>' : visible.length ? visible.map((item, index) => {
const title = escapeHtml(item.title || item.key || 'Untitled work');
const context = escapeHtml(item.key || '');
const reason = escapeHtml(item.reason || 'Assigned to you');
return '<article class="my-work-card progressive-my-work-card">' +
'<button class="my-work-card-main" type="button" data-progressive-work-index="' + index + '">' +
'<strong>' + title + '</strong><span class="small">' + context + ' · ' + reason + '</span>' +
'</button></article>';
}).join('') : '<div class="muted">No work in this queue.</div>';
filters.forEach(button => button.setAttribute('aria-pressed', String(button.dataset.workFilter === active)));
};
filters.forEach(button => {
const listener = () => { active = button.dataset.workFilter || 'all'; selectedByUser = true; render(); };
button.addEventListener('click', listener);
listeners.push([button, listener]);
});
const openListener = event => {
const trigger = event.target?.closest?.('[data-progressive-work-index]');
if (!trigger) return;
const item = visibleItems()[Number(trigger.dataset.progressiveWorkIndex)];
if (!item) return;
event.preventDefault?.();
openProgressiveDetail(item, trigger);
};
const closeListener = () => closeProgressiveDetail();
const keyListener = event => {
if (event.key !== 'Escape' || detail?.hidden) return;
event.preventDefault?.();
closeProgressiveDetail();
};
list?.addEventListener?.('click', openListener);
closeDetail?.addEventListener?.('click', closeListener);
lifecycleTarget.addEventListener?.('keydown', keyListener);
const applySnapshot = snapshot => {
if (stopped) return false;
const transferable = snapshot && typeof snapshot === 'object' &&
Object.prototype.hasOwnProperty.call(snapshot, 'context');
const contextDegraded = transferable && (
!snapshot.context || snapshot.freshness?.sections?.context?.degraded
);
if (contextDegraded) {
contextUnavailable = true;
render();
notify();
if (status) status.textContent = 'Assigned work is reconnecting…';
return false;
}
contextUnavailable = false;
if (transferable) liveSnapshot = snapshot;
const context = snapshot?.context || snapshot || {};
confirmedLogin = String(context.user?.login || '').trim();
items = buildMyWork({ ...context, notifications:snapshot?.notifications || context.notifications || [] });
updateCounts();
render();
notify();
const assigned = items.filter(item => item.is_assigned).length;
if (status) status.textContent = assigned + ' assigned work item' + (assigned === 1 ? '' : 's') + ' ready.';
return true;
};
if (typeof createContextPoller === 'function') {
poller = createContextPoller({
...pollerOptions,
fetchContext: fetchProgressiveSnapshot,
onSnapshot: applySnapshot,
onError: () => {
if (status && !stopped) status.textContent = 'Assigned work is reconnecting…';
},
isHidden: pollerOptions.isHidden || (() => Boolean(document.hidden)),
});
const recover = () => {
if (!document.hidden) void poller.refresh();
};
['online', 'visibilitychange'].forEach(eventName => {
lifecycleTarget.addEventListener?.(eventName, recover);
lifecycleListeners.push([eventName, recover]);
});
}
return {
login() { return confirmedLogin; },
counts() { return queueCounts(); },
subscribe(subscriber) {
subscribers.add(subscriber);
return () => subscribers.delete(subscriber);
},
openFirst() {
const item = visibleItems()[0];
if (!item) return deferredQueues[active] ? 'loading' : 'empty';
const trigger = list?.querySelector?.('[data-progressive-work-index="0"]') || null;
openProgressiveDetail(item, trigger);
return 'opened';
},
selectQueue(name, {openFirst = false} = {}) {
const allowed = new Set(['all','attention','filed','authored','issue','pull','review','update','today','agenda','later','draft']);
if (!allowed.has(name)) return 'unsupported';
active = name;
selectedByUser = true;
render();
notify();
return openFirst ? this.openFirst() : 'selected';
},
handoff() {
const state = { selectedFilter:selectedByUser ? active : null };
if (openWork) {
state.openWork = {
kind:openWork.kind, key:openWork.key, number:openWork.number,
repository:openWork.repository,
};
openWork = null;
}
if (liveSnapshot) {
state.liveSnapshot = liveSnapshot;
liveSnapshot = null;
liveSnapshotPromise = null;
} else if (liveSnapshotPromise) {
state.liveSnapshotPromise = liveSnapshotPromise;
liveSnapshotPromise = null;
}
return state;
},
async start() {
if (status) status.textContent = 'Loading assigned work…';
if (poller) {
const request = poller.start();
liveSnapshotPromise = request.then(snapshot => (
snapshot && typeof snapshot === 'object' &&
Object.prototype.hasOwnProperty.call(snapshot, 'context') ? snapshot : null
));
return Boolean(await request);
}
const request = Promise.resolve().then(() => fetchProgressiveSnapshot());
liveSnapshotPromise = request.then(snapshot => (
snapshot && typeof snapshot === 'object' &&
Object.prototype.hasOwnProperty.call(snapshot, 'context') ? snapshot : null
), () => null);
try {
const snapshot = await request;
if (stopped) return false;
const applied = applySnapshot(snapshot);
if (!liveSnapshot) liveSnapshotPromise = null;
return applied;
} catch (_error) {
if (status && !stopped) status.textContent = 'Assigned work is reconnecting…';
return false;
}
},
stop() {
stopped = true;
poller?.stop();
listeners.forEach(([button, listener]) => button.removeEventListener?.('click', listener));
list?.removeEventListener?.('click', openListener);
closeDetail?.removeEventListener?.('click', closeListener);
lifecycleTarget.removeEventListener?.('keydown', keyListener);
lifecycleListeners.forEach(([eventName, listener]) =>
lifecycleTarget.removeEventListener?.(eventName, listener));
subscribers.clear();
},
};
}
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
window.stackchainProgressiveMyWork = createProgressiveMyWork({
document,
lifecycleTarget: window,
liveSnapshot:window.stackchainProgressiveLiveSnapshot,
fetchSnapshot: async (revisions = {}, { signal } = {}) => {
const query = createContextPoller.buildRevisionQuery(revisions);
const response = await fetch('api/v1/live' + (query ? '?' + query : ''), {
headers:{Accept:'application/json'}, signal,
});
if (!response.ok) {
const error = new Error('HTTP ' + response.status);
error.retryAfterMs = createContextPoller.retryAfterMs(response.headers.get('Retry-After'));
throw error;
}
return response.json();
},
});
void window.stackchainProgressiveMyWork.start();
}
if (typeof module !== 'undefined' && module.exports) module.exports = createProgressiveMyWork;

46
frontend/protect-today.js Normal file
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;

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More