Skip to content

feat(web): closed-tab Web Push notifications (SW + PWA + server VAPID push) - #23

Merged
radroid merged 2 commits into
mainfrom
t3code/web-push-notifications
Jul 27, 2026
Merged

feat(web): closed-tab Web Push notifications (SW + PWA + server VAPID push)#23
radroid merged 2 commits into
mainfrom
t3code/web-push-notifications

Conversation

@radroid

@radroid radroid commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Closes #19

What

Closes the accepted v1 gap from #14 (in-tab notifications only): deliver a browser notification when the T3 tab is fully closed, via a service worker + PWA manifest + server-side Web Push.

Client (apps/web)

  • PWA manifest + 192/512 icons + iOS meta (installability; iOS Safari Web Push requires an installed PWA).
  • Push-only service worker (public/sw.js) — deliberately no fetch handler (not an offline PWA; avoids stale-asset risk). Shows on push, focuses/opens the waiting thread on notificationclick, and suppresses when any T3 tab is open so the merged in-page NotificationCoordinator stays the single source while a tab is alive.
  • PushSubscriptionManager keeps the subscription in sync with the existing notifyOnNeedsInput setting + permission (reacts to a post-mount grant via the Permissions API). Pure, unit-tested VAPID/subscription helpers (push.logic.ts).

Server (apps/server/src/t3x/webPush, fork-seam clean)

Mirrors the auto-resume feature: zero edits to upstream-owned files (contracts / ws.ts / http.ts / Migrations.ts / server.ts). Everything mounts through the existing T3xLayerLive + T3xRoutesLive seams.

  • Reactor taps OrchestrationEngine.streamDomainEvents, recomputes the awareness phase with the shared projectThreadAwareness, edge-detects the same transitions the web client does (waiting_for_input / waiting_for_approval; running → completed), and sends Web Push to registered subscriptions, pruning expired (404/410) ones.
  • JSON subscription store in the state dir (keyed by endpoint) — no DB migration.
  • VAPID keypair via ServerSecretStore (generated + persisted on first boot; T3X_VAPID_PUBLIC_KEY/T3X_VAPID_PRIVATE_KEY/T3X_VAPID_SUBJECT env override supported).
  • Raw routes GET/POST /api/t3x/push/{vapid-public-key,subscribe,unsubscribe} (read/operate scoped), called from the client exactly like AutoResumeOverlay calls /api/t3x/auto-resume.

Identity note

apps/server is single-user/single-environment (no Clerk server-side), so subscriptions key by auth session (device); "notify me" = push to all subscriptions in the environment.

Verified

  • apps/web + apps/server typecheck clean (0 errors).
  • Unit tests pass: web push.logic (7), server attention edge-tracker + suite (157).
  • web-push loads at runtime (VAPID generation OK); server + bin construction tests (277) pass with the reactor/routes wired in.

Manual QA (needs a real browser + push service — not automatable here)

  1. Open T3 over HTTPS (the Tailscale URL works); grant notification permission; on iPhone add to Home Screen.
  2. Start an agent turn, then close the tab; when the agent needs input / completes, confirm a push arrives and clicking it opens the thread.
  3. With a tab open, confirm no duplicate (in-page path handles it).

Deliberate v1 tradeoffs

  • De-dup is service-worker-side (suppress when a tab is open). When a tab is open but backgrounded, the push is received-but-not-shown; Chrome's silent-push budget makes this fine at this low volume, but a future refinement is server-side presence gating.
  • Multi-device: push goes to all registered devices; per-device presence isn't tracked yet.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b8bd921f-e071-40e8-a705-9de730eac990

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/web-push-notifications

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@radroid

radroid commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

Adversarial self-review + fixes

