Skip to content
Draft
55 changes: 55 additions & 0 deletions packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import type {
DismissalArtefact,
LineReferenceArtefact,
NoteArtefact,
OrganizationMemberBasic,
PriorityJudgmentArtefact,
RepoSelectionArtefact,
SafetyJudgmentArtefact,
Expand All @@ -72,6 +73,7 @@ import type {
SuggestedReviewerWriteEntry,
Task,
TaskChannel,
TaskMention,
TaskRun,
TaskRunArtefact,
TaskThreadMessage,
Expand Down Expand Up @@ -2302,6 +2304,25 @@ export class PostHogAPIClient {
return (await response.json()) as TaskChannel;
}

// Mentions of the current user across task threads, newest first.
async getTaskMentions(options?: { since?: string }): Promise<TaskMention[]> {
const teamId = await this.getTeamId();
const urlPath = `/api/projects/${teamId}/task_mentions/`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
if (options?.since) {
url.searchParams.set("since", options.since);
}
const response = await this.api.fetcher.fetch({
method: "get",
url,
path: urlPath,
});
if (!response.ok) {
throw new Error(`Failed to fetch task mentions: ${response.statusText}`);
}
return (await response.json()) as TaskMention[];
}

async getTaskThreadMessages(taskId: string): Promise<TaskThreadMessage[]> {
const teamId = await this.getTeamId();
const urlPath = `/api/projects/${teamId}/tasks/${taskId}/thread_messages/`;
Expand Down Expand Up @@ -2382,6 +2403,40 @@ export class PostHogAPIClient {
return (await response.json()) as TaskThreadMessage;
}

// Everyone in the current organization — the pool of taggable teammates for
// thread @-mentions. Membership churn is slow, so callers cache aggressively.
async listOrganizationMembers(): Promise<OrganizationMemberBasic[]> {
const ORG_MEMBERS_MAX_PAGES = 20;
const ORG_MEMBERS_PAGE_SIZE = 200;
const all: OrganizationMemberBasic[] = [];
let urlPath = `/api/organizations/@current/members/?limit=${ORG_MEMBERS_PAGE_SIZE}`;
for (let i = 0; i < ORG_MEMBERS_MAX_PAGES; i++) {
const response = await this.api.fetcher.fetch({
method: "get",
url: new URL(`${this.api.baseUrl}${urlPath}`),
path: urlPath,
});
if (!response.ok) {
throw new Error(
`Failed to fetch organization members: ${response.statusText}`,
);
}
const page = (await response.json()) as {
results: OrganizationMemberBasic[];
next: string | null;
};
all.push(...page.results);
if (!page.next) return all;
const nextUrl = new URL(page.next);
urlPath = `${nextUrl.pathname}${nextUrl.search}`;
}
log.warn(
`listOrganizationMembers hit MAX_PAGES (${ORG_MEMBERS_MAX_PAGES}); returning partial results`,
{ returned: all.length },
);
return all;
}

async sendRunCommand(
taskId: string,
runId: string,
Expand Down
75 changes: 75 additions & 0 deletions packages/core/src/canvas/mentionActivity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { TaskMention, UserBasic } from "@posthog/shared/domain-types";
import { describe, expect, it } from "vitest";
import { countUnseenActivity, toMentionActivityItems } from "./mentionActivity";

const ann: UserBasic = {
id: 2,
uuid: "ann-uuid",
email: "ann@posthog.com",
first_name: "Ann",
};

function mention(overrides: Partial<TaskMention> = {}): TaskMention {
return {
id: "mention-1",
message_id: "m1",
task_id: "t1",
task_title: "Task t1",
channel_id: "c1",
channel_name: "general",
author: ann,
content: "ping @[Me](me@posthog.com)",
created_at: "2026-07-01T10:00:00Z",
...overrides,
};
}

describe("toMentionActivityItems", () => {
it("maps mention DTOs to feed items", () => {
expect(toMentionActivityItems([mention()])).toEqual([
{
messageId: "m1",
taskId: "t1",
taskTitle: "Task t1",
channelId: "c1",
channelName: "general",
author: ann,
content: "ping @[Me](me@posthog.com)",
createdAt: "2026-07-01T10:00:00Z",
},
]);
});

it("labels untitled tasks and tolerates missing channel and author", () => {
const items = toMentionActivityItems([
mention({
task_title: "",
channel_id: null,
channel_name: null,
author: null,
}),
]);
expect(items[0]).toMatchObject({
taskTitle: "Untitled task",
channelId: null,
channelName: null,
author: null,
});
});
});

describe("countUnseenActivity", () => {
const items = toMentionActivityItems([
mention({ message_id: "m2", created_at: "2026-07-03T10:00:00Z" }),
mention({ message_id: "m1", created_at: "2026-07-01T10:00:00Z" }),
]);

it("counts everything when never seen", () => {
expect(countUnseenActivity(items, null)).toBe(2);
});

it("counts only items after the last-seen timestamp", () => {
expect(countUnseenActivity(items, "2026-07-02T00:00:00Z")).toBe(1);
expect(countUnseenActivity(items, "2026-07-04T00:00:00Z")).toBe(0);
});
});
45 changes: 45 additions & 0 deletions packages/core/src/canvas/mentionActivity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { TaskMention, UserBasic } from "@posthog/shared/domain-types";

/**
* The Activity feed — thread messages that @-mention the current user — as
* served by the backend mentions index (`getTaskMentions`). Mentions are
* extracted server-side at write time, so the client only maps DTOs to items.
*/

export interface MentionActivityItem {
messageId: string;
taskId: string;
taskTitle: string;
/** Backend channel (tasks product Channel UUID); null for channel-less tasks. */
channelId: string | null;
/** Backend channel name, for the "#channel" label. */
channelName: string | null;
author: UserBasic | null;
content: string;
createdAt: string;
}

/** Map mention DTOs (already newest-first from the backend) to feed items. */
export function toMentionActivityItems(
mentions: readonly TaskMention[],
): MentionActivityItem[] {
return mentions.map((mention) => ({
messageId: mention.message_id,
taskId: mention.task_id,
taskTitle: mention.task_title || "Untitled task",
channelId: mention.channel_id ?? null,
channelName: mention.channel_name ?? null,
author: mention.author ?? null,
content: mention.content,
createdAt: mention.created_at,
}));
}

/** How many items arrived after the viewer last opened the Activity page. */
export function countUnseenActivity(
items: readonly MentionActivityItem[],
lastSeenAt: string | null,
): number {
if (!lastSeenAt) return items.length;
return items.filter((item) => item.createdAt > lastSeenAt).length;
}
13 changes: 10 additions & 3 deletions packages/shared/src/analytics-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -820,7 +820,9 @@ export type ChannelsSurface =
| "pinned"
| "dashboards_grid"
| "canvas"
| "context";
| "context"
| "thread_panel"
| "activity";

export type ChannelActionType =
| "enter_space"
Expand All @@ -847,7 +849,10 @@ export type ChannelActionType =
| "unfile_task"
| "archive_task"
| "open_task"
| "copy_link";
| "copy_link"
| "mention_member"
| "view_activity"
| "open_mention";

export interface ChannelActionProperties {
action_type: ChannelActionType;
Expand All @@ -858,8 +863,10 @@ export interface ChannelActionProperties {
task_id?: string;
/** For file_task: destination channel when different from `channel_id`. */
target_channel_id?: string;
/** For nav_click: which destination ("home"|"inbox"|"canvas"|"agents"|"files"|"settings"). */
/** For nav_click: which destination ("home"|"activity"|"inbox"|"canvas"|"agents"|"files"|"settings"). */
nav_target?: string;
/** For mention_member: the tagged teammate's user uuid. */
mentioned_user_id?: string;
/** For new_task_suggestion: the starter-prompt card label. */
suggestion_label?: string;
/** Whether the underlying mutation resolved successfully. */
Expand Down
22 changes: 22 additions & 0 deletions packages/shared/src/domain-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ export interface UserBasic {
is_email_verified?: boolean | null;
}

/** One row from the org members list; trimmed to what mention pickers need. */
export interface OrganizationMemberBasic {
id: string;
user: UserBasic;
}

export interface Task {
id: string;
task_number: number | null;
Expand Down Expand Up @@ -86,6 +92,22 @@ export interface TaskThreadMessage {
forwarded_by?: UserBasic | null;
}

/**
* One @-mention of the current user in a task's thread, from the backend
* mentions index (`/task_mentions/`). Mirrors `TaskMentionDTO`.
*/
export interface TaskMention {
id: string;
message_id: string;
task_id: string;
task_title: string;
channel_id?: string | null;
channel_name?: string | null;
author?: UserBasic | null;
content: string;
created_at: string;
}

export type TaskRunStatus =
| "not_started"
| "queued"
Expand Down
6 changes: 6 additions & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@ export {
export { buildDiscussReportPrompt } from "./inbox-prompts";
export type { AvailableSuggestedReviewer, SourceProduct } from "./inbox-types";
export { EXTERNAL_LINKS } from "./links";
export {
formatMention,
type MentionSegment,
mentionsToPlainText,
splitMentionSegments,
} from "./mentions";
export {
getOauthClientIdFromRegion,
OAUTH_SCOPE_VERSION,
Expand Down
88 changes: 88 additions & 0 deletions packages/shared/src/mentions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import {
formatMention,
mentionsToPlainText,
splitMentionSegments,
} from "./mentions";

describe("formatMention", () => {
it("serializes name and email into a token", () => {
expect(formatMention("Raquel Smith", "raquel@posthog.com")).toBe(
"@[Raquel Smith](raquel@posthog.com)",
);
});

it.each([
["strips brackets from names", "A [b] c", "a@x.com", "@[A b c](a@x.com)"],
[
"falls back to the email local part",
"[]",
"ann@x.com",
"@[ann](ann@x.com)",
],
])("%s", (_label, name, email, expected) => {
expect(formatMention(name, email)).toBe(expected);
});

it("round-trips through the parser", () => {
const token = formatMention("Raquel Smith", "raquel@posthog.com");
const segments = splitMentionSegments(`hey ${token}!`);
expect(segments).toEqual([
{ type: "text", text: "hey " },
{
type: "mention",
text: token,
name: "Raquel Smith",
email: "raquel@posthog.com",
},
{ type: "text", text: "!" },
]);
});
});

describe("splitMentionSegments", () => {
it("returns a single text segment when there are no mentions", () => {
expect(splitMentionSegments("no mentions here")).toEqual([
{ type: "text", text: "no mentions here" },
]);
});

it("handles adjacent and repeated mentions", () => {
const content = "@[A](a@x.com)@[B](b@x.com) and @[A](a@x.com)";
const segments = splitMentionSegments(content);
expect(segments.map((s) => s.type)).toEqual([
"mention",
"mention",
"text",
"mention",
]);
});

it("ignores markdown links and bare @ text", () => {
const content = "see [docs](https://x.com) and email me @ home";
expect(splitMentionSegments(content)).toEqual([
{ type: "text", text: content },
]);
});

it("ignores malformed tokens", () => {
for (const content of [
"@[no email]()",
"@[unclosed](a@x.com",
"@[](a@x.com)",
"@[spaced email](a b@x.com)",
]) {
expect(
splitMentionSegments(content).every((s) => s.type === "text"),
).toBe(true);
}
});
});

describe("mentionsToPlainText", () => {
it("flattens tokens to @Name", () => {
expect(mentionsToPlainText("hi @[Ann Lee](ann@x.com), ship it")).toBe(
"hi @Ann Lee, ship it",
);
});
});
Loading
Loading