Skip to content

Commit 53052ef

Browse files
authored
feat(channels): add thread @-mentions and an Activity page
Thread composers in the Channels space get an @-mention typeahead over the org's members; mentions are stored as inline @[Name](email) tokens in the message content, so no message schema change is needed. A new Activity page (nav item with unread badge in the channels sidebar) lists mentions of the viewer across channel threads, derived client-side from the task feeds and threads already polled, and opens the thread via the channel deep-link route. Generated-By: PostHog Code Task-Id: a2ff8500-aaa6-4ce0-b01a-656d3b2c3b61
1 parent 7ece0f0 commit 53052ef

22 files changed

Lines changed: 1374 additions & 25 deletions

packages/api-client/src/posthog-client.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import type {
4949
DismissalArtefact,
5050
LineReferenceArtefact,
5151
NoteArtefact,
52+
OrganizationMemberBasic,
5253
PriorityJudgmentArtefact,
5354
RepoSelectionArtefact,
5455
SafetyJudgmentArtefact,
@@ -2384,6 +2385,40 @@ export class PostHogAPIClient {
23842385
return (await response.json()) as TaskThreadMessage;
23852386
}
23862387

2388+
// Everyone in the current organization — the pool of taggable teammates for
2389+
// thread @-mentions. Membership churn is slow, so callers cache aggressively.
2390+
async listOrganizationMembers(): Promise<OrganizationMemberBasic[]> {
2391+
const ORG_MEMBERS_MAX_PAGES = 20;
2392+
const ORG_MEMBERS_PAGE_SIZE = 200;
2393+
const all: OrganizationMemberBasic[] = [];
2394+
let urlPath = `/api/organizations/@current/members/?limit=${ORG_MEMBERS_PAGE_SIZE}`;
2395+
for (let i = 0; i < ORG_MEMBERS_MAX_PAGES; i++) {
2396+
const response = await this.api.fetcher.fetch({
2397+
method: "get",
2398+
url: new URL(`${this.api.baseUrl}${urlPath}`),
2399+
path: urlPath,
2400+
});
2401+
if (!response.ok) {
2402+
throw new Error(
2403+
`Failed to fetch organization members: ${response.statusText}`,
2404+
);
2405+
}
2406+
const page = (await response.json()) as {
2407+
results: OrganizationMemberBasic[];
2408+
next: string | null;
2409+
};
2410+
all.push(...page.results);
2411+
if (!page.next) return all;
2412+
const nextUrl = new URL(page.next);
2413+
urlPath = `${nextUrl.pathname}${nextUrl.search}`;
2414+
}
2415+
log.warn(
2416+
`listOrganizationMembers hit MAX_PAGES (${ORG_MEMBERS_MAX_PAGES}); returning partial results`,
2417+
{ returned: all.length },
2418+
);
2419+
return all;
2420+
}
2421+
23872422
async sendRunCommand(
23882423
taskId: string,
23892424
runId: string,
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { formatMention } from "@posthog/shared";
2+
import type {
3+
Task,
4+
TaskThreadMessage,
5+
UserBasic,
6+
} from "@posthog/shared/domain-types";
7+
import { describe, expect, it } from "vitest";
8+
import {
9+
buildMentionActivity,
10+
countUnseenActivity,
11+
type MentionActivityTaskRef,
12+
} from "./mentionActivity";
13+
14+
const me: UserBasic = {
15+
id: 1,
16+
uuid: "me-uuid",
17+
email: "me@posthog.com",
18+
};
19+
const ann: UserBasic = {
20+
id: 2,
21+
uuid: "ann-uuid",
22+
email: "ann@posthog.com",
23+
first_name: "Ann",
24+
};
25+
26+
function task(id: string, title = `Task ${id}`): Task {
27+
return { id, title } as Task;
28+
}
29+
30+
function message(
31+
id: string,
32+
author: UserBasic | null,
33+
content: string,
34+
createdAt: string,
35+
): TaskThreadMessage {
36+
return {
37+
id,
38+
task: "t1",
39+
content,
40+
created_at: createdAt,
41+
author,
42+
};
43+
}
44+
45+
const meToken = formatMention("Me", me.email);
46+
47+
function refs(...tasks: Task[]): MentionActivityTaskRef[] {
48+
return tasks.map((t) => ({
49+
task: t,
50+
channelName: "general",
51+
folderChannelId: "folder-1",
52+
}));
53+
}
54+
55+
describe("buildMentionActivity", () => {
56+
it("returns messages from others that mention me, newest first", () => {
57+
const threads = new Map([
58+
[
59+
"t1",
60+
[
61+
message("m1", ann, `ping ${meToken}`, "2026-07-01T10:00:00Z"),
62+
message("m2", ann, "no mention", "2026-07-01T11:00:00Z"),
63+
],
64+
],
65+
["t2", [message("m3", ann, `also ${meToken}`, "2026-07-02T09:00:00Z")]],
66+
]);
67+
const items = buildMentionActivity(
68+
me.email,
69+
refs(task("t1"), task("t2")),
70+
threads,
71+
);
72+
expect(items.map((i) => i.messageId)).toEqual(["m3", "m1"]);
73+
expect(items[1]).toMatchObject({
74+
taskId: "t1",
75+
taskTitle: "Task t1",
76+
channelName: "general",
77+
folderChannelId: "folder-1",
78+
author: ann,
79+
});
80+
});
81+
82+
it("matches the mention email case-insensitively", () => {
83+
const threads = new Map([
84+
[
85+
"t1",
86+
[
87+
message(
88+
"m1",
89+
ann,
90+
"hi @[Me](ME@PostHog.com)",
91+
"2026-07-01T10:00:00Z",
92+
),
93+
],
94+
],
95+
]);
96+
expect(
97+
buildMentionActivity(me.email, refs(task("t1")), threads),
98+
).toHaveLength(1);
99+
});
100+
101+
it("skips my own messages even when they mention me", () => {
102+
const threads = new Map([
103+
[
104+
"t1",
105+
[message("m1", me, `note to self ${meToken}`, "2026-07-01T10:00:00Z")],
106+
],
107+
]);
108+
expect(buildMentionActivity(me.email, refs(task("t1")), threads)).toEqual(
109+
[],
110+
);
111+
});
112+
113+
it("includes authorless messages that mention me", () => {
114+
const threads = new Map([
115+
["t1", [message("m1", null, meToken, "2026-07-01T10:00:00Z")]],
116+
]);
117+
expect(
118+
buildMentionActivity(me.email, refs(task("t1")), threads),
119+
).toHaveLength(1);
120+
});
121+
122+
it("returns nothing without a current user email", () => {
123+
const threads = new Map([
124+
["t1", [message("m1", ann, meToken, "2026-07-01T10:00:00Z")]],
125+
]);
126+
expect(buildMentionActivity(null, refs(task("t1")), threads)).toEqual([]);
127+
});
128+
129+
it("labels untitled tasks", () => {
130+
const threads = new Map([
131+
["t1", [message("m1", ann, meToken, "2026-07-01T10:00:00Z")]],
132+
]);
133+
const items = buildMentionActivity(me.email, refs(task("t1", "")), threads);
134+
expect(items[0]?.taskTitle).toBe("Untitled task");
135+
});
136+
});
137+
138+
describe("countUnseenActivity", () => {
139+
const threads = new Map([
140+
[
141+
"t1",
142+
[
143+
message("m1", ann, meToken, "2026-07-01T10:00:00Z"),
144+
message("m2", ann, meToken, "2026-07-03T10:00:00Z"),
145+
],
146+
],
147+
]);
148+
const items = buildMentionActivity(me.email, refs(task("t1")), threads);
149+
150+
it("counts everything when never seen", () => {
151+
expect(countUnseenActivity(items, null)).toBe(2);
152+
});
153+
154+
it("counts only items after the last-seen timestamp", () => {
155+
expect(countUnseenActivity(items, "2026-07-02T00:00:00Z")).toBe(1);
156+
expect(countUnseenActivity(items, "2026-07-04T00:00:00Z")).toBe(0);
157+
});
158+
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { mentionsUser } from "@posthog/shared";
2+
import type {
3+
Task,
4+
TaskThreadMessage,
5+
UserBasic,
6+
} from "@posthog/shared/domain-types";
7+
8+
/**
9+
* Derives the Activity feed — thread messages that @-mention the current user
10+
* — from data every client already has: channel task lists and their thread
11+
* messages. Mentions live inline in message content (see `@posthog/shared`
12+
* mentions), so no dedicated notifications backend is involved.
13+
*/
14+
15+
/** A channel task paired with the channel context the feed needs. */
16+
export interface MentionActivityTaskRef {
17+
task: Task;
18+
/** Backend channel name, for the "#channel" label. */
19+
channelName: string;
20+
/** Desktop folder channel id (the /website route param); null when unmapped. */
21+
folderChannelId: string | null;
22+
}
23+
24+
export interface MentionActivityItem {
25+
messageId: string;
26+
taskId: string;
27+
taskTitle: string;
28+
channelName: string;
29+
folderChannelId: string | null;
30+
author: UserBasic | null;
31+
content: string;
32+
createdAt: string;
33+
}
34+
35+
/** Mentions of the current user across channel threads, newest first. */
36+
export function buildMentionActivity(
37+
currentUserEmail: string | null | undefined,
38+
tasks: MentionActivityTaskRef[],
39+
threadsByTaskId: ReadonlyMap<string, TaskThreadMessage[]>,
40+
): MentionActivityItem[] {
41+
if (!currentUserEmail) return [];
42+
const self = currentUserEmail.toLowerCase();
43+
const items: MentionActivityItem[] = [];
44+
for (const { task, channelName, folderChannelId } of tasks) {
45+
for (const message of threadsByTaskId.get(task.id) ?? []) {
46+
// Self-mentions aren't notifications.
47+
if (message.author?.email?.toLowerCase() === self) continue;
48+
if (!mentionsUser(message.content, self)) continue;
49+
items.push({
50+
messageId: message.id,
51+
taskId: task.id,
52+
taskTitle: task.title || "Untitled task",
53+
channelName,
54+
folderChannelId,
55+
author: message.author ?? null,
56+
content: message.content,
57+
createdAt: message.created_at,
58+
});
59+
}
60+
}
61+
return items.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
62+
}
63+
64+
/** How many items arrived after the viewer last opened the Activity page. */
65+
export function countUnseenActivity(
66+
items: readonly MentionActivityItem[],
67+
lastSeenAt: string | null,
68+
): number {
69+
if (!lastSeenAt) return items.length;
70+
return items.filter((item) => item.createdAt > lastSeenAt).length;
71+
}

packages/shared/src/analytics-events.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -832,7 +832,8 @@ export type ChannelsSurface =
832832
| "dashboards_grid"
833833
| "canvas"
834834
| "context"
835-
| "thread_panel";
835+
| "thread_panel"
836+
| "activity";
836837

837838
export type ChannelActionType =
838839
| "enter_space"
@@ -860,7 +861,10 @@ export type ChannelActionType =
860861
| "open_task"
861862
| "collapse_thread"
862863
| "expand_thread"
863-
| "copy_link";
864+
| "copy_link"
865+
| "mention_member"
866+
| "view_activity"
867+
| "open_mention";
864868

865869
export interface ChannelActionProperties {
866870
action_type: ChannelActionType;
@@ -871,8 +875,10 @@ export interface ChannelActionProperties {
871875
task_id?: string;
872876
/** For file_task: destination channel when different from `channel_id`. */
873877
target_channel_id?: string;
874-
/** For nav_click: which destination ("home"|"inbox"|"canvas"|"agents"|"files"|"settings"). */
878+
/** For nav_click: which destination ("home"|"activity"|"inbox"|"canvas"|"agents"|"files"|"settings"). */
875879
nav_target?: string;
880+
/** For mention_member: the tagged teammate's user uuid. */
881+
mentioned_user_id?: string;
876882
/** For new_task_suggestion: the starter-prompt card label. */
877883
suggestion_label?: string;
878884
/** Whether the underlying mutation resolved successfully. */

packages/shared/src/domain-types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ export interface UserBasic {
3636
is_email_verified?: boolean | null;
3737
}
3838

39+
/** One row from the org members list; trimmed to what mention pickers need. */
40+
export interface OrganizationMemberBasic {
41+
id: string;
42+
user: UserBasic;
43+
}
44+
3945
export interface Task {
4046
id: string;
4147
task_number: number | null;

packages/shared/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,14 @@ export {
119119
export { buildDiscussReportPrompt } from "./inbox-prompts";
120120
export type { AvailableSuggestedReviewer, SourceProduct } from "./inbox-types";
121121
export { EXTERNAL_LINKS } from "./links";
122+
export {
123+
extractMentionEmails,
124+
formatMention,
125+
type MentionSegment,
126+
mentionsToPlainText,
127+
mentionsUser,
128+
splitMentionSegments,
129+
} from "./mentions";
122130
export {
123131
getOauthClientIdFromRegion,
124132
OAUTH_SCOPE_VERSION,

0 commit comments

Comments
 (0)