Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Schemas.PaginatedTaskList> {
const teamId = await this.getTeamId();
const params: Record<string, string | number | boolean> = {
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<Schemas.PaginatedTaskList>;
}

async getTaskSummaries(ids: string[]) {
if (ids.length === 0) return [];
const TASK_SUMMARIES_MAX_PAGES = 50;
Expand Down
121 changes: 121 additions & 0 deletions packages/ui/src/features/canvas/components/ActivityTasksTab.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
type="button"
onClick={openTask}
className="flex w-full items-center gap-2.5 rounded-md px-2 py-2 text-left hover:bg-fill-secondary"
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
<TaskIcon
taskRunStatus={task.latest_run?.status}
originProduct={task.origin_product}
/>
</span>
<Text size="1" weight="medium" className="min-w-0 flex-1 truncate">
{task.title || "Untitled task"}
</Text>
<Text size="1" className="shrink-0 text-muted-foreground">
{formatRelativeTimeShort(task.updated_at)}
</Text>
</button>
);
}

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<HTMLDivElement>({
rootMargin: "400px 0px",
});

useEffect(() => {
if (sentinelInView && hasNextPage && !isFetchingNextPage) {
void fetchNextPage();
}
}, [sentinelInView, hasNextPage, isFetchingNextPage, fetchNextPage]);

if (isLoading && tasks.length === 0) {
return (
<div className="flex justify-center py-16">
<Spinner />
</div>
);
}

if (tasks.length === 0) {
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<ListChecksIcon size={20} />
</EmptyMedia>
<EmptyTitle>No tasks yet</EmptyTitle>
<EmptyDescription>
Tasks you start show up here, newest first.
</EmptyDescription>
</EmptyHeader>
</Empty>
);
}

return (
<div className="flex flex-col gap-0.5">
{tasks.map((task) => (
<TaskRow key={task.id} task={task} />
))}
<div ref={sentinelRef} />
{isFetchingNextPage && (
<div className="flex justify-center py-3">
<Spinner />
</div>
)}
{!hasNextPage && (
<Text size="1" className="block py-3 text-center text-muted-foreground">
That's everything.
</Text>
)}
</div>
);
}
49 changes: 45 additions & 4 deletions packages/ui/src/features/canvas/components/ActivityView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Avatar,
AvatarFallback,
Button,
cn,
Empty,
EmptyDescription,
EmptyHeader,
Expand All @@ -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";
Expand Down Expand Up @@ -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 (
<nav className="mt-3 flex items-center gap-px">
{TABS.map((tab) => {
const active = tab.key === activeTab;
return (
<Button
key={tab.key}
variant="default"
size="sm"
data-selected={active || undefined}
className={cn(active && "bg-fill-selected")}
onClick={() => onSelect(tab.key)}
>
{tab.label}
</Button>
);
})}
</nav>
);
}

// 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 });
Expand Down Expand Up @@ -155,6 +192,7 @@ export function ActivityView() {
const [seenAtOpen] = useState(
() => useActivitySeenStore.getState().lastSeenAt,
);
const [activeTab, setActiveTab] = useState<ActivityTab>("mentions");

useEffect(() => {
track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
Expand All @@ -176,10 +214,13 @@ export function ActivityView() {
Activity
</Text>
<Text size="2" className="block text-muted-foreground">
Mentions of you across contexts.
Mentions and the tasks you've worked on.
</Text>
<ActivityTabBar activeTab={activeTab} onSelect={setActiveTab} />
<div className="mt-4">
{isLoading && items.length === 0 ? (
{activeTab === "tasks" ? (
<ActivityTasksTab />
) : isLoading && items.length === 0 ? (
<div className="flex justify-center py-16">
<Spinner />
</div>
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/features/tasks/taskKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
44 changes: 44 additions & 0 deletions packages/ui/src/features/tasks/useTasks.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 },
Expand Down
Loading