From f79ccd88b675d0aa8bf306927f777ae22ae13234 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Thu, 30 Jul 2026 15:18:03 -0400 Subject: [PATCH 1/4] feat(notifications): native OS notifications for @-mentions Poll the task-mentions index from a boot contribution and route new mentions of the current user through the NotificationBus, so they get the same suppression, settings gating, completion sound, and click-through-to-task behavior as agent activity notifications. Generated-By: PostHog Code Task-Id: d77f2cfe-cdea-4bc9-af8a-1d3eea39da59 --- .../src/canvas/mentionNotifications.test.ts | 103 +++++++++++++ .../core/src/canvas/mentionNotifications.ts | 72 +++++++++ .../mentionNotifications.contribution.test.ts | 139 ++++++++++++++++++ .../mentionNotifications.contribution.ts | 120 +++++++++++++++ .../notifications/notifications.module.ts | 4 + 5 files changed, 438 insertions(+) create mode 100644 packages/core/src/canvas/mentionNotifications.test.ts create mode 100644 packages/core/src/canvas/mentionNotifications.ts create mode 100644 packages/ui/src/features/notifications/mentionNotifications.contribution.test.ts create mode 100644 packages/ui/src/features/notifications/mentionNotifications.contribution.ts diff --git a/packages/core/src/canvas/mentionNotifications.test.ts b/packages/core/src/canvas/mentionNotifications.test.ts new file mode 100644 index 0000000000..eca6e160ec --- /dev/null +++ b/packages/core/src/canvas/mentionNotifications.test.ts @@ -0,0 +1,103 @@ +import type { TaskMention } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { + advanceMentionWatch, + baselineMentionWatch, + INITIAL_MENTION_WATCH_STATE, +} from "./mentionNotifications"; + +function mention(overrides: Partial): TaskMention { + return { + id: "mention-1", + message_id: "message-1", + task_id: "task-1", + task_title: "Fix flaky tests", + content: "@[Adam](adam@posthog.com) can you look?", + created_at: "2026-07-30T10:00:00Z", + ...overrides, + }; +} + +describe("baselineMentionWatch", () => { + it("absorbs the backlog without notifying and remembers the newest timestamp", () => { + const state = baselineMentionWatch( + [ + mention({ message_id: "m2", created_at: "2026-07-30T10:05:00Z" }), + mention({ message_id: "m1", created_at: "2026-07-30T10:00:00Z" }), + ], + "2026-07-30T11:00:00Z", + ); + expect(state.seenThrough).toBe("2026-07-30T10:05:00Z"); + expect(state.notifiedMessageIds).toEqual(["m2", "m1"]); + }); + + it("baselines an empty backlog to now so the first real mention still notifies", () => { + const state = baselineMentionWatch([], "2026-07-30T11:00:00Z"); + expect(state.seenThrough).toBe("2026-07-30T11:00:00Z"); + + const { toNotify } = advanceMentionWatch(state, [ + mention({ message_id: "m1", created_at: "2026-07-30T11:01:00Z" }), + ]); + expect(toNotify.map((m) => m.message_id)).toEqual(["m1"]); + }); +}); + +describe("advanceMentionWatch", () => { + it("returns nothing and keeps state identity when the poll is empty", () => { + const state = baselineMentionWatch([], "2026-07-30T11:00:00Z"); + const result = advanceMentionWatch(state, []); + expect(result.toNotify).toEqual([]); + expect(result.state).toBe(state); + }); + + it("notifies new mentions oldest first and advances the watermark", () => { + const state = baselineMentionWatch([], "2026-07-30T11:00:00Z"); + const { state: next, toNotify } = advanceMentionWatch(state, [ + mention({ message_id: "m2", created_at: "2026-07-30T11:05:00Z" }), + mention({ message_id: "m1", created_at: "2026-07-30T11:01:00Z" }), + ]); + expect(toNotify.map((m) => m.message_id)).toEqual(["m1", "m2"]); + expect(next.seenThrough).toBe("2026-07-30T11:05:00Z"); + }); + + it("dedupes messages already notified", () => { + const first = advanceMentionWatch( + baselineMentionWatch([], "2026-07-30T11:00:00Z"), + [mention({ message_id: "m1", created_at: "2026-07-30T11:01:00Z" })], + ); + const second = advanceMentionWatch(first.state, [ + mention({ message_id: "m1", created_at: "2026-07-30T11:01:00Z" }), + mention({ message_id: "m2", created_at: "2026-07-30T11:02:00Z" }), + ]); + expect(second.toNotify.map((m) => m.message_id)).toEqual(["m2"]); + }); + + it("never moves the watermark backwards", () => { + const state = { + seenThrough: "2026-07-30T12:00:00Z", + notifiedMessageIds: [], + }; + const { state: next } = advanceMentionWatch(state, [ + mention({ message_id: "m1", created_at: "2026-07-30T11:59:00Z" }), + ]); + expect(next.seenThrough).toBe("2026-07-30T12:00:00Z"); + }); + + it("caps the dedupe set at 500 ids, keeping the newest", () => { + const backlog = Array.from({ length: 499 }, (_, i) => + mention({ message_id: `old-${i}`, created_at: "2026-07-30T10:00:00Z" }), + ); + const state = baselineMentionWatch(backlog, "2026-07-30T11:00:00Z"); + const { state: next } = advanceMentionWatch(state, [ + mention({ message_id: "new-1", created_at: "2026-07-30T11:01:00Z" }), + mention({ message_id: "new-2", created_at: "2026-07-30T11:02:00Z" }), + ]); + expect(next.notifiedMessageIds).toHaveLength(500); + expect(next.notifiedMessageIds.slice(0, 2)).toEqual(["new-2", "new-1"]); + expect(next.notifiedMessageIds).not.toContain("old-498"); + }); + + it("keeps INITIAL state inert", () => { + expect(INITIAL_MENTION_WATCH_STATE.seenThrough).toBeNull(); + }); +}); diff --git a/packages/core/src/canvas/mentionNotifications.ts b/packages/core/src/canvas/mentionNotifications.ts new file mode 100644 index 0000000000..e8414cf400 --- /dev/null +++ b/packages/core/src/canvas/mentionNotifications.ts @@ -0,0 +1,72 @@ +import type { TaskMention } from "@posthog/shared/domain-types"; + +/** + * Watch state for turning the mentions index (`getTaskMentions`) into + * notifications: what has already been processed, so each poll only surfaces + * mentions the user hasn't been notified about. + */ +export interface MentionWatchState { + /** Newest `created_at` already processed; null until the first fetch baselines. */ + seenThrough: string | null; + /** Message ids already notified, newest first, capped. */ + notifiedMessageIds: readonly string[]; +} + +export const INITIAL_MENTION_WATCH_STATE: MentionWatchState = { + seenThrough: null, + notifiedMessageIds: [], +}; + +// Bounds the dedupe set so a long-running session can't grow it without limit. +const MAX_TRACKED_MESSAGE_IDS = 500; + +/** + * Absorb the first fetch without notifying: the backlog isn't news. An empty + * first page baselines to `now` so the next mention to arrive still counts as + * new rather than becoming the baseline itself. + */ +export function baselineMentionWatch( + fetched: readonly TaskMention[], + now: string, +): MentionWatchState { + const newest = fetched.reduce( + (max, mention) => + max === null || mention.created_at > max ? mention.created_at : max, + null, + ); + return { + seenThrough: newest ?? now, + notifiedMessageIds: fetched + .slice(0, MAX_TRACKED_MESSAGE_IDS) + .map((mention) => mention.message_id), + }; +} + +/** + * Fold a poll's results into the state: which mentions to notify (oldest + * first, deduped by message) and the state to carry forward. + */ +export function advanceMentionWatch( + state: MentionWatchState, + fetched: readonly TaskMention[], +): { state: MentionWatchState; toNotify: TaskMention[] } { + if (fetched.length === 0) return { state, toNotify: [] }; + const alreadyNotified = new Set(state.notifiedMessageIds); + const toNotify = fetched + .filter((mention) => !alreadyNotified.has(mention.message_id)) + .sort((a, b) => (a.created_at < b.created_at ? -1 : 1)); + const newestFetched = fetched.reduce( + (max, mention) => (mention.created_at > max ? mention.created_at : max), + state.seenThrough ?? "", + ); + return { + state: { + seenThrough: newestFetched, + notifiedMessageIds: [ + ...toNotify.map((mention) => mention.message_id).reverse(), + ...state.notifiedMessageIds, + ].slice(0, MAX_TRACKED_MESSAGE_IDS), + }, + toNotify, + }; +} diff --git a/packages/ui/src/features/notifications/mentionNotifications.contribution.test.ts b/packages/ui/src/features/notifications/mentionNotifications.contribution.test.ts new file mode 100644 index 0000000000..32da812d72 --- /dev/null +++ b/packages/ui/src/features/notifications/mentionNotifications.contribution.test.ts @@ -0,0 +1,139 @@ +import type { TaskMention } from "@posthog/shared/domain-types"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { MentionNotificationsContribution } from "./mentionNotifications.contribution"; +import type { NotificationBus } from "./notifications"; + +function mention(overrides: Partial): TaskMention { + return { + id: "mention-1", + message_id: "message-1", + task_id: "task-1", + task_title: "Fix flaky tests", + author: { + id: 2, + uuid: "user-2", + first_name: "Charles", + last_name: "V", + email: "charles@posthog.com", + }, + content: "@[Adam](adam@posthog.com) can you look?", + created_at: "2026-07-30T10:00:00Z", + ...overrides, + }; +} + +const notify = vi.fn(); +const bus = { notify } as unknown as NotificationBus; + +describe("MentionNotificationsContribution", () => { + let contribution: MentionNotificationsContribution; + let mentionsByTick: TaskMention[][]; + let sinceByTick: (string | undefined)[]; + + beforeEach(() => { + vi.clearAllMocks(); + mentionsByTick = []; + sinceByTick = []; + contribution = new MentionNotificationsContribution(bus); + contribution.getClient = async () => ({ + getTaskMentions: async (options?: { since?: string }) => { + sinceByTick.push(options?.since); + return mentionsByTick.shift() ?? []; + }, + }); + }); + + it("absorbs the first fetch silently, then notifies new mentions with a task target", async () => { + mentionsByTick = [ + [mention({ message_id: "old", created_at: "2026-07-30T09:00:00Z" })], + [mention({ message_id: "new", created_at: "2026-07-30T10:00:00Z" })], + ]; + + await contribution.tick(); + expect(notify).not.toHaveBeenCalled(); + expect(sinceByTick[0]).toBeUndefined(); + + await contribution.tick(); + expect(sinceByTick[1]).toBe("2026-07-30T09:00:00Z"); + expect(notify).toHaveBeenCalledExactlyOnceWith({ + body: 'Charles V mentioned you in "Fix flaky tests"', + target: { kind: "task", taskId: "task-1" }, + toast: { level: "warning", description: "@Adam can you look?" }, + }); + }); + + it("collapses a burst into one summary notification", async () => { + mentionsByTick = [ + [], + Array.from({ length: 4 }, (_, i) => + mention({ + message_id: `m${i}`, + created_at: `2026-07-30T10:0${i}:00Z`, + }), + ), + ]; + + await contribution.tick(); + await contribution.tick(); + expect(notify).toHaveBeenCalledExactlyOnceWith({ + body: "4 new mentions from teammates", + toast: { level: "warning" }, + }); + }); + + it("re-baselines after logout so another account's backlog stays silent", async () => { + mentionsByTick = [ + [], + [mention({ message_id: "backlog", created_at: "2026-07-30T10:00:00Z" })], + ]; + await contribution.tick(); + + const client = contribution.getClient; + contribution.getClient = async () => null; + await contribution.tick(); + + contribution.getClient = client; + await contribution.tick(); + expect(notify).not.toHaveBeenCalled(); + expect(sinceByTick).toEqual([undefined, undefined]); + }); + + it("falls back to 'Someone' for agent-authored mentions", async () => { + mentionsByTick = [ + [], + [ + mention({ + message_id: "agent", + author: null, + created_at: "2026-07-30T10:00:00Z", + }), + ], + ]; + await contribution.tick(); + await contribution.tick(); + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ + body: 'Someone mentioned you in "Fix flaky tests"', + }), + ); + }); + + it("keeps polling after a failed fetch", async () => { + contribution.getClient = async () => ({ + getTaskMentions: async () => { + throw new Error("network down"); + }, + }); + await contribution.tick(); + + mentionsByTick = [[], []]; + contribution.getClient = async () => ({ + getTaskMentions: async (options?: { since?: string }) => { + sinceByTick.push(options?.since); + return mentionsByTick.shift() ?? []; + }, + }); + await contribution.tick(); + expect(sinceByTick[0]).toBeUndefined(); + }); +}); diff --git a/packages/ui/src/features/notifications/mentionNotifications.contribution.ts b/packages/ui/src/features/notifications/mentionNotifications.contribution.ts new file mode 100644 index 0000000000..189ebfe423 --- /dev/null +++ b/packages/ui/src/features/notifications/mentionNotifications.contribution.ts @@ -0,0 +1,120 @@ +import type { PostHogAPIClient } from "@posthog/api-client/posthog-client"; +import { + advanceMentionWatch, + baselineMentionWatch, + INITIAL_MENTION_WATCH_STATE, + type MentionWatchState, +} from "@posthog/core/canvas/mentionNotifications"; +import type { Contribution } from "@posthog/di/contribution"; +import { splitMentionSegments } from "@posthog/shared"; +import type { TaskMention } from "@posthog/shared/domain-types"; +import { getAuthenticatedClient } from "@posthog/ui/features/auth/authClientImperative"; +import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; +import { logger } from "@posthog/ui/shell/logger"; +import { inject, injectable } from "inversify"; +import { NotificationBus } from "./notifications"; + +const POLL_INTERVAL_MS = 60_000; +// Past this, individual notifications read as spam; collapse to one summary. +const MAX_INDIVIDUAL_NOTIFICATIONS = 3; +const MAX_TITLE_LENGTH = 50; +const MAX_PREVIEW_LENGTH = 120; + +type MentionsClient = Pick; + +const log = logger.scope("mention-notifications"); + +/** + * Polls the mentions index and routes new @-mentions of the current user + * through the NotificationBus, so they get the same suppression, settings + * gating, sound, and click-through-to-task as agent activity. + */ +@injectable() +export class MentionNotificationsContribution implements Contribution { + private state: MentionWatchState = INITIAL_MENTION_WATCH_STATE; + private inFlight = false; + + // Instance field rather than an injected dependency: resolving auth per tick + // covers login, logout, and project switches without subscription plumbing, + // and tests swap it for a stub. + getClient: () => Promise = getAuthenticatedClient; + + constructor(@inject(NotificationBus) private readonly bus: NotificationBus) {} + + start(): void { + void this.tick(); + setInterval(() => void this.tick(), POLL_INTERVAL_MS); + } + + async tick(): Promise { + if (this.inFlight) return; + this.inFlight = true; + try { + const client = await this.getClient(); + if (!client) { + // Logged out: re-baseline on next login so another account's backlog + // doesn't fire as new mentions. + this.state = INITIAL_MENTION_WATCH_STATE; + return; + } + const since = this.state.seenThrough; + const mentions = await client.getTaskMentions( + since ? { since } : undefined, + ); + if (since === null) { + this.state = baselineMentionWatch(mentions, new Date().toISOString()); + return; + } + const { state, toNotify } = advanceMentionWatch(this.state, mentions); + this.state = state; + this.notifyAll(toNotify); + } catch (error) { + log.warn("Mention poll failed", { error }); + } finally { + this.inFlight = false; + } + } + + private notifyAll(mentions: TaskMention[]): void { + if (mentions.length === 0) return; + if (mentions.length > MAX_INDIVIDUAL_NOTIFICATIONS) { + this.bus.notify({ + body: `${mentions.length} new mentions from teammates`, + toast: { level: "warning" }, + }); + return; + } + for (const mention of mentions) { + const author = mention.author + ? userDisplayName(mention.author) + : "Someone"; + const title = truncate( + mention.task_title || "Untitled task", + MAX_TITLE_LENGTH, + ); + this.bus.notify({ + body: `${author} mentioned you in "${title}"`, + target: { kind: "task", taskId: mention.task_id }, + toast: { + level: "warning", + description: mentionPreview(mention.content), + }, + }); + } + } +} + +function truncate(text: string, max: number): string { + return text.length <= max ? text : `${text.slice(0, max)}...`; +} + +function mentionPreview(content: string): string | undefined { + const plain = splitMentionSegments(content) + .map((segment) => + segment.type === "mention" ? `@${segment.name}` : segment.text, + ) + .join("") + .replace(/\s+/g, " ") + .trim(); + return plain ? truncate(plain, MAX_PREVIEW_LENGTH) : undefined; +} diff --git a/packages/ui/src/features/notifications/notifications.module.ts b/packages/ui/src/features/notifications/notifications.module.ts index 52c629c437..2fc2eb6da3 100644 --- a/packages/ui/src/features/notifications/notifications.module.ts +++ b/packages/ui/src/features/notifications/notifications.module.ts @@ -1,8 +1,12 @@ +import { CONTRIBUTION } from "@posthog/di/contribution"; import { ContainerModule } from "inversify"; +import { MentionNotificationsContribution } from "./mentionNotifications.contribution"; import { NotificationBus } from "./notifications"; import { SpeechNotifier } from "./speechNotifier"; export const notificationsUiModule = new ContainerModule(({ bind }) => { bind(NotificationBus).toSelf().inSingletonScope(); bind(SpeechNotifier).toSelf().inSingletonScope(); + bind(MentionNotificationsContribution).toSelf().inSingletonScope(); + bind(CONTRIBUTION).toService(MentionNotificationsContribution); }); From 28a7c4da4e932499d63d2707b402d29de96c58d5 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Thu, 30 Jul 2026 17:11:54 -0400 Subject: [PATCH 2/4] refactor(notifications): drive mention notifications off the mentions query cache Watch the channels UI's existing task-mentions query instead of running a second poll: one fetch path, one source of truth. Notifications now live where the spaces layout lives, which is the surface the product is converging on. Generated-By: PostHog Code Task-Id: d77f2cfe-cdea-4bc9-af8a-1d3eea39da59 --- .../canvas/hooks/useMentionActivity.ts | 2 +- .../mentionNotifications.contribution.test.ts | 135 ++++++++---------- .../mentionNotifications.contribution.ts | 87 ++++++----- 3 files changed, 102 insertions(+), 122 deletions(-) diff --git a/packages/ui/src/features/canvas/hooks/useMentionActivity.ts b/packages/ui/src/features/canvas/hooks/useMentionActivity.ts index 51fa6f9e73..25e5039893 100644 --- a/packages/ui/src/features/canvas/hooks/useMentionActivity.ts +++ b/packages/ui/src/features/canvas/hooks/useMentionActivity.ts @@ -9,7 +9,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useMemo } from "react"; const ACTIVITY_POLL_INTERVAL_MS = 60_000; -const TASK_MENTIONS_QUERY_KEY = ["task-mentions"] as const; +export const TASK_MENTIONS_QUERY_KEY = ["task-mentions"] as const; /** * Thread messages across all channels that @-mention the current user, diff --git a/packages/ui/src/features/notifications/mentionNotifications.contribution.test.ts b/packages/ui/src/features/notifications/mentionNotifications.contribution.test.ts index 32da812d72..b62d71d359 100644 --- a/packages/ui/src/features/notifications/mentionNotifications.contribution.test.ts +++ b/packages/ui/src/features/notifications/mentionNotifications.contribution.test.ts @@ -1,8 +1,12 @@ import type { TaskMention } from "@posthog/shared/domain-types"; +import { AUTH_SCOPED_QUERY_META } from "@posthog/ui/features/auth/useCurrentUser"; +import { QueryClient } from "@tanstack/react-query"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { MentionNotificationsContribution } from "./mentionNotifications.contribution"; import type { NotificationBus } from "./notifications"; +const MENTIONS_KEY = ["task-mentions"]; + function mention(overrides: Partial): TaskMention { return { id: "mention-1", @@ -26,35 +30,29 @@ const notify = vi.fn(); const bus = { notify } as unknown as NotificationBus; describe("MentionNotificationsContribution", () => { - let contribution: MentionNotificationsContribution; - let mentionsByTick: TaskMention[][]; - let sinceByTick: (string | undefined)[]; + let queryClient: QueryClient; beforeEach(() => { vi.clearAllMocks(); - mentionsByTick = []; - sinceByTick = []; - contribution = new MentionNotificationsContribution(bus); - contribution.getClient = async () => ({ - getTaskMentions: async (options?: { since?: string }) => { - sinceByTick.push(options?.since); - return mentionsByTick.shift() ?? []; - }, + queryClient = new QueryClient(); + queryClient.setQueryDefaults(MENTIONS_KEY, { + meta: AUTH_SCOPED_QUERY_META, }); + new MentionNotificationsContribution(bus, queryClient).start(); }); - it("absorbs the first fetch silently, then notifies new mentions with a task target", async () => { - mentionsByTick = [ - [mention({ message_id: "old", created_at: "2026-07-30T09:00:00Z" })], - [mention({ message_id: "new", created_at: "2026-07-30T10:00:00Z" })], - ]; - - await contribution.tick(); + it("absorbs the first page silently, then notifies new mentions with a task target", () => { + const backlog = mention({ + message_id: "old", + created_at: "2026-07-30T09:00:00Z", + }); + queryClient.setQueryData(MENTIONS_KEY, [backlog]); expect(notify).not.toHaveBeenCalled(); - expect(sinceByTick[0]).toBeUndefined(); - await contribution.tick(); - expect(sinceByTick[1]).toBe("2026-07-30T09:00:00Z"); + queryClient.setQueryData(MENTIONS_KEY, [ + mention({ message_id: "new", created_at: "2026-07-30T10:00:00Z" }), + backlog, + ]); expect(notify).toHaveBeenCalledExactlyOnceWith({ body: 'Charles V mentioned you in "Fix flaky tests"', target: { kind: "task", taskId: "task-1" }, @@ -62,78 +60,63 @@ describe("MentionNotificationsContribution", () => { }); }); - it("collapses a burst into one summary notification", async () => { - mentionsByTick = [ - [], - Array.from({ length: 4 }, (_, i) => - mention({ - message_id: `m${i}`, - created_at: `2026-07-30T10:0${i}:00Z`, - }), - ), + it("does not re-notify when the query refreshes with the same data", () => { + queryClient.setQueryData(MENTIONS_KEY, []); + const mentions = [ + mention({ message_id: "new", created_at: "2026-07-30T10:00:00Z" }), ]; + queryClient.setQueryData(MENTIONS_KEY, mentions); + queryClient.setQueryData(MENTIONS_KEY, [...mentions]); + expect(notify).toHaveBeenCalledTimes(1); + }); - await contribution.tick(); - await contribution.tick(); + it("collapses a burst into one summary notification", () => { + queryClient.setQueryData(MENTIONS_KEY, []); + queryClient.setQueryData( + MENTIONS_KEY, + Array.from({ length: 4 }, (_, i) => + mention({ message_id: `m${i}`, created_at: `2026-07-30T10:0${i}:00Z` }), + ), + ); expect(notify).toHaveBeenCalledExactlyOnceWith({ body: "4 new mentions from teammates", toast: { level: "warning" }, }); }); - it("re-baselines after logout so another account's backlog stays silent", async () => { - mentionsByTick = [ - [], - [mention({ message_id: "backlog", created_at: "2026-07-30T10:00:00Z" })], - ]; - await contribution.tick(); + it("re-baselines after the auth-scoped query is removed on logout", () => { + queryClient.setQueryData(MENTIONS_KEY, []); + queryClient.removeQueries({ queryKey: MENTIONS_KEY }); - const client = contribution.getClient; - contribution.getClient = async () => null; - await contribution.tick(); + queryClient.setQueryData(MENTIONS_KEY, [ + mention({ message_id: "backlog", created_at: "2026-07-30T10:00:00Z" }), + ]); + expect(notify).not.toHaveBeenCalled(); + }); - contribution.getClient = client; - await contribution.tick(); + it("ignores queries without the auth-scoped meta", () => { + const bare = new QueryClient(); + new MentionNotificationsContribution(bus, bare).start(); + bare.setQueryData(MENTIONS_KEY, []); + bare.setQueryData(MENTIONS_KEY, [ + mention({ message_id: "new", created_at: "2026-07-30T10:00:00Z" }), + ]); expect(notify).not.toHaveBeenCalled(); - expect(sinceByTick).toEqual([undefined, undefined]); }); - it("falls back to 'Someone' for agent-authored mentions", async () => { - mentionsByTick = [ - [], - [ - mention({ - message_id: "agent", - author: null, - created_at: "2026-07-30T10:00:00Z", - }), - ], - ]; - await contribution.tick(); - await contribution.tick(); + it("falls back to 'Someone' for agent-authored mentions", () => { + queryClient.setQueryData(MENTIONS_KEY, []); + queryClient.setQueryData(MENTIONS_KEY, [ + mention({ + message_id: "agent", + author: null, + created_at: "2026-07-30T10:00:00Z", + }), + ]); expect(notify).toHaveBeenCalledWith( expect.objectContaining({ body: 'Someone mentioned you in "Fix flaky tests"', }), ); }); - - it("keeps polling after a failed fetch", async () => { - contribution.getClient = async () => ({ - getTaskMentions: async () => { - throw new Error("network down"); - }, - }); - await contribution.tick(); - - mentionsByTick = [[], []]; - contribution.getClient = async () => ({ - getTaskMentions: async (options?: { since?: string }) => { - sinceByTick.push(options?.since); - return mentionsByTick.shift() ?? []; - }, - }); - await contribution.tick(); - expect(sinceByTick[0]).toBeUndefined(); - }); }); diff --git a/packages/ui/src/features/notifications/mentionNotifications.contribution.ts b/packages/ui/src/features/notifications/mentionNotifications.contribution.ts index 189ebfe423..03fe585402 100644 --- a/packages/ui/src/features/notifications/mentionNotifications.contribution.ts +++ b/packages/ui/src/features/notifications/mentionNotifications.contribution.ts @@ -1,4 +1,3 @@ -import type { PostHogAPIClient } from "@posthog/api-client/posthog-client"; import { advanceMentionWatch, baselineMentionWatch, @@ -8,71 +7,65 @@ import { import type { Contribution } from "@posthog/di/contribution"; import { splitMentionSegments } from "@posthog/shared"; import type { TaskMention } from "@posthog/shared/domain-types"; -import { getAuthenticatedClient } from "@posthog/ui/features/auth/authClientImperative"; +import { TASK_MENTIONS_QUERY_KEY } from "@posthog/ui/features/canvas/hooks/useMentionActivity"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; -import { logger } from "@posthog/ui/shell/logger"; +import { + IMPERATIVE_QUERY_CLIENT, + type ImperativeQueryClient, +} from "@posthog/ui/shell/queryClient"; import { inject, injectable } from "inversify"; import { NotificationBus } from "./notifications"; -const POLL_INTERVAL_MS = 60_000; // Past this, individual notifications read as spam; collapse to one summary. const MAX_INDIVIDUAL_NOTIFICATIONS = 3; const MAX_TITLE_LENGTH = 50; const MAX_PREVIEW_LENGTH = 120; -type MentionsClient = Pick; - -const log = logger.scope("mention-notifications"); - /** - * Polls the mentions index and routes new @-mentions of the current user - * through the NotificationBus, so they get the same suppression, settings - * gating, sound, and click-through-to-task as agent activity. + * Routes new @-mentions of the current user through the NotificationBus, so + * they get the same suppression, settings gating, sound, and click-through-to- + * task as agent activity. + * + * Rides the mentions query the channels UI already polls (`useMentionActivity`) + * rather than fetching itself — one poll, one source of truth. Notifications + * therefore live where the spaces layout lives, which is where the product is + * headed anyway. */ @injectable() export class MentionNotificationsContribution implements Contribution { private state: MentionWatchState = INITIAL_MENTION_WATCH_STATE; - private inFlight = false; - - // Instance field rather than an injected dependency: resolving auth per tick - // covers login, logout, and project switches without subscription plumbing, - // and tests swap it for a stub. - getClient: () => Promise = getAuthenticatedClient; - constructor(@inject(NotificationBus) private readonly bus: NotificationBus) {} + constructor( + @inject(NotificationBus) private readonly bus: NotificationBus, + @inject(IMPERATIVE_QUERY_CLIENT) + private readonly queryClient: ImperativeQueryClient, + ) {} start(): void { - void this.tick(); - setInterval(() => void this.tick(), POLL_INTERVAL_MS); - } - - async tick(): Promise { - if (this.inFlight) return; - this.inFlight = true; - try { - const client = await this.getClient(); - if (!client) { - // Logged out: re-baseline on next login so another account's backlog - // doesn't fire as new mentions. + this.queryClient.getQueryCache().subscribe((event) => { + if (!isTaskMentionsKey(event.query.queryKey)) return; + if (event.type === "removed") { + // Auth-scoped queries are removed on logout; re-baseline so the next + // account's backlog stays silent. this.state = INITIAL_MENTION_WATCH_STATE; return; } - const since = this.state.seenThrough; - const mentions = await client.getTaskMentions( - since ? { since } : undefined, - ); - if (since === null) { - this.state = baselineMentionWatch(mentions, new Date().toISOString()); - return; - } - const { state, toNotify } = advanceMentionWatch(this.state, mentions); - this.state = state; - this.notifyAll(toNotify); - } catch (error) { - log.warn("Mention poll failed", { error }); - } finally { - this.inFlight = false; + if (event.type !== "updated") return; + if (event.query.meta?.authScoped !== true) return; + const mentions = event.query.state.data as TaskMention[] | undefined; + if (mentions) this.absorb(mentions); + }); + } + + private absorb(mentions: readonly TaskMention[]): void { + if (this.state.seenThrough === null) { + // The first page after boot or login is backlog, not news. + this.state = baselineMentionWatch(mentions, new Date().toISOString()); + return; } + const { state, toNotify } = advanceMentionWatch(this.state, mentions); + this.state = state; + this.notifyAll(toNotify); } private notifyAll(mentions: TaskMention[]): void { @@ -104,6 +97,10 @@ export class MentionNotificationsContribution implements Contribution { } } +function isTaskMentionsKey(queryKey: readonly unknown[]): boolean { + return queryKey.length === 1 && queryKey[0] === TASK_MENTIONS_QUERY_KEY[0]; +} + function truncate(text: string, max: number): string { return text.length <= max ? text : `${text.slice(0, max)}...`; } From ecf829a84239a466c9505aeb5b1dfbafbcf1125c Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Thu, 30 Jul 2026 17:37:13 -0400 Subject: [PATCH 3/4] feat(notifications): ring once per mention batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NotificationDescriptor gains muteSound (skip the completion sound and native chime) and notify() reports the routed channel, so the mention batch mutes every notification after its first delivered one — three mentions in a poll window ring once instead of three times. A suppressed mention (its task is being viewed) hands the sound to the next one. Generated-By: PostHog Code Task-Id: d77f2cfe-cdea-4bc9-af8a-1d3eea39da59 --- .../mentionNotifications.contribution.test.ts | 23 ++++++++++++ .../mentionNotifications.contribution.ts | 8 ++++- .../notifications/notifications.test.ts | 36 +++++++++++++++++++ .../features/notifications/notifications.ts | 32 +++++++++++------ 4 files changed, 87 insertions(+), 12 deletions(-) diff --git a/packages/ui/src/features/notifications/mentionNotifications.contribution.test.ts b/packages/ui/src/features/notifications/mentionNotifications.contribution.test.ts index b62d71d359..84e554aca4 100644 --- a/packages/ui/src/features/notifications/mentionNotifications.contribution.test.ts +++ b/packages/ui/src/features/notifications/mentionNotifications.contribution.test.ts @@ -56,10 +56,33 @@ describe("MentionNotificationsContribution", () => { expect(notify).toHaveBeenCalledExactlyOnceWith({ body: 'Charles V mentioned you in "Fix flaky tests"', target: { kind: "task", taskId: "task-1" }, + muteSound: false, toast: { level: "warning", description: "@Adam can you look?" }, }); }); + it("rings once per batch: only the first delivered mention carries sound", () => { + queryClient.setQueryData(MENTIONS_KEY, []); + queryClient.setQueryData(MENTIONS_KEY, [ + mention({ message_id: "m2", created_at: "2026-07-30T10:02:00Z" }), + mention({ message_id: "m1", created_at: "2026-07-30T10:01:00Z" }), + ]); + expect(notify).toHaveBeenCalledTimes(2); + expect(notify.mock.calls[0][0]).toMatchObject({ muteSound: false }); + expect(notify.mock.calls[1][0]).toMatchObject({ muteSound: true }); + }); + + it("hands the batch's sound to the next mention when the first is suppressed", () => { + notify.mockReturnValueOnce("suppress"); + queryClient.setQueryData(MENTIONS_KEY, []); + queryClient.setQueryData(MENTIONS_KEY, [ + mention({ message_id: "m2", created_at: "2026-07-30T10:02:00Z" }), + mention({ message_id: "m1", created_at: "2026-07-30T10:01:00Z" }), + ]); + expect(notify.mock.calls[0][0]).toMatchObject({ muteSound: false }); + expect(notify.mock.calls[1][0]).toMatchObject({ muteSound: false }); + }); + it("does not re-notify when the query refreshes with the same data", () => { queryClient.setQueryData(MENTIONS_KEY, []); const mentions = [ diff --git a/packages/ui/src/features/notifications/mentionNotifications.contribution.ts b/packages/ui/src/features/notifications/mentionNotifications.contribution.ts index 03fe585402..3c58918d8d 100644 --- a/packages/ui/src/features/notifications/mentionNotifications.contribution.ts +++ b/packages/ui/src/features/notifications/mentionNotifications.contribution.ts @@ -77,6 +77,10 @@ export class MentionNotificationsContribution implements Contribution { }); return; } + // One meep per batch, not per mention: mute the sound on every + // notification after the first that actually delivered (a suppressed + // mention — its task is being viewed — shouldn't spend the batch's sound). + let sounded = false; for (const mention of mentions) { const author = mention.author ? userDisplayName(mention.author) @@ -85,14 +89,16 @@ export class MentionNotificationsContribution implements Contribution { mention.task_title || "Untitled task", MAX_TITLE_LENGTH, ); - this.bus.notify({ + const channel = this.bus.notify({ body: `${author} mentioned you in "${title}"`, target: { kind: "task", taskId: mention.task_id }, + muteSound: sounded, toast: { level: "warning", description: mentionPreview(mention.content), }, }); + if (channel !== "suppress") sounded = true; } } } diff --git a/packages/ui/src/features/notifications/notifications.test.ts b/packages/ui/src/features/notifications/notifications.test.ts index 2c5e53a81f..122861bd41 100644 --- a/packages/ui/src/features/notifications/notifications.test.ts +++ b/packages/ui/src/features/notifications/notifications.test.ts @@ -298,4 +298,40 @@ describe("sound", () => { bus.notifyPromptComplete("My task", "end_turn", TASK_ID, durationMs); expect(play).toHaveBeenCalledWith("meep", 80, [], expectedRate); }); + + it("muteSound skips the completion sound and silences the native chime", () => { + const { bus, play, notify } = makeBus({ hasFocus: false }); + const channel = bus.notify({ body: "quiet", muteSound: true }); + expect(channel).toBe("native"); + expect(play).not.toHaveBeenCalled(); + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ silent: true }), + ); + }); + + it("muteSound skips the sound on the toast tier too", () => { + const { bus, play } = makeBus({ + hasFocus: true, + activeTarget: taskTarget(OTHER_TASK_ID), + }); + const channel = bus.notify({ + body: "quiet", + target: taskTarget(TASK_ID), + muteSound: true, + toast: { level: "warning" }, + }); + expect(channel).toBe("toast"); + expect(play).not.toHaveBeenCalled(); + expect(toastMock.warning).toHaveBeenCalled(); + }); + + it("reports suppression so batch producers can hand the sound to the next item", () => { + const { bus, play } = makeBus({ + hasFocus: true, + activeTarget: taskTarget(TASK_ID), + }); + const channel = bus.notify({ body: "unseen", target: taskTarget(TASK_ID) }); + expect(channel).toBe("suppress"); + expect(play).not.toHaveBeenCalled(); + }); }); diff --git a/packages/ui/src/features/notifications/notifications.ts b/packages/ui/src/features/notifications/notifications.ts index 2b54f9dc53..4a344caf29 100644 --- a/packages/ui/src/features/notifications/notifications.ts +++ b/packages/ui/src/features/notifications/notifications.ts @@ -20,7 +20,10 @@ import { type INotificationSettings, NOTIFICATION_SETTINGS_PROVIDER, } from "./identifiers"; -import { routeNotification } from "./routeNotification"; +import { + type NotificationChannel, + routeNotification, +} from "./routeNotification"; const MAX_TITLE_LENGTH = 50; const log = logger.scope("notifications"); @@ -42,6 +45,10 @@ export interface NotificationDescriptor { duration?: number; }; silent?: boolean; + // Skip the completion sound AND the native chime for this notification. + // Batch producers set it on every notification after their first delivered + // one, so a burst rings once instead of once per item. + muteSound?: boolean; // How long the task took, in ms. When the user enables sound scaling, this // drives the completion sound's playback rate (fast task -> faster/higher). soundDurationMs?: number; @@ -79,13 +86,13 @@ export class NotificationBus { private readonly view: IActiveView, ) {} - notify(descriptor: NotificationDescriptor): void { + notify(descriptor: NotificationDescriptor): NotificationChannel { const channel = routeNotification({ appFocused: this.view.hasFocus(), viewingTarget: this.view.getActiveTarget(), notificationTarget: descriptor.target, }); - if (channel === "suppress") return; + if (channel === "suppress") return channel; const settings = this.settings.get(); const playbackRate = @@ -95,16 +102,18 @@ export class NotificationBus { : 1; // Sound fires on both delivered tiers (toast + native), not on suppress — // matching the pre-bus behavior where any non-suppressed notification rang. - playCompletionSound( - settings.completionSound, - settings.completionVolume, - settings.customSounds, - playbackRate, - ); + if (!descriptor.muteSound) { + playCompletionSound( + settings.completionSound, + settings.completionVolume, + settings.customSounds, + playbackRate, + ); + } if (channel === "toast") { this.showToast(descriptor); - return; + return channel; } // native @@ -118,13 +127,14 @@ export class NotificationBus { this.notifications.notify({ title: descriptor.title ?? "PostHog", body: descriptor.body, - silent: descriptor.silent ?? willPlaySound, + silent: descriptor.silent ?? (descriptor.muteSound || willPlaySound), target: descriptor.target, }); } if (settings.dockBadgeNotifications) this.notifications.showUnreadIndicator(); if (settings.dockBounceNotifications) this.notifications.requestAttention(); + return channel; } // --- Task-specific producers (delegate to notify) --- From 61d5db0f38fbac2a7a98f43f8eeb26ce5b35e89c Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Thu, 30 Jul 2026 17:48:40 -0400 Subject: [PATCH 4/4] refactor(notifications): one notification per mention batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A batch of new mentions is now a single notification — one banner, one sound — instead of individual notifications sharing a sound. Same-task batches keep click-through ("Charles mentioned you 2 times in ..."), mixed-task batches fall back to a count. Reverts the muteSound bus machinery, which this makes unnecessary. Generated-By: PostHog Code Task-Id: d77f2cfe-cdea-4bc9-af8a-1d3eea39da59 --- .../mentionNotifications.contribution.test.ts | 42 +++++++++---- .../mentionNotifications.contribution.ts | 59 +++++++++++-------- .../notifications/notifications.test.ts | 36 ----------- .../features/notifications/notifications.ts | 32 ++++------ 4 files changed, 75 insertions(+), 94 deletions(-) diff --git a/packages/ui/src/features/notifications/mentionNotifications.contribution.test.ts b/packages/ui/src/features/notifications/mentionNotifications.contribution.test.ts index 84e554aca4..dfd92c9f74 100644 --- a/packages/ui/src/features/notifications/mentionNotifications.contribution.test.ts +++ b/packages/ui/src/features/notifications/mentionNotifications.contribution.test.ts @@ -56,31 +56,43 @@ describe("MentionNotificationsContribution", () => { expect(notify).toHaveBeenCalledExactlyOnceWith({ body: 'Charles V mentioned you in "Fix flaky tests"', target: { kind: "task", taskId: "task-1" }, - muteSound: false, toast: { level: "warning", description: "@Adam can you look?" }, }); }); - it("rings once per batch: only the first delivered mention carries sound", () => { + it("collapses a same-task, same-author batch into one notification with click-through", () => { queryClient.setQueryData(MENTIONS_KEY, []); queryClient.setQueryData(MENTIONS_KEY, [ - mention({ message_id: "m2", created_at: "2026-07-30T10:02:00Z" }), + mention({ + message_id: "m2", + created_at: "2026-07-30T10:02:00Z", + content: "@[Adam](adam@posthog.com) second ping", + }), mention({ message_id: "m1", created_at: "2026-07-30T10:01:00Z" }), ]); - expect(notify).toHaveBeenCalledTimes(2); - expect(notify.mock.calls[0][0]).toMatchObject({ muteSound: false }); - expect(notify.mock.calls[1][0]).toMatchObject({ muteSound: true }); + expect(notify).toHaveBeenCalledExactlyOnceWith({ + body: 'Charles V mentioned you 2 times in "Fix flaky tests"', + target: { kind: "task", taskId: "task-1" }, + toast: { level: "warning", description: "@Adam second ping" }, + }); }); - it("hands the batch's sound to the next mention when the first is suppressed", () => { - notify.mockReturnValueOnce("suppress"); + it("counts a same-task batch from several authors without naming one", () => { queryClient.setQueryData(MENTIONS_KEY, []); queryClient.setQueryData(MENTIONS_KEY, [ - mention({ message_id: "m2", created_at: "2026-07-30T10:02:00Z" }), + mention({ + message_id: "m2", + created_at: "2026-07-30T10:02:00Z", + author: { id: 3, uuid: "user-3", email: "alessandro@posthog.com" }, + }), mention({ message_id: "m1", created_at: "2026-07-30T10:01:00Z" }), ]); - expect(notify.mock.calls[0][0]).toMatchObject({ muteSound: false }); - expect(notify.mock.calls[1][0]).toMatchObject({ muteSound: false }); + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ + body: '2 new mentions in "Fix flaky tests"', + target: { kind: "task", taskId: "task-1" }, + }), + ); }); it("does not re-notify when the query refreshes with the same data", () => { @@ -93,12 +105,16 @@ describe("MentionNotificationsContribution", () => { expect(notify).toHaveBeenCalledTimes(1); }); - it("collapses a burst into one summary notification", () => { + it("collapses a cross-task batch into one untargeted summary", () => { queryClient.setQueryData(MENTIONS_KEY, []); queryClient.setQueryData( MENTIONS_KEY, Array.from({ length: 4 }, (_, i) => - mention({ message_id: `m${i}`, created_at: `2026-07-30T10:0${i}:00Z` }), + mention({ + message_id: `m${i}`, + task_id: `task-${i}`, + created_at: `2026-07-30T10:0${i}:00Z`, + }), ), ); expect(notify).toHaveBeenCalledExactlyOnceWith({ diff --git a/packages/ui/src/features/notifications/mentionNotifications.contribution.ts b/packages/ui/src/features/notifications/mentionNotifications.contribution.ts index 3c58918d8d..4f76aa0815 100644 --- a/packages/ui/src/features/notifications/mentionNotifications.contribution.ts +++ b/packages/ui/src/features/notifications/mentionNotifications.contribution.ts @@ -16,8 +16,6 @@ import { import { inject, injectable } from "inversify"; import { NotificationBus } from "./notifications"; -// Past this, individual notifications read as spam; collapse to one summary. -const MAX_INDIVIDUAL_NOTIFICATIONS = 3; const MAX_TITLE_LENGTH = 50; const MAX_PREVIEW_LENGTH = 120; @@ -68,41 +66,54 @@ export class MentionNotificationsContribution implements Contribution { this.notifyAll(toNotify); } + // A batch is one notification, however many mentions it holds — one banner, + // one sound. Copy narrows with what the batch shares: same author and task + // name both; same task names the task and keeps click-through; mixed tasks + // fall back to a count (no target — a click can't pick one task). private notifyAll(mentions: TaskMention[]): void { if (mentions.length === 0) return; - if (mentions.length > MAX_INDIVIDUAL_NOTIFICATIONS) { + if (mentions.length === 1) { + const mention = mentions[0]; this.bus.notify({ - body: `${mentions.length} new mentions from teammates`, - toast: { level: "warning" }, - }); - return; - } - // One meep per batch, not per mention: mute the sound on every - // notification after the first that actually delivered (a suppressed - // mention — its task is being viewed — shouldn't spend the batch's sound). - let sounded = false; - for (const mention of mentions) { - const author = mention.author - ? userDisplayName(mention.author) - : "Someone"; - const title = truncate( - mention.task_title || "Untitled task", - MAX_TITLE_LENGTH, - ); - const channel = this.bus.notify({ - body: `${author} mentioned you in "${title}"`, + body: `${authorName(mention)} mentioned you in "${taskTitle(mention)}"`, target: { kind: "task", taskId: mention.task_id }, - muteSound: sounded, toast: { level: "warning", description: mentionPreview(mention.content), }, }); - if (channel !== "suppress") sounded = true; + return; } + const first = mentions[0]; + const newest = mentions[mentions.length - 1]; + if (mentions.some((mention) => mention.task_id !== first.task_id)) { + this.bus.notify({ + body: `${mentions.length} new mentions from teammates`, + toast: { level: "warning" }, + }); + return; + } + const sameAuthor = mentions.every( + (mention) => mention.author?.uuid === first.author?.uuid, + ); + this.bus.notify({ + body: sameAuthor + ? `${authorName(first)} mentioned you ${mentions.length} times in "${taskTitle(first)}"` + : `${mentions.length} new mentions in "${taskTitle(first)}"`, + target: { kind: "task", taskId: first.task_id }, + toast: { level: "warning", description: mentionPreview(newest.content) }, + }); } } +function authorName(mention: TaskMention): string { + return mention.author ? userDisplayName(mention.author) : "Someone"; +} + +function taskTitle(mention: TaskMention): string { + return truncate(mention.task_title || "Untitled task", MAX_TITLE_LENGTH); +} + function isTaskMentionsKey(queryKey: readonly unknown[]): boolean { return queryKey.length === 1 && queryKey[0] === TASK_MENTIONS_QUERY_KEY[0]; } diff --git a/packages/ui/src/features/notifications/notifications.test.ts b/packages/ui/src/features/notifications/notifications.test.ts index 122861bd41..2c5e53a81f 100644 --- a/packages/ui/src/features/notifications/notifications.test.ts +++ b/packages/ui/src/features/notifications/notifications.test.ts @@ -298,40 +298,4 @@ describe("sound", () => { bus.notifyPromptComplete("My task", "end_turn", TASK_ID, durationMs); expect(play).toHaveBeenCalledWith("meep", 80, [], expectedRate); }); - - it("muteSound skips the completion sound and silences the native chime", () => { - const { bus, play, notify } = makeBus({ hasFocus: false }); - const channel = bus.notify({ body: "quiet", muteSound: true }); - expect(channel).toBe("native"); - expect(play).not.toHaveBeenCalled(); - expect(notify).toHaveBeenCalledWith( - expect.objectContaining({ silent: true }), - ); - }); - - it("muteSound skips the sound on the toast tier too", () => { - const { bus, play } = makeBus({ - hasFocus: true, - activeTarget: taskTarget(OTHER_TASK_ID), - }); - const channel = bus.notify({ - body: "quiet", - target: taskTarget(TASK_ID), - muteSound: true, - toast: { level: "warning" }, - }); - expect(channel).toBe("toast"); - expect(play).not.toHaveBeenCalled(); - expect(toastMock.warning).toHaveBeenCalled(); - }); - - it("reports suppression so batch producers can hand the sound to the next item", () => { - const { bus, play } = makeBus({ - hasFocus: true, - activeTarget: taskTarget(TASK_ID), - }); - const channel = bus.notify({ body: "unseen", target: taskTarget(TASK_ID) }); - expect(channel).toBe("suppress"); - expect(play).not.toHaveBeenCalled(); - }); }); diff --git a/packages/ui/src/features/notifications/notifications.ts b/packages/ui/src/features/notifications/notifications.ts index 4a344caf29..2b54f9dc53 100644 --- a/packages/ui/src/features/notifications/notifications.ts +++ b/packages/ui/src/features/notifications/notifications.ts @@ -20,10 +20,7 @@ import { type INotificationSettings, NOTIFICATION_SETTINGS_PROVIDER, } from "./identifiers"; -import { - type NotificationChannel, - routeNotification, -} from "./routeNotification"; +import { routeNotification } from "./routeNotification"; const MAX_TITLE_LENGTH = 50; const log = logger.scope("notifications"); @@ -45,10 +42,6 @@ export interface NotificationDescriptor { duration?: number; }; silent?: boolean; - // Skip the completion sound AND the native chime for this notification. - // Batch producers set it on every notification after their first delivered - // one, so a burst rings once instead of once per item. - muteSound?: boolean; // How long the task took, in ms. When the user enables sound scaling, this // drives the completion sound's playback rate (fast task -> faster/higher). soundDurationMs?: number; @@ -86,13 +79,13 @@ export class NotificationBus { private readonly view: IActiveView, ) {} - notify(descriptor: NotificationDescriptor): NotificationChannel { + notify(descriptor: NotificationDescriptor): void { const channel = routeNotification({ appFocused: this.view.hasFocus(), viewingTarget: this.view.getActiveTarget(), notificationTarget: descriptor.target, }); - if (channel === "suppress") return channel; + if (channel === "suppress") return; const settings = this.settings.get(); const playbackRate = @@ -102,18 +95,16 @@ export class NotificationBus { : 1; // Sound fires on both delivered tiers (toast + native), not on suppress — // matching the pre-bus behavior where any non-suppressed notification rang. - if (!descriptor.muteSound) { - playCompletionSound( - settings.completionSound, - settings.completionVolume, - settings.customSounds, - playbackRate, - ); - } + playCompletionSound( + settings.completionSound, + settings.completionVolume, + settings.customSounds, + playbackRate, + ); if (channel === "toast") { this.showToast(descriptor); - return channel; + return; } // native @@ -127,14 +118,13 @@ export class NotificationBus { this.notifications.notify({ title: descriptor.title ?? "PostHog", body: descriptor.body, - silent: descriptor.silent ?? (descriptor.muteSound || willPlaySound), + silent: descriptor.silent ?? willPlaySound, target: descriptor.target, }); } if (settings.dockBadgeNotifications) this.notifications.showUnreadIndicator(); if (settings.dockBounceNotifications) this.notifications.requestAttention(); - return channel; } // --- Task-specific producers (delegate to notify) ---