Skip to content

feat(notifications): focus-aware notification bus on quill toasts#2934

Open
adamleithp wants to merge 2 commits into
mainfrom
feat/notification-bus
Open

feat(notifications): focus-aware notification bus on quill toasts#2934
adamleithp wants to merge 2 commits into
mainfrom
feat/notification-bus

Conversation

@adamleithp

@adamleithp adamleithp commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

What

A single notification bus that every app notification routes through, delivering by focus + what you're currently viewing:

You are… Result
viewing the exact target (task/canvas) suppress (you can already see it)
app focused, but elsewhere in-app toast with a click-through
app unfocused (another OS app) native OS notification — click deep-links to the target
2026-06-25 13 22 15 image Screenshot 2026-06-25 at 13 24 20 image

How

  • NotificationBus (renderer) replaces TaskNotificationService. A pure, table-tested routeNotification() decides the tier from a generic NotificationTarget union (task | canvas), defined in @posthog/platform (so core/host-router/ui share it without an internal-import violation).
  • Native click routing generalized via a new OpenTargetLinkService + deep-link router onOpenTarget/getPendingOpenTarget. The renderer consumer drains pending every (re)subscribe, so a click landing during a reconnect/HMR gap still routes (was the "OS deep-link doesn't route" bug).
  • Producers migrated: task prompt-complete + permission requests now flow through the bus (focused-elsewhere becomes a toast, not a native notification); canvas-generation-done re-emits through it (detection kept).
  • Toast primitive rebuilt on @posthog/quill — provider-managed dismiss, stacking, and auto-dismiss (fixes the hand-rolled sonner-custom toast that couldn't be dismissed). All ~18 direct-sonner callers migrated; sonner dropped entirely.
  • New Settings → Notifications section: defaults, an OS-permission banner, reset-to-defaults, and a per-tier test harness (in-app toast / deep-link toast / native / deep-link notification / dock badge / bounce). Canvas copy gated on the bluebird flag.

Behavior change worth calling out

Task notifications that previously fired a native OS notification while the app was focused-but-elsewhere now show an in-app toast; native is reserved for when the app is backgrounded.

Testing

  • Pure routeNotification / targetsEqual table tests; zod↔platform NotificationTarget parity test; useOpenTargetDeepLink consumer test (warm task + canvas targets + pending-drain); NotificationBus tier-routing tests; core NotificationService target-click tests.
  • Full workspace typecheck green; UI/core/host-router suites green; biome clean; host-boundary no new violations.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown

React Doctor found no issues in the changed files. 🎉

Reviewed by React Doctor for commit 32bef75.

@adamleithp adamleithp force-pushed the feat/notification-bus branch from 77a5864 to 94658f5 Compare June 25, 2026 12:42
@greptile-apps

greptile-apps Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Reviews (1): Last reviewed commit: "feat(notifications): focus-aware notific..." | Re-trigger Greptile

Comment thread packages/ui/src/primitives/toast.tsx
Comment thread packages/core/src/links/open-target-link.ts
Route every app notification through one bus that delivers by focus + what
you're looking at:

- viewing the exact target (task/canvas) → suppress
- app focused but elsewhere → in-app toast (with a click-through)
- app unfocused → native OS notification (click deep-links to the target)

Core changes:
- NotificationBus (renderer) replaces TaskNotificationService; pure, tested
  routeNotification() decides the tier from a generic NotificationTarget union
  (task | canvas) defined in @posthog/platform.
- Native click routing generalized via a new OpenTargetLinkService + deep-link
  router onOpenTarget/getPendingOpenTarget; the renderer consumer drains pending
  on every (re)subscribe so a click during a reconnect/HMR gap still routes.
- Task notifications (prompt-complete, permission) migrated through the bus;
  canvas-generation-done re-emits through it (detection kept).
- Toast primitive rebuilt on @posthog/quill (provider-managed dismiss, stacking,
  auto-dismiss); all direct-sonner callers migrated and sonner dropped.
- New Settings → Notifications section: defaults, OS-permission banner,
  reset-to-defaults, and a per-tier test harness. Canvas copy gated on the
  bluebird flag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@adamleithp adamleithp force-pushed the feat/notification-bus branch from 94658f5 to 32bef75 Compare June 25, 2026 12:54
@adamleithp adamleithp requested a review from a team June 25, 2026 13:07

@adboio adboio left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not tested too extensively but seems legit!! left a few comments, nothing blocking but lmk what you think :)

import type { NotificationTarget } from "@posthog/platform/notifications";

// Whether two targets point at the same thing (same kind + ids).
export function targetsEqual(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[still reading code, maybe this is stupid, will update if so lol]

can/should we make this a bit more generic? like just give NotificationTarget some uniqueIdentifier property so we can just return a.kind === b.kind && a.uniqueId === b.uniqueId ? otherwise we'll have to update this if/when new target types are introduced

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

edit: just looked at the NotificationTarget type, both task and canvas are effectively the same thing (seems like identifier + "sub-identifier" (? not familiar w/ canvas)

in that case, could we make it a bit more generic and maybe not even split out the kind on the target?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call on the maintainability point — done in 64df565. Collapsed the per-kind equality chain into a single targetKey(target) identity function; targetsEqual is now just targetKey(a) === targetKey(b), so adding a kind touches one exhaustive switch (compile error if you forget) instead of growing an if-chain that silently returns false.

On dropping kind / a flat uniqueId: I kept kind because it's load-bearing for navigation, not just equality — deriveAction in notifications.ts switches on it to pick navigateToTaskDetail(taskId) vs navigateToChannelDashboard(channelId, dashboardId), which need different fields. And canvas identity is composite (channelId + dashboardId), so a single uniqueId would have to be a synthesized composite key anyway — which is exactly what targetKey now produces. So we get the generic comparison you wanted while keeping the typed per-kind fields navigation relies on.

Comment on lines +42 to +46
// The single channel every app notification flows through. Reads focus + the
// active route, decides suppress / toast / native (see routeNotification), and
// dispatches accordingly. Native delivery + dock effects are gated by the user's
// notification settings; the in-app toast always shows (it's non-intrusive and
// only appears while the app is focused).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] not just here specifically, but lots of bgi comments throughout, thoughts on stripping them down a bit and shipping this with a markdown file that explains the notif system instead ? no strong opinion here just personal pref

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair — left them as concise inline comments for now rather than a separate markdown file. My reasoning: the routing decision (suppress/toast/native) is the one genuinely non-obvious bit and it's right next to the code it explains, where it's hardest to let drift out of sync. A standalone doc tends to rot once the code moves. Happy to pull it into a notifications/README.md if the comment density bugs you on a re-read though — no attachment.

Address review: collapse the per-kind equality chain into a single
targetKey() identity function. New target kinds are now a compile error
in one exhaustive switch instead of a silent false. Keeps `kind` (click
navigation in deriveAction dispatches on it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

2 participants