From 53052efe92a32f561fe94553049e772cd5592960 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Tue, 7 Jul 2026 13:06:27 +0100 Subject: [PATCH 1/5] 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 --- packages/api-client/src/posthog-client.ts | 35 ++++ .../core/src/canvas/mentionActivity.test.ts | 158 ++++++++++++++ packages/core/src/canvas/mentionActivity.ts | 71 +++++++ packages/shared/src/analytics-events.ts | 12 +- packages/shared/src/domain-types.ts | 6 + packages/shared/src/index.ts | 8 + packages/shared/src/mentions.test.ts | 107 ++++++++++ packages/shared/src/mentions.ts | 86 ++++++++ .../canvas/components/ActivityView.tsx | 188 +++++++++++++++++ .../canvas/components/ChannelsSidebar.tsx | 37 ++++ .../canvas/components/MentionComposer.tsx | 196 ++++++++++++++++++ .../canvas/components/MentionText.tsx | 42 ++++ .../canvas/components/ThreadPanel.tsx | 65 ++++-- .../canvas/hooks/useMentionActivity.ts | 116 +++++++++++ .../features/canvas/hooks/useOrgMembers.ts | 33 +++ .../canvas/stores/activitySeenStore.ts | 23 ++ .../canvas/utils/mentionComposer.test.ts | 110 ++++++++++ .../features/canvas/utils/mentionComposer.ts | 70 +++++++ packages/ui/src/router/navigationBridge.ts | 4 + packages/ui/src/router/routeTree.gen.ts | 21 ++ .../ui/src/router/routes/website/activity.tsx | 8 + packages/ui/src/router/useAppView.ts | 3 + 22 files changed, 1374 insertions(+), 25 deletions(-) create mode 100644 packages/core/src/canvas/mentionActivity.test.ts create mode 100644 packages/core/src/canvas/mentionActivity.ts create mode 100644 packages/shared/src/mentions.test.ts create mode 100644 packages/shared/src/mentions.ts create mode 100644 packages/ui/src/features/canvas/components/ActivityView.tsx create mode 100644 packages/ui/src/features/canvas/components/MentionComposer.tsx create mode 100644 packages/ui/src/features/canvas/components/MentionText.tsx create mode 100644 packages/ui/src/features/canvas/hooks/useMentionActivity.ts create mode 100644 packages/ui/src/features/canvas/hooks/useOrgMembers.ts create mode 100644 packages/ui/src/features/canvas/stores/activitySeenStore.ts create mode 100644 packages/ui/src/features/canvas/utils/mentionComposer.test.ts create mode 100644 packages/ui/src/features/canvas/utils/mentionComposer.ts create mode 100644 packages/ui/src/router/routes/website/activity.tsx diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index e359023d56..688adc9f10 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -49,6 +49,7 @@ import type { DismissalArtefact, LineReferenceArtefact, NoteArtefact, + OrganizationMemberBasic, PriorityJudgmentArtefact, RepoSelectionArtefact, SafetyJudgmentArtefact, @@ -2384,6 +2385,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 { + 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, diff --git a/packages/core/src/canvas/mentionActivity.test.ts b/packages/core/src/canvas/mentionActivity.test.ts new file mode 100644 index 0000000000..82696a5fb0 --- /dev/null +++ b/packages/core/src/canvas/mentionActivity.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/canvas/mentionActivity.ts b/packages/core/src/canvas/mentionActivity.ts new file mode 100644 index 0000000000..55ffdd8dd1 --- /dev/null +++ b/packages/core/src/canvas/mentionActivity.ts @@ -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, +): 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; +} diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index 3792f2e718..5e7304cbd9 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -832,7 +832,8 @@ export type ChannelsSurface = | "dashboards_grid" | "canvas" | "context" - | "thread_panel"; + | "thread_panel" + | "activity"; export type ChannelActionType = | "enter_space" @@ -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; @@ -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. */ diff --git a/packages/shared/src/domain-types.ts b/packages/shared/src/domain-types.ts index 31a72890a6..b894719fb6 100644 --- a/packages/shared/src/domain-types.ts +++ b/packages/shared/src/domain-types.ts @@ -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; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7174f95a77..a5ad9961a5 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -119,6 +119,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, diff --git a/packages/shared/src/mentions.test.ts b/packages/shared/src/mentions.test.ts new file mode 100644 index 0000000000..4d42506d2f --- /dev/null +++ b/packages/shared/src/mentions.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { + extractMentionEmails, + formatMention, + mentionsToPlainText, + mentionsUser, + 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("extractMentionEmails / mentionsUser", () => { + const content = "cc @[Ann](Ann@PostHog.com) and @[Bob](bob@posthog.com)"; + + it("lowercases and dedupes emails", () => { + expect( + extractMentionEmails(`${content} again @[Ann](ann@posthog.com)`), + ).toEqual(["ann@posthog.com", "bob@posthog.com"]); + }); + + it("matches the mentioned user case-insensitively", () => { + expect(mentionsUser(content, "ann@posthog.com")).toBe(true); + expect(mentionsUser(content, "ANN@posthog.com")).toBe(true); + expect(mentionsUser(content, "carol@posthog.com")).toBe(false); + expect(mentionsUser(content, null)).toBe(false); + }); +}); + +describe("mentionsToPlainText", () => { + it("flattens tokens to @Name", () => { + expect(mentionsToPlainText("hi @[Ann Lee](ann@x.com), ship it")).toBe( + "hi @Ann Lee, ship it", + ); + }); +}); diff --git a/packages/shared/src/mentions.ts b/packages/shared/src/mentions.ts new file mode 100644 index 0000000000..d1fcf6616d --- /dev/null +++ b/packages/shared/src/mentions.ts @@ -0,0 +1,86 @@ +/** + * @-mention tokens embedded in channel thread message content. + * + * Mentions are stored inline as `@[Display Name](email)` so the plain string + * survives every transport/storage layer unchanged, older clients degrade to + * readable text, and any client can both render mentions as chips and answer + * "does this message mention me?" without a backend round-trip. + */ + +const MENTION_PATTERN = /@\[([^\][\n]+)\]\(([^\s()]+@[^\s()]+)\)/g; + +export interface MentionTextSegment { + type: "text"; + text: string; +} + +export interface MentionUserSegment { + type: "mention"; + /** The raw token as it appears in the content. */ + text: string; + name: string; + email: string; +} + +export type MentionSegment = MentionTextSegment | MentionUserSegment; + +/** Serialize a user reference into the inline mention token. */ +export function formatMention(name: string, email: string): string { + // Brackets and newlines would break token parsing; email is the identity so + // it falls back to the local part when the display name is unusable. + const safeName = + name.replace(/[[\]\n]/g, " ").trim() || email.split("@")[0] || email; + return `@[${safeName}](${email})`; +} + +/** Split content into text and mention segments, in document order. */ +export function splitMentionSegments(content: string): MentionSegment[] { + const segments: MentionSegment[] = []; + let lastIndex = 0; + MENTION_PATTERN.lastIndex = 0; + for (const match of content.matchAll(MENTION_PATTERN)) { + const index = match.index ?? 0; + if (index > lastIndex) { + segments.push({ type: "text", text: content.slice(lastIndex, index) }); + } + segments.push({ + type: "mention", + text: match[0], + name: match[1] ?? "", + email: match[2] ?? "", + }); + lastIndex = index + match[0].length; + } + if (lastIndex < content.length) { + segments.push({ type: "text", text: content.slice(lastIndex) }); + } + return segments; +} + +/** Emails mentioned in the content, lowercased and deduped. */ +export function extractMentionEmails(content: string): string[] { + const emails = new Set(); + for (const segment of splitMentionSegments(content)) { + if (segment.type === "mention") emails.add(segment.email.toLowerCase()); + } + return [...emails]; +} + +/** Whether the content mentions the given user (by email, case-insensitive). */ +export function mentionsUser( + content: string, + email: string | null | undefined, +): boolean { + if (!email) return false; + const needle = email.toLowerCase(); + return extractMentionEmails(content).includes(needle); +} + +/** Content with mention tokens flattened to `@Display Name` for plain surfaces. */ +export function mentionsToPlainText(content: string): string { + return splitMentionSegments(content) + .map((segment) => + segment.type === "mention" ? `@${segment.name}` : segment.text, + ) + .join(""); +} diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx new file mode 100644 index 0000000000..8f6e69c633 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -0,0 +1,188 @@ +import { AtIcon, LinkIcon } from "@phosphor-icons/react"; +import type { MentionActivityItem } from "@posthog/core/canvas/mentionActivity"; +import { + Avatar, + AvatarFallback, + Button, + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, + Spinner, +} from "@posthog/quill"; +import { formatRelativeTimeShort } from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; +import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; +import { MentionText } from "@posthog/ui/features/canvas/components/MentionText"; +import { useMentionActivity } from "@posthog/ui/features/canvas/hooks/useMentionActivity"; +import { useActivitySeenStore } from "@posthog/ui/features/canvas/stores/activitySeenStore"; +import { copyChannelLink } from "@posthog/ui/features/canvas/utils/copyChannelLink"; +import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; +import { + navigateToChannelTask, + navigateToTaskDetail, +} from "@posthog/ui/router/navigationBridge"; +import { track } from "@posthog/ui/shell/analytics"; +import { Text } from "@radix-ui/themes"; +import { useEffect, useState } from "react"; + +function ActivityRow({ + item, + isNew, + currentUserEmail, +}: { + item: MentionActivityItem; + /** Arrived since the viewer last opened this page. */ + isNew: boolean; + currentUserEmail?: string | null; +}) { + const openThread = () => { + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "open_mention", + surface: "activity", + channel_id: item.folderChannelId ?? undefined, + task_id: item.taskId, + }); + // The channel thread route is the deep-link target; tasks whose channel + // folder is gone fall back to the plain task view. + if (item.folderChannelId) { + navigateToChannelTask(item.folderChannelId, item.taskId); + } else { + navigateToTaskDetail(item.taskId); + } + }; + + return ( +
+ + {item.folderChannelId && ( + + )} +
+ ); +} + +// The Activity page: every channel-thread message that @-mentions the viewer, +// newest first. Opening it clears the sidebar badge. +export function ActivityView() { + const client = useOptionalAuthenticatedClient(); + const { data: currentUser } = useCurrentUser({ client }); + const { items, isLoading } = useMentionActivity(); + const markSeen = useActivitySeenStore((s) => s.markSeen); + // Snapshot before marking seen so rows that were new on arrival keep their + // dot for this visit. + const [seenAtOpen] = useState( + () => useActivitySeenStore.getState().lastSeenAt, + ); + + useEffect(() => { + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "view_activity", + surface: "activity", + }); + }, []); + + // Re-mark as items stream in so the badge stays cleared while reading. + // biome-ignore lint/correctness/useExhaustiveDependencies: re-run per new item + useEffect(() => { + markSeen(); + }, [markSeen, items.length]); + + return ( +
+
+ + Activity + + + Mentions of you across channel threads. + +
+ {isLoading && items.length === 0 ? ( +
+ +
+ ) : items.length === 0 ? ( + + + + + + No mentions yet + + When a teammate tags you with @ in a channel thread, it lands + here. + + + + ) : ( +
+ {items.map((item) => ( + seenAtOpen} + currentUserEmail={currentUser?.email} + /> + ))} +
+ )} +
+
+
+ ); +} diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx index 1f2cdc8a11..bfe540df6b 100644 --- a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx @@ -1,4 +1,5 @@ import { + BellIcon, BrainIcon, GearSixIcon, HouseIcon, @@ -6,17 +7,22 @@ import { SquaresFourIcon, TrayIcon, } from "@phosphor-icons/react"; +import { countUnseenActivity } from "@posthog/core/canvas/mentionActivity"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { HOME_TAB_FLAG } from "@posthog/shared/constants"; import { ChannelsList } from "@posthog/ui/features/canvas/components/ChannelsList"; import { useChannelsSidebarStore } from "@posthog/ui/features/canvas/components/channelsSidebarStore"; +import { useMentionActivity } from "@posthog/ui/features/canvas/hooks/useMentionActivity"; +import { useActivitySeenStore } from "@posthog/ui/features/canvas/stores/activitySeenStore"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { openSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; +import { SidebarCountBadge } from "@posthog/ui/features/sidebar/components/items/SidebarCountBadge"; import { ProjectSwitcher } from "@posthog/ui/features/sidebar/components/ProjectSwitcher"; import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem"; import { UpdateBanner } from "@posthog/ui/features/sidebar/components/UpdateBanner"; import { ResizableSidebar } from "@posthog/ui/primitives/ResizableSidebar"; import { + navigateToActivity, navigateToAgents, navigateToCanvas, navigateToInbox, @@ -27,6 +33,7 @@ import { useAppView } from "@posthog/ui/router/useAppView"; import { track } from "@posthog/ui/shell/analytics"; import { Box, Flex } from "@radix-ui/themes"; import { useRouterState } from "@tanstack/react-router"; +import { useMemo } from "react"; // Fire a nav_click event, then run the destination's navigation. function trackNav(navTarget: string, navigate: () => void) { @@ -43,11 +50,40 @@ function trackNav(navTarget: string, navigate: () => void) { // dashboard) so the Canvas nav item highlights only there. const NON_CANVAS_WEBSITE_PREFIXES = [ "/website/home", + "/website/activity", "/website/skills", "/website/mcp-servers", "/website/command-center", ]; +// The Activity nav row with its unread-mentions dot. Its own component so the +// mention fan-out queries only mount once here. +function ActivityNavItem({ isActive }: { isActive: boolean }) { + const { items } = useMentionActivity(); + const lastSeenAt = useActivitySeenStore((s) => s.lastSeenAt); + const unseen = useMemo( + () => countUnseenActivity(items, lastSeenAt), + [items, lastSeenAt], + ); + return ( + } + label={ + + Activity + + + } + isActive={isActive} + onClick={() => trackNav("activity", navigateToActivity)} + /> + ); +} + // The global nav brought over from the Code app — a single icon+label row each, // no rail. Home points at the /website/home mirror so it stays in the Channels // space (same shared HomeView, channels chrome kept); the other rows are @@ -83,6 +119,7 @@ function ChannelsNav() { onClick={() => trackNav("home", navigateToWebsiteHome)} /> )} + void; + /** Fired on Enter (without Shift) while the suggestion popup is closed. */ + onSubmit: () => void; + /** The taggable pool; typically the org's members. */ + members: UserBasic[]; + onMentionInsert?: (member: UserBasic) => void; + placeholder?: string; + rows?: number; + textareaClassName?: string; + /** Rendered inside the input group after the textarea (send button etc.). */ + children?: ReactNode; +} + +/** + * The thread composer: a textarea that opens an @-mention typeahead over the + * org's members. Selecting a member inserts an inline mention token (see + * `@posthog/shared` mentions) that notifies them in the Activity page. + */ +export function MentionComposer({ + value, + onValueChange, + onSubmit, + members, + onMentionInsert, + placeholder, + rows, + textareaClassName, + children, +}: MentionComposerProps) { + const textareaRef = useRef(null); + const itemRefs = useRef<(HTMLButtonElement | null)[]>([]); + const [active, setActive] = useState(null); + const [selectedIndex, setSelectedIndex] = useState(0); + // Esc hides the popup for the current trigger; typing a new `@` re-arms it. + const [dismissedStart, setDismissedStart] = useState(null); + + const syncActive = useCallback((el: HTMLTextAreaElement) => { + const caret = el.selectionStart ?? el.value.length; + setActive(findMentionQuery(el.value, caret)); + }, []); + + const suggestions = useMemo( + () => (active ? filterMentionCandidates(members, active.query) : []), + [active, members], + ); + const open = + !!active && active.start !== dismissedStart && suggestions.length > 0; + + // biome-ignore lint/correctness/useExhaustiveDependencies: reset selection per query + useEffect(() => { + setSelectedIndex(0); + }, [active?.start, active?.query]); + + useEffect(() => { + if (!value) { + setActive(null); + setDismissedStart(null); + } + }, [value]); + + useEffect(() => { + itemRefs.current[selectedIndex]?.scrollIntoView({ block: "nearest" }); + }, [selectedIndex]); + + const insert = useCallback( + (member: UserBasic) => { + const el = textareaRef.current; + if (!el || !active) return; + const caret = el.selectionStart ?? value.length; + const result = applyMention(value, active, caret, member); + onValueChange(result.text); + setActive(null); + onMentionInsert?.(member); + requestAnimationFrame(() => { + el.focus(); + el.setSelectionRange(result.caret, result.caret); + }); + }, + [active, value, onValueChange, onMentionInsert], + ); + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (open) { + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + const delta = event.key === "ArrowDown" ? 1 : suggestions.length - 1; + setSelectedIndex((i) => (i + delta) % suggestions.length); + return; + } + if (event.key === "Enter" || event.key === "Tab") { + event.preventDefault(); + const member = suggestions[selectedIndex]; + if (member) insert(member); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + setDismissedStart(active.start); + return; + } + } + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + onSubmit(); + } + }; + + return ( +
+ {open && ( +
+
+ {suggestions.map((member, index) => ( + + ))} +
+
+ )} + + { + onValueChange(event.target.value); + syncActive(event.target); + }} + onSelect={(event) => syncActive(event.currentTarget)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + rows={rows} + className={textareaClassName} + /> + {children} + +
+ ); +} diff --git a/packages/ui/src/features/canvas/components/MentionText.tsx b/packages/ui/src/features/canvas/components/MentionText.tsx new file mode 100644 index 0000000000..962b0b17f5 --- /dev/null +++ b/packages/ui/src/features/canvas/components/MentionText.tsx @@ -0,0 +1,42 @@ +import { splitMentionSegments } from "@posthog/shared"; +import { Text } from "@radix-ui/themes"; +import { useMemo } from "react"; + +/** + * Thread message content with inline mention tokens rendered as highlighted + * `@Name` chips; a mention of the viewer gets the stronger treatment. + */ +export function MentionText({ + content, + currentUserEmail, + className, +}: { + content: string; + currentUserEmail?: string | null; + className?: string; +}) { + const segments = useMemo(() => splitMentionSegments(content), [content]); + const selfEmail = currentUserEmail?.toLowerCase(); + return ( + + {segments.map((segment, index) => + segment.type === "mention" ? ( + + @{segment.name} + + ) : ( + segment.text + ), + )} + + ); +} diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index b3b406e338..4cd1b38221 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -15,18 +15,24 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, - InputGroup, InputGroupAddon, InputGroupButton, - InputGroupTextarea, Spinner, } from "@posthog/quill"; import { formatRelativeTimeShort } from "@posthog/shared"; -import type { Task, TaskThreadMessage } from "@posthog/shared/domain-types"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import type { + Task, + TaskThreadMessage, + UserBasic, +} from "@posthog/shared/domain-types"; import { isTerminalStatus } from "@posthog/shared/domain-types"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; +import { MentionComposer } from "@posthog/ui/features/canvas/components/MentionComposer"; +import { MentionText } from "@posthog/ui/features/canvas/components/MentionText"; +import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers"; import { useTaskThread, useTaskThreadMutations, @@ -34,14 +40,16 @@ import { import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; import { toast } from "@posthog/ui/primitives/toast"; +import { track } from "@posthog/ui/shell/analytics"; import { Text } from "@radix-ui/themes"; import { useQuery } from "@tanstack/react-query"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; function ThreadMessageRow({ message, isTaskAuthor, isOwnMessage, + currentUserEmail, canForward, onSendToAgent, onDelete, @@ -50,6 +58,7 @@ function ThreadMessageRow({ /** Whether the current user authored the task (may forward to the agent). */ isTaskAuthor: boolean; isOwnMessage: boolean; + currentUserEmail?: string | null; canForward: boolean; onSendToAgent: () => void; onDelete: () => void; @@ -71,9 +80,11 @@ function ThreadMessageRow({ {formatRelativeTimeShort(message.created_at)} - - {message.content} - + {forwarded && ( @@ -148,10 +159,23 @@ export function ThreadPanel({ isPosting, isSendingToAgent, } = useTaskThreadMutations(taskId); + const { members } = useOrgMembers({ enabled: !collapsed }); const [draft, setDraft] = useState(""); const scrollRef = useRef(null); + const handleMentionInsert = useCallback( + (member: UserBasic) => { + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "mention_member", + surface: "thread_panel", + task_id: taskId, + mentioned_user_id: member.uuid, + }); + }, + [taskId], + ); + // Keep the newest message in view, Slack-style. // biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new messages useEffect(() => { @@ -267,6 +291,7 @@ export function ThreadPanel({ !!currentUser?.uuid && currentUser.uuid === message.author?.uuid } + currentUserEmail={currentUser?.email} canForward={canForward} onSendToAgent={() => handleSendToAgent(message.id)} onDelete={() => handleDelete(message.id)} @@ -277,20 +302,16 @@ export function ThreadPanel({
- - setDraft(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - submit(); - } - }} - placeholder="Reply in thread…" - rows={2} - className="max-h-40 text-[13px]" - /> + - +
); diff --git a/packages/ui/src/features/canvas/hooks/useMentionActivity.ts b/packages/ui/src/features/canvas/hooks/useMentionActivity.ts new file mode 100644 index 0000000000..c917181d15 --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useMentionActivity.ts @@ -0,0 +1,116 @@ +import { + buildMentionActivity, + type MentionActivityItem, + type MentionActivityTaskRef, +} from "@posthog/core/canvas/mentionActivity"; +import type { Task, TaskThreadMessage } from "@posthog/shared/domain-types"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { + AUTH_SCOPED_QUERY_META, + useCurrentUser, +} from "@posthog/ui/features/auth/useCurrentUser"; +import { channelFeedQueryKey } from "@posthog/ui/features/canvas/hooks/useChannelFeed"; +import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { + normalizeChannelName, + PERSONAL_CHANNEL_NAME, + useTaskChannels, +} from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +import { taskThreadQueryKey } from "@posthog/ui/features/canvas/hooks/useTaskThread"; +import { useQueries } from "@tanstack/react-query"; +import { useMemo } from "react"; + +// Mentions ride on channel/thread data the backend has no aggregate view of, +// so the feed is assembled client-side: tasks per channel, then each recent +// task's thread. Query keys are shared with the channel feed and thread panel, +// so an open channel keeps this fresh for free. Capping the thread fan-out to +// the most recent tasks bounds request volume; a backend mentions index is the +// eventual replacement if channels outgrow this. +const ACTIVITY_POLL_INTERVAL_MS = 60_000; +const MAX_SCANNED_TASKS = 50; + +/** + * Thread messages across all channels that @-mention the current user, + * newest first. Mount once per surface (sidebar badge, Activity page) — + * results are shared through the react-query cache. + */ +export function useMentionActivity(options?: { enabled?: boolean }): { + items: MentionActivityItem[]; + isLoading: boolean; +} { + const enabled = options?.enabled ?? true; + const client = useOptionalAuthenticatedClient(); + const { data: currentUser } = useCurrentUser({ client }); + const { channels: taskChannels, isLoading: isLoadingChannels } = + useTaskChannels(); + const { channels: folderChannels } = useChannels({ enabled }); + + const taskQueries = useQueries({ + queries: taskChannels.map((channel) => ({ + queryKey: channelFeedQueryKey(channel.id), + queryFn: async (): Promise => { + if (!client) throw new Error("Not authenticated"); + return (await client.getTasks({ + channel: channel.id, + })) as unknown as Task[]; + }, + enabled: enabled && !!client, + refetchInterval: ACTIVITY_POLL_INTERVAL_MS, + meta: AUTH_SCOPED_QUERY_META, + })), + }); + + const taskRefs = useMemo(() => { + const folderIdByName = new Map( + folderChannels.map((folder) => [ + normalizeChannelName(folder.name), + folder.id, + ]), + ); + const refs: MentionActivityTaskRef[] = []; + taskChannels.forEach((channel, index) => { + const folderChannelId = + folderIdByName.get( + channel.channel_type === "personal" + ? PERSONAL_CHANNEL_NAME + : channel.name, + ) ?? null; + for (const task of taskQueries[index]?.data ?? []) { + refs.push({ task, channelName: channel.name, folderChannelId }); + } + }); + return refs + .sort((a, b) => b.task.created_at.localeCompare(a.task.created_at)) + .slice(0, MAX_SCANNED_TASKS); + }, [taskChannels, folderChannels, taskQueries]); + + const threadQueries = useQueries({ + queries: taskRefs.map((ref) => ({ + queryKey: taskThreadQueryKey(ref.task.id), + queryFn: async (): Promise => { + if (!client) throw new Error("Not authenticated"); + return client.getTaskThreadMessages(ref.task.id); + }, + enabled: enabled && !!client, + refetchInterval: ACTIVITY_POLL_INTERVAL_MS, + meta: AUTH_SCOPED_QUERY_META, + })), + }); + + const items = useMemo(() => { + const threadsByTaskId = new Map(); + taskRefs.forEach((ref, index) => { + const messages = threadQueries[index]?.data; + if (messages) threadsByTaskId.set(ref.task.id, messages); + }); + return buildMentionActivity(currentUser?.email, taskRefs, threadsByTaskId); + }, [taskRefs, threadQueries, currentUser?.email]); + + const isLoading = + enabled && + (isLoadingChannels || + taskQueries.some((query) => query.isLoading) || + threadQueries.some((query) => query.isLoading)); + + return { items, isLoading }; +} diff --git a/packages/ui/src/features/canvas/hooks/useOrgMembers.ts b/packages/ui/src/features/canvas/hooks/useOrgMembers.ts new file mode 100644 index 0000000000..334692f544 --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useOrgMembers.ts @@ -0,0 +1,33 @@ +import type { UserBasic } from "@posthog/shared/domain-types"; +import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; +import { useMemo } from "react"; + +// Membership churn is slow; one fetch per session window is plenty. +const ORG_MEMBERS_STALE_MS = 5 * 60_000; + +export const ORG_MEMBERS_QUERY_KEY = ["org-members"] as const; + +/** Members of the current organization, sorted by display name. */ +export function useOrgMembers(options?: { enabled?: boolean }): { + members: UserBasic[]; + isLoading: boolean; +} { + const query = useAuthenticatedQuery( + ORG_MEMBERS_QUERY_KEY, + (client) => client.listOrganizationMembers(), + { + enabled: options?.enabled ?? true, + staleTime: ORG_MEMBERS_STALE_MS, + }, + ); + const members = useMemo( + () => + (query.data ?? []) + .map((member) => member.user) + .filter((user) => !!user?.email) + .sort((a, b) => userDisplayName(a).localeCompare(userDisplayName(b))), + [query.data], + ); + return { members, isLoading: query.isLoading }; +} diff --git a/packages/ui/src/features/canvas/stores/activitySeenStore.ts b/packages/ui/src/features/canvas/stores/activitySeenStore.ts new file mode 100644 index 0000000000..687068deb1 --- /dev/null +++ b/packages/ui/src/features/canvas/stores/activitySeenStore.ts @@ -0,0 +1,23 @@ +import { electronStorage } from "@posthog/ui/shell/rendererStorage"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +// When the viewer last opened the Activity page; mentions newer than this +// count toward the sidebar's unread badge. +interface ActivitySeenState { + lastSeenAt: string | null; + markSeen: () => void; +} + +export const useActivitySeenStore = create()( + persist( + (set) => ({ + lastSeenAt: null, + markSeen: () => set({ lastSeenAt: new Date().toISOString() }), + }), + { + name: "channels-activity-seen", + storage: electronStorage, + }, + ), +); diff --git a/packages/ui/src/features/canvas/utils/mentionComposer.test.ts b/packages/ui/src/features/canvas/utils/mentionComposer.test.ts new file mode 100644 index 0000000000..a2db99a00e --- /dev/null +++ b/packages/ui/src/features/canvas/utils/mentionComposer.test.ts @@ -0,0 +1,110 @@ +import type { UserBasic } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { + applyMention, + filterMentionCandidates, + findMentionQuery, +} from "./mentionComposer"; + +function member(overrides: Partial & { email: string }): UserBasic { + return { + id: 1, + uuid: overrides.email, + first_name: "", + last_name: "", + ...overrides, + }; +} + +const ann = member({ + email: "ann@posthog.com", + first_name: "Ann", + last_name: "Lee", +}); +const bob = member({ + email: "bob@posthog.com", + first_name: "Bob", + last_name: "Stone", +}); +const raquel = member({ + email: "raquel@posthog.com", + first_name: "Raquel", + last_name: "Smith", +}); +const members = [ann, bob, raquel]; + +describe("findMentionQuery", () => { + it.each([ + ["at text start", "@ra", 3, { start: 0, query: "ra" }], + ["after whitespace", "hey @ra", 7, { start: 4, query: "ra" }], + ["empty query right after @", "hey @", 5, { start: 4, query: "" }], + [ + "query with a space", + "hey @raquel sm", + 14, + { start: 4, query: "raquel sm" }, + ], + ["mid-word @ (emails)", "mail me@work", 12, null], + ["query opening with [ (inserted token)", "@[Ann](a) x", 9, null], + ["query starting with a space", "hey @ home", 10, null], + ["query spanning a newline", "hey @ra\nnew", 11, null], + ["no @ before caret", "hello", 5, null], + ])("%s", (_label, text, caret, expected) => { + expect(findMentionQuery(text, caret)).toEqual(expected); + }); + + it("only considers text before the caret", () => { + expect(findMentionQuery("@raquel", 3)).toEqual({ start: 0, query: "ra" }); + }); +}); + +describe("filterMentionCandidates", () => { + it("returns everyone for an empty query", () => { + expect(filterMentionCandidates(members, "")).toEqual([ann, bob, raquel]); + }); + + it("ranks name prefix over word prefix over email over substring", () => { + const smithers = member({ + email: "s@posthog.com", + first_name: "Smi", + last_name: "Thers", + }); + expect(filterMentionCandidates([...members, smithers], "sm")).toEqual([ + smithers, // name prefix + raquel, // last-name word prefix + ]); + }); + + it("matches by email", () => { + expect(filterMentionCandidates(members, "bob@")).toEqual([bob]); + }); + + it("is case-insensitive and respects the limit", () => { + expect(filterMentionCandidates(members, "RAQ")).toEqual([raquel]); + expect(filterMentionCandidates(members, "", 2)).toHaveLength(2); + }); + + it("returns empty when nothing matches", () => { + expect(filterMentionCandidates(members, "zzz")).toEqual([]); + }); +}); + +describe("applyMention", () => { + it("replaces the active query with a token and trailing space", () => { + const text = "hey @raq can you look"; + const active = { start: 4, query: "raq" }; + const result = applyMention(text, active, 8, raquel); + expect(result.text).toBe( + "hey @[Raquel Smith](raquel@posthog.com) can you look", + ); + expect(result.caret).toBe( + "hey @[Raquel Smith](raquel@posthog.com) ".length, + ); + }); + + it("works at the end of the text", () => { + const result = applyMention("@", { start: 0, query: "" }, 1, ann); + expect(result.text).toBe("@[Ann Lee](ann@posthog.com) "); + expect(result.caret).toBe(result.text.length); + }); +}); diff --git a/packages/ui/src/features/canvas/utils/mentionComposer.ts b/packages/ui/src/features/canvas/utils/mentionComposer.ts new file mode 100644 index 0000000000..bdbe401f78 --- /dev/null +++ b/packages/ui/src/features/canvas/utils/mentionComposer.ts @@ -0,0 +1,70 @@ +import { formatMention } from "@posthog/shared"; +import type { UserBasic } from "@posthog/shared/domain-types"; +import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; + +/** The in-progress `@query` between the trigger and the caret. */ +export interface ActiveMentionQuery { + /** Index of the `@` in the full text. */ + start: number; + query: string; +} + +/** + * The mention query the caret is inside, or null when the caret isn't in one. + * The `@` must open a word (start of text or after whitespace); a query + * starting with `[` is an already-inserted token, not a fresh trigger. + */ +export function findMentionQuery( + text: string, + caret: number, +): ActiveMentionQuery | null { + const upToCaret = text.slice(0, caret); + const start = upToCaret.lastIndexOf("@"); + if (start === -1) return null; + if (start > 0 && !/\s/.test(upToCaret[start - 1] ?? "")) return null; + const query = upToCaret.slice(start + 1); + if (query.startsWith("[") || query.startsWith(" ")) return null; + if (query.includes("\n") || query.length > 60) return null; + return { start, query }; +} + +/** Members matching the query, best-first: name prefix, word prefix, email, substring. */ +export function filterMentionCandidates( + members: UserBasic[], + query: string, + limit = 8, +): UserBasic[] { + const q = query.trim().toLowerCase(); + const scored: Array<{ member: UserBasic; score: number }> = []; + for (const member of members) { + const name = userDisplayName(member).toLowerCase(); + const email = member.email.toLowerCase(); + let score: number | null = null; + if (!q || name.startsWith(q)) score = 0; + else if (name.split(/\s+/).some((word) => word.startsWith(q))) score = 1; + else if (email.startsWith(q)) score = 2; + else if (name.includes(q) || email.includes(q)) score = 3; + if (score !== null) scored.push({ member, score }); + } + return scored + .sort( + (a, b) => + a.score - b.score || + userDisplayName(a.member).localeCompare(userDisplayName(b.member)), + ) + .slice(0, limit) + .map((entry) => entry.member); +} + +/** Replace the active `@query` with the member's mention token. */ +export function applyMention( + text: string, + active: ActiveMentionQuery, + caret: number, + member: UserBasic, +): { text: string; caret: number } { + const token = `${formatMention(userDisplayName(member), member.email)} `; + const before = text.slice(0, active.start); + const next = before + token + text.slice(caret); + return { text: next, caret: before.length + token.length }; +} diff --git a/packages/ui/src/router/navigationBridge.ts b/packages/ui/src/router/navigationBridge.ts index bf59aea0eb..396ab1628f 100644 --- a/packages/ui/src/router/navigationBridge.ts +++ b/packages/ui/src/router/navigationBridge.ts @@ -40,6 +40,10 @@ export function navigateToTaskPending(key: string): void { }); } +export function navigateToActivity(): void { + void getRouterOrNull()?.navigate({ to: "/website/activity" }); +} + export function navigateToChannel(channelId: string): void { void getRouterOrNull()?.navigate({ to: "/website/$channelId", diff --git a/packages/ui/src/router/routeTree.gen.ts b/packages/ui/src/router/routeTree.gen.ts index 356c9a3813..693034132b 100644 --- a/packages/ui/src/router/routeTree.gen.ts +++ b/packages/ui/src/router/routeTree.gen.ts @@ -23,6 +23,7 @@ import { Route as WebsiteNewRouteImport } from './routes/website/new' import { Route as WebsiteMcpServersRouteImport } from './routes/website/mcp-servers' import { Route as WebsiteHomeRouteImport } from './routes/website/home' import { Route as WebsiteCommandCenterRouteImport } from './routes/website/command-center' +import { Route as WebsiteActivityRouteImport } from './routes/website/activity' import { Route as SettingsCategoryRouteImport } from './routes/settings/$category' import { Route as FoldersFolderIdRouteImport } from './routes/folders/$folderId' import { Route as CodePrRouteImport } from './routes/code/pr' @@ -145,6 +146,11 @@ const WebsiteCommandCenterRoute = WebsiteCommandCenterRouteImport.update({ path: '/command-center', getParentRoute: () => WebsiteRoute, } as any) +const WebsiteActivityRoute = WebsiteActivityRouteImport.update({ + id: '/activity', + path: '/activity', + getParentRoute: () => WebsiteRoute, +} as any) const SettingsCategoryRoute = SettingsCategoryRouteImport.update({ id: '/settings/$category', path: '/settings/$category', @@ -437,6 +443,7 @@ export interface FileRoutesByFullPath { '/code/pr': typeof CodePrRoute '/folders/$folderId': typeof FoldersFolderIdRoute '/settings/$category': typeof SettingsCategoryRoute + '/website/activity': typeof WebsiteActivityRoute '/website/command-center': typeof WebsiteCommandCenterRoute '/website/home': typeof WebsiteHomeRoute '/website/mcp-servers': typeof WebsiteMcpServersRoute @@ -501,6 +508,7 @@ export interface FileRoutesByTo { '/code/pr': typeof CodePrRoute '/folders/$folderId': typeof FoldersFolderIdRoute '/settings/$category': typeof SettingsCategoryRoute + '/website/activity': typeof WebsiteActivityRoute '/website/command-center': typeof WebsiteCommandCenterRoute '/website/home': typeof WebsiteHomeRoute '/website/mcp-servers': typeof WebsiteMcpServersRoute @@ -561,6 +569,7 @@ export interface FileRoutesById { '/code/pr': typeof CodePrRoute '/folders/$folderId': typeof FoldersFolderIdRoute '/settings/$category': typeof SettingsCategoryRoute + '/website/activity': typeof WebsiteActivityRoute '/website/command-center': typeof WebsiteCommandCenterRoute '/website/home': typeof WebsiteHomeRoute '/website/mcp-servers': typeof WebsiteMcpServersRoute @@ -630,6 +639,7 @@ export interface FileRouteTypes { | '/code/pr' | '/folders/$folderId' | '/settings/$category' + | '/website/activity' | '/website/command-center' | '/website/home' | '/website/mcp-servers' @@ -694,6 +704,7 @@ export interface FileRouteTypes { | '/code/pr' | '/folders/$folderId' | '/settings/$category' + | '/website/activity' | '/website/command-center' | '/website/home' | '/website/mcp-servers' @@ -753,6 +764,7 @@ export interface FileRouteTypes { | '/code/pr' | '/folders/$folderId' | '/settings/$category' + | '/website/activity' | '/website/command-center' | '/website/home' | '/website/mcp-servers' @@ -927,6 +939,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof WebsiteCommandCenterRouteImport parentRoute: typeof WebsiteRoute } + '/website/activity': { + id: '/website/activity' + path: '/activity' + fullPath: '/website/activity' + preLoaderRoute: typeof WebsiteActivityRouteImport + parentRoute: typeof WebsiteRoute + } '/settings/$category': { id: '/settings/$category' path: '/settings/$category' @@ -1288,6 +1307,7 @@ declare module '@tanstack/react-router' { } interface WebsiteRouteChildren { + WebsiteActivityRoute: typeof WebsiteActivityRoute WebsiteCommandCenterRoute: typeof WebsiteCommandCenterRoute WebsiteHomeRoute: typeof WebsiteHomeRoute WebsiteMcpServersRoute: typeof WebsiteMcpServersRoute @@ -1305,6 +1325,7 @@ interface WebsiteRouteChildren { } const WebsiteRouteChildren: WebsiteRouteChildren = { + WebsiteActivityRoute: WebsiteActivityRoute, WebsiteCommandCenterRoute: WebsiteCommandCenterRoute, WebsiteHomeRoute: WebsiteHomeRoute, WebsiteMcpServersRoute: WebsiteMcpServersRoute, diff --git a/packages/ui/src/router/routes/website/activity.tsx b/packages/ui/src/router/routes/website/activity.tsx new file mode 100644 index 0000000000..badafe210d --- /dev/null +++ b/packages/ui/src/router/routes/website/activity.tsx @@ -0,0 +1,8 @@ +import { ActivityView } from "@posthog/ui/features/canvas/components/ActivityView"; +import { createFileRoute } from "@tanstack/react-router"; + +// Channels-space Activity page: @-mentions of the viewer across channel +// threads. The sidebar's Activity nav badge counts what's new here. +export const Route = createFileRoute("/website/activity")({ + component: ActivityView, +}); diff --git a/packages/ui/src/router/useAppView.ts b/packages/ui/src/router/useAppView.ts index a3eed99447..1c13492204 100644 --- a/packages/ui/src/router/useAppView.ts +++ b/packages/ui/src/router/useAppView.ts @@ -12,6 +12,7 @@ export type AppViewType = | "task-input" | "folder-settings" | "home" + | "activity" | "inbox" | "agents" | "archived" @@ -66,6 +67,8 @@ function deriveFromMatches(matches: Match[]): AppView { // active-state highlighting works identically in either space. case "/website/home": return { type: "home" }; + case "/website/activity": + return { type: "activity" }; case "/code/inbox": return { type: "inbox" }; case "/code/agents": From 131f26183f2a770db9935ed3148f1dd4f09c881d Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Tue, 7 Jul 2026 13:06:29 +0100 Subject: [PATCH 2/5] fix(channels): address react-doctor and review findings on mention composer applyMention now replaces the whole @word (caret moved backward mid-query no longer leaks typed characters into the message) and reuses an existing following space instead of doubling it. MentionComposer drops its state-sync effects for ref-guarded render-time adjustments and scrolls in the key handler, clearing react-doctor's no-adjust-state-on-prop-change errors. MentionText keys segments by character offset instead of array index. Generated-By: PostHog Code Task-Id: a2ff8500-aaa6-4ce0-b01a-656d3b2c3b61 --- .../canvas/components/MentionComposer.tsx | 48 ++++++++++--------- .../canvas/components/MentionText.tsx | 19 +++++--- .../canvas/utils/mentionComposer.test.ts | 29 +++++++++-- .../features/canvas/utils/mentionComposer.ts | 20 ++++++-- 4 files changed, 79 insertions(+), 37 deletions(-) diff --git a/packages/ui/src/features/canvas/components/MentionComposer.tsx b/packages/ui/src/features/canvas/components/MentionComposer.tsx index 1b4ed696c5..40877cf728 100644 --- a/packages/ui/src/features/canvas/components/MentionComposer.tsx +++ b/packages/ui/src/features/canvas/components/MentionComposer.tsx @@ -14,14 +14,7 @@ import { } from "@posthog/ui/features/canvas/utils/mentionComposer"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import { Text } from "@radix-ui/themes"; -import { - type ReactNode, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import { type ReactNode, useCallback, useMemo, useRef, useState } from "react"; interface MentionComposerProps { value: string; @@ -73,21 +66,28 @@ export function MentionComposer({ const open = !!active && active.start !== dismissedStart && suggestions.length > 0; - // biome-ignore lint/correctness/useExhaustiveDependencies: reset selection per query - useEffect(() => { - setSelectedIndex(0); - }, [active?.start, active?.query]); - - useEffect(() => { + // Render-time adjustments (ref-guarded, same idiom as SuggestionList): when + // the parent clears the draft the mention context goes with it, and a new + // query restarts keyboard selection at the top. + const prevValueRef = useRef(value); + if (prevValueRef.current !== value) { + prevValueRef.current = value; if (!value) { setActive(null); setDismissedStart(null); } - }, [value]); - - useEffect(() => { - itemRefs.current[selectedIndex]?.scrollIntoView({ block: "nearest" }); - }, [selectedIndex]); + } + const activeKey = active ? `${active.start}:${active.query}` : ""; + const prevActiveKeyRef = useRef(activeKey); + if (prevActiveKeyRef.current !== activeKey) { + prevActiveKeyRef.current = activeKey; + if (selectedIndex !== 0) setSelectedIndex(0); + } + // The list can shrink while a lower row is selected (members filter down). + const highlightedIndex = Math.min( + selectedIndex, + Math.max(0, suggestions.length - 1), + ); const insert = useCallback( (member: UserBasic) => { @@ -111,12 +111,14 @@ export function MentionComposer({ if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); const delta = event.key === "ArrowDown" ? 1 : suggestions.length - 1; - setSelectedIndex((i) => (i + delta) % suggestions.length); + const next = (highlightedIndex + delta) % suggestions.length; + setSelectedIndex(next); + itemRefs.current[next]?.scrollIntoView({ block: "nearest" }); return; } if (event.key === "Enter" || event.key === "Tab") { event.preventDefault(); - const member = suggestions[selectedIndex]; + const member = suggestions[highlightedIndex]; if (member) insert(member); return; } @@ -145,7 +147,7 @@ export function MentionComposer({ - {item.folderChannelId && ( + {folderChannelId && (