Ran an adversarial review of this branch and pushed fixes for the actionable findings:

  • Reactor priming (medium): the store-empty short-circuit ran before tracker.observe, so the first transition after a device subscribed could be swallowed as first-seen. Now observes the phase unconditionally and gates only the send on subscription count. Also added a conservative event denylist (skip meta/mode-change events) so we don't refetch the shell on every domain event.
  • VAPID recovery (medium): if the server's VAPID key ever changes, old subscriptions became permanently undeliverable with no recovery. Now the client re-subscribes when an existing subscription's applicationServerKey ≠ the current server key, and the server also prunes on 403 (not just 404/410).
  • Permission-revoked cleanup (low): unsubscribe server-side when notification permission flips to denied while the setting stays on.
  • Malformed body (low): a non-JSON request body now returns 400 instead of 500.

Known v1 limitations (documented, not blocking)

  • SW de-dup vs. silent-push budget: the service worker suppresses when any T3 tab is open (so notifications are never doubled), which means a push received while a tab is open shows nothing. Chrome's silent-push budget tolerates this at low volume, but the clean long-term fix is server-side presence gating (skip pushing to a device whose auth session has a live connection). Edge: if the only open tab is on a route that doesn't mount the in-page coordinator (e.g. /pair), that push is suppressed without an in-page fallback.
  • Post-auth subscribe timing: if the very first subscribe attempt races primary-environment auth it no-ops until the next permission/setting change; narrow and usually masked.

Verification: both packages typecheck clean (0 errors); web + server unit tests pass; web-push loads at runtime; server+bin construction tests (277) pass. Browser end-to-end remains manual QA.

radroid and others added 2 commits July 27, 2026 11:30
… push)

Closes the accepted v1 gap from #14: notifications previously only fired while
a T3 tab was open. This delivers a push when the tab is fully closed.

Client (apps/web):
- PWA manifest + icons + installability (required for iOS Safari Web Push).
- Push-only service worker (no fetch handler, so no offline/stale-asset risk):
  shows a notification on push, focuses/opens the waiting thread on click, and
  suppresses when any T3 tab is open so the in-page NotificationCoordinator
  stays the single source while a tab is alive.
- PushSubscriptionManager keeps the subscription in sync with the existing
  notifyOnNeedsInput setting + permission (reacts to a post-mount grant via the
  Permissions API). Pure, tested VAPID/subscription helpers.

Server (apps/server/src/t3x/webPush — fork-seam clean, no upstream edits):
- Reactor taps OrchestrationEngine.streamDomainEvents, recomputes the awareness
  phase with the shared projectThreadAwareness, edge-detects the same
  transitions the web client does (waiting_for_input/approval; running->
  completed), and sends Web Push to registered subscriptions, pruning expired
  (404/410) ones.
- JSON subscription store in the state dir; VAPID keypair via ServerSecretStore
  (T3X_VAPID_* env override supported); raw
  /api/t3x/push/{subscribe,unsubscribe,vapid-public-key} routes mounted through
  the existing T3xRoutesLive seam.

Verified: apps/web + apps/server typecheck clean (0 errors); web + server unit
tests pass; web-push loads at runtime; server+bin construction tests (277) pass.
Browser end-to-end (grant permission, close tab, receive push) is manual QA.

Closes #19

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…covery, cleanup)

- Reactor: observe the awareness phase unconditionally (prime the tracker even
  with no subscribers) so the first transition after a device subscribes isn't
  swallowed as a first-seen observation; gate only the send on subscription
  count. Add a conservative event denylist (skip meta/mode-change events) to
  avoid a shell fetch + recompute on every domain event.
- send: also prune subscriptions on 403 (VAPID mismatch), not just 404/410.
- push client: re-subscribe when an existing browser subscription's
  applicationServerKey no longer matches the current server VAPID key
  (self-heals after a key change), instead of re-affirming a dead subscription.
- PushSubscriptionManager: unsubscribe when notification permission is revoked
  (denied) while the setting stays on.
- routes: a malformed (non-JSON) body now returns 400 instead of 500.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@radroid
radroid force-pushed the t3code/web-push-notifications branch from 24a2253 to a19b7eb Compare July 27, 2026 15:34
@radroid
radroid merged commit 9541837 into main Jul 27, 2026
2 checks passed
@radroid
radroid deleted the t3code/web-push-notifications branch July 27, 2026 19:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(web): closed-tab browser push notifications (SW + PWA + server Web Push)

1 participant