diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index e25eada011..8237a253a9 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, @@ -2302,6 +2304,25 @@ export class PostHogAPIClient { return (await response.json()) as TaskChannel; } + // Mentions of the current user across task threads, newest first. + async getTaskMentions(options?: { since?: string }): Promise { + 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/`; @@ -2382,6 +2403,40 @@ export class PostHogAPIClient { return (await response.json()) as TaskThreadMessage; } + // Everyone in the current organization — the pool of taggable teammates for + // thread @-mentions. Membership churn is slow, so callers cache aggressively. + async listOrganizationMembers(): Promise { + 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..e0038500d0 --- /dev/null +++ b/packages/core/src/canvas/mentionActivity.test.ts @@ -0,0 +1,75 @@ +import type { TaskMention, UserBasic } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { countUnseenActivity, toMentionActivityItems } from "./mentionActivity"; + +const ann: UserBasic = { + id: 2, + uuid: "ann-uuid", + email: "ann@posthog.com", + first_name: "Ann", +}; + +function mention(overrides: Partial = {}): TaskMention { + 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); + }); +}); diff --git a/packages/core/src/canvas/mentionActivity.ts b/packages/core/src/canvas/mentionActivity.ts new file mode 100644 index 0000000000..e876adb377 --- /dev/null +++ b/packages/core/src/canvas/mentionActivity.ts @@ -0,0 +1,45 @@ +import type { TaskMention, UserBasic } from "@posthog/shared/domain-types"; + +/** + * The Activity feed — thread messages that @-mention the current user — as + * served by the backend mentions index (`getTaskMentions`). Mentions are + * extracted server-side at write time, so the client only maps DTOs to items. + */ + +export interface MentionActivityItem { + messageId: string; + taskId: string; + taskTitle: string; + /** Backend channel (tasks product Channel UUID); null for channel-less tasks. */ + channelId: string | null; + /** Backend channel name, for the "#channel" label. */ + channelName: string | null; + author: UserBasic | null; + content: string; + createdAt: string; +} + +/** Map mention DTOs (already newest-first from the backend) to feed items. */ +export function toMentionActivityItems( + mentions: readonly TaskMention[], +): MentionActivityItem[] { + return mentions.map((mention) => ({ + messageId: mention.message_id, + taskId: mention.task_id, + taskTitle: mention.task_title || "Untitled task", + channelId: mention.channel_id ?? null, + channelName: mention.channel_name ?? null, + author: mention.author ?? null, + content: mention.content, + createdAt: mention.created_at, + })); +} + +/** How many items arrived after the viewer last opened the Activity page. */ +export function countUnseenActivity( + items: readonly MentionActivityItem[], + lastSeenAt: string | null, +): number { + if (!lastSeenAt) return items.length; + return items.filter((item) => item.createdAt > lastSeenAt).length; +} diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index e8e553f8ea..428a640d4d 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -820,7 +820,9 @@ export type ChannelsSurface = | "pinned" | "dashboards_grid" | "canvas" - | "context"; + | "context" + | "thread_panel" + | "activity"; export type ChannelActionType = | "enter_space" @@ -847,7 +849,10 @@ export type ChannelActionType = | "unfile_task" | "archive_task" | "open_task" - | "copy_link"; + | "copy_link" + | "mention_member" + | "view_activity" + | "open_mention"; export interface ChannelActionProperties { action_type: ChannelActionType; @@ -858,8 +863,10 @@ export interface ChannelActionProperties { task_id?: string; /** For file_task: destination channel when different from `channel_id`. */ target_channel_id?: string; - /** For nav_click: which destination ("home"|"inbox"|"canvas"|"agents"|"files"|"settings"). */ + /** For nav_click: which destination ("home"|"activity"|"inbox"|"canvas"|"agents"|"files"|"settings"). */ nav_target?: string; + /** For mention_member: the tagged teammate's user uuid. */ + mentioned_user_id?: string; /** For new_task_suggestion: the starter-prompt card label. */ suggestion_label?: string; /** Whether the underlying mutation resolved successfully. */ 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 9c71d7df07..b41eb1864d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -115,6 +115,12 @@ export { export { buildDiscussReportPrompt } from "./inbox-prompts"; export type { AvailableSuggestedReviewer, SourceProduct } from "./inbox-types"; export { EXTERNAL_LINKS } from "./links"; +export { + formatMention, + type MentionSegment, + mentionsToPlainText, + splitMentionSegments, +} from "./mentions"; export { getOauthClientIdFromRegion, OAUTH_SCOPE_VERSION, diff --git a/packages/shared/src/mentions.test.ts b/packages/shared/src/mentions.test.ts new file mode 100644 index 0000000000..b66dd59b61 --- /dev/null +++ b/packages/shared/src/mentions.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { + formatMention, + mentionsToPlainText, + splitMentionSegments, +} from "./mentions"; + +describe("formatMention", () => { + it("serializes name and email into a token", () => { + expect(formatMention("Raquel Smith", "raquel@posthog.com")).toBe( + "@[Raquel Smith](raquel@posthog.com)", + ); + }); + + it.each([ + ["strips brackets from names", "A [b] c", "a@x.com", "@[A b c](a@x.com)"], + [ + "falls back to the email local part", + "[]", + "ann@x.com", + "@[ann](ann@x.com)", + ], + ])("%s", (_label, name, email, expected) => { + expect(formatMention(name, email)).toBe(expected); + }); + + it("round-trips through the parser", () => { + const token = formatMention("Raquel Smith", "raquel@posthog.com"); + const segments = splitMentionSegments(`hey ${token}!`); + expect(segments).toEqual([ + { type: "text", text: "hey " }, + { + type: "mention", + text: token, + name: "Raquel Smith", + email: "raquel@posthog.com", + }, + { type: "text", text: "!" }, + ]); + }); +}); + +describe("splitMentionSegments", () => { + it("returns a single text segment when there are no mentions", () => { + expect(splitMentionSegments("no mentions here")).toEqual([ + { type: "text", text: "no mentions here" }, + ]); + }); + + it("handles adjacent and repeated mentions", () => { + const content = "@[A](a@x.com)@[B](b@x.com) and @[A](a@x.com)"; + const segments = splitMentionSegments(content); + expect(segments.map((s) => s.type)).toEqual([ + "mention", + "mention", + "text", + "mention", + ]); + }); + + it("ignores markdown links and bare @ text", () => { + const content = "see [docs](https://x.com) and email me @ home"; + expect(splitMentionSegments(content)).toEqual([ + { type: "text", text: content }, + ]); + }); + + it("ignores malformed tokens", () => { + for (const content of [ + "@[no email]()", + "@[unclosed](a@x.com", + "@[](a@x.com)", + "@[spaced email](a b@x.com)", + ]) { + expect( + splitMentionSegments(content).every((s) => s.type === "text"), + ).toBe(true); + } + }); +}); + +describe("mentionsToPlainText", () => { + it("flattens tokens to @Name", () => { + expect(mentionsToPlainText("hi @[Ann Lee](ann@x.com), ship it")).toBe( + "hi @Ann Lee, ship it", + ); + }); +}); diff --git a/packages/shared/src/mentions.ts b/packages/shared/src/mentions.ts new file mode 100644 index 0000000000..182efe6203 --- /dev/null +++ b/packages/shared/src/mentions.ts @@ -0,0 +1,68 @@ +/** + * @-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`). + */ + +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..525a3cbd62 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -0,0 +1,218 @@ +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 { + navigateToChannelThread, + 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, + }); + // Land on the message in its channel with the thread open; tasks whose + // channel folder is gone fall back to the plain task view. + if (folderChannelId) { + navigateToChannelThread(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 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/ChannelFeedView.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx index 9f73742883..76653e65f7 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx @@ -29,6 +29,7 @@ import { TooltipContent, TooltipProvider, TooltipTrigger, + useChatMessageScroller, } from "@posthog/quill"; import { formatRelativeTimeShort } from "@posthog/shared"; import type { Task, TaskRunStatus } from "@posthog/shared/domain-types"; @@ -42,7 +43,7 @@ import { xmlToPlainText } from "@posthog/ui/features/message-editor/content"; import { extractChannelContext } from "@posthog/ui/features/sessions/components/session-update/channelContext"; import { getOriginProductMeta } from "@posthog/ui/features/sidebar/components/items/TaskIcon"; import { Text } from "@radix-ui/themes"; -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; // Feed rows poll their reply counts slower than the open thread panel — the // shared query key means an open panel naturally speeds the row up too. @@ -324,6 +325,17 @@ function FeedItem({ ); } +// Jumps the feed to a linked message once it's in the list. A frame later +// than mount so it wins over the provider's initial scroll-to-end. +function ScrollToFocusMessage({ messageId }: { messageId: string }) { + const { scrollToMessage } = useChatMessageScroller(); + useEffect(() => { + const frame = requestAnimationFrame(() => scrollToMessage(messageId)); + return () => cancelAnimationFrame(frame); + }, [messageId, scrollToMessage]); + return null; +} + // The Slack-style channel feed: every task kicked off in the channel, oldest // first, rendered as a kickoff message + task card. Multiplayer — the list is // team-visible and polls for teammates' cards and status flips. @@ -333,12 +345,15 @@ export function ChannelFeedView({ emptyState, onOpenTask, onOpenThread, + focusMessageId, }: { tasks: Task[]; isLoading: boolean; emptyState?: React.ReactNode; onOpenTask: (task: Task) => void; onOpenThread: (task: Task) => void; + /** Message (task) to scroll into view when the feed opens via a link. */ + focusMessageId?: string; }) { if (isLoading && tasks.length === 0) { return ( @@ -369,6 +384,9 @@ export function ChannelFeedView({ + {focusMessageId && tasks.some((t) => t.id === focusMessageId) && ( + + )} ); 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)} /> )} + { + it.each([ + ["empty draft", ""], + ["plain text", "just a reply"], + ["a mention mid-text", "hey @[Raquel Smith](raquel@posthog.com) look"], + ["a mention at the start", "@[Ann Lee](ann@posthog.com) morning"], + ["adjacent mentions", "@[Ann Lee](a@x.com) @[Bob Stone](b@x.com)"], + ["multi-line text", "line one\nline two"], + ["a mention after a newline", "ping\n@[Ann Lee](ann@posthog.com) here"], + ])("%s", (_label, value) => { + expect(roundTrip(value)).toBe(value); + }); +}); + +describe("MentionComposer", () => { + it("renders mention tokens as chips, not raw markdown", () => { + render( + , + ); + + const chip = screen.getByText("@Raquel Smith"); + expect(chip).toBeInTheDocument(); + expect(chip.getAttribute("title")).toBe("raquel@posthog.com"); + expect( + screen.queryByText(/@\[Raquel Smith\]\(raquel@posthog\.com\)/), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/features/canvas/components/MentionComposer.tsx b/packages/ui/src/features/canvas/components/MentionComposer.tsx new file mode 100644 index 0000000000..42be7d98c5 --- /dev/null +++ b/packages/ui/src/features/canvas/components/MentionComposer.tsx @@ -0,0 +1,359 @@ +import "../../message-editor/components/message-editor.css"; +import { Avatar, AvatarFallback, InputGroup } from "@posthog/quill"; +import { formatMention, splitMentionSegments } from "@posthog/shared"; +import type { UserBasic } from "@posthog/shared/domain-types"; +import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; +import { filterMentionCandidates } from "@posthog/ui/features/canvas/utils/mentionComposer"; +import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; +import { Text } from "@radix-ui/themes"; +import type { Editor, JSONContent } from "@tiptap/core"; +import Mention, { + type MentionNodeAttrs, + type MentionOptions, +} from "@tiptap/extension-mention"; +import Placeholder from "@tiptap/extension-placeholder"; +import { EditorContent, useEditor } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import clsx from "clsx"; +import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; + +interface MentionComposerProps { + /** Draft content in the inline token format (`@[Name](email)`). */ + value: string; + onValueChange: (value: string) => 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; + editorClassName?: string; + /** Rendered inside the input group after the editor (send button etc.). */ + children?: ReactNode; +} + +// The chip style mirrors the task composer's mention chips (MentionChipNode). +const MENTION_CHIP_CLASS = + "inline select-all cursor-default rounded-[var(--radius-1)] bg-[var(--accent-a3)] px-1 py-px font-medium text-[var(--accent-11)]"; + +/** The composer's schema; `suggestion` is only wired up by the live component. */ +export function buildMentionComposerExtensions({ + placeholder, + suggestion, +}: { + placeholder?: string; + suggestion?: MentionOptions["suggestion"]; +}) { + return [ + StarterKit.configure({ + heading: false, + blockquote: false, + codeBlock: false, + bulletList: false, + orderedList: false, + listItem: false, + horizontalRule: false, + bold: false, + italic: false, + strike: false, + code: false, + }), + Placeholder.configure({ placeholder: placeholder ?? "" }), + Mention.configure({ + renderText: ({ node }) => + formatMention(String(node.attrs.label), String(node.attrs.id)), + renderHTML: ({ node }) => [ + "span", + { + class: MENTION_CHIP_CLASS, + title: String(node.attrs.id), + contenteditable: "false", + }, + `@${node.attrs.label}`, + ], + ...(suggestion ? { suggestion } : {}), + }), + ]; +} + +/** The draft's token string, rebuilt as a tiptap document. */ +export function tokensToDoc(value: string): JSONContent { + const content: JSONContent[] | undefined = value ? [] : undefined; + if (content) { + for (const line of value.split("\n")) { + if (content.length > 0) content.push({ type: "hardBreak" }); + for (const segment of splitMentionSegments(line)) { + content.push( + segment.type === "mention" + ? { + type: "mention", + attrs: { id: segment.email, label: segment.name }, + } + : { type: "text", text: segment.text }, + ); + } + } + } + return { type: "doc", content: [{ type: "paragraph", content }] }; +} + +/** The editor document, serialized back to the inline token format. */ +export function docToTokens(editor: Editor): string { + return editor.getText({ + blockSeparator: "\n", + textSerializers: { hardBreak: () => "\n" }, + }); +} + +// Live suggestion-session state, mutated synchronously from the suggestion +// plugin's callbacks so the editor's keydown handlers always see the current +// popup without waiting on a React render. +interface SuggestionUi { + items: UserBasic[]; + index: number; + /** Esc pressed for the current trigger; a new `@` re-arms the popup. */ + dismissed: boolean; + command: ((member: UserBasic) => void) | null; +} + +/** + * The thread composer: a rich-text box that opens an @-mention typeahead over + * the org's members. Selecting a member inserts a mention chip (rendered like + * the task composer's chips); the draft round-trips through the inline token + * format from `@posthog/shared` mentions, which notifies the tagged member in + * the Activity page. + */ +export function MentionComposer({ + value, + onValueChange, + onSubmit, + members, + onMentionInsert, + placeholder, + editorClassName, + children, +}: MentionComposerProps) { + const itemRefs = useRef<(HTMLButtonElement | null)[]>([]); + const uiRef = useRef({ + items: [], + index: 0, + dismissed: false, + command: null, + }); + const [popup, setPopup] = useState<{ + items: UserBasic[]; + index: number; + } | null>(null); + + // The extension is created once; refs keep its callbacks reading live props. + const membersRef = useRef(members); + membersRef.current = members; + const onMentionInsertRef = useRef(onMentionInsert); + onMentionInsertRef.current = onMentionInsert; + const onValueChangeRef = useRef(onValueChange); + onValueChangeRef.current = onValueChange; + const onSubmitRef = useRef(onSubmit); + onSubmitRef.current = onSubmit; + // Guards the controlled-value sync against echoing our own updates. + const lastEmittedRef = useRef(value); + + // biome-ignore lint/correctness/useExhaustiveDependencies: created once per mount; a changed placeholder can't rebuild the editor's extensions + const extensions = useMemo(() => { + const syncPopup = () => { + const { items, index, dismissed } = uiRef.current; + setPopup(!dismissed && items.length > 0 ? { items, index } : null); + }; + + return buildMentionComposerExtensions({ + placeholder, + suggestion: { + char: "@", + items: ({ query }) => + filterMentionCandidates(membersRef.current, query), + command: ({ editor, range, props }) => { + const member = props as unknown as UserBasic; + // Reuse an existing following space rather than doubling it up. + const nodeAfter = editor.view.state.selection.$to.nodeAfter; + const to = nodeAfter?.text?.startsWith(" ") ? range.to + 1 : range.to; + editor + .chain() + .focus() + .insertContentAt({ from: range.from, to }, [ + { + type: "mention", + attrs: { + id: member.email, + label: userDisplayName(member), + }, + }, + { type: "text", text: " " }, + ]) + .run(); + onMentionInsertRef.current?.(member); + }, + render: () => ({ + onStart: (props) => { + uiRef.current = { + items: props.items as UserBasic[], + index: 0, + dismissed: false, + command: (member) => + props.command(member as unknown as MentionNodeAttrs), + }; + syncPopup(); + }, + onUpdate: (props) => { + const items = props.items as UserBasic[]; + uiRef.current = { + ...uiRef.current, + items, + index: Math.min( + uiRef.current.index, + Math.max(0, items.length - 1), + ), + command: (member) => + props.command(member as unknown as MentionNodeAttrs), + }; + syncPopup(); + }, + onKeyDown: ({ event }) => { + const ui = uiRef.current; + if (event.key === "Escape") { + uiRef.current = { ...ui, dismissed: true }; + syncPopup(); + return true; + } + if (ui.dismissed || ui.items.length === 0) return false; + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + const delta = event.key === "ArrowDown" ? 1 : ui.items.length - 1; + const index = (ui.index + delta) % ui.items.length; + uiRef.current = { ...ui, index }; + syncPopup(); + itemRefs.current[index]?.scrollIntoView({ block: "nearest" }); + return true; + } + if (event.key === "Enter" || event.key === "Tab") { + const member = ui.items[ui.index]; + if (member) ui.command?.(member); + return true; + } + return false; + }, + onExit: () => { + uiRef.current = { + items: [], + index: 0, + dismissed: false, + command: null, + }; + syncPopup(); + }, + }), + }, + }); + }, []); + + const editor = useEditor({ + extensions, + content: tokensToDoc(value), + editorProps: { + attributes: { + class: + "cli-editor min-h-[2.75em] w-full break-words bg-transparent outline-none [overflow-wrap:break-word] [white-space:pre-wrap]", + spellcheck: "false", + }, + handleKeyDown: (_view, event) => { + if (event.key === "Enter" && !event.shiftKey) { + const { items, dismissed } = uiRef.current; + // The suggestion plugin owns Enter while its popup is showing. + if (items.length > 0 && !dismissed) return false; + event.preventDefault(); + onSubmitRef.current(); + return true; + } + return false; + }, + }, + onUpdate: ({ editor: current }) => { + const text = docToTokens(current); + lastEmittedRef.current = text; + onValueChangeRef.current(text); + }, + }); + + // Adopt external draft changes (clear after submit, restore on error). + useEffect(() => { + if (!editor || value === lastEmittedRef.current) return; + lastEmittedRef.current = value; + editor.commands.setContent(tokensToDoc(value)); + }, [editor, value]); + + const select = (member: UserBasic) => uiRef.current.command?.(member); + + return ( +
+ {popup && ( +
+
+ {popup.items.map((member, index) => ( + + ))} +
+
+ )} + { + if (!(event.target as HTMLElement).closest("button")) { + editor?.commands.focus(); + } + }} + > +
+ +
+ {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..c162894bf8 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,15 @@ 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/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index 911af635c9..e4fce2d9b8 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -31,7 +31,14 @@ import { useCallback, useEffect, useMemo, useRef } from "react"; // task rendered as a card everyone in the channel sees; the composer stays // pinned at the bottom and threads open in a right-hand panel. The channel's // artifacts/history/context views stay in the tabs above (ChannelHeader). -export function WebsiteChannelHome({ channelId }: { channelId: string }) { +export function WebsiteChannelHome({ + channelId, + focusThreadTaskId, +}: { + channelId: string; + /** Task whose message to scroll to and whose thread to open (mention links). */ + focusThreadTaskId?: string; +}) { const navigate = useNavigate(); const queryClient = useQueryClient(); const { channels } = useChannels(); @@ -56,11 +63,16 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { const openThread = useThreadPanelStore((s) => s.openThread); const closeThread = useThreadPanelStore((s) => s.closeThread); - // A thread from another channel shouldn't linger when switching feeds. - // biome-ignore lint/correctness/useExhaustiveDependencies: re-close per channel + // A thread from another channel shouldn't linger when switching feeds; a + // mention link's `thread` search param opens its thread instead. + // biome-ignore lint/correctness/useExhaustiveDependencies: re-run per channel useEffect(() => { - closeThread(); - }, [closeThread, channelId]); + if (focusThreadTaskId) { + openThread(focusThreadTaskId); + } else { + closeThread(); + } + }, [closeThread, openThread, channelId, focusThreadTaskId]); const handleSuggestionSelect = useCallback( (prompt: string, mode?: string) => { @@ -166,6 +178,7 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { emptyState={emptyState} onOpenTask={handleOpenTask} onOpenThread={handleOpenThread} + focusMessageId={focusThreadTaskId} />
client.getTaskMentions(), + { + 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..9f492341f0 --- /dev/null +++ b/packages/ui/src/features/canvas/utils/mentionComposer.test.ts @@ -0,0 +1,61 @@ +import type { UserBasic } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { filterMentionCandidates } 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("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([]); + }); +}); 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..a953f9d781 --- /dev/null +++ b/packages/ui/src/features/canvas/utils/mentionComposer.ts @@ -0,0 +1,30 @@ +import type { UserBasic } from "@posthog/shared/domain-types"; +import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; + +/** 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); +} diff --git a/packages/ui/src/router/navigationBridge.ts b/packages/ui/src/router/navigationBridge.ts index f5859e384e..4e4b2d82ec 100644 --- a/packages/ui/src/router/navigationBridge.ts +++ b/packages/ui/src/router/navigationBridge.ts @@ -33,6 +33,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", @@ -47,6 +51,20 @@ export function navigateToChannelTask(channelId: string, taskId: string): void { }); } +// Opens the channel feed scrolled to the task's message with its thread panel +// open — the landing spot for mention notifications and shared thread links, +// as opposed to navigateToChannelTask's full task view. +export function navigateToChannelThread( + channelId: string, + taskId: string, +): void { + void getRouterOrNull()?.navigate({ + to: "/website/$channelId", + params: { channelId }, + search: { thread: taskId }, + }); +} + export function navigateToChannelNewTask(channelId: string): void { void getRouterOrNull()?.navigate({ to: "/website/$channelId/new", diff --git a/packages/ui/src/router/routeTree.gen.ts b/packages/ui/src/router/routeTree.gen.ts index c378c10fa9..919530be4b 100644 --- a/packages/ui/src/router/routeTree.gen.ts +++ b/packages/ui/src/router/routeTree.gen.ts @@ -22,6 +22,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 CodeInboxRouteImport } from './routes/code/inbox' @@ -139,6 +140,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', @@ -429,6 +435,7 @@ export interface FileRoutesByFullPath { '/code/inbox': typeof CodeInboxRouteWithChildren '/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 @@ -492,6 +499,7 @@ export interface FileRoutesByTo { '/code/home': typeof CodeHomeRoute '/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 @@ -551,6 +559,7 @@ export interface FileRoutesById { '/code/inbox': typeof CodeInboxRouteWithChildren '/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 @@ -619,6 +628,7 @@ export interface FileRouteTypes { | '/code/inbox' | '/folders/$folderId' | '/settings/$category' + | '/website/activity' | '/website/command-center' | '/website/home' | '/website/mcp-servers' @@ -682,6 +692,7 @@ export interface FileRouteTypes { | '/code/home' | '/folders/$folderId' | '/settings/$category' + | '/website/activity' | '/website/command-center' | '/website/home' | '/website/mcp-servers' @@ -740,6 +751,7 @@ export interface FileRouteTypes { | '/code/inbox' | '/folders/$folderId' | '/settings/$category' + | '/website/activity' | '/website/command-center' | '/website/home' | '/website/mcp-servers' @@ -906,6 +918,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' @@ -1267,6 +1286,7 @@ declare module '@tanstack/react-router' { } interface WebsiteRouteChildren { + WebsiteActivityRoute: typeof WebsiteActivityRoute WebsiteCommandCenterRoute: typeof WebsiteCommandCenterRoute WebsiteHomeRoute: typeof WebsiteHomeRoute WebsiteMcpServersRoute: typeof WebsiteMcpServersRoute @@ -1285,6 +1305,7 @@ interface WebsiteRouteChildren { } const WebsiteRouteChildren: WebsiteRouteChildren = { + WebsiteActivityRoute: WebsiteActivityRoute, WebsiteCommandCenterRoute: WebsiteCommandCenterRoute, WebsiteHomeRoute: WebsiteHomeRoute, WebsiteMcpServersRoute: WebsiteMcpServersRoute, diff --git a/packages/ui/src/router/routes/website/$channelId/index.tsx b/packages/ui/src/router/routes/website/$channelId/index.tsx index 22de42fe44..97577f9215 100644 --- a/packages/ui/src/router/routes/website/$channelId/index.tsx +++ b/packages/ui/src/router/routes/website/$channelId/index.tsx @@ -2,10 +2,17 @@ import { WebsiteChannelHome } from "@posthog/ui/features/canvas/components/Websi import { createFileRoute } from "@tanstack/react-router"; export const Route = createFileRoute("/website/$channelId/")({ + // `thread` opens a task's thread panel over the feed (mention links). + validateSearch: (search: Record): { thread?: string } => ({ + thread: typeof search.thread === "string" ? search.thread : undefined, + }), component: ChannelHomeRoute, }); function ChannelHomeRoute() { const { channelId } = Route.useParams(); - return ; + const { thread } = Route.useSearch(); + return ( + + ); } 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 ec2fad08d3..3d9e5c713d 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" @@ -65,6 +66,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":