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/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 new file mode 100644 index 0000000000..dfd92c9f74 --- /dev/null +++ b/packages/ui/src/features/notifications/mentionNotifications.contribution.test.ts @@ -0,0 +1,161 @@ +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", + 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 queryClient: QueryClient; + + beforeEach(() => { + vi.clearAllMocks(); + queryClient = new QueryClient(); + queryClient.setQueryDefaults(MENTIONS_KEY, { + meta: AUTH_SCOPED_QUERY_META, + }); + new MentionNotificationsContribution(bus, queryClient).start(); + }); + + 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(); + + 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" }, + toast: { level: "warning", description: "@Adam can you look?" }, + }); + }); + + 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", + content: "@[Adam](adam@posthog.com) second ping", + }), + mention({ message_id: "m1", created_at: "2026-07-30T10:01:00Z" }), + ]); + 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("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", + author: { id: 3, uuid: "user-3", email: "alessandro@posthog.com" }, + }), + mention({ message_id: "m1", created_at: "2026-07-30T10:01:00Z" }), + ]); + 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", () => { + 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); + }); + + 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}`, + task_id: `task-${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 the auth-scoped query is removed on logout", () => { + queryClient.setQueryData(MENTIONS_KEY, []); + queryClient.removeQueries({ queryKey: MENTIONS_KEY }); + + queryClient.setQueryData(MENTIONS_KEY, [ + mention({ message_id: "backlog", created_at: "2026-07-30T10:00:00Z" }), + ]); + expect(notify).not.toHaveBeenCalled(); + }); + + 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(); + }); + + 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"', + }), + ); + }); +}); 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..4f76aa0815 --- /dev/null +++ b/packages/ui/src/features/notifications/mentionNotifications.contribution.ts @@ -0,0 +1,134 @@ +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 { TASK_MENTIONS_QUERY_KEY } from "@posthog/ui/features/canvas/hooks/useMentionActivity"; +import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; +import { + IMPERATIVE_QUERY_CLIENT, + type ImperativeQueryClient, +} from "@posthog/ui/shell/queryClient"; +import { inject, injectable } from "inversify"; +import { NotificationBus } from "./notifications"; + +const MAX_TITLE_LENGTH = 50; +const MAX_PREVIEW_LENGTH = 120; + +/** + * 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; + + constructor( + @inject(NotificationBus) private readonly bus: NotificationBus, + @inject(IMPERATIVE_QUERY_CLIENT) + private readonly queryClient: ImperativeQueryClient, + ) {} + + start(): void { + 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; + } + 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); + } + + // 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 === 1) { + const mention = mentions[0]; + this.bus.notify({ + body: `${authorName(mention)} mentioned you in "${taskTitle(mention)}"`, + target: { kind: "task", taskId: mention.task_id }, + toast: { + level: "warning", + description: mentionPreview(mention.content), + }, + }); + 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]; +} + +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); });