diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 00bbbc7700..2be08f6fa3 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -2514,6 +2514,8 @@ export class PostHogAPIClient { async getTaskActivity(options?: { before?: string; beforeId?: string; + limit?: number; + unreadOnly?: boolean; }): Promise { const teamId = await this.getTeamId(); const urlPath = `/api/projects/${teamId}/task_activity/`; @@ -2522,6 +2524,12 @@ export class PostHogAPIClient { url.searchParams.set("before", options.before); url.searchParams.set("before_id", options.beforeId); } + if (options?.limit) { + url.searchParams.set("limit", String(options.limit)); + } + if (options?.unreadOnly) { + url.searchParams.set("unread_only", "true"); + } const response = await this.api.fetcher.fetch({ method: "get", url, @@ -2556,6 +2564,22 @@ export class PostHogAPIClient { return (await response.json()) as TaskActivityMarkReadResult; } + async markAllTaskActivityRead(): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/task_activity/mark_all_read/`; + const response = await this.api.fetcher.fetch({ + method: "post", + url: new URL(`${this.api.baseUrl}${urlPath}`), + path: urlPath, + }); + if (!response.ok) { + throw new Error( + `Failed to mark all task activity read: ${response.statusText}`, + ); + } + return (await response.json()) as TaskActivityMarkReadResult; + } + async getTaskThreadMessages(taskId: string): Promise { const teamId = await this.getTeamId(); const urlPath = `/api/projects/${teamId}/tasks/${taskId}/thread_messages/`; diff --git a/packages/ui/src/features/canvas/components/ActivityHoverCard.tsx b/packages/ui/src/features/canvas/components/ActivityHoverCard.tsx new file mode 100644 index 0000000000..4b8dedf939 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ActivityHoverCard.tsx @@ -0,0 +1,119 @@ +import { ChecksIcon } from "@phosphor-icons/react"; +import { Button, PopoverContent, Spinner } from "@posthog/quill"; +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 { ActivityRow } from "@posthog/ui/features/canvas/components/ActivityView"; +import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { + useMarkAllTaskActivityRead, + useMarkTaskActivityRead, +} from "@posthog/ui/features/canvas/hooks/useMarkTaskActivityRead"; +import { useTaskActivity } from "@posthog/ui/features/canvas/hooks/useTaskActivity"; +import { normalizeChannelName } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +import { track } from "@posthog/ui/shell/analytics"; +import { useEffect, useMemo } from "react"; + +export function ActivityHoverCard({ onClose }: { onClose: () => void }) { + const client = useOptionalAuthenticatedClient(); + const { data: currentUser } = useCurrentUser({ client }); + const { items, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage } = + useTaskActivity({ unreadOnly: true, limit: 500 }); + const unreadItems = items.filter((item) => item.isUnread); + const { mutate: markTasksRead } = useMarkTaskActivityRead(); + const { mutate: markAllRead, isPending: isMarkingAllRead } = + useMarkAllTaskActivityRead(); + const { channels } = useChannels(); + const folderIdByName = useMemo( + () => + new Map( + channels.map((channel) => [ + normalizeChannelName(channel.name), + channel.id, + ]), + ), + [channels], + ); + useEffect(() => { + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "view_activity", + surface: "activity_panel", + }); + }, []); + + const markRead = (taskId: string, activityAt: string) => { + markTasksRead([{ task_id: taskId, seen_before: activityAt }]); + }; + + return ( + +
+ Activity + {unreadItems.length > 0 && ( + + )} +
+
+ {isLoading && unreadItems.length === 0 ? ( +
+ +
+ ) : unreadItems.length === 0 ? ( +
+ Okay. +
+ ) : ( +
+ {unreadItems.map((item) => ( + + markRead(activity.taskId, activity.activityAt) + } + onMarkRead={(activity) => + markRead(activity.taskId, activity.activityAt) + } + currentUser={currentUser} + surface="activity_panel" + onNavigate={onClose} + /> + ))} + {hasNextPage && ( + + )} +
+ )} +
+
+ ); +} diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index 58f5358596..155edee1e5 100644 --- a/packages/ui/src/features/canvas/components/ActivityView.tsx +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -27,7 +27,10 @@ import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; import { MentionText } from "@posthog/ui/features/canvas/components/MentionText"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; -import { useMarkTaskActivityRead } from "@posthog/ui/features/canvas/hooks/useMarkTaskActivityRead"; +import { + useMarkAllTaskActivityRead, + useMarkTaskActivityRead, +} from "@posthog/ui/features/canvas/hooks/useMarkTaskActivityRead"; import { useTaskActivity } from "@posthog/ui/features/canvas/hooks/useTaskActivity"; import { normalizeChannelName } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { copyChannelLink } from "@posthog/ui/features/canvas/utils/copyChannelLink"; @@ -105,12 +108,14 @@ export function activityHeadline( } } -function ActivityRow({ +export function ActivityRow({ item, folderChannelId, onOpen, onMarkRead, currentUser, + surface = "activity", + onNavigate, }: { item: TaskActivityItem; /** Desktop folder channel id (the /website route param); null when unmapped. */ @@ -118,6 +123,8 @@ function ActivityRow({ onOpen: (item: TaskActivityItem) => void; onMarkRead: (item: TaskActivityItem) => void; currentUser?: UserBasic | null; + surface?: "activity" | "activity_panel"; + onNavigate?: () => void; }) { const isAgentActivity = item.activityKind === "awaiting_input" || @@ -126,11 +133,12 @@ function ActivityRow({ const openTask = () => { track(ANALYTICS_EVENTS.CHANNEL_ACTION, { action_type: "open_task", - surface: "activity", + surface, channel_id: folderChannelId ?? undefined, task_id: item.taskId, }); onOpen(item); + onNavigate?.(); // The channel thread route is the deep-link target; tasks whose channel // folder is gone fall back to the plain task view. if (folderChannelId) { @@ -234,8 +242,9 @@ export function ActivityView() { isFetchingNextPage, fetchNextPage, } = useTaskActivity(); - const { mutate: markTasksRead, isPending: isMarkingRead } = - useMarkTaskActivityRead(); + const { mutate: markTasksRead } = useMarkTaskActivityRead(); + const { mutate: markAllRead, isPending: isMarkingRead } = + useMarkAllTaskActivityRead(); // Opening a row is what marks it read. The server does the same when the task is // reached any other way, so the feed converges either way. const markRead = useCallback( @@ -243,16 +252,6 @@ export function ActivityView() { markTasksRead([{ task_id: item.taskId, seen_before: item.activityAt }]), [markTasksRead], ); - const markAllRead = useCallback(() => { - markTasksRead( - items - .filter((item) => item.isUnread) - .map((item) => ({ - task_id: item.taskId, - seen_before: item.activityAt, - })), - ); - }, [items, markTasksRead]); // 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. @@ -297,7 +296,7 @@ export function ActivityView() { size="sm" loading={isMarkingRead} disabled={isMarkingRead} - onClick={markAllRead} + onClick={() => markAllRead()} > Mark all as read diff --git a/packages/ui/src/features/canvas/components/ChannelNav.test.tsx b/packages/ui/src/features/canvas/components/ChannelNav.test.tsx new file mode 100644 index 0000000000..47c9753af6 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelNav.test.tsx @@ -0,0 +1,42 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@posthog/ui/features/canvas/hooks/useTaskActivity", () => ({ + useTaskActivity: () => ({ unreadCount: 1 }), +})); +vi.mock( + "@posthog/ui/features/command-center/useCommandCenterActiveCount", + () => ({ useCommandCenterActiveCount: () => 0 }), +); +vi.mock("@posthog/ui/features/inbox/hooks/useInboxAllReports", () => ({ + useInboxAllReports: () => ({ counts: { pulls: 0 } }), +})); +vi.mock("@posthog/ui/router/useAppView", () => ({ + useAppView: () => ({ type: "task-input" }), +})); +vi.mock("@posthog/ui/router/navigationBridge", () => ({ + navigateToActivity: vi.fn(), + navigateToInbox: vi.fn(), + navigateToWebsiteCommandCenter: vi.fn(), +})); +vi.mock("@posthog/ui/shell/analytics", () => ({ track: vi.fn() })); +vi.mock("./ActivityHoverCard", () => ({ + ActivityHoverCard: () =>
Unread activity card
, +})); + +import { ChannelNav } from "./ChannelNav"; + +describe("ChannelNav", () => { + it("opens unread activity from the bell after the hover delay", async () => { + const user = userEvent.setup(); + render(); + + await user.hover(screen.getByLabelText("Activity")); + expect(screen.queryByText("Unread activity card")).not.toBeInTheDocument(); + + expect( + await screen.findByText("Unread activity card", {}, { timeout: 1_000 }), + ).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/features/canvas/components/ChannelNav.tsx b/packages/ui/src/features/canvas/components/ChannelNav.tsx index c95247fa7a..d80f16291c 100644 --- a/packages/ui/src/features/canvas/components/ChannelNav.tsx +++ b/packages/ui/src/features/canvas/components/ChannelNav.tsx @@ -1,5 +1,5 @@ import { BellIcon, EnvelopeSimple, Lightning } from "@phosphor-icons/react"; -import { cn } from "@posthog/quill"; +import { cn, Popover, PopoverTrigger } from "@posthog/quill"; import { ANALYTICS_EVENTS, type SidebarNavItem, @@ -20,7 +20,13 @@ import { } from "@posthog/ui/router/navigationBridge"; import { useAppView } from "@posthog/ui/router/useAppView"; import { track } from "@posthog/ui/shell/analytics"; -import type { ReactNode } from "react"; +import { + type ComponentPropsWithoutRef, + forwardRef, + type ReactNode, + useState, +} from "react"; +import { ActivityHoverCard } from "./ActivityHoverCard"; const INBOX_REFETCH_INTERVAL_MS = 60_000; @@ -44,26 +50,53 @@ function NavIcon({ }) { return ( - + badge={badge} + /> ); } +interface NavButtonProps extends ComponentPropsWithoutRef<"button"> { + icon: ReactNode; + label: string; + isActive: boolean; + badge?: ReactNode; +} + +const NavButton = forwardRef( + ( + { icon, label, isActive, onClick, badge, className, ...buttonProps }, + ref, + ) => ( + + ), +); +NavButton.displayName = "NavButton"; + export function ChannelNav() { const view = useAppView(); + const [activityOpen, setActivityOpen] = useState(false); const { counts } = useInboxAllReports({ ignoreFilters: true, @@ -94,15 +127,35 @@ export function ChannelNav() { onClick={withTrack("inbox", navigateToInbox)} badge={} /> - } - label="Activity" - isActive={isActivity} - onClick={withTrack("activity", navigateToActivity)} - badge={ - - } - /> + + + } + label="Activity" + isActive={isActivity} + onClick={() => { + setActivityOpen(false); + withTrack("activity", navigateToActivity)(); + }} + badge={ + + } + /> + } + /> + {activityOpen && ( + setActivityOpen(false)} /> + )} + diff --git a/packages/ui/src/features/canvas/hooks/useMarkTaskActivityRead.ts b/packages/ui/src/features/canvas/hooks/useMarkTaskActivityRead.ts index 4845259e83..173ee267a0 100644 --- a/packages/ui/src/features/canvas/hooks/useMarkTaskActivityRead.ts +++ b/packages/ui/src/features/canvas/hooks/useMarkTaskActivityRead.ts @@ -25,8 +25,8 @@ export function useMarkTaskActivityRead() { const marked = new Map( activities.map((activity) => [activity.task_id, activity.seen_before]), ); - queryClient.setQueryData>( - TASK_ACTIVITY_QUERY_KEY, + queryClient.setQueriesData>( + { queryKey: TASK_ACTIVITY_QUERY_KEY }, (data) => { if (!data) return data; const clearing = data.pages @@ -58,3 +58,33 @@ export function useMarkTaskActivityRead() { }, }); } + +export function useMarkAllTaskActivityRead() { + const client = useOptionalAuthenticatedClient(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async () => { + if (!client) throw new Error("Not authenticated"); + return client.markAllTaskActivityRead(); + }, + onMutate: async () => { + queryClient.setQueriesData>( + { queryKey: TASK_ACTIVITY_QUERY_KEY }, + (data) => { + if (!data) return data; + return { + ...data, + pages: data.pages.map((page) => ({ + ...page, + unread_count: 0, + results: page.results.map((row) => ({ + ...row, + is_unread: false, + })), + })), + }; + }, + ); + }, + }); +} diff --git a/packages/ui/src/features/canvas/hooks/useTaskActivity.test.tsx b/packages/ui/src/features/canvas/hooks/useTaskActivity.test.tsx index c1f4a0075f..8a5a113c28 100644 --- a/packages/ui/src/features/canvas/hooks/useTaskActivity.test.tsx +++ b/packages/ui/src/features/canvas/hooks/useTaskActivity.test.tsx @@ -9,6 +9,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mockClient = vi.hoisted(() => ({ getTaskActivity: vi.fn(), + markAllTaskActivityRead: vi.fn(), markTaskActivityRead: vi.fn(), })); @@ -16,7 +17,10 @@ vi.mock("@posthog/ui/features/auth/authClient", () => ({ useOptionalAuthenticatedClient: () => mockClient, })); -import { useMarkTaskActivityRead } from "./useMarkTaskActivityRead"; +import { + useMarkAllTaskActivityRead, + useMarkTaskActivityRead, +} from "./useMarkTaskActivityRead"; import { TASK_ACTIVITY_QUERY_KEY, useTaskActivity } from "./useTaskActivity"; function activity(overrides: Partial): TaskActivity { @@ -89,6 +93,24 @@ describe("task activity hooks", () => { }); }); + it("requests only unread activity for the hover card", async () => { + mockClient.getTaskActivity.mockResolvedValue({ + results: [activity({})], + unread_count: 1, + }); + + const hook = renderHook( + () => useTaskActivity({ unreadOnly: true, limit: 500 }), + { wrapper }, + ); + + await waitFor(() => expect(hook.result.current.items).toHaveLength(1)); + expect(mockClient.getTaskActivity).toHaveBeenCalledWith({ + limit: 500, + unreadOnly: true, + }); + }); + it("does not optimistically clear activity newer than the marker", async () => { const page: TaskActivityPage = { results: [activity({ activity_at: "2026-07-01T11:00:00Z" })], @@ -150,4 +172,39 @@ describe("task activity hooks", () => { expect(hook.result.current.activity.items).toHaveLength(1); expect(mockClient.getTaskActivity).toHaveBeenCalledOnce(); }); + + it("clears unread state across the full and unread-only caches", async () => { + const data = { + pages: [ + { + results: [activity({})], + unread_count: 1, + }, + ], + pageParams: [undefined], + }; + queryClient.setQueryData(["task-activity"], data); + queryClient.setQueryData(["task-activity", { unreadOnly: true }], data); + mockClient.markAllTaskActivityRead.mockResolvedValue({ + marked_read: 1, + unread_count: 0, + }); + + const hook = renderHook(() => useMarkAllTaskActivityRead(), { wrapper }); + act(() => hook.result.current.mutate()); + + await waitFor(() => + expect(mockClient.markAllTaskActivityRead).toHaveBeenCalledOnce(), + ); + for (const queryKey of [ + ["task-activity"], + ["task-activity", { unreadOnly: true }], + ]) { + const cached = queryClient.getQueryData<{ + pages: TaskActivityPage[]; + }>(queryKey); + expect(cached?.pages[0]?.unread_count).toBe(0); + expect(cached?.pages[0]?.results[0]?.is_unread).toBe(false); + } + }); }); diff --git a/packages/ui/src/features/canvas/hooks/useTaskActivity.ts b/packages/ui/src/features/canvas/hooks/useTaskActivity.ts index 5dd3d428ab..397776fa53 100644 --- a/packages/ui/src/features/canvas/hooks/useTaskActivity.ts +++ b/packages/ui/src/features/canvas/hooks/useTaskActivity.ts @@ -7,7 +7,7 @@ import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authCl import { AUTH_SCOPED_QUERY_META } from "@posthog/ui/features/auth/useCurrentUser"; import { useInfiniteQuery } from "@tanstack/react-query"; import { useMemo } from "react"; -import { TASK_ACTIVITY_QUERY_KEY } from "../task-activity/taskActivityQuery"; +import { taskActivityQueryKey } from "../task-activity/taskActivityQuery"; export { TASK_ACTIVITY_QUERY_KEY } from "../task-activity/taskActivityQuery"; @@ -17,7 +17,11 @@ export { TASK_ACTIVITY_QUERY_KEY } from "../task-activity/taskActivityQuery"; * index. Mount once per surface (sidebar badge, Activity page) — results are * shared through the react-query cache. */ -export function useTaskActivity(options?: { enabled?: boolean }): { +export function useTaskActivity(options?: { + enabled?: boolean; + unreadOnly?: boolean; + limit?: number; +}): { items: TaskActivityItem[]; unreadCount: number; isLoading: boolean; @@ -26,11 +30,19 @@ export function useTaskActivity(options?: { enabled?: boolean }): { fetchNextPage: () => Promise; } { const client = useOptionalAuthenticatedClient(); + const unreadOnly = options?.unreadOnly ?? false; const query = useInfiniteQuery({ - queryKey: TASK_ACTIVITY_QUERY_KEY, + queryKey: taskActivityQueryKey(unreadOnly), queryFn: ({ pageParam }) => { if (!client) throw new Error("Not authenticated"); - return client.getTaskActivity(pageParam); + if (!pageParam && !options?.limit && !unreadOnly) { + return client.getTaskActivity(); + } + return client.getTaskActivity({ + ...pageParam, + ...(options?.limit ? { limit: options.limit } : {}), + ...(unreadOnly ? { unreadOnly: true } : {}), + }); }, initialPageParam: undefined as | { before: string; beforeId: string } diff --git a/packages/ui/src/features/canvas/task-activity/taskActivity.contribution.test.ts b/packages/ui/src/features/canvas/task-activity/taskActivity.contribution.test.ts index f49f60f4ca..ce2a2ae2ec 100644 --- a/packages/ui/src/features/canvas/task-activity/taskActivity.contribution.test.ts +++ b/packages/ui/src/features/canvas/task-activity/taskActivity.contribution.test.ts @@ -40,6 +40,13 @@ describe("TaskActivityContribution", () => { pageParams: [undefined], }, ); + queryClient.setQueryData>( + ["task-activity", { unreadOnly: true }], + { + pages: [{ results: [], unread_count: 0 }], + pageParams: [undefined], + }, + ); activityListener?.({ taskId: "task-1", @@ -62,6 +69,12 @@ describe("TaskActivityContribution", () => { }, ], }); + expect( + queryClient.getQueryData>([ + "task-activity", + { unreadOnly: true }, + ])?.pages[0], + ).toMatchObject({ unread_count: 1, results: [{ task_id: "task-1" }] }); }); it("does not recreate activity data after the authenticated query is removed", () => { diff --git a/packages/ui/src/features/canvas/task-activity/taskActivity.contribution.ts b/packages/ui/src/features/canvas/task-activity/taskActivity.contribution.ts index 9fdddb53f3..f3e59c998d 100644 --- a/packages/ui/src/features/canvas/task-activity/taskActivity.contribution.ts +++ b/packages/ui/src/features/canvas/task-activity/taskActivity.contribution.ts @@ -10,7 +10,7 @@ import { } from "@posthog/ui/shell/queryClient"; import type { InfiniteData } from "@tanstack/react-query"; import { inject, injectable } from "inversify"; -import { TASK_ACTIVITY_QUERY_KEY } from "./taskActivityQuery"; +import { taskActivityQueryKey } from "./taskActivityQuery"; @injectable() export class TaskActivityContribution implements Contribution { @@ -28,14 +28,22 @@ export class TaskActivityContribution implements Contribution { } private apply(signal: TaskActivitySignal): void { + this.applyToQuery(signal, taskActivityQueryKey(false)); + this.applyToQuery(signal, taskActivityQueryKey(true)); + } + + private applyToQuery( + signal: TaskActivitySignal, + queryKey: readonly unknown[], + ): void { const activityQuery = this.queryClient.getQueryCache().find({ - queryKey: TASK_ACTIVITY_QUERY_KEY, + queryKey, exact: true, }); if (activityQuery?.meta?.authScoped !== true) return; this.queryClient.setQueryData>( - TASK_ACTIVITY_QUERY_KEY, + queryKey, (data) => { const previous = data?.pages .flatMap((page) => page.results) diff --git a/packages/ui/src/features/canvas/task-activity/taskActivityQuery.ts b/packages/ui/src/features/canvas/task-activity/taskActivityQuery.ts index 2f4f449476..e9c89d9969 100644 --- a/packages/ui/src/features/canvas/task-activity/taskActivityQuery.ts +++ b/packages/ui/src/features/canvas/task-activity/taskActivityQuery.ts @@ -1 +1,6 @@ export const TASK_ACTIVITY_QUERY_KEY = ["task-activity"] as const; + +export const taskActivityQueryKey = (unreadOnly: boolean) => + unreadOnly + ? ([...TASK_ACTIVITY_QUERY_KEY, { unreadOnly: true }] as const) + : TASK_ACTIVITY_QUERY_KEY; diff --git a/packages/ui/src/features/sidebar/components/items/ActivityItem.tsx b/packages/ui/src/features/sidebar/components/items/ActivityItem.tsx index 847446924f..9933172fd0 100644 --- a/packages/ui/src/features/sidebar/components/items/ActivityItem.tsx +++ b/packages/ui/src/features/sidebar/components/items/ActivityItem.tsx @@ -1,5 +1,8 @@ import { BellIcon } from "@phosphor-icons/react"; +import { Popover, PopoverTrigger } from "@posthog/quill"; +import { ActivityHoverCard } from "@posthog/ui/features/canvas/components/ActivityHoverCard"; import { useTaskActivity } from "@posthog/ui/features/canvas/hooks/useTaskActivity"; +import { useState } from "react"; import { SidebarItem } from "../SidebarItem"; import { SidebarCountBadge } from "./SidebarCountBadge"; @@ -18,21 +21,39 @@ export function ActivityItem({ depth = 0, }: ActivityItemProps) { const { unreadCount } = useTaskActivity(); + const [open, setOpen] = useState(false); return ( - } - label={ - <> - Activity - - - } - isActive={isActive} - onClick={onClick} - /> + + + + } + label={ + <> + Activity + + + } + isActive={isActive} + onClick={() => { + setOpen(false); + onClick(); + }} + /> + + } + /> + {open && setOpen(false)} />} + ); }