From e6bea6c9141d8d2c3672753520ff57732a221057 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Tue, 14 Jul 2026 11:53:54 -0700 Subject: [PATCH] feat(canvas): add recent tasks tab to Activity view The Activity view showed only channel mentions. Add a "Tasks" tab alongside "Mentions" listing the current user's tasks, newest first, each row showing the task title and its live status icon (reusing the sidebar's TaskIcon). The tasks list is offset-paginated via a new getTasksPage API method and a useRecentTasksInfinite hook, and auto-loads the next page from an IntersectionObserver sentinel as the user scrolls near the bottom. Generated-By: PostHog Code Task-Id: 51f7b839-f0eb-482c-adfe-7a2c2e6ad8d1 --- packages/api-client/src/posthog-client.ts | 42 ++++++ .../canvas/components/ActivityTasksTab.tsx | 121 ++++++++++++++++++ .../canvas/components/ActivityView.tsx | 49 ++++++- packages/ui/src/features/tasks/taskKeys.ts | 2 + packages/ui/src/features/tasks/useTasks.ts | 44 +++++++ 5 files changed, 254 insertions(+), 4 deletions(-) create mode 100644 packages/ui/src/features/canvas/components/ActivityTasksTab.tsx diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 88bacc8312..a2862a9948 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -2379,6 +2379,48 @@ export class PostHogAPIClient { return data.results ?? []; } + // Offset-paginated task list for infinite-scroll surfaces (Activity feed). + // Unlike getTasks (which fetches a single large page and drops the envelope), + // this returns the full paginated response so callers can advance `offset`. + async getTasksPage(options?: { + repository?: string; + createdBy?: number; + originProduct?: string; + internal?: boolean; + channel?: string; + limit?: number; + offset?: number; + }): Promise { + const teamId = await this.getTeamId(); + const params: Record = { + limit: options?.limit ?? 30, + }; + + if (options?.offset) { + params.offset = options.offset; + } + if (options?.repository) { + params.repository = options.repository; + } + if (options?.createdBy) { + params.created_by = options.createdBy; + } + if (options?.originProduct) { + params.origin_product = options.originProduct; + } + if (options?.internal) { + params.internal = true; + } + if (options?.channel) { + params.channel = options.channel; + } + + return this.api.get(`/api/projects/{project_id}/tasks/`, { + path: { project_id: teamId.toString() }, + query: params, + }) as unknown as Promise; + } + async getTaskSummaries(ids: string[]) { if (ids.length === 0) return []; const TASK_SUMMARIES_MAX_PAGES = 50; diff --git a/packages/ui/src/features/canvas/components/ActivityTasksTab.tsx b/packages/ui/src/features/canvas/components/ActivityTasksTab.tsx new file mode 100644 index 0000000000..400439b3b2 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ActivityTasksTab.tsx @@ -0,0 +1,121 @@ +import { ListChecksIcon } from "@phosphor-icons/react"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, + Spinner, +} from "@posthog/quill"; +import { formatRelativeTimeShort } from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import type { Task } from "@posthog/shared/domain-types"; +import { TaskIcon } from "@posthog/ui/features/sidebar/components/items/TaskIcon"; +import { useRecentTasksInfinite } from "@posthog/ui/features/tasks/useTasks"; +import { useInView } from "@posthog/ui/primitives/hooks/useInView"; +import { + navigateToChannelTask, + navigateToTaskDetail, +} from "@posthog/ui/router/navigationBridge"; +import { track } from "@posthog/ui/shell/analytics"; +import { Text } from "@radix-ui/themes"; +import { useEffect } from "react"; + +function TaskRow({ task }: { task: Task }) { + const openTask = () => { + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "open_task", + surface: "activity", + channel_id: task.channel ?? undefined, + task_id: task.id, + }); + // Channel-filed tasks keep the channels chrome under /website; everything + // else opens the plain task view. + if (task.channel) { + navigateToChannelTask(task.channel, task.id); + } else { + navigateToTaskDetail(task.id); + } + }; + + return ( + + ); +} + +export function ActivityTasksTab() { + const { tasks, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage } = + useRecentTasksInfinite(); + + // Sentinel below the list: as it scrolls into view (with a margin so it fires + // before the user reaches the very bottom), pull the next page. + const [sentinelRef, sentinelInView] = useInView({ + rootMargin: "400px 0px", + }); + + useEffect(() => { + if (sentinelInView && hasNextPage && !isFetchingNextPage) { + void fetchNextPage(); + } + }, [sentinelInView, hasNextPage, isFetchingNextPage, fetchNextPage]); + + if (isLoading && tasks.length === 0) { + return ( +
+ +
+ ); + } + + if (tasks.length === 0) { + return ( + + + + + + No tasks yet + + Tasks you start show up here, newest first. + + + + ); + } + + return ( +
+ {tasks.map((task) => ( + + ))} +
+ {isFetchingNextPage && ( +
+ +
+ )} + {!hasNextPage && ( + + That's everything. + + )} +
+ ); +} diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index e3323ae71c..a3e924cbd1 100644 --- a/packages/ui/src/features/canvas/components/ActivityView.tsx +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -4,6 +4,7 @@ import { Avatar, AvatarFallback, Button, + cn, Empty, EmptyDescription, EmptyHeader, @@ -16,6 +17,7 @@ import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; +import { ActivityTasksTab } from "@posthog/ui/features/canvas/components/ActivityTasksTab"; 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"; @@ -125,8 +127,43 @@ function ActivityRow({ ); } -// The Activity page: every channel-thread message that @-mentions the viewer, -// newest first. Opening it clears the sidebar badge. +type ActivityTab = "mentions" | "tasks"; + +const TABS: { key: ActivityTab; label: string }[] = [ + { key: "mentions", label: "Mentions" }, + { key: "tasks", label: "Tasks" }, +]; + +function ActivityTabBar({ + activeTab, + onSelect, +}: { + activeTab: ActivityTab; + onSelect: (tab: ActivityTab) => void; +}) { + return ( + + ); +} + +// The Activity page: mentions of the viewer across channel threads, plus the +// tasks they've recently worked on. Opening it clears the sidebar badge. export function ActivityView() { const client = useOptionalAuthenticatedClient(); const { data: currentUser } = useCurrentUser({ client }); @@ -155,6 +192,7 @@ export function ActivityView() { const [seenAtOpen] = useState( () => useActivitySeenStore.getState().lastSeenAt, ); + const [activeTab, setActiveTab] = useState("mentions"); useEffect(() => { track(ANALYTICS_EVENTS.CHANNEL_ACTION, { @@ -176,10 +214,13 @@ export function ActivityView() { Activity - Mentions of you across contexts. + Mentions and the tasks you've worked on. +
- {isLoading && items.length === 0 ? ( + {activeTab === "tasks" ? ( + + ) : isLoading && items.length === 0 ? (
diff --git a/packages/ui/src/features/tasks/taskKeys.ts b/packages/ui/src/features/tasks/taskKeys.ts index d805aec7fe..1032378e37 100644 --- a/packages/ui/src/features/tasks/taskKeys.ts +++ b/packages/ui/src/features/tasks/taskKeys.ts @@ -9,6 +9,8 @@ export const taskKeys = { all: ["tasks"] as const, lists: () => [...taskKeys.all, "list"] as const, list: (filters?: TaskListFilters) => [...taskKeys.lists(), filters] as const, + infiniteList: (filters?: TaskListFilters) => + [...taskKeys.lists(), "infinite", filters] as const, // Extract the filters object from a `list` query key. Keeps knowledge of the // key's shape (filters live in the last slot) here, next to `list`, instead // of letting consumers reach in by positional index. diff --git a/packages/ui/src/features/tasks/useTasks.ts b/packages/ui/src/features/tasks/useTasks.ts index d902484060..52cfc9ee7c 100644 --- a/packages/ui/src/features/tasks/useTasks.ts +++ b/packages/ui/src/features/tasks/useTasks.ts @@ -1,10 +1,16 @@ import type { Schemas } from "@posthog/api-client"; import type { Task } from "@posthog/shared/domain-types"; +import { useAuthenticatedInfiniteQuery } from "@posthog/ui/hooks/useAuthenticatedInfiniteQuery"; import { keepPreviousData } from "@tanstack/react-query"; +import { useMemo } from "react"; import { useAuthenticatedQuery } from "../../hooks/useAuthenticatedQuery"; import { useMeQuery } from "../auth/useMeQuery"; import { taskKeys } from "./taskKeys"; +// Page size for the infinite Activity feed. Small enough that the first paint is +// cheap; the sentinel fetches the next page well before the user hits bottom. +const RECENT_TASKS_PAGE_SIZE = 30; + // Full-task polls are heavy (~630KB per response at 100 tasks — descriptions // and latest_run blobs included), and idle-poll churn was the app's largest // memory/CPU drain. The sidebar's primary freshness comes from the slim @@ -46,6 +52,44 @@ export function useTasks( ); } +// The current user's tasks, newest first, as an offset-paginated infinite +// query — the data source for the Activity feed's Tasks tab. Scoped to the +// signed-in user (their recent work), so it never fans out to the whole team. +export function useRecentTasksInfinite(options?: { enabled?: boolean }) { + const { data: currentUser } = useMeQuery(); + const createdBy = currentUser?.id; + + const query = useAuthenticatedInfiniteQuery< + Schemas.PaginatedTaskList, + number + >( + taskKeys.infiniteList({ createdBy }), + (client, offset) => + client.getTasksPage({ + createdBy, + limit: RECENT_TASKS_PAGE_SIZE, + offset, + }), + { + enabled: (options?.enabled ?? true) && !!createdBy, + initialPageParam: 0, + getNextPageParam: (lastPage, allPages) => { + const loaded = allPages.reduce((n, p) => n + p.results.length, 0); + return loaded < lastPage.count ? loaded : undefined; + }, + refetchInterval: TASK_LIST_POLL_INTERVAL_MS, + }, + ); + + const tasks = useMemo( + () => + (query.data?.pages.flatMap((p) => p.results) ?? []) as unknown as Task[], + [query.data?.pages], + ); + + return { ...query, tasks, totalCount: query.data?.pages[0]?.count ?? 0 }; +} + export function useTaskSummaries( ids: string[], options?: { enabled?: boolean },