Skip to content

Commit 622b5af

Browse files
authored
feat(channels): add thread @-mentions and an Activity page (#3158)
1 parent 85752ed commit 622b5af

22 files changed

Lines changed: 1364 additions & 25 deletions

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import type {
5050
DismissalArtefact,
5151
LineReferenceArtefact,
5252
NoteArtefact,
53+
OrganizationMemberBasic,
5354
PriorityJudgmentArtefact,
5455
RepoSelectionArtefact,
5556
SafetyJudgmentArtefact,
@@ -73,6 +74,7 @@ import type {
7374
SuggestedReviewerWriteEntry,
7475
Task,
7576
TaskChannel,
77+
TaskMention,
7678
TaskRun,
7779
TaskRunArtefact,
7880
TaskThreadMessage,
@@ -2303,6 +2305,25 @@ export class PostHogAPIClient {
23032305
return (await response.json()) as TaskChannel;
23042306
}
23052307

2308+
// Mentions of the current user across task threads, newest first.
2309+
async getTaskMentions(options?: { since?: string }): Promise<TaskMention[]> {
2310+
const teamId = await this.getTeamId();
2311+
const urlPath = `/api/projects/${teamId}/task_mentions/`;
2312+
const url = new URL(`${this.api.baseUrl}${urlPath}`);
2313+
if (options?.since) {
2314+
url.searchParams.set("since", options.since);
2315+
}
2316+
const response = await this.api.fetcher.fetch({
2317+
method: "get",
2318+
url,
2319+
path: urlPath,
2320+
});
2321+
if (!response.ok) {
2322+
throw new Error(`Failed to fetch task mentions: ${response.statusText}`);
2323+
}
2324+
return (await response.json()) as TaskMention[];
2325+
}
2326+
23062327
async getTaskThreadMessages(taskId: string): Promise<TaskThreadMessage[]> {
23072328
const teamId = await this.getTeamId();
23082329
const urlPath = `/api/projects/${teamId}/tasks/${taskId}/thread_messages/`;
@@ -2383,6 +2404,40 @@ export class PostHogAPIClient {
23832404
return (await response.json()) as TaskThreadMessage;
23842405
}
23852406

2407+
// Everyone in the current organization — the pool of taggable teammates for
2408+
// thread @-mentions. Membership churn is slow, so callers cache aggressively.
2409+
async listOrganizationMembers(): Promise<OrganizationMemberBasic[]> {
2410+
const ORG_MEMBERS_MAX_PAGES = 20;
2411+
const ORG_MEMBERS_PAGE_SIZE = 200;
2412+
const all: OrganizationMemberBasic[] = [];
2413+
let urlPath = `/api/organizations/@current/members/?limit=${ORG_MEMBERS_PAGE_SIZE}`;
2414+
for (let i = 0; i < ORG_MEMBERS_MAX_PAGES; i++) {
2415+
const response = await this.api.fetcher.fetch({
2416+
method: "get",
2417+
url: new URL(`${this.api.baseUrl}${urlPath}`),
2418+
path: urlPath,
2419+
});
2420+
if (!response.ok) {
2421+
throw new Error(
2422+
`Failed to fetch organization members: ${response.statusText}`,
2423+
);
2424+
}
2425+
const page = (await response.json()) as {
2426+
results: OrganizationMemberBasic[];
2427+
next: string | null;
2428+
};
2429+
all.push(...page.results);
2430+
if (!page.next) return all;
2431+
const nextUrl = new URL(page.next);
2432+
urlPath = `${nextUrl.pathname}${nextUrl.search}`;
2433+
}
2434+
log.warn(
2435+
`listOrganizationMembers hit MAX_PAGES (${ORG_MEMBERS_MAX_PAGES}); returning partial results`,
2436+
{ returned: all.length },
2437+
);
2438+
return all;
2439+
}
2440+
23862441
async sendRunCommand(
23872442
taskId: string,
23882443
runId: string,
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import type { TaskMention, UserBasic } from "@posthog/shared/domain-types";
2+
import { describe, expect, it } from "vitest";
3+
import {
4+
countUnseenActivity,
5+
mergeTaskMentions,
6+
toMentionActivityItems,
7+
} from "./mentionActivity";
8+
9+
const ann: UserBasic = {
10+
id: 2,
11+
uuid: "ann-uuid",
12+
email: "ann@posthog.com",
13+
first_name: "Ann",
14+
};
15+
16+
function mention(overrides: Partial<TaskMention> = {}): TaskMention {
17+
return {
18+
id: "mention-1",
19+
message_id: "m1",
20+
task_id: "t1",
21+
task_title: "Task t1",
22+
channel_id: "c1",
23+
channel_name: "general",
24+
author: ann,
25+
content: "ping @[Me](me@posthog.com)",
26+
created_at: "2026-07-01T10:00:00Z",
27+
...overrides,
28+
};
29+
}
30+
31+
describe("toMentionActivityItems", () => {
32+
it("maps mention DTOs to feed items", () => {
33+
expect(toMentionActivityItems([mention()])).toEqual([
34+
{
35+
messageId: "m1",
36+
taskId: "t1",
37+
taskTitle: "Task t1",
38+
channelId: "c1",
39+
channelName: "general",
40+
author: ann,
41+
content: "ping @[Me](me@posthog.com)",
42+
createdAt: "2026-07-01T10:00:00Z",
43+
},
44+
]);
45+
});
46+
47+
it("labels untitled tasks and tolerates missing channel and author", () => {
48+
const items = toMentionActivityItems([
49+
mention({
50+
task_title: "",
51+
channel_id: null,
52+
channel_name: null,
53+
author: null,
54+
}),
55+
]);
56+
expect(items[0]).toMatchObject({
57+
taskTitle: "Untitled task",
58+
channelId: null,
59+
channelName: null,
60+
author: null,
61+
});
62+
});
63+
});
64+
65+
describe("countUnseenActivity", () => {
66+
const items = toMentionActivityItems([
67+
mention({ message_id: "m2", created_at: "2026-07-03T10:00:00Z" }),
68+
mention({ message_id: "m1", created_at: "2026-07-01T10:00:00Z" }),
69+
]);
70+
71+
it("counts everything when never seen", () => {
72+
expect(countUnseenActivity(items, null)).toBe(2);
73+
});
74+
75+
it("counts only items after the last-seen timestamp", () => {
76+
expect(countUnseenActivity(items, "2026-07-02T00:00:00Z")).toBe(1);
77+
expect(countUnseenActivity(items, "2026-07-04T00:00:00Z")).toBe(0);
78+
});
79+
});
80+
81+
describe("mergeTaskMentions", () => {
82+
it("prepends newly-fetched mentions ahead of the previous page", () => {
83+
const previous = [
84+
mention({ message_id: "m1", created_at: "2026-07-01T10:00:00Z" }),
85+
];
86+
const incoming = [
87+
mention({ message_id: "m2", created_at: "2026-07-02T10:00:00Z" }),
88+
];
89+
expect(
90+
mergeTaskMentions(previous, incoming).map((m) => m.message_id),
91+
).toEqual(["m2", "m1"]);
92+
});
93+
94+
it("replaces a mention that was re-fetched instead of duplicating it", () => {
95+
const previous = [
96+
mention({
97+
message_id: "m1",
98+
content: "old",
99+
created_at: "2026-07-01T10:00:00Z",
100+
}),
101+
];
102+
const incoming = [
103+
mention({
104+
message_id: "m1",
105+
content: "edited",
106+
created_at: "2026-07-01T10:00:00Z",
107+
}),
108+
];
109+
const merged = mergeTaskMentions(previous, incoming);
110+
expect(merged).toHaveLength(1);
111+
expect(merged[0].content).toBe("edited");
112+
});
113+
114+
it("returns the previous page unchanged when there is nothing new", () => {
115+
const previous = [
116+
mention({ message_id: "m1", created_at: "2026-07-01T10:00:00Z" }),
117+
];
118+
expect(mergeTaskMentions(previous, [])).toEqual(previous);
119+
});
120+
121+
it("caps the merged result so a long session can't grow it unbounded", () => {
122+
const previous = Array.from({ length: 300 }, (_, i) =>
123+
mention({
124+
message_id: `old-${i}`,
125+
created_at: `2026-06-01T${String(i % 24).padStart(2, "0")}:00:00Z`,
126+
}),
127+
);
128+
const incoming = [
129+
mention({ message_id: "newest", created_at: "2026-07-05T10:00:00Z" }),
130+
];
131+
const merged = mergeTaskMentions(previous, incoming);
132+
expect(merged).toHaveLength(300);
133+
expect(merged[0].message_id).toBe("newest");
134+
});
135+
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type { TaskMention, UserBasic } from "@posthog/shared/domain-types";
2+
3+
/**
4+
* The Activity feed — thread messages that @-mention the current user — as
5+
* served by the backend mentions index (`getTaskMentions`). Mentions are
6+
* extracted server-side at write time, so the client only maps DTOs to items.
7+
*/
8+
9+
export interface MentionActivityItem {
10+
messageId: string;
11+
taskId: string;
12+
taskTitle: string;
13+
/** Backend channel (tasks product Channel UUID); null for channel-less tasks. */
14+
channelId: string | null;
15+
/** Backend channel name, for the "#channel" label. */
16+
channelName: string | null;
17+
author: UserBasic | null;
18+
content: string;
19+
createdAt: string;
20+
}
21+
22+
/** Map mention DTOs (already newest-first from the backend) to feed items. */
23+
export function toMentionActivityItems(
24+
mentions: readonly TaskMention[],
25+
): MentionActivityItem[] {
26+
return mentions.map((mention) => ({
27+
messageId: mention.message_id,
28+
taskId: mention.task_id,
29+
taskTitle: mention.task_title || "Untitled task",
30+
channelId: mention.channel_id ?? null,
31+
channelName: mention.channel_name ?? null,
32+
author: mention.author ?? null,
33+
content: mention.content,
34+
createdAt: mention.created_at,
35+
}));
36+
}
37+
38+
/** How many items arrived after the viewer last opened the Activity page. */
39+
export function countUnseenActivity(
40+
items: readonly MentionActivityItem[],
41+
lastSeenAt: string | null,
42+
): number {
43+
if (!lastSeenAt) return items.length;
44+
return items.filter((item) => item.createdAt > lastSeenAt).length;
45+
}
46+
47+
// Bounds the cache so a long-running session's accumulated feed can't grow
48+
// without limit.
49+
const MAX_CACHED_MENTIONS = 300;
50+
51+
/**
52+
* Fold a page of freshly-fetched mentions into the previously cached set —
53+
* dedupe by message, keep newest first. Lets repolls fetch only what's new
54+
* (via `since`) instead of re-fetching the whole top page every time.
55+
*/
56+
export function mergeTaskMentions(
57+
previous: readonly TaskMention[],
58+
incoming: readonly TaskMention[],
59+
): TaskMention[] {
60+
if (incoming.length === 0) return [...previous];
61+
const byMessageId = new Map(
62+
previous.map((mention) => [mention.message_id, mention]),
63+
);
64+
for (const mention of incoming) byMessageId.set(mention.message_id, mention);
65+
return Array.from(byMessageId.values())
66+
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1))
67+
.slice(0, MAX_CACHED_MENTIONS);
68+
}

packages/shared/src/analytics-events.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -834,7 +834,8 @@ export type ChannelsSurface =
834834
| "dashboards_grid"
835835
| "canvas"
836836
| "context"
837-
| "thread_panel";
837+
| "thread_panel"
838+
| "activity";
838839

839840
export type ChannelActionType =
840841
| "enter_space"
@@ -862,7 +863,10 @@ export type ChannelActionType =
862863
| "open_task"
863864
| "collapse_thread"
864865
| "expand_thread"
865-
| "copy_link";
866+
| "copy_link"
867+
| "mention_member"
868+
| "view_activity"
869+
| "open_mention";
866870

867871
export interface ChannelActionProperties {
868872
action_type: ChannelActionType;
@@ -873,8 +877,10 @@ export interface ChannelActionProperties {
873877
task_id?: string;
874878
/** For file_task: destination channel when different from `channel_id`. */
875879
target_channel_id?: string;
876-
/** For nav_click: which destination ("home"|"inbox"|"canvas"|"agents"|"files"|"settings"). */
880+
/** For nav_click: which destination ("home"|"activity"|"inbox"|"canvas"|"agents"|"files"|"settings"). */
877881
nav_target?: string;
882+
/** For mention_member: the tagged teammate's user uuid. */
883+
mentioned_user_id?: string;
878884
/** For new_task_suggestion: the starter-prompt card label. */
879885
suggestion_label?: string;
880886
/** Whether the underlying mutation resolved successfully. */

packages/shared/src/domain-types.ts

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

40+
/** One row from the org members list; trimmed to what mention pickers need. */
41+
export interface OrganizationMemberBasic {
42+
id: string;
43+
user: UserBasic;
44+
}
45+
4046
export interface Task {
4147
id: string;
4248
task_number: number | null;
@@ -87,6 +93,22 @@ export interface TaskThreadMessage {
8793
forwarded_by?: UserBasic | null;
8894
}
8995

96+
/**
97+
* One @-mention of the current user in a task's thread, from the backend
98+
* mentions index (`/task_mentions/`). Mirrors `TaskMentionDTO`.
99+
*/
100+
export interface TaskMention {
101+
id: string;
102+
message_id: string;
103+
task_id: string;
104+
task_title: string;
105+
channel_id?: string | null;
106+
channel_name?: string | null;
107+
author?: UserBasic | null;
108+
content: string;
109+
created_at: string;
110+
}
111+
90112
export type TaskRunStatus =
91113
| "not_started"
92114
| "queued"

packages/shared/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,12 @@ export {
120120
export { buildDiscussReportPrompt } from "./inbox-prompts";
121121
export type { AvailableSuggestedReviewer, SourceProduct } from "./inbox-types";
122122
export { EXTERNAL_LINKS } from "./links";
123+
export {
124+
formatMention,
125+
type MentionSegment,
126+
mentionsToPlainText,
127+
splitMentionSegments,
128+
} from "./mentions";
123129
export {
124130
getOauthClientIdFromRegion,
125131
OAUTH_SCOPE_VERSION,

0 commit comments

Comments
 (0)