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

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

coderabbitaiBot 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

Copy link
Copy Markdown
OwnerAuthor

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 beforetracker.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.

radroidand 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
radroidforce-pushed the t3code/web-push-notifications branch from 24a2253 to a19b7ebCompareJuly 27, 2026 15:34
@radroid
radroid merged commit 9541837 into mainJul 27, 2026
2 checks passed
@radroid
radroid deleted the t3code/web-push-notifications branch July 27, 2026 19:02
Sign up for freeto 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

@radroid
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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

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

coderabbitaiBot 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

Copy link
Copy Markdown
OwnerAuthor

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 beforetracker.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.

radroidand 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
radroidforce-pushed the t3code/web-push-notifications branch from 24a2253 to a19b7ebCompareJuly 27, 2026 15:34
@radroid
radroid merged commit 9541837 into mainJul 27, 2026
2 checks passed
@radroid
radroid deleted the t3code/web-push-notifications branch July 27, 2026 19:02
Sign up for freeto 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

@radroid
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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

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

coderabbitaiBot 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

Copy link
Copy Markdown
OwnerAuthor

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 beforetracker.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.

radroidand 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
radroidforce-pushed the t3code/web-push-notifications branch from 24a2253 to a19b7ebCompareJuly 27, 2026 15:34
@radroid
radroid merged commit 9541837 into mainJul 27, 2026
2 checks passed
@radroid
radroid deleted the t3code/web-push-notifications branch July 27, 2026 19:02
Sign up for freeto 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

@radroid
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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

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

coderabbitaiBot 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

Copy link
Copy Markdown
OwnerAuthor

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 beforetracker.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.

radroidand 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
radroidforce-pushed the t3code/web-push-notifications branch from 24a2253 to a19b7ebCompareJuly 27, 2026 15:34
@radroid
radroid merged commit 9541837 into mainJul 27, 2026
2 checks passed
@radroid
radroid deleted the t3code/web-push-notifications branch July 27, 2026 19:02
Sign up for freeto 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

@radroid
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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

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

coderabbitaiBot 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

Copy link
Copy Markdown
OwnerAuthor

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 beforetracker.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.

radroidand 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
radroidforce-pushed the t3code/web-push-notifications branch from 24a2253 to a19b7ebCompareJuly 27, 2026 15:34
@radroid
radroid merged commit 9541837 into mainJul 27, 2026
2 checks passed
@radroid
radroid deleted the t3code/web-push-notifications branch July 27, 2026 19:02
Sign up for freeto 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

@radroid
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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

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

coderabbitaiBot 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

Copy link
Copy Markdown
OwnerAuthor

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 beforetracker.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.

radroidand 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
radroidforce-pushed the t3code/web-push-notifications branch from 24a2253 to a19b7ebCompareJuly 27, 2026 15:34
@radroid
radroid merged commit 9541837 into mainJul 27, 2026
2 checks passed
@radroid
radroid deleted the t3code/web-push-notifications branch July 27, 2026 19:02
Sign up for freeto 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

@radroid
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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

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

coderabbitaiBot 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

Copy link
Copy Markdown
OwnerAuthor

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 beforetracker.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.

radroidand 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
radroidforce-pushed the t3code/web-push-notifications branch from 24a2253 to a19b7ebCompareJuly 27, 2026 15:34
@radroid
radroid merged commit 9541837 into mainJul 27, 2026
2 checks passed
@radroid
radroid deleted the t3code/web-push-notifications branch July 27, 2026 19:02
Sign up for freeto 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

@radroid
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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

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

coderabbitaiBot 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

Copy link
Copy Markdown
OwnerAuthor

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 beforetracker.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.

radroidand 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
radroidforce-pushed the t3code/web-push-notifications branch from 24a2253 to a19b7ebCompareJuly 27, 2026 15:34
@radroid
radroid merged commit 9541837 into mainJul 27, 2026
2 checks passed
@radroid
radroid deleted the t3code/web-push-notifications branch July 27, 2026 19:02
Sign up for freeto 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

@radroid