diff --git a/apps/code/src/renderer/desktop-contributions.ts b/apps/code/src/renderer/desktop-contributions.ts index c3565ea3eb..3f2f6d37d7 100644 --- a/apps/code/src/renderer/desktop-contributions.ts +++ b/apps/code/src/renderer/desktop-contributions.ts @@ -13,6 +13,7 @@ import { agentUiModule } from "@posthog/ui/features/agent/agent.module"; import { authUiModule } from "@posthog/ui/features/auth/auth.module"; import { billingUiModule } from "@posthog/ui/features/billing/billing.module"; import { browserTabsUiModule } from "@posthog/ui/features/browser-tabs/browser-tabs.module"; +import { taskActivityUiModule } from "@posthog/ui/features/canvas/task-activity/taskActivity.module"; import { cloneUiModule } from "@posthog/ui/features/clone/clone.module"; import { connectivityUiModule } from "@posthog/ui/features/connectivity/connectivity.module"; import { discordPresenceUiModule } from "@posthog/ui/features/discord-presence/discordPresence.module"; @@ -36,6 +37,7 @@ export function registerDesktopContributions(): void { authUiModule, autoresearchCoreModule, billingUiModule, + taskActivityUiModule, taskThreadCoreModule, browserTabsUiModule, cloneUiModule, diff --git a/apps/web/src/web-container.ts b/apps/web/src/web-container.ts index ebeacc79b1..3075d139ce 100644 --- a/apps/web/src/web-container.ts +++ b/apps/web/src/web-container.ts @@ -190,6 +190,7 @@ import { BROWSER_TABS_CLIENT, type BrowserTabsClient, } from "@posthog/ui/features/browser-tabs/browserTabsClient"; +import { taskActivityUiModule } from "@posthog/ui/features/canvas/task-activity/taskActivity.module"; import { REVIEW_HOST, type ReviewHost, @@ -779,6 +780,7 @@ container.bind(REVIEW_HOST).toConstantValue(webReviewHost); // (notificationsUiModule) is resolved by SessionService on task events and by // the settings test harness; it needs these three providers. container.load(notificationsUiModule); +container.load(taskActivityUiModule); container.bind(NOTIFICATIONS_SERVICE).toConstantValue(webNotifications); container .bind(NOTIFICATION_SETTINGS_PROVIDER) diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 565dfbf0dd..00bbbc7700 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -86,6 +86,9 @@ import type { SuggestedReviewersArtefact, SuggestedReviewerWriteEntry, Task, + TaskActivityMarkReadResult, + TaskActivityPage, + TaskActivityReadMarker, TaskChannel, TaskMention, TaskRun, @@ -2506,6 +2509,53 @@ 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?: { + 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, + path: urlPath, + }); + if (!response.ok) { + throw new Error(`Failed to fetch task activity: ${response.statusText}`); + } + return (await response.json()) as TaskActivityPage; + } + + // Read state is per task, so callers name the tasks the user has seen rather than + // clearing the whole feed. + async markTaskActivityRead( + activities: TaskActivityReadMarker[], + ): 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({ activities }), + }, + }); + 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 { 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..09a00c8d3c --- /dev/null +++ b/packages/core/src/canvas/taskActivity.test.ts @@ -0,0 +1,68 @@ +import type { TaskActivity, UserBasic } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { toTaskActivityItems } from "./taskActivity"; + +const ann: UserBasic = { + id: 2, + uuid: "ann-uuid", + email: "ann@posthog.com", + first_name: "Ann", +}; + +function activity(overrides: Partial = {}): TaskActivity { + return { + id: "activity-1", + 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", + is_unread: true, + ...overrides, + }; +} + +describe("toTaskActivityItems", () => { + it("maps the authoritative activity and unread state", () => { + expect(toTaskActivityItems([activity()])).toEqual([ + { + id: "activity-1", + 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", + isUnread: true, + }, + ]); + }); + + it("labels untitled tasks and tolerates missing optional values", () => { + const [item] = toTaskActivityItems([ + activity({ + task_title: "", + channel_id: null, + channel_name: null, + latest_author: null, + latest_message_id: null, + activity_kind: "created", + snippet: "", + }), + ]); + expect(item).toMatchObject({ + taskTitle: "Untitled task", + channelId: null, + channelName: null, + author: null, + messageId: null, + }); + }); +}); diff --git a/packages/core/src/canvas/taskActivity.ts b/packages/core/src/canvas/taskActivity.ts new file mode 100644 index 0000000000..0d4b9ab422 --- /dev/null +++ b/packages/core/src/canvas/taskActivity.ts @@ -0,0 +1,48 @@ +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 { + id: string; + 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; + isUnread: boolean; +} + +/** Map activity DTOs (already newest-first from the backend) to feed items. */ +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, + 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, + isUnread: row.is_unread, + })); +} diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index b5311e4b88..f9d7d76621 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -6830,6 +6830,37 @@ export class SessionService { return () => {}; } + public async watchCreatedCloudTask(task: Task): Promise { + const run = task.latest_run; + if (run?.environment !== "cloud") return; + + const authStatus = await this.getAuthCredentialsStatus(); + if (authStatus.kind !== "ready") return; + + this.updateSessionTaskTitle( + task.id, + task.title || task.description || "Cloud Task", + ); + this.watchCloudTask( + task.id, + run.id, + authStatus.auth.apiHost, + authStatus.auth.projectId, + undefined, + run.log_url, + typeof run.state?.initial_permission_mode === "string" + ? run.state.initial_permission_mode + : undefined, + run.runtime_adapter === "codex" ? "codex" : "claude", + run.model ?? undefined, + task.description ?? undefined, + undefined, + run.status, + run.reasoning_effort ?? undefined, + run.state, + ); + } + private logReconcileSkipOnce( taskId: string, reason: string, diff --git a/packages/core/src/task-detail/taskCreationSaga.test.ts b/packages/core/src/task-detail/taskCreationSaga.test.ts index edc3f8bca2..2e04f0daff 100644 --- a/packages/core/src/task-detail/taskCreationSaga.test.ts +++ b/packages/core/src/task-detail/taskCreationSaga.test.ts @@ -47,6 +47,7 @@ const host = mockHost as unknown as ITaskCreationHost; const sessionService = { connectToTask: vi.fn(), disconnectFromTask: vi.fn(), + watchCreatedCloudTask: vi.fn(), rememberInitialCloudPrompt: vi.fn(), markTaskCreationInFlight: vi.fn(), } as unknown as SessionService; @@ -186,6 +187,9 @@ describe("TaskCreationSaga", () => { pendingUserArtifactIds: undefined, }); expect(sendRunCommandMock).not.toHaveBeenCalled(); + expect(sessionService.watchCreatedCloudTask).toHaveBeenCalledWith( + startedTask, + ); expect(onTaskReady).toHaveBeenCalledTimes(1); expect(onTaskReady.mock.calls[0][0].task.latest_run?.branch).toBe( "release/remembered-branch", @@ -633,6 +637,9 @@ describe("TaskCreationSaga", () => { // Warm-activated at create time: no fresh run is created or started. expect(createTaskRunMock).not.toHaveBeenCalled(); expect(startTaskRunMock).not.toHaveBeenCalled(); + expect(sessionService.watchCreatedCloudTask).toHaveBeenCalledWith( + warmActivatedTask, + ); }); it("suppresses warm reuse when attachments exist but no warm lease is known", async () => { diff --git a/packages/core/src/task-detail/taskCreationSaga.ts b/packages/core/src/task-detail/taskCreationSaga.ts index f7648b44f6..59ebd86710 100644 --- a/packages/core/src/task-detail/taskCreationSaga.ts +++ b/packages/core/src/task-detail/taskCreationSaga.ts @@ -319,8 +319,11 @@ export class TaskCreationSaga extends Saga< ); } - if (!hasProvisioning && !shouldStartCloudRun && this.deps.onTaskReady) { - this.deps.onTaskReady({ task, workspace }); + if (!hasProvisioning && !shouldStartCloudRun) { + if (!taskId && workspaceMode === "cloud") { + await this.deps.sessionService.watchCreatedCloudTask(task); + } + this.deps.onTaskReady?.({ task, workspace }); } if (hasProvisioning) { @@ -469,8 +472,9 @@ export class TaskCreationSaga extends Saga< }, }); - if (!hasProvisioning && this.deps.onTaskReady) { - this.deps.onTaskReady({ task, workspace }); + if (!hasProvisioning) { + await this.deps.sessionService.watchCreatedCloudTask(task); + this.deps.onTaskReady?.({ task, workspace }); } } diff --git a/packages/shared/src/domain-types.ts b/packages/shared/src/domain-types.ts index e070d38788..4aa90fd2c7 100644 --- a/packages/shared/src/domain-types.ts +++ b/packages/shared/src/domain-types.ts @@ -139,6 +139,51 @@ export interface TaskMention { created_at: string; } +/** Which signal produced an activity row; mirrors the backend `activity_kind`. */ +export type TaskActivityKind = + | "awaiting_input" + | "completed" + | "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 { + id: string; + 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; + is_unread: boolean; +} + +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 { + marked_read: number; + unread_count: number; +} + export type TaskRunStatus = | "not_started" | "queued" 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..d2418bc6de --- /dev/null +++ b/packages/ui/src/features/canvas/components/ActivityView.test.tsx @@ -0,0 +1,62 @@ +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(); + }); + + it("prefixes channel names with a hash", () => { + const { getByText } = render( +
+ {activityHeadline( + item({ activityKind: "completed", channelName: "me" }), + "me@posthog.com", + )} +
, + ); + expect(getByText("#me")).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index 0103d4d327..18b186efa7 100644 --- a/packages/ui/src/features/canvas/components/ActivityView.tsx +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -1,6 +1,9 @@ -import { AtIcon, LinkIcon } from "@phosphor-icons/react"; -import type { MentionActivityItem } from "@posthog/core/canvas/mentionActivity"; +import { BellIcon, LinkIcon, RobotIcon } from "@phosphor-icons/react"; +import type { TaskActivityItem } from "@posthog/core/canvas/taskActivity"; import { + Avatar, + AvatarFallback, + Badge, Button, Empty, EmptyDescription, @@ -11,14 +14,15 @@ 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"; 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 { 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 { @@ -27,28 +31,97 @@ import { } from "@posthog/ui/router/navigationBridge"; import { track } from "@posthog/ui/shell/analytics"; import { Text } from "@radix-ui/themes"; -import { useEffect, useMemo, useState } from "react"; +import type { ReactNode } from "react"; +import { useCallback, useEffect, useMemo } 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. */ +export function activityHeadline( + item: TaskActivityItem, + currentUserEmail?: string | null, +): ReactNode { + switch (item.activityKind) { + case "awaiting_input": + return ( + <> + 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 ( + <> + {item.author.email === currentUserEmail + ? "You replied" + : `${userDisplayName(item.author)} replied`} + + + ); + case "mention": + return ( + <> + + {userDisplayName(item.author)} + {" "} + mentioned you + + + ); + default: + return "You created this task"; + } +} function ActivityRow({ item, folderChannelId, - isNew, - currentUserEmail, + onOpen, + currentUser, }: { - 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. */ - isNew: boolean; - currentUserEmail?: string | null; + onOpen: (item: TaskActivityItem) => void; + currentUser?: UserBasic | null; }) { - const openThread = () => { + const isAgentActivity = + item.activityKind === "awaiting_input" || + item.activityKind === "completed" || + (item.activityKind === "message" && !item.author); + 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, }); + 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) { @@ -62,46 +135,50 @@ function ActivityRow({
{folderChannelId && ( @@ -121,12 +198,22 @@ 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. Rows clear as they are opened, not +// when the page is; merely landing here shouldn't dismiss what you haven't read. export function ActivityView() { const client = useOptionalAuthenticatedClient(); const { data: currentUser } = useCurrentUser({ client }); - const { items, isLoading } = useMentionActivity(); + 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( + (item: TaskActivityItem) => + markTasksRead([{ task_id: item.taskId, seen_before: item.activityAt }]), + [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. @@ -145,13 +232,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", @@ -159,12 +239,6 @@ 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]); - return (
@@ -172,7 +246,7 @@ export function ActivityView() { Activity - Mentions of you across channels. + Tasks you're involved in across channels.
{isLoading && items.length === 0 ? ( @@ -183,12 +257,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,13 +270,24 @@ export function ActivityView() {
{items.map((item) => ( seenAtOpen} - currentUserEmail={currentUser?.email} + onOpen={markRead} + currentUser={currentUser} /> ))} + {hasNextPage && ( + + )}
)}
diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.test.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.test.tsx index 284c79a152..ee0fea3017 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.test.tsx @@ -3,7 +3,37 @@ import { Theme } from "@radix-ui/themes"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { TaskFeedRow } from "./ChannelFeedView"; + +vi.mock("@tanstack/react-router", () => ({ + Link: ({ + children, + onClick, + }: { + children: React.ReactNode; + onClick?: () => void; + }) => ( + { + event.preventDefault(); + onClick?.(); + }} + > + {children} + + ), +})); +vi.mock("@posthog/ui/features/canvas/hooks/useChannelTaskData", () => ({ + useChannelTaskData: () => undefined, +})); +vi.mock("@posthog/ui/features/sidebar/useTaskPrStatus", () => ({ + useTaskPrStatus: () => ({ prState: null }), +})); +vi.mock("@posthog/ui/features/browser-tabs/TaskTabIcon", () => ({ + TaskTabIcon: () => , +})); + +import { TaskCard, TaskFeedRow } from "./ChannelFeedView"; const task = { id: "task-1", @@ -50,6 +80,20 @@ function mockLayout(charsPerLine: number) { } describe("TaskFeedRow", () => { + it("reports when its task is opened", async () => { + const user = userEvent.setup(); + const onOpen = vi.fn(); + render( + + + , + ); + + await user.click(screen.getByText(task.title)); + + expect(onOpen).toHaveBeenCalledOnce(); + }); + it("expands a truncated prompt", async () => { mockLayout(20); const user = userEvent.setup(); diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx index 2b1878b44f..369a0cf1d0 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx @@ -48,6 +48,7 @@ import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; import { TaskTabIcon } from "@posthog/ui/features/browser-tabs/TaskTabIcon"; import type { ChannelFeedSystemMessage } from "@posthog/ui/features/canvas/hooks/useChannelFeedMessages"; import { useChannelTaskData } from "@posthog/ui/features/canvas/hooks/useChannelTaskData"; +import { useMarkTaskActivityRead } from "@posthog/ui/features/canvas/hooks/useMarkTaskActivityRead"; import { useTaskThread } from "@posthog/ui/features/canvas/hooks/useTaskThread"; import { taskCardNavigation } from "@posthog/ui/features/canvas/taskCardNavigation"; import { copyChannelLink } from "@posthog/ui/features/canvas/utils/copyChannelLink"; @@ -274,10 +275,12 @@ export function TaskCard({ task, channelId, inThread = false, + onOpen, }: { task: Task; channelId: string; inThread?: boolean; + onOpen?: () => void; }) { const statusDisplay = useTaskStatusDisplay(task); const prUrl = @@ -289,6 +292,7 @@ export function TaskCard({ { const seen = new Map(); @@ -584,6 +589,17 @@ const FeedItem = memo(function FeedItem({ onOpenTask: (task: Task) => void; onOpenThread: (task: Task) => void; }) { + const { mutate: markTasksRead } = useMarkTaskActivityRead(); + const markRead = useCallback(() => { + markTasksRead([ + { task_id: task.id, seen_before: new Date().toISOString() }, + ]); + }, [markTasksRead, task.id]); + const openTask = useCallback(() => { + markRead(); + onOpenTask(task); + }, [markRead, onOpenTask, task]); + return ( - onOpenTask(task)}> + } > - + { + if (!client) throw new Error("Not authenticated"); + if (activities.length === 0) return; + return client.markTaskActivityRead(activities); + }, + onMutate: async (activities: TaskActivityReadMarker[]) => { + const marked = new Map( + activities.map((activity) => [activity.task_id, activity.seen_before]), + ); + queryClient.setQueryData>( + TASK_ACTIVITY_QUERY_KEY, + (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 { + ...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..c1f4a0075f --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useTaskActivity.test.tsx @@ -0,0 +1,153 @@ +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); + }); + + it("keeps an activity row after marking it read", async () => { + mockClient.getTaskActivity.mockResolvedValue({ + results: [activity({ id: "local:task-1" })], + unread_count: 1, + }); + mockClient.markTaskActivityRead.mockResolvedValue({ + marked_read: 0, + unread_count: 0, + }); + + const hook = renderHook( + () => ({ activity: useTaskActivity(), mark: useMarkTaskActivityRead() }), + { wrapper }, + ); + await waitFor(() => + expect(hook.result.current.activity.items).toHaveLength(1), + ); + + act(() => { + hook.result.current.mark.mutate([ + { task_id: "task-1", seen_before: "2026-07-01T10:00:00Z" }, + ]); + }); + + await waitFor(() => + expect(hook.result.current.activity.unreadCount).toBe(0), + ); + expect(hook.result.current.activity.items).toHaveLength(1); + expect(mockClient.getTaskActivity).toHaveBeenCalledOnce(); + }); +}); 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..5dd3d428ab --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useTaskActivity.ts @@ -0,0 +1,61 @@ +import { + type TaskActivityItem, + toTaskActivityItems, +} from "@posthog/core/canvas/taskActivity"; +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"; +import { TASK_ACTIVITY_QUERY_KEY } from "../task-activity/taskActivityQuery"; + +export { TASK_ACTIVITY_QUERY_KEY } from "../task-activity/taskActivityQuery"; + +/** + * 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[]; + unreadCount: number; + isLoading: boolean; + hasNextPage: boolean; + isFetchingNextPage: boolean; + fetchNextPage: () => Promise; +} { + 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), + staleTime: Number.POSITIVE_INFINITY, + meta: AUTH_SCOPED_QUERY_META, + }); + const items = useMemo( + () => + toTaskActivityItems( + query.data?.pages.flatMap((page) => page.results) ?? [], + ), + [query.data], + ); + return { + items, + 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..f3adee20b4 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,45 @@ 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("does not mark activity read when loading a thread preview", async () => { + mockClient.getTaskThreadMessages.mockResolvedValue([message()]); + + const hook = renderHook( + () => useTaskThread("task-id", { markActivityRead: false }), + { wrapper }, + ); + + await waitFor(() => expect(hook.result.current.messages).toHaveLength(1)); + expect(mockClient.markTaskActivityRead).not.toHaveBeenCalled(); + }); + 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 a5d09da53e..05e58c4ca2 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 { useMarkTaskActivityRead } from "@posthog/ui/features/canvas/hooks/useMarkTaskActivityRead"; import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo, useRef } from "react"; const THREAD_POLL_INTERVAL_MS = 5_000; @@ -16,13 +18,24 @@ export function taskThreadQueryKey(taskId: string | undefined) { export function useTaskThread( taskId: string | undefined, - options?: { pollIntervalMs?: number; enabled?: boolean }, + options?: { + pollIntervalMs?: number; + enabled?: boolean; + markActivityRead?: boolean; + }, ): { messages: TaskThreadMessage[]; isLoading: boolean; } { const pollIntervalMs = options?.pollIntervalMs ?? THREAD_POLL_INTERVAL_MS; const enabled = options?.enabled ?? true; + const markActivityRead = options?.markActivityRead ?? true; + 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), @@ -32,6 +45,21 @@ export function useTaskThread( staleTime: pollIntervalMs, }, ); + useEffect(() => { + if (!taskId || !enabled || !markActivityRead || 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, + markActivityRead, + markTasksRead, + opening, + query.dataUpdatedAt, + ]); return { messages: query.data ?? [], 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/canvas/task-activity/taskActivity.contribution.test.ts b/packages/ui/src/features/canvas/task-activity/taskActivity.contribution.test.ts new file mode 100644 index 0000000000..f49f60f4ca --- /dev/null +++ b/packages/ui/src/features/canvas/task-activity/taskActivity.contribution.test.ts @@ -0,0 +1,77 @@ +import type { TaskActivityPage } from "@posthog/shared/domain-types"; +import { AUTH_SCOPED_QUERY_META } from "@posthog/ui/features/auth/useCurrentUser"; +import type { + NotificationBus, + TaskActivitySignal, +} from "@posthog/ui/features/notifications/notifications"; +import { type InfiniteData, QueryClient } from "@tanstack/react-query"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { TaskActivityContribution } from "./taskActivity.contribution"; + +let activityListener: ((signal: TaskActivitySignal) => void) | undefined; +const notificationBus = { + subscribeToTaskActivity: vi.fn( + (listener: (signal: TaskActivitySignal) => void) => { + activityListener = listener; + return vi.fn(); + }, + ), +} as unknown as NotificationBus; + +describe("TaskActivityContribution", () => { + let queryClient: QueryClient; + let contribution: TaskActivityContribution; + + beforeEach(() => { + vi.clearAllMocks(); + queryClient = new QueryClient(); + contribution = new TaskActivityContribution(notificationBus, queryClient); + contribution.start(); + }); + + it("shows task activity immediately when its backend projection is not available yet", () => { + queryClient.setQueryDefaults(["task-activity"], { + meta: AUTH_SCOPED_QUERY_META, + }); + queryClient.setQueryData>( + ["task-activity"], + { + pages: [{ results: [], unread_count: 0 }], + pageParams: [undefined], + }, + ); + + activityListener?.({ + taskId: "task-1", + taskTitle: "Channel task", + activityKind: "awaiting_input", + activityAt: "2026-07-27T10:00:00Z", + }); + + const cached = queryClient.getQueryData>([ + "task-activity", + ]); + expect(cached?.pages[0]).toMatchObject({ + unread_count: 1, + results: [ + { + task_id: "task-1", + task_title: "Channel task", + activity_kind: "awaiting_input", + is_unread: true, + }, + ], + }); + }); + + it("does not recreate activity data after the authenticated query is removed", () => { + activityListener?.({ + taskId: "task-1", + taskTitle: "Previous user's task", + activityKind: "completed", + activityAt: "2026-07-27T10:00:00Z", + }); + + expect(queryClient.getQueryData(["task-activity"])).toBeUndefined(); + }); +}); diff --git a/packages/ui/src/features/canvas/task-activity/taskActivity.contribution.ts b/packages/ui/src/features/canvas/task-activity/taskActivity.contribution.ts new file mode 100644 index 0000000000..9fdddb53f3 --- /dev/null +++ b/packages/ui/src/features/canvas/task-activity/taskActivity.contribution.ts @@ -0,0 +1,85 @@ +import type { Contribution } from "@posthog/di/contribution"; +import type { TaskActivityPage } from "@posthog/shared/domain-types"; +import { + NotificationBus, + type TaskActivitySignal, +} from "@posthog/ui/features/notifications/notifications"; +import { + IMPERATIVE_QUERY_CLIENT, + type ImperativeQueryClient, +} from "@posthog/ui/shell/queryClient"; +import type { InfiniteData } from "@tanstack/react-query"; +import { inject, injectable } from "inversify"; +import { TASK_ACTIVITY_QUERY_KEY } from "./taskActivityQuery"; + +@injectable() +export class TaskActivityContribution implements Contribution { + constructor( + @inject(NotificationBus) + private readonly notificationBus: NotificationBus, + @inject(IMPERATIVE_QUERY_CLIENT) + private readonly queryClient: ImperativeQueryClient, + ) {} + + start(): void { + this.notificationBus.subscribeToTaskActivity((signal) => { + this.apply(signal); + }); + } + + private apply(signal: TaskActivitySignal): void { + const activityQuery = this.queryClient.getQueryCache().find({ + queryKey: TASK_ACTIVITY_QUERY_KEY, + exact: true, + }); + if (activityQuery?.meta?.authScoped !== true) return; + + this.queryClient.setQueryData>( + TASK_ACTIVITY_QUERY_KEY, + (data) => { + const previous = data?.pages + .flatMap((page) => page.results) + .find((row) => row.task_id === signal.taskId); + const activity = { + id: `local:${signal.taskId}`, + task_id: signal.taskId, + task_title: signal.taskTitle, + channel_id: previous?.channel_id ?? null, + channel_name: previous?.channel_name ?? null, + activity_at: signal.activityAt, + activity_kind: signal.activityKind, + snippet: "", + latest_author: null, + latest_message_id: null, + is_unread: true, + }; + if (!data) { + return { + pages: [{ results: [activity], unread_count: 1 }], + pageParams: [undefined], + }; + } + const unreadIncrement = previous?.is_unread ? 0 : 1; + return { + ...data, + pages: data.pages.map((page, index) => ({ + ...page, + unread_count: + index === 0 + ? page.unread_count + unreadIncrement + : page.unread_count, + results: + index === 0 + ? [ + activity, + ...page.results.filter( + (row) => row.task_id !== signal.taskId, + ), + ] + : page.results.filter((row) => row.task_id !== signal.taskId), + })), + }; + }, + ); + } +} diff --git a/packages/ui/src/features/canvas/task-activity/taskActivity.module.ts b/packages/ui/src/features/canvas/task-activity/taskActivity.module.ts new file mode 100644 index 0000000000..ec674037c9 --- /dev/null +++ b/packages/ui/src/features/canvas/task-activity/taskActivity.module.ts @@ -0,0 +1,8 @@ +import { CONTRIBUTION } from "@posthog/di/contribution"; +import { ContainerModule } from "inversify"; +import { TaskActivityContribution } from "./taskActivity.contribution"; + +export const taskActivityUiModule = new ContainerModule(({ bind }) => { + bind(TaskActivityContribution).toSelf().inSingletonScope(); + bind(CONTRIBUTION).toService(TaskActivityContribution); +}); diff --git a/packages/ui/src/features/canvas/task-activity/taskActivityQuery.ts b/packages/ui/src/features/canvas/task-activity/taskActivityQuery.ts new file mode 100644 index 0000000000..2f4f449476 --- /dev/null +++ b/packages/ui/src/features/canvas/task-activity/taskActivityQuery.ts @@ -0,0 +1 @@ +export const TASK_ACTIVITY_QUERY_KEY = ["task-activity"] as const; diff --git a/packages/ui/src/features/notifications/notifications.test.ts b/packages/ui/src/features/notifications/notifications.test.ts index 603b0bcff3..2c5e53a81f 100644 --- a/packages/ui/src/features/notifications/notifications.test.ts +++ b/packages/ui/src/features/notifications/notifications.test.ts @@ -118,6 +118,53 @@ describe("notifyPromptComplete", () => { expect(notify).toHaveBeenCalledTimes(delivered ? 1 : 0); }, ); + + it("notifies activity subscribers when a task finishes", () => { + const { bus } = makeBus({ hasFocus: false }); + const listener = vi.fn(); + const unsubscribe = bus.subscribeToTaskActivity(listener); + + bus.notifyPromptComplete("My task", "end_turn", TASK_ID); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + taskId: TASK_ID, + taskTitle: "My task", + activityKind: "completed", + }), + ); + + unsubscribe(); + bus.notifyPromptComplete("My task", "end_turn", TASK_ID); + expect(listener).toHaveBeenCalledOnce(); + }); + + it("notifies activity subscribers when a task needs input", () => { + const { bus } = makeBus({ hasFocus: false }); + const listener = vi.fn(); + bus.subscribeToTaskActivity(listener); + + bus.notifyPermissionRequest("My task", TASK_ID); + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + taskId: TASK_ID, + taskTitle: "My task", + activityKind: "awaiting_input", + }), + ); + }); + + it("delivers the notification when an activity subscriber fails", () => { + const { bus, notify } = makeBus({ hasFocus: false }); + bus.subscribeToTaskActivity(() => { + throw new Error("activity refresh failed"); + }); + + expect(() => + bus.notifyPromptComplete("My task", "end_turn", TASK_ID), + ).not.toThrow(); + expect(notify).toHaveBeenCalledOnce(); + }); }); describe("native tier settings gating (app unfocused)", () => { diff --git a/packages/ui/src/features/notifications/notifications.ts b/packages/ui/src/features/notifications/notifications.ts index f1256ec238..2b54f9dc53 100644 --- a/packages/ui/src/features/notifications/notifications.ts +++ b/packages/ui/src/features/notifications/notifications.ts @@ -3,8 +3,10 @@ import { NOTIFICATIONS_SERVICE, type NotificationTarget, } from "@posthog/platform/notifications"; +import type { TaskActivityKind } from "@posthog/shared/domain-types"; import { toast } from "@posthog/ui/primitives/toast"; import { openNotificationTarget } from "@posthog/ui/router/navigationBridge"; +import { logger } from "@posthog/ui/shell/logger"; import { playbackRateForTaskDuration, playCompletionSound, @@ -21,6 +23,7 @@ import { import { routeNotification } from "./routeNotification"; const MAX_TITLE_LENGTH = 50; +const log = logger.scope("notifications"); // In-app toast presentation for the focused-but-elsewhere tier. Only levels that // support an action link are allowed (the bus derives the action from `target`). @@ -49,6 +52,13 @@ export interface NotificationDescriptor { error?: unknown; } +export interface TaskActivitySignal { + taskId: string; + taskTitle: string; + activityKind: Extract; + activityAt: string; +} + // The single channel every app notification flows through. Reads focus + the // active route, decides suppress / toast / native (see routeNotification), and // dispatches accordingly. Native delivery + dock effects are gated by the user's @@ -56,6 +66,10 @@ export interface NotificationDescriptor { // only appears while the app is focused). @injectable() export class NotificationBus { + private readonly taskActivityListeners = new Set< + (signal: TaskActivitySignal) => void + >(); + constructor( @inject(NOTIFICATIONS_SERVICE) private readonly notifications: INotifications, @@ -128,6 +142,14 @@ export class NotificationBus { toast: { level: "success" }, soundDurationMs: durationMs, }); + this.emitTaskActivity(taskId, taskTitle, "completed"); + } + + subscribeToTaskActivity( + listener: (signal: TaskActivitySignal) => void, + ): () => void { + this.taskActivityListeners.add(listener); + return () => this.taskActivityListeners.delete(listener); } notifyPermissionRequest(taskTitle: string, taskId?: string): void { @@ -136,6 +158,7 @@ export class NotificationBus { target: taskId ? { kind: "task", taskId } : undefined, toast: { level: "warning" }, }); + this.emitTaskActivity(taskId, taskTitle, "awaiting_input"); } // Error entry point: the toast carries a one-line summary; the raw payload @@ -190,4 +213,25 @@ export class NotificationBus { if (title.length <= MAX_TITLE_LENGTH) return title; return `${title.slice(0, MAX_TITLE_LENGTH)}...`; } + + private emitTaskActivity( + taskId: string | undefined, + taskTitle: string, + activityKind: TaskActivitySignal["activityKind"], + ): void { + if (!taskId) return; + const signal: TaskActivitySignal = { + taskId, + taskTitle, + activityKind, + activityAt: new Date().toISOString(), + }; + for (const listener of this.taskActivityListeners) { + try { + listener(signal); + } catch (error) { + log.error("Task activity subscriber failed", { error }); + } + } + } } diff --git a/packages/ui/src/features/sidebar/components/SidebarNavSection.test.tsx b/packages/ui/src/features/sidebar/components/SidebarNavSection.test.tsx index 0af9311f3e..33d83fb141 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, diff --git a/packages/ui/src/features/sidebar/components/items/ActivityItem.tsx b/packages/ui/src/features/sidebar/components/items/ActivityItem.tsx index a9a4f23a25..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/mentionActivity"; -import { useMentionActivity } from "@posthog/ui/features/canvas/hooks/useMentionActivity"; -import { useActivitySeenStore } from "@posthog/ui/features/canvas/stores/activitySeenStore"; -import { useMemo } from "react"; +import { useTaskActivity } from "@posthog/ui/features/canvas/hooks/useTaskActivity"; import { SidebarItem } from "../SidebarItem"; import { SidebarCountBadge } from "./SidebarCountBadge"; @@ -12,20 +9,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 lastSeenAt = useActivitySeenStore((s) => s.lastSeenAt); - const unseen = useMemo( - () => countUnseenActivity(items, lastSeenAt), - [items, lastSeenAt], - ); + const { unreadCount } = useTaskActivity(); return ( 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, });