Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit f79ccd8

Browse files
authored
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
1 parent 5ac5892 commit f79ccd8

5 files changed

Lines changed: 438 additions & 0 deletions

File tree

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import type { TaskMention } from "@posthog/shared/domain-types";
2+
import { describe, expect, it } from "vitest";
3+
import {
4+
advanceMentionWatch,
5+
baselineMentionWatch,
6+
INITIAL_MENTION_WATCH_STATE,
7+
} from "./mentionNotifications";
8+
9+
function mention(overrides: Partial<TaskMention>): TaskMention {
10+
return {
11+
id: "mention-1",
12+
message_id: "message-1",
13+
task_id: "task-1",
14+
task_title: "Fix flaky tests",
15+
content: "@[Adam](adam@posthog.com) can you look?",
16+
created_at: "2026-07-30T10:00:00Z",
17+
...overrides,
18+
};
19+
}
20+
21+
describe("baselineMentionWatch", () => {
22+
it("absorbs the backlog without notifying and remembers the newest timestamp", () => {
23+
const state = baselineMentionWatch(
24+
[
25+
mention({ message_id: "m2", created_at: "2026-07-30T10:05:00Z" }),
26+
mention({ message_id: "m1", created_at: "2026-07-30T10:00:00Z" }),
27+
],
28+
"2026-07-30T11:00:00Z",
29+
);
30+
expect(state.seenThrough).toBe("2026-07-30T10:05:00Z");
31+
expect(state.notifiedMessageIds).toEqual(["m2", "m1"]);
32+
});
33+
34+
it("baselines an empty backlog to now so the first real mention still notifies", () => {
35+
const state = baselineMentionWatch([], "2026-07-30T11:00:00Z");
36+
expect(state.seenThrough).toBe("2026-07-30T11:00:00Z");
37+
38+
const { toNotify } = advanceMentionWatch(state, [
39+
mention({ message_id: "m1", created_at: "2026-07-30T11:01:00Z" }),
40+
]);
41+
expect(toNotify.map((m) => m.message_id)).toEqual(["m1"]);
42+
});
43+
});
44+
45+
describe("advanceMentionWatch", () => {
46+
it("returns nothing and keeps state identity when the poll is empty", () => {
47+
const state = baselineMentionWatch([], "2026-07-30T11:00:00Z");
48+
const result = advanceMentionWatch(state, []);
49+
expect(result.toNotify).toEqual([]);
50+
expect(result.state).toBe(state);
51+
});
52+
53+
it("notifies new mentions oldest first and advances the watermark", () => {
54+
const state = baselineMentionWatch([], "2026-07-30T11:00:00Z");
55+
const { state: next, toNotify } = advanceMentionWatch(state, [
56+
mention({ message_id: "m2", created_at: "2026-07-30T11:05:00Z" }),
57+
mention({ message_id: "m1", created_at: "2026-07-30T11:01:00Z" }),
58+
]);
59+
expect(toNotify.map((m) => m.message_id)).toEqual(["m1", "m2"]);
60+
expect(next.seenThrough).toBe("2026-07-30T11:05:00Z");
61+
});
62+
63+
it("dedupes messages already notified", () => {
64+
const first = advanceMentionWatch(
65+
baselineMentionWatch([], "2026-07-30T11:00:00Z"),
66+
[mention({ message_id: "m1", created_at: "2026-07-30T11:01:00Z" })],
67+
);
68+
const second = advanceMentionWatch(first.state, [
69+
mention({ message_id: "m1", created_at: "2026-07-30T11:01:00Z" }),
70+
mention({ message_id: "m2", created_at: "2026-07-30T11:02:00Z" }),
71+
]);
72+
expect(second.toNotify.map((m) => m.message_id)).toEqual(["m2"]);
73+
});
74+
75+
it("never moves the watermark backwards", () => {
76+
const state = {
77+
seenThrough: "2026-07-30T12:00:00Z",
78+
notifiedMessageIds: [],
79+
};
80+
const { state: next } = advanceMentionWatch(state, [
81+
mention({ message_id: "m1", created_at: "2026-07-30T11:59:00Z" }),
82+
]);
83+
expect(next.seenThrough).toBe("2026-07-30T12:00:00Z");
84+
});
85+
86+
it("caps the dedupe set at 500 ids, keeping the newest", () => {
87+
const backlog = Array.from({ length: 499 }, (_, i) =>
88+
mention({ message_id: `old-${i}`, created_at: "2026-07-30T10:00:00Z" }),
89+
);
90+
const state = baselineMentionWatch(backlog, "2026-07-30T11:00:00Z");
91+
const { state: next } = advanceMentionWatch(state, [
92+
mention({ message_id: "new-1", created_at: "2026-07-30T11:01:00Z" }),
93+
mention({ message_id: "new-2", created_at: "2026-07-30T11:02:00Z" }),
94+
]);
95+
expect(next.notifiedMessageIds).toHaveLength(500);
96+
expect(next.notifiedMessageIds.slice(0, 2)).toEqual(["new-2", "new-1"]);
97+
expect(next.notifiedMessageIds).not.toContain("old-498");
98+
});
99+
100+
it("keeps INITIAL state inert", () => {
101+
expect(INITIAL_MENTION_WATCH_STATE.seenThrough).toBeNull();
102+
});
103+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type { TaskMention } from "@posthog/shared/domain-types";
2+
3+
/**
4+
* Watch state for turning the mentions index (`getTaskMentions`) into
5+
* notifications: what has already been processed, so each poll only surfaces
6+
* mentions the user hasn't been notified about.
7+
*/
8+
export interface MentionWatchState {
9+
/** Newest `created_at` already processed; null until the first fetch baselines. */
10+
seenThrough: string | null;
11+
/** Message ids already notified, newest first, capped. */
12+
notifiedMessageIds: readonly string[];
13+
}
14+
15+
export const INITIAL_MENTION_WATCH_STATE: MentionWatchState = {
16+
seenThrough: null,
17+
notifiedMessageIds: [],
18+
};
19+
20+
// Bounds the dedupe set so a long-running session can't grow it without limit.
21+
const MAX_TRACKED_MESSAGE_IDS = 500;
22+
23+
/**
24+
* Absorb the first fetch without notifying: the backlog isn't news. An empty
25+
* first page baselines to `now` so the next mention to arrive still counts as
26+
* new rather than becoming the baseline itself.
27+
*/
28+
export function baselineMentionWatch(
29+
fetched: readonly TaskMention[],
30+
now: string,
31+
): MentionWatchState {
32+
const newest = fetched.reduce<string | null>(
33+
(max, mention) =>
34+
max === null || mention.created_at > max ? mention.created_at : max,
35+
null,
36+
);
37+
return {
38+
seenThrough: newest ?? now,
39+
notifiedMessageIds: fetched
40+
.slice(0, MAX_TRACKED_MESSAGE_IDS)
41+
.map((mention) => mention.message_id),
42+
};
43+
}
44+
45+
/**
46+
* Fold a poll's results into the state: which mentions to notify (oldest
47+
* first, deduped by message) and the state to carry forward.
48+
*/
49+
export function advanceMentionWatch(
50+
state: MentionWatchState,
51+
fetched: readonly TaskMention[],
52+
): { state: MentionWatchState; toNotify: TaskMention[] } {
53+
if (fetched.length === 0) return { state, toNotify: [] };
54+
const alreadyNotified = new Set(state.notifiedMessageIds);
55+
const toNotify = fetched
56+
.filter((mention) => !alreadyNotified.has(mention.message_id))
57+
.sort((a, b) => (a.created_at < b.created_at ? -1 : 1));
58+
const newestFetched = fetched.reduce(
59+
(max, mention) => (mention.created_at > max ? mention.created_at : max),
60+
state.seenThrough ?? "",
61+
);
62+
return {
63+
state: {
64+
seenThrough: newestFetched,
65+
notifiedMessageIds: [
66+
...toNotify.map((mention) => mention.message_id).reverse(),
67+
...state.notifiedMessageIds,
68+
].slice(0, MAX_TRACKED_MESSAGE_IDS),
69+
},
70+
toNotify,
71+
};
72+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import type { TaskMention } from "@posthog/shared/domain-types";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
import { MentionNotificationsContribution } from "./mentionNotifications.contribution";
4+
import type { NotificationBus } from "./notifications";
5+
6+
function mention(overrides: Partial<TaskMention>): TaskMention {
7+
return {
8+
id: "mention-1",
9+
message_id: "message-1",
10+
task_id: "task-1",
11+
task_title: "Fix flaky tests",
12+
author: {
13+
id: 2,
14+
uuid: "user-2",
15+
first_name: "Charles",
16+
last_name: "V",
17+
email: "charles@posthog.com",
18+
},
19+
content: "@[Adam](adam@posthog.com) can you look?",
20+
created_at: "2026-07-30T10:00:00Z",
21+
...overrides,
22+
};
23+
}
24+
25+
const notify = vi.fn();
26+
const bus = { notify } as unknown as NotificationBus;
27+
28+
describe("MentionNotificationsContribution", () => {
29+
let contribution: MentionNotificationsContribution;
30+
let mentionsByTick: TaskMention[][];
31+
let sinceByTick: (string | undefined)[];
32+
33+
beforeEach(() => {
34+
vi.clearAllMocks();
35+
mentionsByTick = [];
36+
sinceByTick = [];
37+
contribution = new MentionNotificationsContribution(bus);
38+
contribution.getClient = async () => ({
39+
getTaskMentions: async (options?: { since?: string }) => {
40+
sinceByTick.push(options?.since);
41+
return mentionsByTick.shift() ?? [];
42+
},
43+
});
44+
});
45+
46+
it("absorbs the first fetch silently, then notifies new mentions with a task target", async () => {
47+
mentionsByTick = [
48+
[mention({ message_id: "old", created_at: "2026-07-30T09:00:00Z" })],
49+
[mention({ message_id: "new", created_at: "2026-07-30T10:00:00Z" })],
50+
];
51+
52+
await contribution.tick();
53+
expect(notify).not.toHaveBeenCalled();
54+
expect(sinceByTick[0]).toBeUndefined();
55+
56+
await contribution.tick();
57+
expect(sinceByTick[1]).toBe("2026-07-30T09:00:00Z");
58+
expect(notify).toHaveBeenCalledExactlyOnceWith({
59+
body: 'Charles V mentioned you in "Fix flaky tests"',
60+
target: { kind: "task", taskId: "task-1" },
61+
toast: { level: "warning", description: "@Adam can you look?" },
62+
});
63+
});
64+
65+
it("collapses a burst into one summary notification", async () => {
66+
mentionsByTick = [
67+
[],
68+
Array.from({ length: 4 }, (_, i) =>
69+
mention({
70+
message_id: `m${i}`,
71+
created_at: `2026-07-30T10:0${i}:00Z`,
72+
}),
73+
),
74+
];
75+
76+
await contribution.tick();
77+
await contribution.tick();
78+
expect(notify).toHaveBeenCalledExactlyOnceWith({
79+
body: "4 new mentions from teammates",
80+
toast: { level: "warning" },
81+
});
82+
});
83+
84+
it("re-baselines after logout so another account's backlog stays silent", async () => {
85+
mentionsByTick = [
86+
[],
87+
[mention({ message_id: "backlog", created_at: "2026-07-30T10:00:00Z" })],
88+
];
89+
await contribution.tick();
90+
91+
const client = contribution.getClient;
92+
contribution.getClient = async () => null;
93+
await contribution.tick();
94+
95+
contribution.getClient = client;
96+
await contribution.tick();
97+
expect(notify).not.toHaveBeenCalled();
98+
expect(sinceByTick).toEqual([undefined, undefined]);
99+
});
100+
101+
it("falls back to 'Someone' for agent-authored mentions", async () => {
102+
mentionsByTick = [
103+
[],
104+
[
105+
mention({
106+
message_id: "agent",
107+
author: null,
108+
created_at: "2026-07-30T10:00:00Z",
109+
}),
110+
],
111+
];
112+
await contribution.tick();
113+
await contribution.tick();
114+
expect(notify).toHaveBeenCalledWith(
115+
expect.objectContaining({
116+
body: 'Someone mentioned you in "Fix flaky tests"',
117+
}),
118+
);
119+
});
120+
121+
it("keeps polling after a failed fetch", async () => {
122+
contribution.getClient = async () => ({
123+
getTaskMentions: async () => {
124+
throw new Error("network down");
125+
},
126+
});
127+
await contribution.tick();
128+
129+
mentionsByTick = [[], []];
130+
contribution.getClient = async () => ({
131+
getTaskMentions: async (options?: { since?: string }) => {
132+
sinceByTick.push(options?.since);
133+
return mentionsByTick.shift() ?? [];
134+
},
135+
});
136+
await contribution.tick();
137+
expect(sinceByTick[0]).toBeUndefined();
138+
});
139+
});

0 commit comments

Comments
 (0)