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
35 changes: 35 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 Down Expand Up @@ -2382,6 +2383,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
158 changes: 158 additions & 0 deletions packages/core/src/canvas/mentionActivity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { formatMention } from "@posthog/shared";
import type {
Task,
TaskThreadMessage,
UserBasic,
} from "@posthog/shared/domain-types";
import { describe, expect, it } from "vitest";
import {
buildMentionActivity,
countUnseenActivity,
type MentionActivityTaskRef,
} from "./mentionActivity";

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

function task(id: string, title = `Task ${id}`): Task {
return { id, title } as Task;
}

function message(
id: string,
author: UserBasic | null,
content: string,
createdAt: string,
): TaskThreadMessage {
return {
id,
task: "t1",
content,
created_at: createdAt,
author,
};
}

const meToken = formatMention("Me", me.email);

function refs(...tasks: Task[]): MentionActivityTaskRef[] {
return tasks.map((t) => ({
task: t,
channelName: "general",
folderChannelId: "folder-1",
}));
}

describe("buildMentionActivity", () => {
it("returns messages from others that mention me, newest first", () => {
const threads = new Map([
[
"t1",
[
message("m1", ann, `ping ${meToken}`, "2026-07-01T10:00:00Z"),
message("m2", ann, "no mention", "2026-07-01T11:00:00Z"),
],
],
["t2", [message("m3", ann, `also ${meToken}`, "2026-07-02T09:00:00Z")]],
]);
const items = buildMentionActivity(
me.email,
refs(task("t1"), task("t2")),
threads,
);
expect(items.map((i) => i.messageId)).toEqual(["m3", "m1"]);
expect(items[1]).toMatchObject({
taskId: "t1",
taskTitle: "Task t1",
channelName: "general",
folderChannelId: "folder-1",
author: ann,
});
});

it("matches the mention email case-insensitively", () => {
const threads = new Map([
[
"t1",
[
message(
"m1",
ann,
"hi @[Me](ME@PostHog.com)",
"2026-07-01T10:00:00Z",
),
],
],
]);
expect(
buildMentionActivity(me.email, refs(task("t1")), threads),
).toHaveLength(1);
});

it("skips my own messages even when they mention me", () => {
const threads = new Map([
[
"t1",
[message("m1", me, `note to self ${meToken}`, "2026-07-01T10:00:00Z")],
],
]);
expect(buildMentionActivity(me.email, refs(task("t1")), threads)).toEqual(
[],
);
});

it("includes authorless messages that mention me", () => {
const threads = new Map([
["t1", [message("m1", null, meToken, "2026-07-01T10:00:00Z")]],
]);
expect(
buildMentionActivity(me.email, refs(task("t1")), threads),
).toHaveLength(1);
});

it("returns nothing without a current user email", () => {
const threads = new Map([
["t1", [message("m1", ann, meToken, "2026-07-01T10:00:00Z")]],
]);
expect(buildMentionActivity(null, refs(task("t1")), threads)).toEqual([]);
});

it("labels untitled tasks", () => {
const threads = new Map([
["t1", [message("m1", ann, meToken, "2026-07-01T10:00:00Z")]],
]);
const items = buildMentionActivity(me.email, refs(task("t1", "")), threads);
expect(items[0]?.taskTitle).toBe("Untitled task");
});
});

describe("countUnseenActivity", () => {
const threads = new Map([
[
"t1",
[
message("m1", ann, meToken, "2026-07-01T10:00:00Z"),
message("m2", ann, meToken, "2026-07-03T10:00:00Z"),
],
],
]);
const items = buildMentionActivity(me.email, refs(task("t1")), threads);

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);
});
});
71 changes: 71 additions & 0 deletions packages/core/src/canvas/mentionActivity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { mentionsUser } from "@posthog/shared";
import type {
Task,
TaskThreadMessage,
UserBasic,
} from "@posthog/shared/domain-types";

/**
* Derives the Activity feed — thread messages that @-mention the current user
* — from data every client already has: channel task lists and their thread
* messages. Mentions live inline in message content (see `@posthog/shared`
* mentions), so no dedicated notifications backend is involved.
*/

/** A channel task paired with the channel context the feed needs. */
export interface MentionActivityTaskRef {
task: Task;
/** Backend channel name, for the "#channel" label. */
channelName: string;
/** Desktop folder channel id (the /website route param); null when unmapped. */
folderChannelId: string | null;
}

export interface MentionActivityItem {
messageId: string;
taskId: string;
taskTitle: string;
channelName: string;
folderChannelId: string | null;
author: UserBasic | null;
content: string;
createdAt: string;
}

/** Mentions of the current user across channel threads, newest first. */
export function buildMentionActivity(
currentUserEmail: string | null | undefined,
tasks: MentionActivityTaskRef[],
threadsByTaskId: ReadonlyMap<string, TaskThreadMessage[]>,
): MentionActivityItem[] {
if (!currentUserEmail) return [];
const self = currentUserEmail.toLowerCase();
const items: MentionActivityItem[] = [];
for (const { task, channelName, folderChannelId } of tasks) {
for (const message of threadsByTaskId.get(task.id) ?? []) {
// Self-mentions aren't notifications.
if (message.author?.email?.toLowerCase() === self) continue;
if (!mentionsUser(message.content, self)) continue;
items.push({
messageId: message.id,
taskId: task.id,
taskTitle: task.title || "Untitled task",
channelName,
folderChannelId,
author: message.author ?? null,
content: message.content,
createdAt: message.created_at,
});
}
}
return items.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}

/** 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
6 changes: 6 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
8 changes: 8 additions & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,14 @@ export {
export { buildDiscussReportPrompt } from "./inbox-prompts";
export type { AvailableSuggestedReviewer, SourceProduct } from "./inbox-types";
export { EXTERNAL_LINKS } from "./links";
export {
extractMentionEmails,
formatMention,
type MentionSegment,
mentionsToPlainText,
mentionsUser,
splitMentionSegments,
} from "./mentions";
export {
getOauthClientIdFromRegion,
OAUTH_SCOPE_VERSION,
Expand Down
Loading
Loading