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..65eeaaf72b --- /dev/null +++ b/packages/ui/src/features/canvas/components/ActivityHoverCard.test.tsx @@ -0,0 +1,78 @@ +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 }) => ( + + ), + 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}
+ ), + 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: () => [vi.fn(), 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 new file mode 100644 index 0000000000..85e818f1ad --- /dev/null +++ b/packages/ui/src/features/canvas/components/ActivityHoverCard.tsx @@ -0,0 +1,151 @@ +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"; +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 { useInView } from "@posthog/ui/primitives/hooks/useInView"; +import { track } from "@posthog/ui/shell/analytics"; +import { useEffect, useMemo, useState } from "react"; +import { + activityReadPayload, + channelIdForName, + createChannelIdByName, + getUnreadActivityItems, + markLoadedReadLabel, +} from "./activityFeed"; + +interface ActivityHoverCardProps { + onClose: () => void; + side?: "bottom" | "right"; +} + +export function ActivityHoverCard({ + onClose, + side = "right", +}: ActivityHoverCardProps) { + const client = useOptionalAuthenticatedClient(); + const { data: currentUser } = useCurrentUser({ client }); + const { + items, + unreadCount, + isLoading, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = useTaskActivity(); + const [scrollRoot, setScrollRoot] = useState(null); + const [loadMoreRef, loadMoreInView] = useInView({ + root: scrollRoot, + rootMargin: "100px 0px", + }); + const unreadItems = getUnreadActivityItems(items); + const { mutate: markTasksRead, isPending: isMarkingRead } = + useMarkTaskActivityRead(); + const { channels } = useChannels(); + const folderIdByName = useMemo( + () => createChannelIdByName(channels), + [channels], + ); + useEffect(() => { + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "view_activity", + surface: "activity_panel", + }); + }, []); + useEffect(() => { + if (loadMoreInView && hasNextPage) { + void fetchNextPage(); + } + }, [fetchNextPage, hasNextPage, loadMoreInView]); + + const markRead = (taskId: string, activityAt: string) => { + markTasksRead([{ task_id: taskId, seen_before: activityAt }]); + }; + + const markAllRead = () => { + markTasksRead(activityReadPayload(unreadItems)); + }; + + return ( + +
+ Activity + {unreadItems.length > 0 && ( + + )} +
+
+ {isLoading && items.length === 0 ? ( +
+ +
+ ) : items.length === 0 ? ( + + + + + + No recent activity + + New task updates will appear here. + + + + ) : ( +
+ {items.map((item) => ( + + markRead(activity.taskId, activity.activityAt) + } + onMarkRead={(activity) => + markRead(activity.taskId, activity.activityAt) + } + currentUser={currentUser} + surface="activity_panel" + onNavigate={onClose} + compact + /> + ))} +
+ )} +
+ {hasNextPage && isFetchingNextPage && } +
+
+
+ ); +} diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index 58f5358596..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; @@ -105,12 +111,15 @@ export function activityHeadline( } } -function ActivityRow({ +export function ActivityRow({ item, folderChannelId, onOpen, onMarkRead, currentUser, + surface = "activity", + onNavigate, + compact = false, }: { item: TaskActivityItem; /** Desktop folder channel id (the /website route param); null when unmapped. */ @@ -118,6 +127,9 @@ function ActivityRow({ onOpen: (item: TaskActivityItem) => void; onMarkRead: (item: TaskActivityItem) => void; currentUser?: UserBasic | null; + surface?: "activity" | "activity_panel"; + onNavigate?: () => void; + compact?: boolean; }) { const isAgentActivity = item.activityKind === "awaiting_input" || @@ -126,11 +138,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) { @@ -145,7 +158,7 @@ function ActivityRow({ + {compact && ( + + {formatRelativeTimeShort(item.activityAt)} + + )} {item.isUnread && ( )} - {folderChannelId && ( + {folderChannelId && !compact && ( )} 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..fe2da703cb --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelNav.test.tsx @@ -0,0 +1,82 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + view: { type: "task-input" }, +})); + +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: () => mocks.view, +})); +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: () =>
Recent activity card
, +})); + +import { ChannelNav } from "./ChannelNav"; + +describe("ChannelNav", () => { + beforeEach(() => { + mocks.view = { type: "task-input" }; + }); + + it("opens recent activity from the bell after the hover delay", async () => { + const user = userEvent.setup(); + render(); + + await user.hover(screen.getByLabelText("Activity")); + expect(screen.queryByText("Recent activity card")).not.toBeInTheDocument(); + + expect( + await screen.findByText("Recent activity card", {}, { timeout: 1_000 }), + ).toBeInTheDocument(); + }); + + it("closes promptly after the pointer leaves", async () => { + const user = userEvent.setup(); + render(); + const activity = screen.getByLabelText("Activity"); + + await user.hover(activity); + await screen.findByText("Recent activity card", {}, { timeout: 1_000 }); + await user.unhover(activity); + + await waitFor(() => + expect( + screen.queryByText("Recent activity card"), + ).not.toBeInTheDocument(), + ); + }); + + it("does not open the hover card on the Activity page", async () => { + mocks.view = { type: "activity" }; + const user = userEvent.setup(); + render(); + + 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 019acc7b7c..23f0a6fe81 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,8 @@ 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 ComponentPropsWithRef, type ReactNode, useState } from "react"; +import { ActivityHoverCard } from "./ActivityHoverCard"; const INBOX_REFETCH_INTERVAL_MS = 60_000; @@ -87,9 +91,48 @@ function NavIcon({ ); } +interface NavButtonProps extends ComponentPropsWithRef<"button"> { + icon: ReactNode; + label: string; + isActive: boolean; + badge?: ReactNode; +} + +function NavButton({ + icon, + label, + isActive, + onClick, + badge, + className, + ref, + ...buttonProps +}: NavButtonProps) { + return ( + + ); +} + 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,44 @@ export function ChannelNav() { } /> - } - label="Activity" - isActive={isActivity} - onClick={withTrack("activity", navigateToActivity)} - badge={ - - } - /> + setActivityOpen(!isActivity && open)} + > + + } + label="Activity" + isActive={isActivity} + onClick={() => { + setActivityOpen(false); + withTrack("activity", navigateToActivity)(); + }} + badge={ + + } + /> + } + /> + {!isActivity && activityOpen && ( + 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 (