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 (