Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions packages/core/src/canvas/mentionNotifications.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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();
});
});
72 changes: 72 additions & 0 deletions packages/core/src/canvas/mentionNotifications.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>(
(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,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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>): 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"',
}),
);
});
});
Loading
Loading