diff --git a/packages/ui/src/features/archive/ArchivedTasksView.tsx b/packages/ui/src/features/archive/ArchivedTasksView.tsx index 2eb44e0828..92be85e31a 100644 --- a/packages/ui/src/features/archive/ArchivedTasksView.tsx +++ b/packages/ui/src/features/archive/ArchivedTasksView.tsx @@ -11,7 +11,6 @@ import type { RestoreOutcome } from "@posthog/core/archive/archivedTasksControll import { type ArchivedTaskWithDetails, deriveUniqueRepos, - filterAndSortArchivedTasks, formatRelativeDate, mergeArchivedWithTasks, type ArchiveSortColumn as SortColumn, @@ -35,12 +34,17 @@ import { } from "@radix-ui/themes"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useSetHeaderContent } from "../../hooks/useSetHeaderContent"; import { DotsCircleSpinner } from "../../primitives/DotsCircleSpinner"; import { Tooltip } from "../../primitives/Tooltip"; import { toast } from "../../primitives/toast"; -import { useTaskSummaries, useTasks } from "../tasks/useTasks"; +import { useTasks } from "../tasks/useTasks"; +import { + getVisibleArchivedTasks, + shouldLoadMoreArchivedTasks, +} from "./archiveListPagination"; +import { useArchivedTaskSummaries } from "./useArchivedTaskSummaries"; import { useUnarchiveTask } from "./useUnarchiveTask"; const ICON_SIZE = 12; @@ -187,23 +191,31 @@ export type { ArchivedTaskWithDetails }; export interface ArchivedTasksViewPresentationProps { items: ArchivedTaskWithDetails[]; isLoading: boolean; + loadedCount?: number; branchNotFound: BranchNotFoundPrompt | null; onUnarchive: (taskId: string) => void; onDelete: (taskId: string) => void; onContextMenu: (item: ArchivedTaskWithDetails, e: React.MouseEvent) => void; onBranchNotFoundClose: () => void; onRecreateBranch: () => void; + hasNextPage?: boolean; + isFetchingNextPage?: boolean; + onLoadMore?: () => void; } export function ArchivedTasksViewPresentation({ items, isLoading, + loadedCount = items.length, branchNotFound, onUnarchive, onDelete, onContextMenu, onBranchNotFoundClose, onRecreateBranch, + hasNextPage = false, + isFetchingNextPage = false, + onLoadMore, }: ArchivedTasksViewPresentationProps) { const [searchQuery, setSearchQuery] = useState(""); const [sort, setSort] = useState({ @@ -232,22 +244,44 @@ export function ArchivedTasksViewPresentation({ [itemsWithRepo], ); - const filteredItems = useMemo( + const visibleItems = useMemo( () => - filterAndSortArchivedTasks(itemsWithRepo, { - searchQuery, - repoFilter, - sort, - }), - [itemsWithRepo, searchQuery, repoFilter, sort], + getVisibleArchivedTasks( + itemsWithRepo, + { searchQuery, repoFilter, sort }, + loadedCount, + ), + [itemsWithRepo, searchQuery, repoFilter, sort, loadedCount], ); const rowVirtualizer = useVirtualizer({ - count: filteredItems.length, + count: visibleItems.length, getScrollElement: () => tableViewportRef.current, estimateSize: () => 37, overscan: 12, }); const virtualRows = rowVirtualizer.getVirtualItems(); + const lastVirtualRowIndex = virtualRows[virtualRows.length - 1]?.index; + const hasActiveFilter = searchQuery.trim() !== "" || repoFilter !== null; + useEffect(() => { + if ( + shouldLoadMoreArchivedTasks( + lastVirtualRowIndex, + visibleItems.length, + hasActiveFilter, + ) && + hasNextPage && + !isFetchingNextPage + ) { + onLoadMore?.(); + } + }, [ + visibleItems.length, + hasActiveFilter, + hasNextPage, + isFetchingNextPage, + lastVirtualRowIndex, + onLoadMore, + ]); const topSpacerHeight = virtualRows[0]?.start ?? 0; const bottomSpacerHeight = rowVirtualizer.getTotalSize() - @@ -294,7 +328,7 @@ export function ArchivedTasksViewPresentation({ Loading archived tasks... - ) : filteredItems.length === 0 ? ( + ) : visibleItems.length === 0 ? ( {items.length === 0 ? "No archived tasks" : "No matching tasks"} @@ -337,7 +371,7 @@ export function ArchivedTasksViewPresentation({ )} {virtualRows.map((virtualRow) => { - const item = filteredItems[virtualRow.index]; + const item = visibleItems[virtualRow.index]; return ( archivedTasks.map((task) => task.taskId), [archivedTasks], ); - const { data: archivedTaskDetails = [], isLoading: isLoadingTasks } = - useTaskSummaries(archivedTaskIds); + const { + summaries: archivedTaskDetails, + loadedCount, + isLoading: isLoadingTasks, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = useArchivedTaskSummaries(archivedTaskIds); const { restore, remove, runContextMenuAction } = useUnarchiveTask(); useSetHeaderContent( @@ -594,12 +634,16 @@ export function ArchivedTasksView() { setBranchNotFound(null)} onRecreateBranch={handleRecreateBranch} + hasNextPage={hasNextPage} + isFetchingNextPage={isFetchingNextPage} + onLoadMore={() => void fetchNextPage({ cancelRefetch: false })} /> ); } diff --git a/packages/ui/src/features/archive/archiveListPagination.test.ts b/packages/ui/src/features/archive/archiveListPagination.test.ts new file mode 100644 index 0000000000..1bf8afdf6f --- /dev/null +++ b/packages/ui/src/features/archive/archiveListPagination.test.ts @@ -0,0 +1,75 @@ +import type { ArchivedTaskWithRepo } from "@posthog/core/archive/archiveListView"; +import { describe, expect, it } from "vitest"; +import { + getVisibleArchivedTasks, + shouldLoadMoreArchivedTasks, +} from "./archiveListPagination"; + +function item( + id: string, + title: string, + archivedAt: string, +): ArchivedTaskWithRepo { + return { + archived: { + taskId: id, + archivedAt, + folderId: "", + mode: "cloud", + worktreeName: null, + branchName: null, + checkpointId: null, + }, + task: { + id, + title, + created_at: archivedAt, + repository: "posthog/code", + }, + repoName: "code", + }; +} + +const defaultSort = { column: "archived", direction: "desc" } as const; + +describe("getVisibleArchivedTasks", () => { + it("finds matches outside the unfiltered page prefix", () => { + const items = [ + item("first", "First task", "2026-01-03T00:00:00Z"), + item("second", "Matching task", "2026-01-02T00:00:00Z"), + ]; + + expect( + getVisibleArchivedTasks( + items, + { searchQuery: "matching", repoFilter: null, sort: defaultSort }, + 1, + ).map((entry) => entry.archived.taskId), + ).toEqual(["second"]); + }); + + it("sorts the complete archive before limiting rows", () => { + const items = [ + item("older", "Older task", "2026-01-01T00:00:00Z"), + item("newer", "Newer task", "2026-01-03T00:00:00Z"), + ]; + + expect( + getVisibleArchivedTasks( + items, + { searchQuery: "", repoFilter: null, sort: defaultSort }, + 1, + ).map((entry) => entry.archived.taskId), + ).toEqual(["newer"]); + }); +}); + +describe("shouldLoadMoreArchivedTasks", () => { + it("loads near the unfiltered boundary", () => { + expect(shouldLoadMoreArchivedTasks(40, 50, false)).toBe(true); + }); + + it("does not load from the end of filtered results", () => { + expect(shouldLoadMoreArchivedTasks(0, 1, true)).toBe(false); + }); +}); diff --git a/packages/ui/src/features/archive/archiveListPagination.ts b/packages/ui/src/features/archive/archiveListPagination.ts new file mode 100644 index 0000000000..4599dd4c1f --- /dev/null +++ b/packages/ui/src/features/archive/archiveListPagination.ts @@ -0,0 +1,25 @@ +import { + type ArchivedTaskWithRepo, + type ArchiveFilterSortInput, + filterAndSortArchivedTasks, +} from "@posthog/core/archive/archiveListView"; + +export function getVisibleArchivedTasks( + items: ArchivedTaskWithRepo[], + filters: ArchiveFilterSortInput, + loadedCount: number, +): ArchivedTaskWithRepo[] { + return filterAndSortArchivedTasks(items, filters).slice(0, loadedCount); +} + +export function shouldLoadMoreArchivedTasks( + lastVirtualRowIndex: number | undefined, + visibleItemCount: number, + hasActiveFilter: boolean, +): boolean { + return ( + !hasActiveFilter && + lastVirtualRowIndex !== undefined && + lastVirtualRowIndex >= visibleItemCount - 10 + ); +} diff --git a/packages/ui/src/features/archive/useArchivedTaskSummaries.test.ts b/packages/ui/src/features/archive/useArchivedTaskSummaries.test.ts new file mode 100644 index 0000000000..79543c690b --- /dev/null +++ b/packages/ui/src/features/archive/useArchivedTaskSummaries.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { getNextArchivedTaskPage } from "./useArchivedTaskSummaries"; + +describe("getNextArchivedTaskPage", () => { + it("returns the number of requested tasks as the next offset", () => { + expect( + getNextArchivedTaskPage( + [ + { results: [], requested: 50 }, + { results: [], requested: 50 }, + ], + 125, + ), + ).toBe(100); + }); + + it("stops after every archived task has been requested", () => { + expect( + getNextArchivedTaskPage( + [ + { results: [], requested: 50 }, + { results: [], requested: 25 }, + ], + 75, + ), + ).toBeUndefined(); + }); +}); diff --git a/packages/ui/src/features/archive/useArchivedTaskSummaries.ts b/packages/ui/src/features/archive/useArchivedTaskSummaries.ts new file mode 100644 index 0000000000..92d86f7d64 --- /dev/null +++ b/packages/ui/src/features/archive/useArchivedTaskSummaries.ts @@ -0,0 +1,46 @@ +import type { Schemas } from "@posthog/api-client"; +import { useAuthenticatedInfiniteQuery } from "@posthog/ui/hooks/useAuthenticatedInfiniteQuery"; +import { useMemo } from "react"; + +export const ARCHIVED_TASKS_PAGE_SIZE = 50; + +export interface ArchivedTaskSummaryPage { + results: Schemas.TaskSummary[]; + requested: number; +} + +export function getNextArchivedTaskPage( + allPages: ArchivedTaskSummaryPage[], + taskCount: number, +): number | undefined { + const loaded = allPages.reduce((total, page) => total + page.requested, 0); + return loaded < taskCount ? loaded : undefined; +} + +export function useArchivedTaskSummaries(ids: string[]) { + const query = useAuthenticatedInfiniteQuery( + ["tasks", "archived-summaries", ids], + async (client, offset) => { + const pageIds = ids.slice(offset, offset + ARCHIVED_TASKS_PAGE_SIZE); + return { + results: await client.getTaskSummaries(pageIds), + requested: pageIds.length, + }; + }, + { + enabled: ids.length > 0, + initialPageParam: 0, + getNextPageParam: (_lastPage, allPages) => + getNextArchivedTaskPage(allPages, ids.length), + }, + ); + + const summaries = useMemo( + () => query.data?.pages.flatMap((page) => page.results) ?? [], + [query.data?.pages], + ); + const loadedCount = + query.data?.pages.reduce((total, page) => total + page.requested, 0) ?? 0; + + return { ...query, summaries, loadedCount }; +}