Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2514,6 +2514,8 @@ export class PostHogAPIClient {
async getTaskActivity(options?: {
before?: string;
beforeId?: string;
limit?: number;
unreadOnly?: boolean;
}): Promise<TaskActivityPage> {
const teamId = await this.getTeamId();
const urlPath = `/api/projects/${teamId}/task_activity/`;
Expand All @@ -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,
Expand Down Expand Up @@ -2556,6 +2564,22 @@ export class PostHogAPIClient {
return (await response.json()) as TaskActivityMarkReadResult;
}

async markAllTaskActivityRead(): Promise<TaskActivityMarkReadResult> {
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<TaskThreadMessage[]> {
const teamId = await this.getTeamId();
const urlPath = `/api/projects/${teamId}/tasks/${taskId}/thread_messages/`;
Expand Down
119 changes: 119 additions & 0 deletions packages/ui/src/features/canvas/components/ActivityHoverCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<PopoverContent
side="right"
align="start"
sideOffset={8}
className="w-[420px] gap-2 p-2"
>
<div className="flex items-center justify-between px-2 pt-1">
<span className="font-medium text-sm">Activity</span>
{unreadItems.length > 0 && (
<Button
variant="default"
size="sm"
loading={isMarkingAllRead}
disabled={isMarkingAllRead}
onClick={() => markAllRead()}
>
<ChecksIcon size={14} />
Mark all as read
</Button>
)}
</div>
<div className="max-h-[480px] overflow-y-auto">
{isLoading && unreadItems.length === 0 ? (
<div className="flex justify-center py-10">
<Spinner />
</div>
) : unreadItems.length === 0 ? (
<div className="px-2 py-8 text-center text-muted-foreground text-sm">
Okay.
</div>
) : (
<div className="flex flex-col gap-0.5">
{unreadItems.map((item) => (
<ActivityRow
key={item.taskId}
item={item}
folderChannelId={
item.channelName
? (folderIdByName.get(
normalizeChannelName(item.channelName),
) ?? null)
: null
}
onOpen={(activity) =>
markRead(activity.taskId, activity.activityAt)
}
onMarkRead={(activity) =>
markRead(activity.taskId, activity.activityAt)
}
currentUser={currentUser}
surface="activity_panel"
onNavigate={onClose}
/>
))}
{hasNextPage && (
<Button
variant="outline"
className="mt-2 self-center"
loading={isFetchingNextPage}
disabled={isFetchingNextPage}
onClick={() => void fetchNextPage()}
>
Load more
</Button>
)}
</div>
)}
</div>
</PopoverContent>
);
}
31 changes: 15 additions & 16 deletions packages/ui/src/features/canvas/components/ActivityView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -105,19 +108,23 @@ 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. */
folderChannelId: string | null;
onOpen: (item: TaskActivityItem) => void;
onMarkRead: (item: TaskActivityItem) => void;
currentUser?: UserBasic | null;
surface?: "activity" | "activity_panel";
onNavigate?: () => void;
}) {
const isAgentActivity =
item.activityKind === "awaiting_input" ||
Expand All @@ -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) {
Expand Down Expand Up @@ -234,25 +242,16 @@ 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(
(item: TaskActivityItem) =>
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.
Expand Down Expand Up @@ -297,7 +296,7 @@ export function ActivityView() {
size="sm"
loading={isMarkingRead}
disabled={isMarkingRead}
onClick={markAllRead}
onClick={() => markAllRead()}
>
<ChecksIcon size={14} />
Mark all as read
Expand Down
42 changes: 42 additions & 0 deletions packages/ui/src/features/canvas/components/ChannelNav.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";

vi.mock("@posthog/ui/features/canvas/hooks/useTaskActivity", () => ({
useTaskActivity: () => ({ unreadCount: 1 }),
}));
vi.mock(
"@posthog/ui/features/command-center/useCommandCenterActiveCount",
() => ({ useCommandCenterActiveCount: () => 0 }),
);
vi.mock("@posthog/ui/features/inbox/hooks/useInboxAllReports", () => ({
useInboxAllReports: () => ({ counts: { pulls: 0 } }),
}));
vi.mock("@posthog/ui/router/useAppView", () => ({
useAppView: () => ({ type: "task-input" }),
}));
vi.mock("@posthog/ui/router/navigationBridge", () => ({
navigateToActivity: vi.fn(),
navigateToInbox: vi.fn(),
navigateToWebsiteCommandCenter: vi.fn(),
}));
vi.mock("@posthog/ui/shell/analytics", () => ({ track: vi.fn() }));
vi.mock("./ActivityHoverCard", () => ({
ActivityHoverCard: () => <div>Unread activity card</div>,
}));

import { ChannelNav } from "./ChannelNav";

describe("ChannelNav", () => {
it("opens unread activity from the bell after the hover delay", async () => {
const user = userEvent.setup();
render(<ChannelNav />);

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();
});
});
Loading
Loading