From a8f4d876dea54161e8ed78d7dd9b99da145ef460 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Sun, 26 Jul 2026 06:02:55 +0300 Subject: [PATCH 1/4] Add infinite loading for archived tasks Generated-By: PostHog Code Task-Id: 536c4778-77ff-478c-9cbc-69fd42a1a586 --- .../features/archive/ArchivedTasksView.tsx | 45 +++++++++++++++--- .../archive/useArchivedTaskSummaries.test.ts | 28 +++++++++++ .../archive/useArchivedTaskSummaries.ts | 46 +++++++++++++++++++ 3 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 packages/ui/src/features/archive/useArchivedTaskSummaries.test.ts create mode 100644 packages/ui/src/features/archive/useArchivedTaskSummaries.ts diff --git a/packages/ui/src/features/archive/ArchivedTasksView.tsx b/packages/ui/src/features/archive/ArchivedTasksView.tsx index 2eb44e0828..4d33275441 100644 --- a/packages/ui/src/features/archive/ArchivedTasksView.tsx +++ b/packages/ui/src/features/archive/ArchivedTasksView.tsx @@ -35,12 +35,13 @@ 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 { useArchivedTaskSummaries } from "./useArchivedTaskSummaries"; import { useUnarchiveTask } from "./useUnarchiveTask"; const ICON_SIZE = 12; @@ -193,6 +194,9 @@ export interface ArchivedTasksViewPresentationProps { onContextMenu: (item: ArchivedTaskWithDetails, e: React.MouseEvent) => void; onBranchNotFoundClose: () => void; onRecreateBranch: () => void; + hasNextPage?: boolean; + isFetchingNextPage?: boolean; + onLoadMore?: () => void; } export function ArchivedTasksViewPresentation({ @@ -204,6 +208,9 @@ export function ArchivedTasksViewPresentation({ onContextMenu, onBranchNotFoundClose, onRecreateBranch, + hasNextPage = false, + isFetchingNextPage = false, + onLoadMore, }: ArchivedTasksViewPresentationProps) { const [searchQuery, setSearchQuery] = useState(""); const [sort, setSort] = useState({ @@ -248,6 +255,23 @@ export function ArchivedTasksViewPresentation({ overscan: 12, }); const virtualRows = rowVirtualizer.getVirtualItems(); + const lastVirtualRowIndex = virtualRows[virtualRows.length - 1]?.index; + useEffect(() => { + if ( + lastVirtualRowIndex !== undefined && + lastVirtualRowIndex >= filteredItems.length - 10 && + hasNextPage && + !isFetchingNextPage + ) { + onLoadMore?.(); + } + }, [ + filteredItems.length, + hasNextPage, + isFetchingNextPage, + lastVirtualRowIndex, + onLoadMore, + ]); const topSpacerHeight = virtualRows[0]?.start ?? 0; const bottomSpacerHeight = rowVirtualizer.getTotalSize() - @@ -496,8 +520,14 @@ export function ArchivedTasksView() { () => 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( @@ -509,11 +539,11 @@ export function ArchivedTasksView() { const items = useMemo( () => - mergeArchivedWithTasks(archivedTasks, [ + mergeArchivedWithTasks(archivedTasks.slice(0, loadedCount), [ ...listedTasks, ...archivedTaskDetails, ]), - [archivedTasks, listedTasks, archivedTaskDetails], + [archivedTasks, listedTasks, archivedTaskDetails, loadedCount], ); const isLoading = isLoadingArchived || isLoadingTasks; @@ -600,6 +630,9 @@ export function ArchivedTasksView() { onContextMenu={handleContextMenu} onBranchNotFoundClose={() => setBranchNotFound(null)} onRecreateBranch={handleRecreateBranch} + hasNextPage={hasNextPage} + isFetchingNextPage={isFetchingNextPage} + onLoadMore={() => void fetchNextPage({ cancelRefetch: false })} /> ); } 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 }; +} From 16789a93c32a7ca04af1e9978e81721b4f16108c Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Sun, 26 Jul 2026 06:25:42 +0300 Subject: [PATCH 2/4] Address archive pagination review feedback Generated-By: PostHog Code Task-Id: 536c4778-77ff-478c-9cbc-69fd42a1a586 --- .../features/archive/ArchivedTasksView.tsx | 33 +++++----- .../archive/archiveListPagination.test.ts | 62 +++++++++++++++++++ .../features/archive/archiveListPagination.ts | 13 ++++ 3 files changed, 93 insertions(+), 15 deletions(-) create mode 100644 packages/ui/src/features/archive/archiveListPagination.test.ts create mode 100644 packages/ui/src/features/archive/archiveListPagination.ts diff --git a/packages/ui/src/features/archive/ArchivedTasksView.tsx b/packages/ui/src/features/archive/ArchivedTasksView.tsx index 4d33275441..3d752e6d55 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, @@ -41,6 +40,7 @@ import { DotsCircleSpinner } from "../../primitives/DotsCircleSpinner"; import { Tooltip } from "../../primitives/Tooltip"; import { toast } from "../../primitives/toast"; import { useTasks } from "../tasks/useTasks"; +import { getVisibleArchivedTasks } from "./archiveListPagination"; import { useArchivedTaskSummaries } from "./useArchivedTaskSummaries"; import { useUnarchiveTask } from "./useUnarchiveTask"; @@ -188,6 +188,7 @@ export type { ArchivedTaskWithDetails }; export interface ArchivedTasksViewPresentationProps { items: ArchivedTaskWithDetails[]; isLoading: boolean; + loadedCount?: number; branchNotFound: BranchNotFoundPrompt | null; onUnarchive: (taskId: string) => void; onDelete: (taskId: string) => void; @@ -202,6 +203,7 @@ export interface ArchivedTasksViewPresentationProps { export function ArchivedTasksViewPresentation({ items, isLoading, + loadedCount = items.length, branchNotFound, onUnarchive, onDelete, @@ -239,17 +241,17 @@ 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, @@ -259,14 +261,14 @@ export function ArchivedTasksViewPresentation({ useEffect(() => { if ( lastVirtualRowIndex !== undefined && - lastVirtualRowIndex >= filteredItems.length - 10 && + lastVirtualRowIndex >= visibleItems.length - 10 && hasNextPage && !isFetchingNextPage ) { onLoadMore?.(); } }, [ - filteredItems.length, + visibleItems.length, hasNextPage, isFetchingNextPage, lastVirtualRowIndex, @@ -318,7 +320,7 @@ export function ArchivedTasksViewPresentation({ Loading archived tasks... - ) : filteredItems.length === 0 ? ( + ) : visibleItems.length === 0 ? ( {items.length === 0 ? "No archived tasks" : "No matching tasks"} @@ -361,7 +363,7 @@ export function ArchivedTasksViewPresentation({ )} {virtualRows.map((virtualRow) => { - const item = filteredItems[virtualRow.index]; + const item = visibleItems[virtualRow.index]; return ( - mergeArchivedWithTasks(archivedTasks.slice(0, loadedCount), [ + mergeArchivedWithTasks(archivedTasks, [ ...listedTasks, ...archivedTaskDetails, ]), - [archivedTasks, listedTasks, archivedTaskDetails, loadedCount], + [archivedTasks, listedTasks, archivedTaskDetails], ); const isLoading = isLoadingArchived || isLoadingTasks; @@ -624,6 +626,7 @@ export function ArchivedTasksView() { { + 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"]); + }); +}); diff --git a/packages/ui/src/features/archive/archiveListPagination.ts b/packages/ui/src/features/archive/archiveListPagination.ts new file mode 100644 index 0000000000..21097a5ce9 --- /dev/null +++ b/packages/ui/src/features/archive/archiveListPagination.ts @@ -0,0 +1,13 @@ +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); +} From 16dcd3d977e15269407632f3a1a376dfc49ae4b2 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Sun, 26 Jul 2026 21:35:32 +0300 Subject: [PATCH 3/4] Fix archived task filter pagination Generated-By: PostHog Code Task-Id: 536c4778-77ff-478c-9cbc-69fd42a1a586 --- .../ui/src/features/archive/ArchivedTasksView.tsx | 14 +++++++++++--- .../archive/archiveListPagination.test.ts | 15 ++++++++++++++- .../src/features/archive/archiveListPagination.ts | 12 ++++++++++++ 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/features/archive/ArchivedTasksView.tsx b/packages/ui/src/features/archive/ArchivedTasksView.tsx index 3d752e6d55..92be85e31a 100644 --- a/packages/ui/src/features/archive/ArchivedTasksView.tsx +++ b/packages/ui/src/features/archive/ArchivedTasksView.tsx @@ -40,7 +40,10 @@ import { DotsCircleSpinner } from "../../primitives/DotsCircleSpinner"; import { Tooltip } from "../../primitives/Tooltip"; import { toast } from "../../primitives/toast"; import { useTasks } from "../tasks/useTasks"; -import { getVisibleArchivedTasks } from "./archiveListPagination"; +import { + getVisibleArchivedTasks, + shouldLoadMoreArchivedTasks, +} from "./archiveListPagination"; import { useArchivedTaskSummaries } from "./useArchivedTaskSummaries"; import { useUnarchiveTask } from "./useUnarchiveTask"; @@ -258,10 +261,14 @@ export function ArchivedTasksViewPresentation({ }); const virtualRows = rowVirtualizer.getVirtualItems(); const lastVirtualRowIndex = virtualRows[virtualRows.length - 1]?.index; + const hasActiveFilter = searchQuery.trim() !== "" || repoFilter !== null; useEffect(() => { if ( - lastVirtualRowIndex !== undefined && - lastVirtualRowIndex >= visibleItems.length - 10 && + shouldLoadMoreArchivedTasks( + lastVirtualRowIndex, + visibleItems.length, + hasActiveFilter, + ) && hasNextPage && !isFetchingNextPage ) { @@ -269,6 +276,7 @@ export function ArchivedTasksViewPresentation({ } }, [ visibleItems.length, + hasActiveFilter, hasNextPage, isFetchingNextPage, lastVirtualRowIndex, diff --git a/packages/ui/src/features/archive/archiveListPagination.test.ts b/packages/ui/src/features/archive/archiveListPagination.test.ts index 6c4bab84a0..1bf8afdf6f 100644 --- a/packages/ui/src/features/archive/archiveListPagination.test.ts +++ b/packages/ui/src/features/archive/archiveListPagination.test.ts @@ -1,6 +1,9 @@ import type { ArchivedTaskWithRepo } from "@posthog/core/archive/archiveListView"; import { describe, expect, it } from "vitest"; -import { getVisibleArchivedTasks } from "./archiveListPagination"; +import { + getVisibleArchivedTasks, + shouldLoadMoreArchivedTasks, +} from "./archiveListPagination"; function item( id: string, @@ -60,3 +63,13 @@ describe("getVisibleArchivedTasks", () => { ).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 index 21097a5ce9..4599dd4c1f 100644 --- a/packages/ui/src/features/archive/archiveListPagination.ts +++ b/packages/ui/src/features/archive/archiveListPagination.ts @@ -11,3 +11,15 @@ export function getVisibleArchivedTasks( ): 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 + ); +} From 58fe37054b79fde5f705e51a9f5ddf113f727ac6 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:40:36 +0000 Subject: [PATCH 4/4] chore(visual): update storybook baselines 1 updated Run: 66940286-cef9-4569-aa5b-23467196322c Co-authored-by: richardsolomou <2622273+richardsolomou@users.noreply.github.com> --- apps/code/snapshots.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/code/snapshots.yml b/apps/code/snapshots.yml index e2fc980da7..751e81b16b 100644 --- a/apps/code/snapshots.yml +++ b/apps/code/snapshots.yml @@ -567,7 +567,7 @@ snapshots: loops-loopslistview--long-mixed-list--dark: hash: v1.k4693efd2.654250bc74fff46c496747eb13031e0e7a7ef264be5bb7be41451d82a497a515.vvrxXbbABkVY3hcHS3oThQ339JFgwIzB8E4n1usVWf0 loops-loopslistview--long-mixed-list--light: - hash: v1.k4693efd2.a8e53087efdaf58e351809775aaf410f53a781a97e10e973ee53fd0db2f94e73.7GcprWAYAgATLtNckNBd-44EyctqKNcb4FXiHA08QJs + hash: v1.k4693efd2.8dee426bab2a63843aefe9cf0765799ae9e3c5ece4adc522dc63faf3b362716b.K90qmg7hD8qD522iR-Y5lbr6ETEzRuYgQdYl0Z520do loops-loopslistview--with-builder-sessions--dark: hash: v1.k4693efd2.84cfc28bedc22a6728ba4ecf69274ab03537faeed5333775991716bf0b5f439e.fHsD0XKPnud-oDM4PLJ7edCbMnVcW6uVMdMHBYp3K08 loops-loopslistview--with-builder-sessions--light: