diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index e359023d56..39f5d8e857 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, @@ -72,6 +73,7 @@ import type { SuggestedReviewerWriteEntry, Task, TaskChannel, + TaskMention, TaskRun, TaskRunArtefact, TaskThreadMessage, @@ -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 { + 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 { const teamId = await this.getTeamId(); const urlPath = `/api/projects/${teamId}/tasks/${taskId}/thread_messages/`; @@ -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 { + 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..517dfca416 --- /dev/null +++ b/packages/core/src/canvas/mentionActivity.test.ts @@ -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 { + 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"); + }); +}); diff --git a/packages/core/src/canvas/mentionActivity.ts b/packages/core/src/canvas/mentionActivity.ts new file mode 100644 index 0000000000..049bd170ec --- /dev/null +++ b/packages/core/src/canvas/mentionActivity.ts @@ -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); +} 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..e8ef685d5c 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; @@ -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" diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7174f95a77..e1e1915794 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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, diff --git a/packages/shared/src/mentions.test.ts b/packages/shared/src/mentions.test.ts new file mode 100644 index 0000000000..5b05ce5623 --- /dev/null +++ b/packages/shared/src/mentions.test.ts @@ -0,0 +1,99 @@ +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)", + "@[double at](a@@x.com)", + ]) { + expect( + splitMentionSegments(content).every((s) => s.type === "text"), + ).toBe(true); + } + }); + + it("scans adversarial unterminated tokens in linear time", () => { + // Regression for CodeQL js/polynomial-redos: with `@` allowed around the + // email separator this input backtracked quadratically. + const content = `@[Z](${"!@".repeat(50_000)}`; + const start = performance.now(); + const segments = splitMentionSegments(content); + expect(performance.now() - start).toBeLessThan(500); + expect(segments).toEqual([{ type: "text", text: content }]); + }); +}); + +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..ad63231ec1 --- /dev/null +++ b/packages/shared/src/mentions.ts @@ -0,0 +1,71 @@ +/** + * @-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 render mentions as chips from the content + * alone. The backend indexes the same tokens at write time to serve the + * mentions feed (`getTaskMentions`). + */ + +// `@` is excluded on both sides of the separator so the match point is +// unambiguous — with it allowed, adversarial input backtracks quadratically +// (CodeQL js/polynomial-redos). Real emails carry exactly one `@` anyway. +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; +} + +/** 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..905360c757 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -0,0 +1,216 @@ +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 { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useMentionActivity } from "@posthog/ui/features/canvas/hooks/useMentionActivity"; +import { normalizeChannelName } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +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, useMemo, useState } from "react"; + +function ActivityRow({ + item, + folderChannelId, + isNew, + currentUserEmail, +}: { + item: MentionActivityItem; + /** Desktop folder channel id (the /website route param); null when unmapped. */ + folderChannelId: string | null; + /** 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: 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 (folderChannelId) { + navigateToChannelTask(folderChannelId, item.taskId); + } else { + navigateToTaskDetail(item.taskId); + } + }; + + return ( +
+ + {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(); + // Items carry backend channel names only; the desktop folder-channel id + // (needed for /website navigation and copy-link) is resolved here, where + // the single useChannels subscription lives. + const { channels: folderChannels } = useChannels(); + const folderIdByName = useMemo( + () => + new Map( + folderChannels.map((folder) => [ + normalizeChannelName(folder.name), + folder.id, + ]), + ), + [folderChannels], + ); + const folderChannelIdFor = (channelName: string | null): string | null => + channelName + ? (folderIdByName.get(normalizeChannelName(channelName)) ?? null) + : null; + 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..afb691ff93 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 +// mentions query only mounts 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; + + // 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); + } + } + 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) => { + 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; + 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[highlightedIndex]; + 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..4e85d61798 --- /dev/null +++ b/packages/ui/src/features/canvas/components/MentionText.tsx @@ -0,0 +1,49 @@ +import { splitMentionSegments } from "@posthog/shared"; +import { Text } from "@radix-ui/themes"; +import { Fragment, 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; +}) { + // Key each segment by its character offset — stable for a given content. + const segments = useMemo(() => { + let offset = 0; + return splitMentionSegments(content).map((segment) => { + const entry = { segment, key: `${offset}` }; + offset += segment.text.length; + return entry; + }); + }, [content]); + const selfEmail = currentUserEmail?.toLowerCase(); + return ( + + {segments.map(({ segment, key }) => + 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..51fa6f9e73 --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useMentionActivity.ts @@ -0,0 +1,49 @@ +import { + type MentionActivityItem, + mergeTaskMentions, + toMentionActivityItems, +} from "@posthog/core/canvas/mentionActivity"; +import type { TaskMention } from "@posthog/shared/domain-types"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; +import { useQueryClient } from "@tanstack/react-query"; +import { useMemo } from "react"; + +const ACTIVITY_POLL_INTERVAL_MS = 60_000; +const TASK_MENTIONS_QUERY_KEY = ["task-mentions"] as const; + +/** + * Thread messages across all channels that @-mention the current user, + * newest first, from the backend mentions index. 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 queryClient = useQueryClient(); + const query = useAuthenticatedQuery( + TASK_MENTIONS_QUERY_KEY, + async (client) => { + const previous = + queryClient.getQueryData(TASK_MENTIONS_QUERY_KEY) ?? []; + // Newest mention already held becomes the low-water mark, so repolls + // ask the backend only for what's new instead of the whole top page. + const since = previous[0]?.created_at; + const incoming = await client.getTaskMentions( + since ? { since } : undefined, + ); + return mergeTaskMentions(previous, incoming); + }, + { + enabled: options?.enabled ?? true, + refetchInterval: ACTIVITY_POLL_INTERVAL_MS, + staleTime: ACTIVITY_POLL_INTERVAL_MS, + }, + ); + const items = useMemo( + () => toMentionActivityItems(query.data ?? []), + [query.data], + ); + return { items, isLoading: query.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..6d3d52a757 --- /dev/null +++ b/packages/ui/src/features/canvas/utils/mentionComposer.test.ts @@ -0,0 +1,133 @@ +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, reusing the existing following 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("consumes the rest of the @word when the caret moved backward", () => { + // "hey @raq" with the caret between "ra" and "q": the trailing "q" is + // still query text and must not leak into the message. + const result = applyMention( + "hey @raq", + { start: 4, query: "ra" }, + 7, + raquel, + ); + expect(result.text).toBe("hey @[Raquel Smith](raquel@posthog.com) "); + expect(result.caret).toBe(result.text.length); + }); + + it("does not consume text beyond the @word", () => { + const result = applyMention( + "@ra world", + { start: 0, query: "ra" }, + 3, + raquel, + ); + expect(result.text).toBe("@[Raquel Smith](raquel@posthog.com) world"); + }); + + 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..fd6a552f16 --- /dev/null +++ b/packages/ui/src/features/canvas/utils/mentionComposer.ts @@ -0,0 +1,82 @@ +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, leaving the + * caret right after it (past any space that already follows). + */ +export function applyMention( + text: string, + active: ActiveMentionQuery, + caret: number, + member: UserBasic, +): { text: string; caret: number } { + // The replacement spans the whole @word: when the caret moved back inside + // the query, the characters typed after it are still mention text. + let end = caret; + while (end < text.length && !/\s/.test(text[end] ?? "")) end++; + const tail = text.slice(end); + const token = formatMention(userDisplayName(member), member.email); + const before = text.slice(0, active.start); + // Reuse an existing following space rather than doubling it up. + const inserted = tail.startsWith(" ") ? token : `${token} `; + return { + text: before + inserted + tail, + caret: before.length + inserted.length + (tail.startsWith(" ") ? 1 : 0), + }; +} 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":