Skip to content
Merged
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
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 @@ -2304,6 +2306,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 @@ -2384,6 +2405,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
135 changes: 135 additions & 0 deletions packages/core/src/canvas/mentionActivity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import type { TaskMention, UserBasic } from "@posthog/shared/domain-types";
import { describe, expect, it } from "vitest";
import {
countUnseenActivity,
mergeTaskMentions,
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);
});
});

describe("mergeTaskMentions", () => {
it("prepends newly-fetched mentions ahead of the previous page", () => {
const previous = [
mention({ message_id: "m1", created_at: "2026-07-01T10:00:00Z" }),
];
const incoming = [
mention({ message_id: "m2", created_at: "2026-07-02T10:00:00Z" }),
];
expect(
mergeTaskMentions(previous, incoming).map((m) => m.message_id),
).toEqual(["m2", "m1"]);
});

it("replaces a mention that was re-fetched instead of duplicating it", () => {
const previous = [
mention({
message_id: "m1",
content: "old",
created_at: "2026-07-01T10:00:00Z",
}),
];
const incoming = [
mention({
message_id: "m1",
content: "edited",
created_at: "2026-07-01T10:00:00Z",
}),
];
const merged = mergeTaskMentions(previous, incoming);
expect(merged).toHaveLength(1);
expect(merged[0].content).toBe("edited");
});

it("returns the previous page unchanged when there is nothing new", () => {
const previous = [
mention({ message_id: "m1", created_at: "2026-07-01T10:00:00Z" }),
];
expect(mergeTaskMentions(previous, [])).toEqual(previous);
});

it("caps the merged result so a long session can't grow it unbounded", () => {
const previous = Array.from({ length: 300 }, (_, i) =>
mention({
message_id: `old-${i}`,
created_at: `2026-06-01T${String(i % 24).padStart(2, "0")}:00:00Z`,
}),
);
const incoming = [
mention({ message_id: "newest", created_at: "2026-07-05T10:00:00Z" }),
];
const merged = mergeTaskMentions(previous, incoming);
expect(merged).toHaveLength(300);
expect(merged[0].message_id).toBe("newest");
});
});
68 changes: 68 additions & 0 deletions packages/core/src/canvas/mentionActivity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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;
}

// Bounds the cache so a long-running session's accumulated feed can't grow
// without limit.
const MAX_CACHED_MENTIONS = 300;

/**
* Fold a page of freshly-fetched mentions into the previously cached set —
* dedupe by message, keep newest first. Lets repolls fetch only what's new
* (via `since`) instead of re-fetching the whole top page every time.
*/
export function mergeTaskMentions(
previous: readonly TaskMention[],
incoming: readonly TaskMention[],
): TaskMention[] {
if (incoming.length === 0) return [...previous];
const byMessageId = new Map(
previous.map((mention) => [mention.message_id, mention]),
);
for (const mention of incoming) byMessageId.set(mention.message_id, mention);
return Array.from(byMessageId.values())
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1))
.slice(0, MAX_CACHED_MENTIONS);
}
12 changes: 9 additions & 3 deletions packages/shared/src/analytics-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -832,7 +832,8 @@ export type ChannelsSurface =
| "dashboards_grid"
| "canvas"
| "context"
| "thread_panel";
| "thread_panel"
| "activity";

export type ChannelActionType =
| "enter_space"
Expand Down Expand Up @@ -860,7 +861,10 @@ export type ChannelActionType =
| "open_task"
| "collapse_thread"
| "expand_thread"
| "copy_link";
| "copy_link"
| "mention_member"
| "view_activity"
| "open_mention";

export interface ChannelActionProperties {
action_type: ChannelActionType;
Expand All @@ -871,8 +875,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 @@ -119,6 +119,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
Loading
Loading