From 3c05d68d0be288fcc21e373f8b0ce592b027d85a Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Sun, 26 Jul 2026 06:56:08 +0100 Subject: [PATCH 01/16] feat(canvas): task-centric activity feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the Channels Activity view from a raw @-mentions list into a task-centric feed: one row per task the user is involved in — created, @-mentioned in, or messaged in — newest activity first, backed by the new `/task_activity/` endpoint. Rows read per activity kind: "… is waiting for your reply" (agent turn awaiting input), "You replied", "… mentioned you", or "You created this task", each with a snippet where one applies. The sidebar badge counts tasks with activity newer than the last visit. The prior mentions plumbing (`useMentionActivity` / `mentionActivity`) stays in place — it still powers per-channel unread state. Why: users want the Activity view to surface all activity involving them — especially tasks an agent updated and is waiting on them for — not just mentions. Generated-By: PostHog Code Task-Id: c10b01e4-4645-4c82-98b2-610b533f7f7c --- packages/api-client/src/posthog-client.ts | 21 +++ packages/core/src/canvas/taskActivity.test.ts | 152 ++++++++++++++++++ packages/core/src/canvas/taskActivity.ts | 81 ++++++++++ packages/shared/src/domain-types.ts | 24 +++ .../canvas/components/ActivityView.tsx | 111 ++++++++----- .../features/canvas/hooks/useTaskActivity.ts | 49 ++++++ .../sidebar/components/items/ActivityItem.tsx | 14 +- .../ui/src/router/routes/website/activity.tsx | 5 +- 8 files changed, 411 insertions(+), 46 deletions(-) create mode 100644 packages/core/src/canvas/taskActivity.test.ts create mode 100644 packages/core/src/canvas/taskActivity.ts create mode 100644 packages/ui/src/features/canvas/hooks/useTaskActivity.ts diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 565dfbf0dd..8940095b44 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -86,6 +86,7 @@ import type { SuggestedReviewersArtefact, SuggestedReviewerWriteEntry, Task, + TaskActivity, TaskChannel, TaskMention, TaskRun, @@ -2506,6 +2507,26 @@ export class PostHogAPIClient { return (await response.json()) as TaskMention[]; } + // Tasks the current user is involved in (created, mentioned, or messaged), + // one row per task, newest activity first. + async getTaskActivity(options?: { since?: string }): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/task_activity/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + if (options?.since) { + url.searchParams.set("since", options.since); + } + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) { + throw new Error(`Failed to fetch task activity: ${response.statusText}`); + } + return (await response.json()) as TaskActivity[]; + } + async getTaskThreadMessages(taskId: string): Promise { const teamId = await this.getTeamId(); const urlPath = `/api/projects/${teamId}/tasks/${taskId}/thread_messages/`; diff --git a/packages/core/src/canvas/taskActivity.test.ts b/packages/core/src/canvas/taskActivity.test.ts new file mode 100644 index 0000000000..643786721e --- /dev/null +++ b/packages/core/src/canvas/taskActivity.test.ts @@ -0,0 +1,152 @@ +import type { TaskActivity, UserBasic } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { + countUnseenActivity, + mergeTaskActivity, + toTaskActivityItems, +} from "./taskActivity"; + +const ann: UserBasic = { + id: 2, + uuid: "ann-uuid", + email: "ann@posthog.com", + first_name: "Ann", +}; + +function activity(overrides: Partial = {}): TaskActivity { + return { + task_id: "t1", + task_title: "Task t1", + channel_id: "c1", + channel_name: "general", + activity_at: "2026-07-01T10:00:00Z", + activity_kind: "mention", + snippet: "ping @[Me](me@posthog.com)", + latest_author: ann, + latest_message_id: "m1", + ...overrides, + }; +} + +describe("toTaskActivityItems", () => { + it("maps activity DTOs to feed items", () => { + expect(toTaskActivityItems([activity()])).toEqual([ + { + taskId: "t1", + taskTitle: "Task t1", + channelId: "c1", + channelName: "general", + activityAt: "2026-07-01T10:00:00Z", + activityKind: "mention", + snippet: "ping @[Me](me@posthog.com)", + author: ann, + messageId: "m1", + }, + ]); + }); + + it("labels untitled tasks and tolerates missing channel/author/message", () => { + const items = toTaskActivityItems([ + activity({ + task_title: "", + channel_id: null, + channel_name: null, + latest_author: null, + latest_message_id: null, + activity_kind: "created", + snippet: "", + }), + ]); + expect(items[0]).toMatchObject({ + taskTitle: "Untitled task", + channelId: null, + channelName: null, + author: null, + messageId: null, + }); + }); +}); + +describe("countUnseenActivity", () => { + const items = toTaskActivityItems([ + activity({ task_id: "t2", activity_at: "2026-07-03T10:00:00Z" }), + activity({ task_id: "t1", activity_at: "2026-07-01T10:00:00Z" }), + ]); + + it("counts everything when never seen", () => { + expect(countUnseenActivity(items, null)).toBe(2); + }); + + it("counts only rows with activity after the last-seen timestamp", () => { + expect(countUnseenActivity(items, "2026-07-02T00:00:00Z")).toBe(1); + expect(countUnseenActivity(items, "2026-07-04T00:00:00Z")).toBe(0); + }); +}); + +describe("mergeTaskActivity", () => { + it("prepends newly-active tasks ahead of the previous page", () => { + const previous = [ + activity({ task_id: "t1", activity_at: "2026-07-01T10:00:00Z" }), + ]; + const incoming = [ + activity({ task_id: "t2", activity_at: "2026-07-02T10:00:00Z" }), + ]; + expect(mergeTaskActivity(previous, incoming).map((r) => r.task_id)).toEqual( + ["t2", "t1"], + ); + }); + + it("replaces a task's row when its activity advances instead of duplicating it", () => { + const previous = [ + activity({ + task_id: "t1", + activity_kind: "mention", + activity_at: "2026-07-01T10:00:00Z", + }), + ]; + const incoming = [ + activity({ + task_id: "t1", + activity_kind: "message", + snippet: "replied", + activity_at: "2026-07-02T10:00:00Z", + }), + ]; + const merged = mergeTaskActivity(previous, incoming); + expect(merged).toHaveLength(1); + expect(merged[0].activity_kind).toBe("message"); + expect(merged[0].activity_at).toBe("2026-07-02T10:00:00Z"); + }); + + it("keeps the newer row when an older duplicate arrives out of order", () => { + const previous = [ + activity({ task_id: "t1", activity_at: "2026-07-05T10:00:00Z" }), + ]; + const incoming = [ + activity({ task_id: "t1", activity_at: "2026-07-01T10:00:00Z" }), + ]; + expect(mergeTaskActivity(previous, incoming)[0].activity_at).toBe( + "2026-07-05T10:00:00Z", + ); + }); + + it("returns the previous page unchanged when there is nothing new", () => { + const previous = [activity({ task_id: "t1" })]; + expect(mergeTaskActivity(previous, [])).toEqual(previous); + }); + + it("caps the merged result so a long session can't grow it unbounded", () => { + const previous = Array.from({ length: 300 }, (_, i) => + activity({ + task_id: `old-${i}`, + activity_at: `2026-06-01T${String(i % 24).padStart(2, "0")}:00:00Z`, + }), + ); + const incoming = [ + activity({ task_id: "newest", activity_at: "2026-07-05T10:00:00Z" }), + ]; + const merged = mergeTaskActivity(previous, incoming); + expect(merged).toHaveLength(300); + expect(merged[0].task_id).toBe("newest"); + }); +}); diff --git a/packages/core/src/canvas/taskActivity.ts b/packages/core/src/canvas/taskActivity.ts new file mode 100644 index 0000000000..7da0c4532d --- /dev/null +++ b/packages/core/src/canvas/taskActivity.ts @@ -0,0 +1,81 @@ +import type { + TaskActivity, + TaskActivityKind, + UserBasic, +} from "@posthog/shared/domain-types"; + +/** + * The Activity feed — tasks the current user is involved in (created, mentioned + * in, or messaged in) — as served by the backend task-activity index + * (`getTaskActivity`). One row per task, newest activity first; the client only + * maps DTOs to items. + */ + +export interface TaskActivityItem { + taskId: string; + taskTitle: string; + /** Backend channel (tasks product Channel UUID); null for channel-less tasks. */ + channelId: string | null; + /** Backend channel name, for the "#channel" label. */ + channelName: string | null; + activityAt: string; + activityKind: TaskActivityKind; + /** Content of the message tied to the latest activity; empty for created rows. */ + snippet: string; + author: UserBasic | null; + messageId: string | null; +} + +/** Map activity DTOs (already newest-first from the backend) to feed items. */ +export function toTaskActivityItems( + activity: readonly TaskActivity[], +): TaskActivityItem[] { + return activity.map((row) => ({ + taskId: row.task_id, + taskTitle: row.task_title || "Untitled task", + channelId: row.channel_id ?? null, + channelName: row.channel_name ?? null, + activityAt: row.activity_at, + activityKind: row.activity_kind, + snippet: row.snippet, + author: row.latest_author ?? null, + messageId: row.latest_message_id ?? null, + })); +} + +/** How many rows have activity after the viewer last opened the Activity page. */ +export function countUnseenActivity( + items: readonly TaskActivityItem[], + lastSeenAt: string | null, +): number { + if (!lastSeenAt) return items.length; + return items.filter((item) => item.activityAt > lastSeenAt).length; +} + +// Bounds the cache so a long-running session's accumulated feed can't grow +// without limit. +const MAX_CACHED_ACTIVITY = 300; + +/** + * Fold a page of freshly-fetched activity into the previously cached set — + * dedupe by task (newest activity wins), keep newest first. Lets repolls fetch + * only what's new (via `since`) instead of re-fetching the whole top page every + * time. A task whose activity advanced comes back on the next poll and replaces + * its stale row rather than duplicating it. + */ +export function mergeTaskActivity( + previous: readonly TaskActivity[], + incoming: readonly TaskActivity[], +): TaskActivity[] { + if (incoming.length === 0) return [...previous]; + const byTaskId = new Map(previous.map((row) => [row.task_id, row])); + for (const row of incoming) { + const existing = byTaskId.get(row.task_id); + if (!existing || row.activity_at > existing.activity_at) { + byTaskId.set(row.task_id, row); + } + } + return Array.from(byTaskId.values()) + .sort((a, b) => (a.activity_at < b.activity_at ? 1 : -1)) + .slice(0, MAX_CACHED_ACTIVITY); +} diff --git a/packages/shared/src/domain-types.ts b/packages/shared/src/domain-types.ts index e070d38788..dedb0ef8ad 100644 --- a/packages/shared/src/domain-types.ts +++ b/packages/shared/src/domain-types.ts @@ -139,6 +139,30 @@ export interface TaskMention { created_at: string; } +/** Which signal produced an activity row; mirrors the backend `activity_kind`. */ +export type TaskActivityKind = + | "awaiting_input" + | "message" + | "mention" + | "created"; + +/** + * One task the current user is involved in, from the backend task-activity feed + * (`/task_activity/`). One row per task, newest activity first. Mirrors + * `TaskActivityDTO`. + */ +export interface TaskActivity { + task_id: string; + task_title: string; + channel_id?: string | null; + channel_name?: string | null; + activity_at: string; + activity_kind: TaskActivityKind; + snippet: string; + latest_author?: UserBasic | null; + latest_message_id?: string | null; +} + export type TaskRunStatus = | "not_started" | "queued" diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index 0103d4d327..42e38aa457 100644 --- a/packages/ui/src/features/canvas/components/ActivityView.tsx +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -1,5 +1,5 @@ -import { AtIcon, LinkIcon } from "@phosphor-icons/react"; -import type { MentionActivityItem } from "@posthog/core/canvas/mentionActivity"; +import { BellIcon, LinkIcon } from "@phosphor-icons/react"; +import type { TaskActivityItem } from "@posthog/core/canvas/taskActivity"; import { Button, Empty, @@ -16,7 +16,7 @@ import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; 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 { useMentionActivity } from "@posthog/ui/features/canvas/hooks/useMentionActivity"; +import { useTaskActivity } from "@posthog/ui/features/canvas/hooks/useTaskActivity"; import { normalizeChannelName } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { useActivitySeenStore } from "@posthog/ui/features/canvas/stores/activitySeenStore"; import { copyChannelLink } from "@posthog/ui/features/canvas/utils/copyChannelLink"; @@ -27,24 +27,70 @@ import { } from "@posthog/ui/router/navigationBridge"; import { track } from "@posthog/ui/shell/analytics"; import { Text } from "@radix-ui/themes"; +import type { ReactNode } from "react"; import { useEffect, useMemo, useState } from "react"; +function ChannelSuffix({ channelName }: { channelName: string | null }) { + if (!channelName) return null; + return ( + <> + {" in "} + + {channelName} + + + ); +} + +/** The lead line describing what happened, chosen by the row's activity kind. */ +function activityHeadline(item: TaskActivityItem): ReactNode { + switch (item.activityKind) { + case "awaiting_input": + return ( + <> + {userDisplayName(item.author) || "The agent"} is waiting for your + reply + + + ); + case "message": + return ( + <> + You replied + + + ); + case "mention": + return ( + <> + + {userDisplayName(item.author)} + {" "} + mentioned you + + + ); + default: + return "You created this task"; + } +} + function ActivityRow({ item, folderChannelId, isNew, currentUserEmail, }: { - item: MentionActivityItem; + item: TaskActivityItem; /** Desktop folder channel id (the /website route param); null when unmapped. */ folderChannelId: string | null; - /** Arrived since the viewer last opened this page. */ + /** Activity arrived since the viewer last opened this page. */ isNew: boolean; currentUserEmail?: string | null; }) { - const openThread = () => { + const openTask = () => { track(ANALYTICS_EVENTS.CHANNEL_ACTION, { - action_type: "open_mention", + action_type: "open_task", surface: "activity", channel_id: folderChannelId ?? undefined, task_id: item.taskId, @@ -62,7 +108,7 @@ function ActivityRow({
{folderChannelId && ( @@ -121,12 +158,12 @@ function ActivityRow({ ); } -// The Activity page: every channel-thread message that @-mentions the viewer, -// newest first. Opening it clears the sidebar badge. +// The Activity page: every task the viewer is involved in — created, mentioned +// in, or messaged in — newest activity first. Opening it clears the sidebar badge. export function ActivityView() { const client = useOptionalAuthenticatedClient(); const { data: currentUser } = useCurrentUser({ client }); - const { items, isLoading } = useMentionActivity(); + const { items, isLoading } = useTaskActivity(); // 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. @@ -172,7 +209,7 @@ export function ActivityView() { Activity - Mentions of you across channels. + Tasks you're involved in across channels.
{isLoading && items.length === 0 ? ( @@ -183,12 +220,12 @@ export function ActivityView() { - + - No mentions yet + No activity yet - When a teammate tags you with @ in a channel thread, it lands - here. + Tasks you create, get tagged in, or reply to across channels + land here. @@ -196,10 +233,10 @@ export function ActivityView() {
{items.map((item) => ( seenAtOpen} + isNew={!seenAtOpen || item.activityAt > seenAtOpen} currentUserEmail={currentUser?.email} /> ))} diff --git a/packages/ui/src/features/canvas/hooks/useTaskActivity.ts b/packages/ui/src/features/canvas/hooks/useTaskActivity.ts new file mode 100644 index 0000000000..a170b88c33 --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useTaskActivity.ts @@ -0,0 +1,49 @@ +import { + mergeTaskActivity, + type TaskActivityItem, + toTaskActivityItems, +} from "@posthog/core/canvas/taskActivity"; +import type { TaskActivity } from "@posthog/shared/domain-types"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; +import { useQueryClient } from "@tanstack/react-query"; +import { useMemo } from "react"; + +const ACTIVITY_POLL_INTERVAL_MS = 60_000; +const TASK_ACTIVITY_QUERY_KEY = ["task-activity"] as const; + +/** + * Tasks the current user is involved in — created, @-mentioned in, or messaged + * in — one row per task, newest activity first, from the backend task-activity + * index. Mount once per surface (sidebar badge, Activity page) — results are + * shared through the react-query cache. + */ +export function useTaskActivity(options?: { enabled?: boolean }): { + items: TaskActivityItem[]; + isLoading: boolean; +} { + const queryClient = useQueryClient(); + const query = useAuthenticatedQuery( + TASK_ACTIVITY_QUERY_KEY, + async (client) => { + const previous = + queryClient.getQueryData(TASK_ACTIVITY_QUERY_KEY) ?? []; + // The most recent activity already held becomes the low-water mark, so + // repolls ask the backend only for what changed. + const since = previous[0]?.activity_at; + const incoming = await client.getTaskActivity( + since ? { since } : undefined, + ); + return mergeTaskActivity(previous, incoming); + }, + { + enabled: options?.enabled ?? true, + refetchInterval: ACTIVITY_POLL_INTERVAL_MS, + staleTime: ACTIVITY_POLL_INTERVAL_MS, + }, + ); + const items = useMemo( + () => toTaskActivityItems(query.data ?? []), + [query.data], + ); + return { items, isLoading: query.isLoading }; +} diff --git a/packages/ui/src/features/sidebar/components/items/ActivityItem.tsx b/packages/ui/src/features/sidebar/components/items/ActivityItem.tsx index a9a4f23a25..8598ccaab0 100644 --- a/packages/ui/src/features/sidebar/components/items/ActivityItem.tsx +++ b/packages/ui/src/features/sidebar/components/items/ActivityItem.tsx @@ -1,6 +1,6 @@ import { BellIcon } from "@phosphor-icons/react"; -import { countUnseenActivity } from "@posthog/core/canvas/mentionActivity"; -import { useMentionActivity } from "@posthog/ui/features/canvas/hooks/useMentionActivity"; +import { countUnseenActivity } from "@posthog/core/canvas/taskActivity"; +import { useTaskActivity } from "@posthog/ui/features/canvas/hooks/useTaskActivity"; import { useActivitySeenStore } from "@posthog/ui/features/canvas/stores/activitySeenStore"; import { useMemo } from "react"; import { SidebarItem } from "../SidebarItem"; @@ -12,15 +12,15 @@ interface ActivityItemProps { depth?: number; } -// The Activity nav row with its unread-mentions dot. Owns the mentions -// subscription so the query mounts once here; the badge counts thread mentions -// newer than the last time the Activity page was opened. +// The Activity nav row with its unread dot. Owns the task-activity subscription +// so the query mounts once here; the badge counts tasks whose activity is newer +// than the last time the Activity page was opened. export function ActivityItem({ isActive, onClick, depth = 0, }: ActivityItemProps) { - const { items } = useMentionActivity(); + const { items } = useTaskActivity(); const lastSeenAt = useActivitySeenStore((s) => s.lastSeenAt); const unseen = useMemo( () => countUnseenActivity(items, lastSeenAt), @@ -35,7 +35,7 @@ export function ActivityItem({ Activity } diff --git a/packages/ui/src/router/routes/website/activity.tsx b/packages/ui/src/router/routes/website/activity.tsx index badafe210d..bdeb495d59 100644 --- a/packages/ui/src/router/routes/website/activity.tsx +++ b/packages/ui/src/router/routes/website/activity.tsx @@ -1,8 +1,9 @@ import { ActivityView } from "@posthog/ui/features/canvas/components/ActivityView"; import { createFileRoute } from "@tanstack/react-router"; -// Channels-space Activity page: @-mentions of the viewer across channel -// threads. The sidebar's Activity nav badge counts what's new here. +// Channels-space Activity page: every task the viewer is involved in — created, +// @-mentioned in, or messaged in — across channels. The sidebar's Activity nav +// badge counts what's new here. export const Route = createFileRoute("/website/activity")({ component: ActivityView, }); From da9575cab5810ad7207f3307ab06d9ff25dad2f5 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Sun, 26 Jul 2026 06:56:10 +0100 Subject: [PATCH 02/16] feat(activity): use authoritative task activity state Generated-By: PostHog Code Task-Id: c4f5025f-8edf-40d4-a163-7174899045d1 --- packages/api-client/src/posthog-client.ts | 24 +++-- packages/core/src/canvas/taskActivity.test.ts | 102 ++---------------- packages/core/src/canvas/taskActivity.ts | 41 +------ packages/shared/src/domain-types.ts | 7 ++ .../canvas/components/ActivityView.tsx | 20 ++-- .../canvas/hooks/useMarkTaskActivityRead.ts | 32 ++++++ .../features/canvas/hooks/useTaskActivity.ts | 25 ++--- .../canvas/stores/activitySeenStore.ts | 23 ---- .../sidebar/components/items/ActivityItem.tsx | 14 +-- 9 files changed, 87 insertions(+), 201 deletions(-) create mode 100644 packages/ui/src/features/canvas/hooks/useMarkTaskActivityRead.ts delete mode 100644 packages/ui/src/features/canvas/stores/activitySeenStore.ts diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 8940095b44..7b23b39013 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -86,7 +86,7 @@ import type { SuggestedReviewersArtefact, SuggestedReviewerWriteEntry, Task, - TaskActivity, + TaskActivityPage, TaskChannel, TaskMention, TaskRun, @@ -2509,13 +2509,10 @@ export class PostHogAPIClient { // Tasks the current user is involved in (created, mentioned, or messaged), // one row per task, newest activity first. - async getTaskActivity(options?: { since?: string }): Promise { + async getTaskActivity(): Promise { const teamId = await this.getTeamId(); const urlPath = `/api/projects/${teamId}/task_activity/`; const url = new URL(`${this.api.baseUrl}${urlPath}`); - if (options?.since) { - url.searchParams.set("since", options.since); - } const response = await this.api.fetcher.fetch({ method: "get", url, @@ -2524,7 +2521,22 @@ export class PostHogAPIClient { if (!response.ok) { throw new Error(`Failed to fetch task activity: ${response.statusText}`); } - return (await response.json()) as TaskActivity[]; + return (await response.json()) as TaskActivityPage; + } + + async markTaskActivityRead(): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/task_activity/mark_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 task activity read: ${response.statusText}`, + ); + } } async getTaskThreadMessages(taskId: string): Promise { diff --git a/packages/core/src/canvas/taskActivity.test.ts b/packages/core/src/canvas/taskActivity.test.ts index 643786721e..09a00c8d3c 100644 --- a/packages/core/src/canvas/taskActivity.test.ts +++ b/packages/core/src/canvas/taskActivity.test.ts @@ -1,10 +1,6 @@ import type { TaskActivity, UserBasic } from "@posthog/shared/domain-types"; import { describe, expect, it } from "vitest"; -import { - countUnseenActivity, - mergeTaskActivity, - toTaskActivityItems, -} from "./taskActivity"; +import { toTaskActivityItems } from "./taskActivity"; const ann: UserBasic = { id: 2, @@ -15,6 +11,7 @@ const ann: UserBasic = { function activity(overrides: Partial = {}): TaskActivity { return { + id: "activity-1", task_id: "t1", task_title: "Task t1", channel_id: "c1", @@ -24,14 +21,16 @@ function activity(overrides: Partial = {}): TaskActivity { snippet: "ping @[Me](me@posthog.com)", latest_author: ann, latest_message_id: "m1", + is_unread: true, ...overrides, }; } describe("toTaskActivityItems", () => { - it("maps activity DTOs to feed items", () => { + it("maps the authoritative activity and unread state", () => { expect(toTaskActivityItems([activity()])).toEqual([ { + id: "activity-1", taskId: "t1", taskTitle: "Task t1", channelId: "c1", @@ -41,12 +40,13 @@ describe("toTaskActivityItems", () => { snippet: "ping @[Me](me@posthog.com)", author: ann, messageId: "m1", + isUnread: true, }, ]); }); - it("labels untitled tasks and tolerates missing channel/author/message", () => { - const items = toTaskActivityItems([ + it("labels untitled tasks and tolerates missing optional values", () => { + const [item] = toTaskActivityItems([ activity({ task_title: "", channel_id: null, @@ -57,7 +57,7 @@ describe("toTaskActivityItems", () => { snippet: "", }), ]); - expect(items[0]).toMatchObject({ + expect(item).toMatchObject({ taskTitle: "Untitled task", channelId: null, channelName: null, @@ -66,87 +66,3 @@ describe("toTaskActivityItems", () => { }); }); }); - -describe("countUnseenActivity", () => { - const items = toTaskActivityItems([ - activity({ task_id: "t2", activity_at: "2026-07-03T10:00:00Z" }), - activity({ task_id: "t1", activity_at: "2026-07-01T10:00:00Z" }), - ]); - - it("counts everything when never seen", () => { - expect(countUnseenActivity(items, null)).toBe(2); - }); - - it("counts only rows with activity after the last-seen timestamp", () => { - expect(countUnseenActivity(items, "2026-07-02T00:00:00Z")).toBe(1); - expect(countUnseenActivity(items, "2026-07-04T00:00:00Z")).toBe(0); - }); -}); - -describe("mergeTaskActivity", () => { - it("prepends newly-active tasks ahead of the previous page", () => { - const previous = [ - activity({ task_id: "t1", activity_at: "2026-07-01T10:00:00Z" }), - ]; - const incoming = [ - activity({ task_id: "t2", activity_at: "2026-07-02T10:00:00Z" }), - ]; - expect(mergeTaskActivity(previous, incoming).map((r) => r.task_id)).toEqual( - ["t2", "t1"], - ); - }); - - it("replaces a task's row when its activity advances instead of duplicating it", () => { - const previous = [ - activity({ - task_id: "t1", - activity_kind: "mention", - activity_at: "2026-07-01T10:00:00Z", - }), - ]; - const incoming = [ - activity({ - task_id: "t1", - activity_kind: "message", - snippet: "replied", - activity_at: "2026-07-02T10:00:00Z", - }), - ]; - const merged = mergeTaskActivity(previous, incoming); - expect(merged).toHaveLength(1); - expect(merged[0].activity_kind).toBe("message"); - expect(merged[0].activity_at).toBe("2026-07-02T10:00:00Z"); - }); - - it("keeps the newer row when an older duplicate arrives out of order", () => { - const previous = [ - activity({ task_id: "t1", activity_at: "2026-07-05T10:00:00Z" }), - ]; - const incoming = [ - activity({ task_id: "t1", activity_at: "2026-07-01T10:00:00Z" }), - ]; - expect(mergeTaskActivity(previous, incoming)[0].activity_at).toBe( - "2026-07-05T10:00:00Z", - ); - }); - - it("returns the previous page unchanged when there is nothing new", () => { - const previous = [activity({ task_id: "t1" })]; - expect(mergeTaskActivity(previous, [])).toEqual(previous); - }); - - it("caps the merged result so a long session can't grow it unbounded", () => { - const previous = Array.from({ length: 300 }, (_, i) => - activity({ - task_id: `old-${i}`, - activity_at: `2026-06-01T${String(i % 24).padStart(2, "0")}:00:00Z`, - }), - ); - const incoming = [ - activity({ task_id: "newest", activity_at: "2026-07-05T10:00:00Z" }), - ]; - const merged = mergeTaskActivity(previous, incoming); - expect(merged).toHaveLength(300); - expect(merged[0].task_id).toBe("newest"); - }); -}); diff --git a/packages/core/src/canvas/taskActivity.ts b/packages/core/src/canvas/taskActivity.ts index 7da0c4532d..0d4b9ab422 100644 --- a/packages/core/src/canvas/taskActivity.ts +++ b/packages/core/src/canvas/taskActivity.ts @@ -12,6 +12,7 @@ import type { */ export interface TaskActivityItem { + id: string; taskId: string; taskTitle: string; /** Backend channel (tasks product Channel UUID); null for channel-less tasks. */ @@ -24,6 +25,7 @@ export interface TaskActivityItem { snippet: string; author: UserBasic | null; messageId: string | null; + isUnread: boolean; } /** Map activity DTOs (already newest-first from the backend) to feed items. */ @@ -31,6 +33,7 @@ export function toTaskActivityItems( activity: readonly TaskActivity[], ): TaskActivityItem[] { return activity.map((row) => ({ + id: row.id, taskId: row.task_id, taskTitle: row.task_title || "Untitled task", channelId: row.channel_id ?? null, @@ -40,42 +43,6 @@ export function toTaskActivityItems( snippet: row.snippet, author: row.latest_author ?? null, messageId: row.latest_message_id ?? null, + isUnread: row.is_unread, })); } - -/** How many rows have activity after the viewer last opened the Activity page. */ -export function countUnseenActivity( - items: readonly TaskActivityItem[], - lastSeenAt: string | null, -): number { - if (!lastSeenAt) return items.length; - return items.filter((item) => item.activityAt > lastSeenAt).length; -} - -// Bounds the cache so a long-running session's accumulated feed can't grow -// without limit. -const MAX_CACHED_ACTIVITY = 300; - -/** - * Fold a page of freshly-fetched activity into the previously cached set — - * dedupe by task (newest activity wins), keep newest first. Lets repolls fetch - * only what's new (via `since`) instead of re-fetching the whole top page every - * time. A task whose activity advanced comes back on the next poll and replaces - * its stale row rather than duplicating it. - */ -export function mergeTaskActivity( - previous: readonly TaskActivity[], - incoming: readonly TaskActivity[], -): TaskActivity[] { - if (incoming.length === 0) return [...previous]; - const byTaskId = new Map(previous.map((row) => [row.task_id, row])); - for (const row of incoming) { - const existing = byTaskId.get(row.task_id); - if (!existing || row.activity_at > existing.activity_at) { - byTaskId.set(row.task_id, row); - } - } - return Array.from(byTaskId.values()) - .sort((a, b) => (a.activity_at < b.activity_at ? 1 : -1)) - .slice(0, MAX_CACHED_ACTIVITY); -} diff --git a/packages/shared/src/domain-types.ts b/packages/shared/src/domain-types.ts index dedb0ef8ad..2b9567b423 100644 --- a/packages/shared/src/domain-types.ts +++ b/packages/shared/src/domain-types.ts @@ -152,6 +152,7 @@ export type TaskActivityKind = * `TaskActivityDTO`. */ export interface TaskActivity { + id: string; task_id: string; task_title: string; channel_id?: string | null; @@ -161,6 +162,12 @@ export interface TaskActivity { snippet: string; latest_author?: UserBasic | null; latest_message_id?: string | null; + is_unread: boolean; +} + +export interface TaskActivityPage { + results: TaskActivity[]; + unread_count: number; } export type TaskRunStatus = diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index 42e38aa457..4b2ca0fcce 100644 --- a/packages/ui/src/features/canvas/components/ActivityView.tsx +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -16,9 +16,9 @@ import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; 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 { 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 { useActivitySeenStore } from "@posthog/ui/features/canvas/stores/activitySeenStore"; import { copyChannelLink } from "@posthog/ui/features/canvas/utils/copyChannelLink"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import { @@ -28,7 +28,7 @@ import { import { track } from "@posthog/ui/shell/analytics"; import { Text } from "@radix-ui/themes"; import type { ReactNode } from "react"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo } from "react"; function ChannelSuffix({ channelName }: { channelName: string | null }) { if (!channelName) return null; @@ -164,6 +164,7 @@ export function ActivityView() { const client = useOptionalAuthenticatedClient(); const { data: currentUser } = useCurrentUser({ client }); const { items, isLoading } = useTaskActivity(); + const { mutate: markRead } = useMarkTaskActivityRead(); // 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. @@ -182,13 +183,6 @@ export function ActivityView() { channelName ? (folderIdByName.get(normalizeChannelName(channelName)) ?? null) : null; - const markSeen = useActivitySeenStore((s) => s.markSeen); - // Snapshot before marking seen so rows that were new on arrival keep their - // dot for this visit. - const [seenAtOpen] = useState( - () => useActivitySeenStore.getState().lastSeenAt, - ); - useEffect(() => { track(ANALYTICS_EVENTS.CHANNEL_ACTION, { action_type: "view_activity", @@ -196,11 +190,9 @@ export function ActivityView() { }); }, []); - // Re-mark as items stream in so the badge stays cleared while reading. - // biome-ignore lint/correctness/useExhaustiveDependencies: re-run per new item useEffect(() => { - markSeen(); - }, [markSeen, items.length]); + if (items.some((item) => item.isUnread)) markRead(); + }, [items, markRead]); return (
@@ -236,7 +228,7 @@ export function ActivityView() { key={item.taskId} item={item} folderChannelId={folderChannelIdFor(item.channelName)} - isNew={!seenAtOpen || item.activityAt > seenAtOpen} + isNew={item.isUnread} currentUserEmail={currentUser?.email} /> ))} diff --git a/packages/ui/src/features/canvas/hooks/useMarkTaskActivityRead.ts b/packages/ui/src/features/canvas/hooks/useMarkTaskActivityRead.ts new file mode 100644 index 0000000000..9d203401bc --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useMarkTaskActivityRead.ts @@ -0,0 +1,32 @@ +import type { TaskActivityPage } from "@posthog/shared/domain-types"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +const TASK_ACTIVITY_QUERY_KEY = ["task-activity"] as const; + +export function useMarkTaskActivityRead() { + const client = useOptionalAuthenticatedClient(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async () => { + if (!client) throw new Error("Not authenticated"); + await client.markTaskActivityRead(); + }, + onSuccess: () => { + queryClient.setQueryData( + TASK_ACTIVITY_QUERY_KEY, + (page) => + page + ? { + ...page, + unread_count: 0, + results: page.results.map((row) => ({ + ...row, + is_unread: false, + })), + } + : page, + ); + }, + }); +} diff --git a/packages/ui/src/features/canvas/hooks/useTaskActivity.ts b/packages/ui/src/features/canvas/hooks/useTaskActivity.ts index a170b88c33..9fce1a6367 100644 --- a/packages/ui/src/features/canvas/hooks/useTaskActivity.ts +++ b/packages/ui/src/features/canvas/hooks/useTaskActivity.ts @@ -1,11 +1,8 @@ import { - mergeTaskActivity, type TaskActivityItem, toTaskActivityItems, } from "@posthog/core/canvas/taskActivity"; -import type { TaskActivity } from "@posthog/shared/domain-types"; import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; -import { useQueryClient } from "@tanstack/react-query"; import { useMemo } from "react"; const ACTIVITY_POLL_INTERVAL_MS = 60_000; @@ -19,22 +16,12 @@ const TASK_ACTIVITY_QUERY_KEY = ["task-activity"] as const; */ export function useTaskActivity(options?: { enabled?: boolean }): { items: TaskActivityItem[]; + unreadCount: number; isLoading: boolean; } { - const queryClient = useQueryClient(); const query = useAuthenticatedQuery( TASK_ACTIVITY_QUERY_KEY, - async (client) => { - const previous = - queryClient.getQueryData(TASK_ACTIVITY_QUERY_KEY) ?? []; - // The most recent activity already held becomes the low-water mark, so - // repolls ask the backend only for what changed. - const since = previous[0]?.activity_at; - const incoming = await client.getTaskActivity( - since ? { since } : undefined, - ); - return mergeTaskActivity(previous, incoming); - }, + (client) => client.getTaskActivity(), { enabled: options?.enabled ?? true, refetchInterval: ACTIVITY_POLL_INTERVAL_MS, @@ -42,8 +29,12 @@ export function useTaskActivity(options?: { enabled?: boolean }): { }, ); const items = useMemo( - () => toTaskActivityItems(query.data ?? []), + () => toTaskActivityItems(query.data?.results ?? []), [query.data], ); - return { items, isLoading: query.isLoading }; + return { + items, + unreadCount: query.data?.unread_count ?? 0, + isLoading: query.isLoading, + }; } diff --git a/packages/ui/src/features/canvas/stores/activitySeenStore.ts b/packages/ui/src/features/canvas/stores/activitySeenStore.ts deleted file mode 100644 index 687068deb1..0000000000 --- a/packages/ui/src/features/canvas/stores/activitySeenStore.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { electronStorage } from "@posthog/ui/shell/rendererStorage"; -import { create } from "zustand"; -import { persist } from "zustand/middleware"; - -// When the viewer last opened the Activity page; mentions newer than this -// count toward the sidebar's unread badge. -interface ActivitySeenState { - lastSeenAt: string | null; - markSeen: () => void; -} - -export const useActivitySeenStore = create()( - persist( - (set) => ({ - lastSeenAt: null, - markSeen: () => set({ lastSeenAt: new Date().toISOString() }), - }), - { - name: "channels-activity-seen", - storage: electronStorage, - }, - ), -); diff --git a/packages/ui/src/features/sidebar/components/items/ActivityItem.tsx b/packages/ui/src/features/sidebar/components/items/ActivityItem.tsx index 8598ccaab0..847446924f 100644 --- a/packages/ui/src/features/sidebar/components/items/ActivityItem.tsx +++ b/packages/ui/src/features/sidebar/components/items/ActivityItem.tsx @@ -1,8 +1,5 @@ import { BellIcon } from "@phosphor-icons/react"; -import { countUnseenActivity } from "@posthog/core/canvas/taskActivity"; import { useTaskActivity } from "@posthog/ui/features/canvas/hooks/useTaskActivity"; -import { useActivitySeenStore } from "@posthog/ui/features/canvas/stores/activitySeenStore"; -import { useMemo } from "react"; import { SidebarItem } from "../SidebarItem"; import { SidebarCountBadge } from "./SidebarCountBadge"; @@ -20,12 +17,7 @@ export function ActivityItem({ onClick, depth = 0, }: ActivityItemProps) { - const { items } = useTaskActivity(); - const lastSeenAt = useActivitySeenStore((s) => s.lastSeenAt); - const unseen = useMemo( - () => countUnseenActivity(items, lastSeenAt), - [items, lastSeenAt], - ); + const { unreadCount } = useTaskActivity(); return ( Activity } From 0bf14e1d42630594744ede2f2e0ef5a43a1092c4 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Sun, 26 Jul 2026 06:56:21 +0100 Subject: [PATCH 03/16] fix(activity): clear activity per task, wherever the task is opened Landing on the Activity page marked everything read, so anything that arrived while it was open was dismissed unseen. Rows now clear when they are opened, and markTaskActivityRead names the tasks rather than sweeping the whole feed. Read state is per task on the server, so reaching a task any other way clears the same row. useTaskThread pulls the feed back in when a thread loads, which keeps the sidebar badge honest without waiting out its poll. The unread dot reads is_unread straight off the row instead of comparing against a snapshot of the last visit. Generated-By: PostHog Code Task-Id: 35a47457-b411-4d4b-80a5-ad06e2e6b1e5 --- packages/api-client/src/posthog-client.ts | 11 ++++- packages/shared/src/domain-types.ts | 6 +++ .../canvas/components/ActivityView.tsx | 27 ++++++----- .../canvas/hooks/useMarkTaskActivityRead.ts | 45 ++++++++++++------- .../features/canvas/hooks/useTaskActivity.ts | 2 +- .../features/canvas/hooks/useTaskThread.ts | 9 ++++ 6 files changed, 70 insertions(+), 30 deletions(-) diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 7b23b39013..3e2463be5c 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -86,6 +86,7 @@ import type { SuggestedReviewersArtefact, SuggestedReviewerWriteEntry, Task, + TaskActivityMarkReadResult, TaskActivityPage, TaskChannel, TaskMention, @@ -2524,19 +2525,27 @@ export class PostHogAPIClient { return (await response.json()) as TaskActivityPage; } - async markTaskActivityRead(): Promise { + // Read state is per task, so callers name the tasks the user has seen rather than + // clearing the whole feed. + async markTaskActivityRead( + taskIds: string[], + ): Promise { const teamId = await this.getTeamId(); const urlPath = `/api/projects/${teamId}/task_activity/mark_read/`; const response = await this.api.fetcher.fetch({ method: "post", url: new URL(`${this.api.baseUrl}${urlPath}`), path: urlPath, + overrides: { + body: JSON.stringify({ task_ids: taskIds }), + }, }); if (!response.ok) { throw new Error( `Failed to mark task activity read: ${response.statusText}`, ); } + return (await response.json()) as TaskActivityMarkReadResult; } async getTaskThreadMessages(taskId: string): Promise { diff --git a/packages/shared/src/domain-types.ts b/packages/shared/src/domain-types.ts index 2b9567b423..ab1c88244f 100644 --- a/packages/shared/src/domain-types.ts +++ b/packages/shared/src/domain-types.ts @@ -167,6 +167,12 @@ export interface TaskActivity { export interface TaskActivityPage { results: TaskActivity[]; + /** Unread tasks across the whole feed, not just this page. Backs the sidebar badge. */ + unread_count: number; +} + +export interface TaskActivityMarkReadResult { + marked_read: number; unread_count: number; } diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index 4b2ca0fcce..511544958c 100644 --- a/packages/ui/src/features/canvas/components/ActivityView.tsx +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -28,7 +28,7 @@ import { import { track } from "@posthog/ui/shell/analytics"; import { Text } from "@radix-ui/themes"; import type { ReactNode } from "react"; -import { useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo } from "react"; function ChannelSuffix({ channelName }: { channelName: string | null }) { if (!channelName) return null; @@ -78,14 +78,13 @@ function activityHeadline(item: TaskActivityItem): ReactNode { function ActivityRow({ item, folderChannelId, - isNew, + onOpen, currentUserEmail, }: { item: TaskActivityItem; /** Desktop folder channel id (the /website route param); null when unmapped. */ folderChannelId: string | null; - /** Activity arrived since the viewer last opened this page. */ - isNew: boolean; + onOpen: (taskId: string) => void; currentUserEmail?: string | null; }) { const openTask = () => { @@ -95,6 +94,7 @@ function ActivityRow({ channel_id: folderChannelId ?? undefined, task_id: item.taskId, }); + onOpen(item.taskId); // The channel thread route is the deep-link target; tasks whose channel // folder is gone fall back to the plain task view. if (folderChannelId) { @@ -113,7 +113,7 @@ function ActivityRow({ > - {isNew && ( + {item.isUnread && ( markTasksRead([taskId]), + [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. @@ -190,10 +197,6 @@ export function ActivityView() { }); }, []); - useEffect(() => { - if (items.some((item) => item.isUnread)) markRead(); - }, [items, markRead]); - return (
@@ -228,7 +231,7 @@ export function ActivityView() { key={item.taskId} item={item} folderChannelId={folderChannelIdFor(item.channelName)} - isNew={item.isUnread} + onOpen={markRead} currentUserEmail={currentUser?.email} /> ))} diff --git a/packages/ui/src/features/canvas/hooks/useMarkTaskActivityRead.ts b/packages/ui/src/features/canvas/hooks/useMarkTaskActivityRead.ts index 9d203401bc..b0e4a5d16a 100644 --- a/packages/ui/src/features/canvas/hooks/useMarkTaskActivityRead.ts +++ b/packages/ui/src/features/canvas/hooks/useMarkTaskActivityRead.ts @@ -1,32 +1,45 @@ import type { TaskActivityPage } from "@posthog/shared/domain-types"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { TASK_ACTIVITY_QUERY_KEY } from "@posthog/ui/features/canvas/hooks/useTaskActivity"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -const TASK_ACTIVITY_QUERY_KEY = ["task-activity"] as const; - +/** + * Clear the unread flag on specific tasks. Read state lives per task on the server, + * so this is also what the server does when the user reaches a task by any other + * route — the optimistic update here just saves a round trip. + */ export function useMarkTaskActivityRead() { const client = useOptionalAuthenticatedClient(); const queryClient = useQueryClient(); return useMutation({ - mutationFn: async () => { + mutationFn: async (taskIds: string[]) => { if (!client) throw new Error("Not authenticated"); - await client.markTaskActivityRead(); + if (taskIds.length === 0) return; + await client.markTaskActivityRead(taskIds); }, - onSuccess: () => { + onMutate: async (taskIds: string[]) => { + const marked = new Set(taskIds); queryClient.setQueryData( TASK_ACTIVITY_QUERY_KEY, - (page) => - page - ? { - ...page, - unread_count: 0, - results: page.results.map((row) => ({ - ...row, - is_unread: false, - })), - } - : page, + (page) => { + if (!page) return page; + const clearing = page.results.filter( + (row) => row.is_unread && marked.has(row.task_id), + ).length; + return { + ...page, + unread_count: Math.max(0, page.unread_count - clearing), + results: page.results.map((row) => + marked.has(row.task_id) ? { ...row, is_unread: false } : row, + ), + }; + }, ); }, + // The server owns the count (it spans tasks past this page), so reconcile once + // the write lands rather than trusting the optimistic subtraction. + onSettled: () => { + queryClient.invalidateQueries({ queryKey: TASK_ACTIVITY_QUERY_KEY }); + }, }); } diff --git a/packages/ui/src/features/canvas/hooks/useTaskActivity.ts b/packages/ui/src/features/canvas/hooks/useTaskActivity.ts index 9fce1a6367..093a336604 100644 --- a/packages/ui/src/features/canvas/hooks/useTaskActivity.ts +++ b/packages/ui/src/features/canvas/hooks/useTaskActivity.ts @@ -6,7 +6,7 @@ import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; import { useMemo } from "react"; const ACTIVITY_POLL_INTERVAL_MS = 60_000; -const TASK_ACTIVITY_QUERY_KEY = ["task-activity"] as const; +export const TASK_ACTIVITY_QUERY_KEY = ["task-activity"] as const; /** * Tasks the current user is involved in — created, @-mentioned in, or messaged diff --git a/packages/ui/src/features/canvas/hooks/useTaskThread.ts b/packages/ui/src/features/canvas/hooks/useTaskThread.ts index a5d09da53e..3f13c69a2e 100644 --- a/packages/ui/src/features/canvas/hooks/useTaskThread.ts +++ b/packages/ui/src/features/canvas/hooks/useTaskThread.ts @@ -5,8 +5,10 @@ import { import { useService } from "@posthog/di/react"; import type { TaskThreadMessage } from "@posthog/shared/domain-types"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { TASK_ACTIVITY_QUERY_KEY } from "@posthog/ui/features/canvas/hooks/useTaskActivity"; import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; const THREAD_POLL_INTERVAL_MS = 5_000; @@ -23,6 +25,7 @@ export function useTaskThread( } { const pollIntervalMs = options?.pollIntervalMs ?? THREAD_POLL_INTERVAL_MS; const enabled = options?.enabled ?? true; + const queryClient = useQueryClient(); const query = useAuthenticatedQuery( taskThreadQueryKey(taskId), (client) => client.getTaskThreadMessages(taskId as string), @@ -32,6 +35,12 @@ export function useTaskThread( staleTime: pollIntervalMs, }, ); + // Loading a thread clears that task's activity row server-side, so pull the feed + // back in to keep the sidebar badge honest without waiting out its poll. + useEffect(() => { + if (!taskId || !enabled) return; + queryClient.invalidateQueries({ queryKey: TASK_ACTIVITY_QUERY_KEY }); + }, [taskId, enabled, queryClient]); return { messages: query.data ?? [], isLoading: query.isLoading }; } From 512e314db0063f23e07e6b805a3a67ae225644f9 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Sun, 26 Jul 2026 08:16:51 +0100 Subject: [PATCH 04/16] fix(sidebar): mock the hook ActivityItem actually uses in nav tests SidebarNavSection.test.tsx still stubbed useMentionActivity and activitySeenStore. ActivityItem moved to useTaskActivity, so the real hook ran, reached for a TRPC client with no provider mounted, and took all 10 render cases down with "useTRPCClient() can only be used inside of a ". Generated-By: PostHog Code Task-Id: 35a47457-b411-4d4b-80a5-ad06e2e6b1e5 --- .../sidebar/components/SidebarNavSection.test.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/features/sidebar/components/SidebarNavSection.test.tsx b/packages/ui/src/features/sidebar/components/SidebarNavSection.test.tsx index 0525255c57..b1b01948fe 100644 --- a/packages/ui/src/features/sidebar/components/SidebarNavSection.test.tsx +++ b/packages/ui/src/features/sidebar/components/SidebarNavSection.test.tsx @@ -67,13 +67,8 @@ vi.mock("@posthog/ui/features/inbox/hooks/useInboxAllReports", () => ({ vi.mock("@posthog/ui/features/tasks/useTasks", () => ({ useTasks: () => ({ data: [] }), })); -vi.mock("@posthog/ui/features/canvas/hooks/useMentionActivity", () => ({ - useMentionActivity: () => ({ items: [] }), -})); -vi.mock("@posthog/ui/features/canvas/stores/activitySeenStore", () => ({ - useActivitySeenStore: ( - selector: (s: { lastSeenAt: number | null }) => unknown, - ) => selector({ lastSeenAt: null }), +vi.mock("@posthog/ui/features/canvas/hooks/useTaskActivity", () => ({ + useTaskActivity: () => ({ items: [], unreadCount: 0, isLoading: false }), })); vi.mock("@tanstack/react-router", () => ({ useRouterState: () => false, From 1ca2f78e3e243d7f67ce65391b7484ab1c068254 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Sun, 26 Jul 2026 11:17:12 +0100 Subject: [PATCH 05/16] fix(canvas): reconcile paginated task activity Generated-By: PostHog Code Task-Id: 744f81ec-6b8e-420c-960c-541b1932b46c --- packages/api-client/src/posthog-client.ts | 14 +- packages/shared/src/domain-types.ts | 7 + .../canvas/components/ActivityView.tsx | 21 ++- .../canvas/hooks/useMarkTaskActivityRead.ts | 54 +++++--- .../canvas/hooks/useTaskActivity.test.tsx | 122 ++++++++++++++++++ .../features/canvas/hooks/useTaskActivity.ts | 44 +++++-- .../canvas/hooks/useTaskThread.test.tsx | 38 +++++- .../features/canvas/hooks/useTaskThread.ts | 22 ++-- 8 files changed, 276 insertions(+), 46 deletions(-) create mode 100644 packages/ui/src/features/canvas/hooks/useTaskActivity.test.tsx diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 3e2463be5c..00bbbc7700 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -88,6 +88,7 @@ import type { Task, TaskActivityMarkReadResult, TaskActivityPage, + TaskActivityReadMarker, TaskChannel, TaskMention, TaskRun, @@ -2510,10 +2511,17 @@ export class PostHogAPIClient { // Tasks the current user is involved in (created, mentioned, or messaged), // one row per task, newest activity first. - async getTaskActivity(): Promise { + async getTaskActivity(options?: { + before?: string; + beforeId?: string; + }): Promise { const teamId = await this.getTeamId(); const urlPath = `/api/projects/${teamId}/task_activity/`; const url = new URL(`${this.api.baseUrl}${urlPath}`); + if (options?.before && options.beforeId) { + url.searchParams.set("before", options.before); + url.searchParams.set("before_id", options.beforeId); + } const response = await this.api.fetcher.fetch({ method: "get", url, @@ -2528,7 +2536,7 @@ export class PostHogAPIClient { // Read state is per task, so callers name the tasks the user has seen rather than // clearing the whole feed. async markTaskActivityRead( - taskIds: string[], + activities: TaskActivityReadMarker[], ): Promise { const teamId = await this.getTeamId(); const urlPath = `/api/projects/${teamId}/task_activity/mark_read/`; @@ -2537,7 +2545,7 @@ export class PostHogAPIClient { url: new URL(`${this.api.baseUrl}${urlPath}`), path: urlPath, overrides: { - body: JSON.stringify({ task_ids: taskIds }), + body: JSON.stringify({ activities }), }, }); if (!response.ok) { diff --git a/packages/shared/src/domain-types.ts b/packages/shared/src/domain-types.ts index ab1c88244f..3cc2856966 100644 --- a/packages/shared/src/domain-types.ts +++ b/packages/shared/src/domain-types.ts @@ -169,6 +169,13 @@ export interface TaskActivityPage { results: TaskActivity[]; /** Unread tasks across the whole feed, not just this page. Backs the sidebar badge. */ unread_count: number; + next_before?: string | null; + next_before_id?: string | null; +} + +export interface TaskActivityReadMarker { + task_id: string; + seen_before: string; } export interface TaskActivityMarkReadResult { diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index 511544958c..d543c3f134 100644 --- a/packages/ui/src/features/canvas/components/ActivityView.tsx +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -84,7 +84,7 @@ function ActivityRow({ item: TaskActivityItem; /** Desktop folder channel id (the /website route param); null when unmapped. */ folderChannelId: string | null; - onOpen: (taskId: string) => void; + onOpen: (item: TaskActivityItem) => void; currentUserEmail?: string | null; }) { const openTask = () => { @@ -94,7 +94,7 @@ function ActivityRow({ channel_id: folderChannelId ?? undefined, task_id: item.taskId, }); - onOpen(item.taskId); + onOpen(item); // The channel thread route is the deep-link target; tasks whose channel // folder is gone fall back to the plain task view. if (folderChannelId) { @@ -164,12 +164,14 @@ function ActivityRow({ export function ActivityView() { const client = useOptionalAuthenticatedClient(); const { data: currentUser } = useCurrentUser({ client }); - const { items, isLoading } = useTaskActivity(); + const { items, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage } = + useTaskActivity(); const { mutate: markTasksRead } = 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( - (taskId: string) => markTasksRead([taskId]), + (item: TaskActivityItem) => + markTasksRead([{ task_id: item.taskId, seen_before: item.activityAt }]), [markTasksRead], ); // Items carry backend channel names only; the desktop folder-channel id @@ -235,6 +237,17 @@ export function ActivityView() { currentUserEmail={currentUser?.email} /> ))} + {hasNextPage && ( + + )}
)}
diff --git a/packages/ui/src/features/canvas/hooks/useMarkTaskActivityRead.ts b/packages/ui/src/features/canvas/hooks/useMarkTaskActivityRead.ts index b0e4a5d16a..7b758ae15e 100644 --- a/packages/ui/src/features/canvas/hooks/useMarkTaskActivityRead.ts +++ b/packages/ui/src/features/canvas/hooks/useMarkTaskActivityRead.ts @@ -1,6 +1,10 @@ -import type { TaskActivityPage } from "@posthog/shared/domain-types"; +import type { + TaskActivityPage, + TaskActivityReadMarker, +} from "@posthog/shared/domain-types"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { TASK_ACTIVITY_QUERY_KEY } from "@posthog/ui/features/canvas/hooks/useTaskActivity"; +import type { InfiniteData } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query"; /** @@ -12,26 +16,42 @@ export function useMarkTaskActivityRead() { const client = useOptionalAuthenticatedClient(); const queryClient = useQueryClient(); return useMutation({ - mutationFn: async (taskIds: string[]) => { + mutationFn: async (activities: TaskActivityReadMarker[]) => { if (!client) throw new Error("Not authenticated"); - if (taskIds.length === 0) return; - await client.markTaskActivityRead(taskIds); + if (activities.length === 0) return; + await client.markTaskActivityRead(activities); }, - onMutate: async (taskIds: string[]) => { - const marked = new Set(taskIds); - queryClient.setQueryData( + onMutate: async (activities: TaskActivityReadMarker[]) => { + const marked = new Map( + activities.map((activity) => [activity.task_id, activity.seen_before]), + ); + queryClient.setQueryData>( TASK_ACTIVITY_QUERY_KEY, - (page) => { - if (!page) return page; - const clearing = page.results.filter( - (row) => row.is_unread && marked.has(row.task_id), - ).length; + (data) => { + if (!data) return data; + const clearing = data.pages + .flatMap((page) => page.results) + .filter((row) => { + const seenBefore = marked.get(row.task_id); + return ( + row.is_unread && seenBefore && row.activity_at <= seenBefore + ); + }).length; return { - ...page, - unread_count: Math.max(0, page.unread_count - clearing), - results: page.results.map((row) => - marked.has(row.task_id) ? { ...row, is_unread: false } : row, - ), + ...data, + pages: data.pages.map((page, index) => ({ + ...page, + unread_count: + index === 0 + ? Math.max(0, page.unread_count - clearing) + : page.unread_count, + results: page.results.map((row) => { + const seenBefore = marked.get(row.task_id); + return seenBefore && row.activity_at <= seenBefore + ? { ...row, is_unread: false } + : row; + }), + })), }; }, ); diff --git a/packages/ui/src/features/canvas/hooks/useTaskActivity.test.tsx b/packages/ui/src/features/canvas/hooks/useTaskActivity.test.tsx new file mode 100644 index 0000000000..dc389431b9 --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useTaskActivity.test.tsx @@ -0,0 +1,122 @@ +import type { + TaskActivity, + TaskActivityPage, +} from "@posthog/shared/domain-types"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockClient = vi.hoisted(() => ({ + getTaskActivity: vi.fn(), + markTaskActivityRead: vi.fn(), +})); + +vi.mock("@posthog/ui/features/auth/authClient", () => ({ + useOptionalAuthenticatedClient: () => mockClient, +})); + +import { useMarkTaskActivityRead } from "./useMarkTaskActivityRead"; +import { TASK_ACTIVITY_QUERY_KEY, useTaskActivity } from "./useTaskActivity"; + +function activity(overrides: Partial): TaskActivity { + return { + id: "activity-1", + task_id: "task-1", + task_title: "Task", + activity_at: "2026-07-01T10:00:00Z", + activity_kind: "mention", + snippet: "Ping", + is_unread: true, + ...overrides, + }; +} + +let queryClient: QueryClient; +function wrapper({ children }: { children: ReactNode }) { + return ( + {children} + ); +} + +describe("task activity hooks", () => { + beforeEach(() => { + vi.clearAllMocks(); + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + }); + + it("loads every activity page", async () => { + mockClient.getTaskActivity + .mockResolvedValueOnce({ + results: [activity({ task_id: "task-2" })], + unread_count: 2, + next_before: "2026-07-01T10:00:00Z", + next_before_id: "activity-1", + }) + .mockResolvedValueOnce({ + results: [ + activity({ + id: "activity-2", + task_id: "task-1", + activity_at: "2026-06-30T10:00:00Z", + }), + ], + unread_count: 2, + next_before: null, + next_before_id: null, + }); + + const hook = renderHook(() => useTaskActivity(), { wrapper }); + await waitFor(() => expect(hook.result.current.items).toHaveLength(1)); + await act(async () => { + await hook.result.current.fetchNextPage(); + }); + + await waitFor(() => + expect(hook.result.current.items.map((item) => item.taskId)).toEqual([ + "task-2", + "task-1", + ]), + ); + expect(mockClient.getTaskActivity).toHaveBeenLastCalledWith({ + before: "2026-07-01T10:00:00Z", + beforeId: "activity-1", + }); + }); + + it("does not optimistically clear activity newer than the marker", async () => { + const page: TaskActivityPage = { + results: [activity({ activity_at: "2026-07-01T11:00:00Z" })], + unread_count: 1, + }; + queryClient.setQueryData(TASK_ACTIVITY_QUERY_KEY, { + pages: [page], + pageParams: [undefined], + }); + mockClient.markTaskActivityRead.mockResolvedValue({ + marked_read: 0, + unread_count: 1, + }); + + const hook = renderHook(() => useMarkTaskActivityRead(), { wrapper }); + act(() => { + hook.result.current.mutate([ + { task_id: "task-1", seen_before: "2026-07-01T10:00:00Z" }, + ]); + }); + + await waitFor(() => + expect(mockClient.markTaskActivityRead).toHaveBeenCalledOnce(), + ); + const cached = queryClient.getQueryData<{ + pages: TaskActivityPage[]; + }>(TASK_ACTIVITY_QUERY_KEY); + expect(cached?.pages[0]?.results[0]?.is_unread).toBe(true); + expect(cached?.pages[0]?.unread_count).toBe(1); + }); +}); diff --git a/packages/ui/src/features/canvas/hooks/useTaskActivity.ts b/packages/ui/src/features/canvas/hooks/useTaskActivity.ts index 093a336604..f5cfc0db39 100644 --- a/packages/ui/src/features/canvas/hooks/useTaskActivity.ts +++ b/packages/ui/src/features/canvas/hooks/useTaskActivity.ts @@ -2,7 +2,10 @@ import { type TaskActivityItem, toTaskActivityItems, } from "@posthog/core/canvas/taskActivity"; -import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; +import type { TaskActivityPage } from "@posthog/shared/domain-types"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { AUTH_SCOPED_QUERY_META } from "@posthog/ui/features/auth/useCurrentUser"; +import { useInfiniteQuery } from "@tanstack/react-query"; import { useMemo } from "react"; const ACTIVITY_POLL_INTERVAL_MS = 60_000; @@ -18,23 +21,42 @@ export function useTaskActivity(options?: { enabled?: boolean }): { items: TaskActivityItem[]; unreadCount: number; isLoading: boolean; + hasNextPage: boolean; + isFetchingNextPage: boolean; + fetchNextPage: () => Promise; } { - const query = useAuthenticatedQuery( - TASK_ACTIVITY_QUERY_KEY, - (client) => client.getTaskActivity(), - { - enabled: options?.enabled ?? true, - refetchInterval: ACTIVITY_POLL_INTERVAL_MS, - staleTime: ACTIVITY_POLL_INTERVAL_MS, + const client = useOptionalAuthenticatedClient(); + const query = useInfiniteQuery({ + queryKey: TASK_ACTIVITY_QUERY_KEY, + queryFn: ({ pageParam }) => { + if (!client) throw new Error("Not authenticated"); + return client.getTaskActivity(pageParam); }, - ); + initialPageParam: undefined as + | { before: string; beforeId: string } + | undefined, + getNextPageParam: (page: TaskActivityPage) => + page.next_before && page.next_before_id + ? { before: page.next_before, beforeId: page.next_before_id } + : undefined, + enabled: !!client && (options?.enabled ?? true), + refetchInterval: ACTIVITY_POLL_INTERVAL_MS, + staleTime: ACTIVITY_POLL_INTERVAL_MS, + meta: AUTH_SCOPED_QUERY_META, + }); const items = useMemo( - () => toTaskActivityItems(query.data?.results ?? []), + () => + toTaskActivityItems( + query.data?.pages.flatMap((page) => page.results) ?? [], + ), [query.data], ); return { items, - unreadCount: query.data?.unread_count ?? 0, + unreadCount: query.data?.pages[0]?.unread_count ?? 0, isLoading: query.isLoading, + hasNextPage: query.hasNextPage, + isFetchingNextPage: query.isFetchingNextPage, + fetchNextPage: query.fetchNextPage, }; } diff --git a/packages/ui/src/features/canvas/hooks/useTaskThread.test.tsx b/packages/ui/src/features/canvas/hooks/useTaskThread.test.tsx index 7f19e98753..b39fc795ee 100644 --- a/packages/ui/src/features/canvas/hooks/useTaskThread.test.tsx +++ b/packages/ui/src/features/canvas/hooks/useTaskThread.test.tsx @@ -1,11 +1,13 @@ import type { TaskThreadMessage } from "@posthog/shared/domain-types"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { act, renderHook } from "@testing-library/react"; +import { act, renderHook, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mockClient = vi.hoisted(() => ({ createTaskThreadMessage: vi.fn(), + getTaskThreadMessages: vi.fn(), + markTaskActivityRead: vi.fn(), sendTaskThreadMessageToAgent: vi.fn(), })); const mockTaskThreadService = vi.hoisted(() => ({ @@ -19,7 +21,10 @@ vi.mock("@posthog/di/react", () => ({ useService: () => mockTaskThreadService, })); -import { usePostTaskThreadMessageToAgent } from "./useTaskThread"; +import { + usePostTaskThreadMessageToAgent, + useTaskThread, +} from "./useTaskThread"; let queryClient: QueryClient; @@ -39,7 +44,7 @@ function message(overrides?: Partial): TaskThreadMessage { }; } -describe("usePostTaskThreadMessageToAgent", () => { +describe("task thread hooks", () => { beforeEach(() => { vi.clearAllMocks(); queryClient = new QueryClient({ @@ -68,6 +73,33 @@ describe("usePostTaskThreadMessageToAgent", () => { ); }); + it("marks activity read only after the thread loads", async () => { + let resolveThread: (messages: TaskThreadMessage[]) => void = () => {}; + mockClient.getTaskThreadMessages.mockReturnValue( + new Promise((resolve) => { + resolveThread = resolve; + }), + ); + mockClient.markTaskActivityRead.mockResolvedValue({ + marked_read: 1, + unread_count: 0, + }); + + renderHook(() => useTaskThread("task-id"), { wrapper }); + expect(mockClient.markTaskActivityRead).not.toHaveBeenCalled(); + + act(() => resolveThread([message()])); + + await waitFor(() => + expect(mockClient.markTaskActivityRead).toHaveBeenCalledWith([ + { + task_id: "task-id", + seen_before: expect.any(String), + }, + ]), + ); + }); + it("returns a forwarding error after the message has been posted", async () => { const sendError = new Error("No active run"); mockTaskThreadService.postMessageToAgent.mockResolvedValue({ diff --git a/packages/ui/src/features/canvas/hooks/useTaskThread.ts b/packages/ui/src/features/canvas/hooks/useTaskThread.ts index 3f13c69a2e..30f4b72188 100644 --- a/packages/ui/src/features/canvas/hooks/useTaskThread.ts +++ b/packages/ui/src/features/canvas/hooks/useTaskThread.ts @@ -5,10 +5,10 @@ import { import { useService } from "@posthog/di/react"; import type { TaskThreadMessage } from "@posthog/shared/domain-types"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; -import { TASK_ACTIVITY_QUERY_KEY } from "@posthog/ui/features/canvas/hooks/useTaskActivity"; +import { useMarkTaskActivityRead } from "@posthog/ui/features/canvas/hooks/useMarkTaskActivityRead"; import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useEffect } from "react"; +import { useEffect, useMemo, useRef } from "react"; const THREAD_POLL_INTERVAL_MS = 5_000; @@ -25,7 +25,12 @@ export function useTaskThread( } { const pollIntervalMs = options?.pollIntervalMs ?? THREAD_POLL_INTERVAL_MS; const enabled = options?.enabled ?? true; - const queryClient = useQueryClient(); + const { mutate: markTasksRead } = useMarkTaskActivityRead(); + const opening = useMemo( + () => ({ taskId, seenBefore: new Date().toISOString() }), + [taskId], + ); + const markedOpening = useRef(null); const query = useAuthenticatedQuery( taskThreadQueryKey(taskId), (client) => client.getTaskThreadMessages(taskId as string), @@ -35,12 +40,13 @@ export function useTaskThread( staleTime: pollIntervalMs, }, ); - // Loading a thread clears that task's activity row server-side, so pull the feed - // back in to keep the sidebar badge honest without waiting out its poll. useEffect(() => { - if (!taskId || !enabled) return; - queryClient.invalidateQueries({ queryKey: TASK_ACTIVITY_QUERY_KEY }); - }, [taskId, enabled, queryClient]); + if (!taskId || !enabled || query.dataUpdatedAt === 0) return; + const openingKey = `${opening.taskId}:${opening.seenBefore}`; + if (markedOpening.current === openingKey) return; + markedOpening.current = openingKey; + markTasksRead([{ task_id: taskId, seen_before: opening.seenBefore }]); + }, [taskId, enabled, markTasksRead, opening, query.dataUpdatedAt]); return { messages: query.data ?? [], isLoading: query.isLoading }; } From 021959aa25579e1ef0506dcc89453fe5eb21966c Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Mon, 27 Jul 2026 11:17:52 +0100 Subject: [PATCH 06/16] fix(canvas): clarify and refresh unread activity Generated-By: PostHog Code Task-Id: 744f81ec-6b8e-420c-960c-541b1932b46c --- packages/shared/src/domain-types.ts | 1 + .../canvas/components/ActivityView.test.tsx | 50 ++++++++++++++ .../canvas/components/ActivityView.tsx | 66 +++++++++++++++---- .../canvas/hooks/useTaskActivity.test.tsx | 25 ++++++- .../features/canvas/hooks/useTaskActivity.ts | 20 +++++- .../notifications/notifications.test.ts | 13 ++++ .../features/notifications/notifications.ts | 8 +++ packages/ui/src/shell/App.tsx | 2 + 8 files changed, 168 insertions(+), 17 deletions(-) create mode 100644 packages/ui/src/features/canvas/components/ActivityView.test.tsx diff --git a/packages/shared/src/domain-types.ts b/packages/shared/src/domain-types.ts index 3cc2856966..4aa90fd2c7 100644 --- a/packages/shared/src/domain-types.ts +++ b/packages/shared/src/domain-types.ts @@ -142,6 +142,7 @@ export interface TaskMention { /** Which signal produced an activity row; mirrors the backend `activity_kind`. */ export type TaskActivityKind = | "awaiting_input" + | "completed" | "message" | "mention" | "created"; diff --git a/packages/ui/src/features/canvas/components/ActivityView.test.tsx b/packages/ui/src/features/canvas/components/ActivityView.test.tsx new file mode 100644 index 0000000000..bc3fc1909d --- /dev/null +++ b/packages/ui/src/features/canvas/components/ActivityView.test.tsx @@ -0,0 +1,50 @@ +import type { TaskActivityItem } from "@posthog/core/canvas/taskActivity"; +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { activityHeadline } from "./ActivityView"; + +function item(overrides: Partial): TaskActivityItem { + return { + id: "activity-1", + taskId: "task-1", + taskTitle: "Say hello", + channelId: null, + channelName: null, + activityAt: "2026-07-27T10:00:00Z", + activityKind: "message", + snippet: "Hello!", + author: null, + messageId: "message-1", + isUnread: true, + ...overrides, + }; +} + +describe("activityHeadline", () => { + it.each([ + [ + "completed run", + item({ activityKind: "completed" }), + "The agent completed this task", + ], + ["agent reply", item({ activityKind: "message" }), "The agent replied"], + [ + "own reply", + item({ + activityKind: "message", + author: { + id: 1, + uuid: "me", + email: "me@posthog.com", + first_name: "Me", + }, + }), + "You replied", + ], + ])("labels a %s", (_name, activity, expected) => { + const { getByText } = render( +
{activityHeadline(activity, "me@posthog.com")}
, + ); + expect(getByText(expected)).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index d543c3f134..11f009be44 100644 --- a/packages/ui/src/features/canvas/components/ActivityView.tsx +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -1,6 +1,9 @@ -import { BellIcon, LinkIcon } from "@phosphor-icons/react"; +import { BellIcon, LinkIcon, RobotIcon } from "@phosphor-icons/react"; import type { TaskActivityItem } from "@posthog/core/canvas/taskActivity"; import { + Avatar, + AvatarFallback, + Badge, Button, Empty, EmptyDescription, @@ -11,6 +14,7 @@ import { } from "@posthog/quill"; import { formatRelativeTimeShort } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import type { UserBasic } from "@posthog/shared/domain-types"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; @@ -43,20 +47,39 @@ function ChannelSuffix({ channelName }: { channelName: string | null }) { } /** The lead line describing what happened, chosen by the row's activity kind. */ -function activityHeadline(item: TaskActivityItem): ReactNode { +export function activityHeadline( + item: TaskActivityItem, + currentUserEmail?: string | null, +): ReactNode { switch (item.activityKind) { case "awaiting_input": return ( <> - {userDisplayName(item.author) || "The agent"} is waiting for your - reply + The agent is waiting for your reply + + + ); + case "completed": + return ( + <> + The agent completed this task ); case "message": + if (!item.author) { + return ( + <> + The agent replied + + + ); + } return ( <> - You replied + {item.author.email === currentUserEmail + ? "You replied" + : `${userDisplayName(item.author)} replied`} ); @@ -79,14 +102,18 @@ function ActivityRow({ item, folderChannelId, onOpen, - currentUserEmail, + currentUser, }: { item: TaskActivityItem; /** Desktop folder channel id (the /website route param); null when unmapped. */ folderChannelId: string | null; onOpen: (item: TaskActivityItem) => void; - currentUserEmail?: string | null; + currentUser?: UserBasic | null; }) { + const isAgentActivity = + item.activityKind === "awaiting_input" || + item.activityKind === "completed" || + (item.activityKind === "message" && !item.author); const openTask = () => { track(ANALYTICS_EVENTS.CHANNEL_ACTION, { action_type: "open_task", @@ -109,10 +136,18 @@ function ActivityRow({