From 77b2b049e4c1c615342d71b38b5f49d36c073f49 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Tue, 28 Jul 2026 21:47:16 +0200 Subject: [PATCH 1/8] feat: add unread activity hover card Generated-By: PostHog Code Task-Id: 4a1c53d9-0ca7-4ebe-9936-0c6dab92ca96 --- packages/api-client/src/posthog-client.ts | 24 +++ .../canvas/components/ActivityView.tsx | 31 ++- .../canvas/hooks/useMarkTaskActivityRead.ts | 34 +++- .../canvas/hooks/useTaskActivity.test.tsx | 59 +++++- .../features/canvas/hooks/useTaskActivity.ts | 20 +- .../taskActivity.contribution.test.ts | 13 ++ .../taskActivity.contribution.ts | 14 +- .../canvas/task-activity/taskActivityQuery.ts | 5 + .../sidebar/components/items/ActivityItem.tsx | 179 ++++++++++++++++-- 9 files changed, 337 insertions(+), 42 deletions(-) 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/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/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..9cb4ff6e7f 100644 --- a/packages/ui/src/features/sidebar/components/items/ActivityItem.tsx +++ b/packages/ui/src/features/sidebar/components/items/ActivityItem.tsx @@ -1,5 +1,24 @@ -import { BellIcon } from "@phosphor-icons/react"; +import { BellIcon, ChecksIcon } from "@phosphor-icons/react"; +import { + Button, + Popover, + PopoverContent, + PopoverTrigger, + 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, useState } from "react"; import { SidebarItem } from "../SidebarItem"; import { SidebarCountBadge } from "./SidebarCountBadge"; @@ -18,21 +37,149 @@ 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)} />} + + ); +} + +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", + }); + }, []); + + return ( + +
+ Activity + {unreadItems.length > 0 && ( + + )} +
+
+ {isLoading && unreadItems.length === 0 ? ( +
+ +
+ ) : unreadItems.length === 0 ? ( +
+ Okay. +
+ ) : ( +
+ {unreadItems.map((item) => ( + + markTasksRead([ + { + task_id: activity.taskId, + seen_before: activity.activityAt, + }, + ]) + } + onMarkRead={(activity) => + markTasksRead([ + { + task_id: activity.taskId, + seen_before: activity.activityAt, + }, + ]) + } + currentUser={currentUser} + surface="activity_panel" + onNavigate={onClose} + /> + ))} + {hasNextPage && ( + + )} +
+ )} +
+
); } From 76d85d79ef37be0e6a0662f066c8f21a4a125539 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Tue, 28 Jul 2026 21:47:17 +0200 Subject: [PATCH 2/8] fix: attach activity card to app rail bell Generated-By: PostHog Code Task-Id: 4a1c53d9-0ca7-4ebe-9936-0c6dab92ca96 --- .../canvas/components/ActivityHoverCard.tsx | 119 ++++++++++++++++ .../canvas/components/ChannelNav.test.tsx | 45 ++++++ .../features/canvas/components/ChannelNav.tsx | 80 +++++++++-- .../sidebar/components/items/ActivityItem.tsx | 134 +----------------- 4 files changed, 238 insertions(+), 140 deletions(-) create mode 100644 packages/ui/src/features/canvas/components/ActivityHoverCard.tsx create mode 100644 packages/ui/src/features/canvas/components/ChannelNav.test.tsx 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/ChannelNav.test.tsx b/packages/ui/src/features/canvas/components/ChannelNav.test.tsx new file mode 100644 index 0000000000..b50fab823c --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelNav.test.tsx @@ -0,0 +1,45 @@ +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/feature-flags/useFeatureFlag", () => ({ + useFeatureFlag: () => false, +})); +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 019acc7b7c..27812c30a6 100644 --- a/packages/ui/src/features/canvas/components/ChannelNav.tsx +++ b/packages/ui/src/features/canvas/components/ChannelNav.tsx @@ -7,7 +7,10 @@ import { } from "@phosphor-icons/react"; import { Button, + cn, Kbd, + Popover, + PopoverTrigger, Tooltip, TooltipContent, TooltipProvider, @@ -36,7 +39,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; @@ -87,9 +96,43 @@ function NavIcon({ ); } +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 loopsEnabled = useFeatureFlag(LOOPS_FLAG, import.meta.env.DEV); + const [activityOpen, setActivityOpen] = useState(false); const { counts } = useInboxAllReports({ ignoreFilters: true, @@ -131,15 +174,32 @@ export function ChannelNav() { } /> - } - label="Activity" - isActive={isActivity} - onClick={withTrack("activity", navigateToActivity)} - badge={ - - } - /> + + + } + label="Activity" + isActive={isActivity} + onClick={() => { + setActivityOpen(false); + withTrack("activity", navigateToActivity)(); + }} + badge={ + + } + /> + } + /> + {activityOpen && ( + setActivityOpen(false)} /> + )} + ); } - -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", - }); - }, []); - - return ( - -
- Activity - {unreadItems.length > 0 && ( - - )} -
-
- {isLoading && unreadItems.length === 0 ? ( -
- -
- ) : unreadItems.length === 0 ? ( -
- Okay. -
- ) : ( -
- {unreadItems.map((item) => ( - - markTasksRead([ - { - task_id: activity.taskId, - seen_before: activity.activityAt, - }, - ]) - } - onMarkRead={(activity) => - markTasksRead([ - { - task_id: activity.taskId, - seen_before: activity.activityAt, - }, - ]) - } - currentUser={currentUser} - surface="activity_panel" - onNavigate={onClose} - /> - ))} - {hasNextPage && ( - - )} -
- )} -
-
- ); -} From 42331e08bf9b8dbfc9067dd0f51e15a228e00031 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Tue, 28 Jul 2026 21:47:19 +0200 Subject: [PATCH 3/8] refactor: use existing API for activity popover Show recent activity from the shared feed cache and batch unread rows through the existing mark-read endpoint. Update the popover layout to a compact Slack-inspired activity list. Generated-By: PostHog Code Task-Id: 4a1c53d9-0ca7-4ebe-9936-0c6dab92ca96 --- packages/api-client/src/posthog-client.ts | 24 -------- .../canvas/components/ActivityHoverCard.tsx | 55 ++++++++--------- .../canvas/components/ActivityView.tsx | 22 ++++--- .../canvas/hooks/useMarkTaskActivityRead.ts | 34 +---------- .../canvas/hooks/useTaskActivity.test.tsx | 59 +------------------ .../features/canvas/hooks/useTaskActivity.ts | 20 ++----- .../taskActivity.contribution.test.ts | 13 ---- .../taskActivity.contribution.ts | 14 +---- .../canvas/task-activity/taskActivityQuery.ts | 5 -- 9 files changed, 48 insertions(+), 198 deletions(-) diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 2be08f6fa3..00bbbc7700 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -2514,8 +2514,6 @@ 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/`; @@ -2524,12 +2522,6 @@ 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, @@ -2564,22 +2556,6 @@ 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 index 4b8dedf939..e80b513eb1 100644 --- a/packages/ui/src/features/canvas/components/ActivityHoverCard.tsx +++ b/packages/ui/src/features/canvas/components/ActivityHoverCard.tsx @@ -5,10 +5,7 @@ import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authCl 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 { 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"; @@ -17,12 +14,10 @@ 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 { items, isLoading } = useTaskActivity(); const unreadItems = items.filter((item) => item.isUnread); - const { mutate: markTasksRead } = useMarkTaskActivityRead(); - const { mutate: markAllRead, isPending: isMarkingAllRead } = - useMarkAllTaskActivityRead(); + const { mutate: markTasksRead, isPending: isMarkingRead } = + useMarkTaskActivityRead(); const { channels } = useChannels(); const folderIdByName = useMemo( () => @@ -45,40 +40,49 @@ export function ActivityHoverCard({ onClose }: { onClose: () => void }) { markTasksRead([{ task_id: taskId, seen_before: activityAt }]); }; + const markAllRead = () => { + markTasksRead( + unreadItems.map((item) => ({ + task_id: item.taskId, + seen_before: item.activityAt, + })), + ); + }; + return ( -
- Activity +
+ Activity {unreadItems.length > 0 && ( )}
-
- {isLoading && unreadItems.length === 0 ? ( +
+ {isLoading && items.length === 0 ? (
- ) : unreadItems.length === 0 ? ( + ) : items.length === 0 ? (
- Okay. + No recent activity.
) : (
- {unreadItems.map((item) => ( + {items.map((item) => ( void }) { onNavigate={onClose} /> ))} - {hasNextPage && ( - - )}
)}
diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index 155edee1e5..2fa5eaec4b 100644 --- a/packages/ui/src/features/canvas/components/ActivityView.tsx +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -27,10 +27,7 @@ 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 { - useMarkAllTaskActivityRead, - useMarkTaskActivityRead, -} from "@posthog/ui/features/canvas/hooks/useMarkTaskActivityRead"; +import { 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"; @@ -242,9 +239,8 @@ export function ActivityView() { isFetchingNextPage, fetchNextPage, } = useTaskActivity(); - const { mutate: markTasksRead } = useMarkTaskActivityRead(); - const { mutate: markAllRead, isPending: isMarkingRead } = - useMarkAllTaskActivityRead(); + const { mutate: markTasksRead, isPending: isMarkingRead } = + useMarkTaskActivityRead(); // 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( @@ -252,6 +248,16 @@ 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. @@ -296,7 +302,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/hooks/useMarkTaskActivityRead.ts b/packages/ui/src/features/canvas/hooks/useMarkTaskActivityRead.ts index 173ee267a0..4845259e83 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.setQueriesData>( - { queryKey: TASK_ACTIVITY_QUERY_KEY }, + queryClient.setQueryData>( + TASK_ACTIVITY_QUERY_KEY, (data) => { if (!data) return data; const clearing = data.pages @@ -58,33 +58,3 @@ 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 8a5a113c28..c1f4a0075f 100644 --- a/packages/ui/src/features/canvas/hooks/useTaskActivity.test.tsx +++ b/packages/ui/src/features/canvas/hooks/useTaskActivity.test.tsx @@ -9,7 +9,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mockClient = vi.hoisted(() => ({ getTaskActivity: vi.fn(), - markAllTaskActivityRead: vi.fn(), markTaskActivityRead: vi.fn(), })); @@ -17,10 +16,7 @@ vi.mock("@posthog/ui/features/auth/authClient", () => ({ useOptionalAuthenticatedClient: () => mockClient, })); -import { - useMarkAllTaskActivityRead, - useMarkTaskActivityRead, -} from "./useMarkTaskActivityRead"; +import { useMarkTaskActivityRead } from "./useMarkTaskActivityRead"; import { TASK_ACTIVITY_QUERY_KEY, useTaskActivity } from "./useTaskActivity"; function activity(overrides: Partial): TaskActivity { @@ -93,24 +89,6 @@ 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" })], @@ -172,39 +150,4 @@ 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 397776fa53..5dd3d428ab 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 { taskActivityQueryKey } from "../task-activity/taskActivityQuery"; +import { TASK_ACTIVITY_QUERY_KEY } from "../task-activity/taskActivityQuery"; export { TASK_ACTIVITY_QUERY_KEY } from "../task-activity/taskActivityQuery"; @@ -17,11 +17,7 @@ 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; - unreadOnly?: boolean; - limit?: number; -}): { +export function useTaskActivity(options?: { enabled?: boolean }): { items: TaskActivityItem[]; unreadCount: number; isLoading: boolean; @@ -30,19 +26,11 @@ export function useTaskActivity(options?: { fetchNextPage: () => Promise; } { const client = useOptionalAuthenticatedClient(); - const unreadOnly = options?.unreadOnly ?? false; const query = useInfiniteQuery({ - queryKey: taskActivityQueryKey(unreadOnly), + queryKey: TASK_ACTIVITY_QUERY_KEY, queryFn: ({ pageParam }) => { if (!client) throw new Error("Not authenticated"); - if (!pageParam && !options?.limit && !unreadOnly) { - return client.getTaskActivity(); - } - return client.getTaskActivity({ - ...pageParam, - ...(options?.limit ? { limit: options.limit } : {}), - ...(unreadOnly ? { unreadOnly: true } : {}), - }); + return client.getTaskActivity(pageParam); }, 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 ce2a2ae2ec..f49f60f4ca 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,13 +40,6 @@ describe("TaskActivityContribution", () => { pageParams: [undefined], }, ); - queryClient.setQueryData>( - ["task-activity", { unreadOnly: true }], - { - pages: [{ results: [], unread_count: 0 }], - pageParams: [undefined], - }, - ); activityListener?.({ taskId: "task-1", @@ -69,12 +62,6 @@ 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 f3e59c998d..9fdddb53f3 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 { taskActivityQueryKey } from "./taskActivityQuery"; +import { TASK_ACTIVITY_QUERY_KEY } from "./taskActivityQuery"; @injectable() export class TaskActivityContribution implements Contribution { @@ -28,22 +28,14 @@ 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, + queryKey: TASK_ACTIVITY_QUERY_KEY, exact: true, }); if (activityQuery?.meta?.authScoped !== true) return; this.queryClient.setQueryData>( - queryKey, + TASK_ACTIVITY_QUERY_KEY, (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 e9c89d9969..2f4f449476 100644 --- a/packages/ui/src/features/canvas/task-activity/taskActivityQuery.ts +++ b/packages/ui/src/features/canvas/task-activity/taskActivityQuery.ts @@ -1,6 +1 @@ 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; From bfc6e1f690a1ecffb62551ecf0ea01e92fd2b648 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Tue, 28 Jul 2026 21:47:21 +0200 Subject: [PATCH 4/8] fix: refine activity popover interactions Use a compact row layout with a fixed top-right timestamp and visible hover state. Close promptly on pointer leave and disable the popover while Activity is already open. Generated-By: PostHog Code Task-Id: 4a1c53d9-0ca7-4ebe-9936-0c6dab92ca96 --- .../canvas/components/ActivityHoverCard.tsx | 1 + .../canvas/components/ActivityView.tsx | 28 ++++++++--- .../canvas/components/ChannelNav.test.tsx | 49 ++++++++++++++++--- .../features/canvas/components/ChannelNav.tsx | 10 ++-- .../sidebar/components/items/ActivityItem.tsx | 49 +++++++++---------- 5 files changed, 94 insertions(+), 43 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ActivityHoverCard.tsx b/packages/ui/src/features/canvas/components/ActivityHoverCard.tsx index e80b513eb1..b0c1da88b0 100644 --- a/packages/ui/src/features/canvas/components/ActivityHoverCard.tsx +++ b/packages/ui/src/features/canvas/components/ActivityHoverCard.tsx @@ -102,6 +102,7 @@ export function ActivityHoverCard({ onClose }: { onClose: () => void }) { currentUser={currentUser} surface="activity_panel" onNavigate={onClose} + compact /> ))}
diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index 2fa5eaec4b..b513835021 100644 --- a/packages/ui/src/features/canvas/components/ActivityView.tsx +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -113,6 +113,7 @@ export function ActivityRow({ currentUser, surface = "activity", onNavigate, + compact = false, }: { item: TaskActivityItem; /** Desktop folder channel id (the /website route param); null when unmapped. */ @@ -122,6 +123,7 @@ export function ActivityRow({ currentUser?: UserBasic | null; surface?: "activity" | "activity_panel"; onNavigate?: () => void; + compact?: boolean; }) { const isAgentActivity = item.activityKind === "awaiting_input" || @@ -150,7 +152,7 @@ export function ActivityRow({ + {compact && ( + + {formatRelativeTimeShort(item.activityAt)} + + )} {item.isUnread && ( )} - {folderChannelId && ( + {folderChannelId && !compact && (
- } + closeDelay={30} + render={
{item}
} /> {open && setOpen(false)} />} From b5a98a5ea9bdecde367b3750e9914b096fabae3b Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Tue, 28 Jul 2026 21:47:23 +0200 Subject: [PATCH 5/8] fix: address activity popover review feedback Label partial read cleanup accurately when unloaded activity remains, preserve pointer travel into the card, and migrate the trigger button to React 19 ref handling. Generated-By: PostHog Code Task-Id: 4a1c53d9-0ca7-4ebe-9936-0c6dab92ca96 --- .../canvas/components/ActivityHoverCard.tsx | 6 ++-- .../features/canvas/components/ChannelNav.tsx | 32 +++++++++---------- .../sidebar/components/items/ActivityItem.tsx | 2 +- 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ActivityHoverCard.tsx b/packages/ui/src/features/canvas/components/ActivityHoverCard.tsx index b0c1da88b0..50edd07996 100644 --- a/packages/ui/src/features/canvas/components/ActivityHoverCard.tsx +++ b/packages/ui/src/features/canvas/components/ActivityHoverCard.tsx @@ -14,7 +14,7 @@ import { useEffect, useMemo } from "react"; export function ActivityHoverCard({ onClose }: { onClose: () => void }) { const client = useOptionalAuthenticatedClient(); const { data: currentUser } = useCurrentUser({ client }); - const { items, isLoading } = useTaskActivity(); + const { items, unreadCount, isLoading } = useTaskActivity(); const unreadItems = items.filter((item) => item.isUnread); const { mutate: markTasksRead, isPending: isMarkingRead } = useMarkTaskActivityRead(); @@ -67,7 +67,9 @@ export function ActivityHoverCard({ onClose }: { onClose: () => void }) { onClick={markAllRead} > - Mark all as read + {unreadItems.length === unreadCount + ? "Mark all as read" + : "Mark visible as read"} )} diff --git a/packages/ui/src/features/canvas/components/ChannelNav.tsx b/packages/ui/src/features/canvas/components/ChannelNav.tsx index 2ad14e3711..1062ea9222 100644 --- a/packages/ui/src/features/canvas/components/ChannelNav.tsx +++ b/packages/ui/src/features/canvas/components/ChannelNav.tsx @@ -39,12 +39,7 @@ import { } from "@posthog/ui/router/navigationBridge"; import { useAppView } from "@posthog/ui/router/useAppView"; import { track } from "@posthog/ui/shell/analytics"; -import { - type ComponentPropsWithoutRef, - forwardRef, - type ReactNode, - useState, -} from "react"; +import { type ComponentPropsWithRef, type ReactNode, useState } from "react"; import { ActivityHoverCard } from "./ActivityHoverCard"; const INBOX_REFETCH_INTERVAL_MS = 60_000; @@ -96,18 +91,24 @@ function NavIcon({ ); } -interface NavButtonProps extends ComponentPropsWithoutRef<"button"> { +interface NavButtonProps extends ComponentPropsWithRef<"button"> { icon: ReactNode; label: string; isActive: boolean; badge?: ReactNode; } -const NavButton = forwardRef( - ( - { icon, label, isActive, onClick, badge, className, ...buttonProps }, - ref, - ) => ( +function NavButton({ + icon, + label, + isActive, + onClick, + badge, + className, + ref, + ...buttonProps +}: NavButtonProps) { + return ( - ), -); -NavButton.displayName = "NavButton"; + ); +} export function ChannelNav() { const view = useAppView(); @@ -181,7 +181,7 @@ export function ChannelNav() { {item}} /> {open && setOpen(false)} />} From 0f5ec39736c1174c911575045faf61a1ce2adf60 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Tue, 28 Jul 2026 21:47:25 +0200 Subject: [PATCH 6/8] feat: paginate the activity popover on scroll Fetch the next activity cursor page when a bottom sentinel enters view and show a compact loading indicator while it loads. Generated-By: PostHog Code Task-Id: 4a1c53d9-0ca7-4ebe-9936-0c6dab92ca96 --- .../components/ActivityHoverCard.test.tsx | 71 +++++++++++++++++++ .../canvas/components/ActivityHoverCard.tsx | 23 +++++- 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/features/canvas/components/ActivityHoverCard.test.tsx diff --git a/packages/ui/src/features/canvas/components/ActivityHoverCard.test.tsx b/packages/ui/src/features/canvas/components/ActivityHoverCard.test.tsx new file mode 100644 index 0000000000..5ee987b73b --- /dev/null +++ b/packages/ui/src/features/canvas/components/ActivityHoverCard.test.tsx @@ -0,0 +1,71 @@ +import { render, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + fetchNextPage: vi.fn(), + hasNextPage: true, + isFetchingNextPage: false, +})); + +vi.mock("@posthog/quill", () => ({ + Button: ({ children }: { children: ReactNode }) => ( + + ), + PopoverContent: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + Spinner: () =>
Loading
, +})); +vi.mock("@posthog/ui/features/auth/authClient", () => ({ + useOptionalAuthenticatedClient: () => ({}), +})); +vi.mock("@posthog/ui/features/auth/useCurrentUser", () => ({ + useCurrentUser: () => ({ data: null }), +})); +vi.mock("@posthog/ui/features/canvas/components/ActivityView", () => ({ + ActivityRow: () =>
Activity row
, +})); +vi.mock("@posthog/ui/features/canvas/hooks/useChannels", () => ({ + useChannels: () => ({ channels: [] }), +})); +vi.mock("@posthog/ui/features/canvas/hooks/useMarkTaskActivityRead", () => ({ + useMarkTaskActivityRead: () => ({ mutate: vi.fn(), isPending: false }), +})); +vi.mock("@posthog/ui/features/canvas/hooks/useTaskActivity", () => ({ + useTaskActivity: () => ({ + items: [], + unreadCount: 0, + isLoading: false, + hasNextPage: mocks.hasNextPage, + isFetchingNextPage: mocks.isFetchingNextPage, + fetchNextPage: mocks.fetchNextPage, + }), +})); +vi.mock("@posthog/ui/primitives/hooks/useInView", () => ({ + useInView: () => [{ current: null }, true], +})); +vi.mock("@posthog/ui/shell/analytics", () => ({ track: vi.fn() })); + +import { ActivityHoverCard } from "./ActivityHoverCard"; + +describe("ActivityHoverCard", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.hasNextPage = true; + mocks.isFetchingNextPage = false; + }); + + it("loads the next page when the bottom sentinel is visible", async () => { + render(); + + await waitFor(() => expect(mocks.fetchNextPage).toHaveBeenCalledOnce()); + }); + + it("does not load when there is no next page", async () => { + mocks.hasNextPage = false; + render(); + + await waitFor(() => expect(mocks.fetchNextPage).not.toHaveBeenCalled()); + }); +}); diff --git a/packages/ui/src/features/canvas/components/ActivityHoverCard.tsx b/packages/ui/src/features/canvas/components/ActivityHoverCard.tsx index 50edd07996..a1cb377d5e 100644 --- a/packages/ui/src/features/canvas/components/ActivityHoverCard.tsx +++ b/packages/ui/src/features/canvas/components/ActivityHoverCard.tsx @@ -8,13 +8,24 @@ import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { 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 { useInView } from "@posthog/ui/primitives/hooks/useInView"; 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, unreadCount, isLoading } = useTaskActivity(); + const { + items, + unreadCount, + isLoading, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = useTaskActivity(); + const [loadMoreRef, loadMoreInView] = useInView({ + rootMargin: "100px 0px", + }); const unreadItems = items.filter((item) => item.isUnread); const { mutate: markTasksRead, isPending: isMarkingRead } = useMarkTaskActivityRead(); @@ -35,6 +46,11 @@ export function ActivityHoverCard({ onClose }: { onClose: () => void }) { surface: "activity_panel", }); }, []); + useEffect(() => { + if (loadMoreInView && hasNextPage && !isFetchingNextPage) { + void fetchNextPage(); + } + }, [fetchNextPage, hasNextPage, isFetchingNextPage, loadMoreInView]); const markRead = (taskId: string, activityAt: string) => { markTasksRead([{ task_id: taskId, seen_before: activityAt }]); @@ -107,6 +123,11 @@ export function ActivityHoverCard({ onClose }: { onClose: () => void }) { compact /> ))} + {hasNextPage && ( +
+ {isFetchingNextPage && } +
+ )} )} From 7e5c21a0ac03024186326632b889b1582e82e93b Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Tue, 28 Jul 2026 21:47:27 +0200 Subject: [PATCH 7/8] fix: address activity popover review feedback Generated-By: PostHog Code Task-Id: 4a1c53d9-0ca7-4ebe-9936-0c6dab92ca96 --- .../components/ActivityHoverCard.test.tsx | 9 +- .../canvas/components/ActivityHoverCard.tsx | 97 +++++++++++-------- .../canvas/components/ActivityView.tsx | 34 +++---- .../canvas/components/ChannelNav.test.tsx | 4 +- .../features/canvas/components/ChannelNav.tsx | 6 +- .../canvas/components/activityFeed.ts | 46 +++++++++ .../sidebar/components/SidebarItem.tsx | 11 ++- .../components/items/ActivityItem.test.tsx | 35 +++++++ .../sidebar/components/items/ActivityItem.tsx | 23 +++-- packages/ui/src/primitives/hooks/useInView.ts | 18 ++-- 10 files changed, 199 insertions(+), 84 deletions(-) create mode 100644 packages/ui/src/features/canvas/components/activityFeed.ts create mode 100644 packages/ui/src/features/sidebar/components/items/ActivityItem.test.tsx diff --git a/packages/ui/src/features/canvas/components/ActivityHoverCard.test.tsx b/packages/ui/src/features/canvas/components/ActivityHoverCard.test.tsx index 5ee987b73b..65eeaaf72b 100644 --- a/packages/ui/src/features/canvas/components/ActivityHoverCard.test.tsx +++ b/packages/ui/src/features/canvas/components/ActivityHoverCard.test.tsx @@ -12,6 +12,13 @@ vi.mock("@posthog/quill", () => ({ Button: ({ children }: { children: ReactNode }) => ( ), + Empty: ({ children }: { children: ReactNode }) =>
{children}
, + EmptyDescription: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + EmptyHeader: ({ children }: { children: ReactNode }) =>
{children}
, + EmptyMedia: ({ children }: { children: ReactNode }) =>
{children}
, + EmptyTitle: ({ children }: { children: ReactNode }) =>
{children}
, PopoverContent: ({ children }: { children: ReactNode }) => (
{children}
), @@ -43,7 +50,7 @@ vi.mock("@posthog/ui/features/canvas/hooks/useTaskActivity", () => ({ }), })); vi.mock("@posthog/ui/primitives/hooks/useInView", () => ({ - useInView: () => [{ current: null }, true], + useInView: () => [vi.fn(), true], })); vi.mock("@posthog/ui/shell/analytics", () => ({ track: vi.fn() })); diff --git a/packages/ui/src/features/canvas/components/ActivityHoverCard.tsx b/packages/ui/src/features/canvas/components/ActivityHoverCard.tsx index a1cb377d5e..85e818f1ad 100644 --- a/packages/ui/src/features/canvas/components/ActivityHoverCard.tsx +++ b/packages/ui/src/features/canvas/components/ActivityHoverCard.tsx @@ -1,5 +1,14 @@ -import { ChecksIcon } from "@phosphor-icons/react"; -import { Button, PopoverContent, Spinner } from "@posthog/quill"; +import { BellIcon, ChecksIcon } from "@phosphor-icons/react"; +import { + Button, + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, + 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"; @@ -7,12 +16,26 @@ import { ActivityRow } from "@posthog/ui/features/canvas/components/ActivityView import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { 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 { useInView } from "@posthog/ui/primitives/hooks/useInView"; import { track } from "@posthog/ui/shell/analytics"; -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { + activityReadPayload, + channelIdForName, + createChannelIdByName, + getUnreadActivityItems, + markLoadedReadLabel, +} from "./activityFeed"; -export function ActivityHoverCard({ onClose }: { onClose: () => void }) { +interface ActivityHoverCardProps { + onClose: () => void; + side?: "bottom" | "right"; +} + +export function ActivityHoverCard({ + onClose, + side = "right", +}: ActivityHoverCardProps) { const client = useOptionalAuthenticatedClient(); const { data: currentUser } = useCurrentUser({ client }); const { @@ -23,21 +46,17 @@ export function ActivityHoverCard({ onClose }: { onClose: () => void }) { isFetchingNextPage, fetchNextPage, } = useTaskActivity(); + const [scrollRoot, setScrollRoot] = useState(null); const [loadMoreRef, loadMoreInView] = useInView({ + root: scrollRoot, rootMargin: "100px 0px", }); - const unreadItems = items.filter((item) => item.isUnread); + const unreadItems = getUnreadActivityItems(items); const { mutate: markTasksRead, isPending: isMarkingRead } = useMarkTaskActivityRead(); const { channels } = useChannels(); const folderIdByName = useMemo( - () => - new Map( - channels.map((channel) => [ - normalizeChannelName(channel.name), - channel.id, - ]), - ), + () => createChannelIdByName(channels), [channels], ); useEffect(() => { @@ -47,27 +66,22 @@ export function ActivityHoverCard({ onClose }: { onClose: () => void }) { }); }, []); useEffect(() => { - if (loadMoreInView && hasNextPage && !isFetchingNextPage) { + if (loadMoreInView && hasNextPage) { void fetchNextPage(); } - }, [fetchNextPage, hasNextPage, isFetchingNextPage, loadMoreInView]); + }, [fetchNextPage, hasNextPage, loadMoreInView]); const markRead = (taskId: string, activityAt: string) => { markTasksRead([{ task_id: taskId, seen_before: activityAt }]); }; const markAllRead = () => { - markTasksRead( - unreadItems.map((item) => ({ - task_id: item.taskId, - seen_before: item.activityAt, - })), - ); + markTasksRead(activityReadPayload(unreadItems)); }; return ( void }) { onClick={markAllRead} > - {unreadItems.length === unreadCount - ? "Mark all as read" - : "Mark visible as read"} + {markLoadedReadLabel(unreadItems.length, unreadCount)} )} -
+
{isLoading && items.length === 0 ? (
) : items.length === 0 ? ( -
- No recent activity. -
+ + + + + + No recent activity + + New task updates will appear here. + + + ) : (
{items.map((item) => ( markRead(activity.taskId, activity.activityAt) } @@ -123,13 +140,11 @@ export function ActivityHoverCard({ onClose }: { onClose: () => void }) { compact /> ))} - {hasNextPage && ( -
- {isFetchingNextPage && } -
- )}
)} +
+ {hasNextPage && isFetchingNextPage && } +
); diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index b513835021..136489b667 100644 --- a/packages/ui/src/features/canvas/components/ActivityView.tsx +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -29,7 +29,6 @@ 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 { 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"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import { @@ -40,6 +39,13 @@ import { track } from "@posthog/ui/shell/analytics"; import { Text } from "@radix-ui/themes"; import type { ReactNode } from "react"; import { useCallback, useEffect, useMemo } from "react"; +import { + activityReadPayload, + channelIdForName, + createChannelIdByName, + getUnreadActivityItems, + markLoadedReadLabel, +} from "./activityFeed"; function ChannelSuffix({ channelName }: { channelName: string | null }) { if (!channelName) return null; @@ -253,6 +259,7 @@ export function ActivityView() { } = useTaskActivity(); const { mutate: markTasksRead, isPending: isMarkingRead } = useMarkTaskActivityRead(); + const unreadItems = useMemo(() => getUnreadActivityItems(items), [items]); // 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( @@ -261,33 +268,18 @@ export function ActivityView() { [markTasksRead], ); const markAllRead = useCallback(() => { - markTasksRead( - items - .filter((item) => item.isUnread) - .map((item) => ({ - task_id: item.taskId, - seen_before: item.activityAt, - })), - ); - }, [items, markTasksRead]); + markTasksRead(activityReadPayload(unreadItems)); + }, [markTasksRead, unreadItems]); // 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, - ]), - ), + () => createChannelIdByName(folderChannels), [folderChannels], ); const folderChannelIdFor = (channelName: string | null): string | null => - channelName - ? (folderIdByName.get(normalizeChannelName(channelName)) ?? null) - : null; + channelIdForName(folderIdByName, channelName); useEffect(() => { track(ANALYTICS_EVENTS.CHANNEL_ACTION, { action_type: "view_activity", @@ -317,7 +309,7 @@ export function ActivityView() { onClick={markAllRead} > - Mark all as read + {markLoadedReadLabel(unreadItems.length, unreadCount)} )}
diff --git a/packages/ui/src/features/canvas/components/ChannelNav.test.tsx b/packages/ui/src/features/canvas/components/ChannelNav.test.tsx index e91e58ec39..fe2da703cb 100644 --- a/packages/ui/src/features/canvas/components/ChannelNav.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelNav.test.tsx @@ -72,7 +72,9 @@ describe("ChannelNav", () => { const user = userEvent.setup(); render(); - await user.hover(screen.getByLabelText("Activity")); + const activity = screen.getByLabelText("Activity"); + expect(activity).toBeEnabled(); + await user.hover(activity); await new Promise((resolve) => setTimeout(resolve, 400)); expect(screen.queryByText("Recent activity card")).not.toBeInTheDocument(); diff --git a/packages/ui/src/features/canvas/components/ChannelNav.tsx b/packages/ui/src/features/canvas/components/ChannelNav.tsx index 1062ea9222..2c09ef5ba8 100644 --- a/packages/ui/src/features/canvas/components/ChannelNav.tsx +++ b/packages/ui/src/features/canvas/components/ChannelNav.tsx @@ -182,7 +182,6 @@ export function ChannelNav() { openOnHover delay={300} closeDelay={100} - disabled={isActivity} render={ {!isActivity && activityOpen && ( - setActivityOpen(false)} /> + setActivityOpen(false)} + /> )} { + return new Map( + channels.map((channel) => [normalizeChannelName(channel.name), channel.id]), + ); +} + +export function channelIdForName( + channelIdByName: Map, + channelName: string | null, +): string | null { + return channelName + ? (channelIdByName.get(normalizeChannelName(channelName)) ?? null) + : null; +} + +export function getUnreadActivityItems( + items: TaskActivityItem[], +): TaskActivityItem[] { + return items.filter((item) => item.isUnread); +} + +export function activityReadPayload(items: TaskActivityItem[]) { + return items.map((item) => ({ + task_id: item.taskId, + seen_before: item.activityAt, + })); +} + +export function markLoadedReadLabel( + loadedUnreadCount: number, + unreadCount: number, +): string { + return loadedUnreadCount === unreadCount + ? "Mark all as read" + : "Mark visible as read"; +} diff --git a/packages/ui/src/features/sidebar/components/SidebarItem.tsx b/packages/ui/src/features/sidebar/components/SidebarItem.tsx index e78d46cd7c..cb74d897a8 100644 --- a/packages/ui/src/features/sidebar/components/SidebarItem.tsx +++ b/packages/ui/src/features/sidebar/components/SidebarItem.tsx @@ -4,6 +4,7 @@ import { OverflowTickerText, useOverflowTickerReveal, } from "@posthog/ui/primitives/OverflowTickerText"; +import type { ComponentPropsWithRef } from "react"; export const INDENT_SIZE = 8; @@ -11,7 +12,11 @@ export function getSidebarItemPaddingLeft(depth: number): string { return `${depth * INDENT_SIZE + 8 + (depth > 0 ? 4 : 0)}px`; } -interface SidebarItemProps { +interface SidebarItemProps + extends Omit< + ComponentPropsWithRef<"button">, + "children" | "onDragStart" | "onDoubleClick" + > { depth: number; icon?: React.ReactNode; label: React.ReactNode; @@ -47,11 +52,15 @@ export function SidebarItem({ badge, endContent, disabled, + ref, + ...buttonProps }: SidebarItemProps) { const { reveal, hoverProps, focusProps } = useOverflowTickerReveal(); return (