Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit c531d06

Browse files
feat(archive): load archived tasks as users scroll (#3819)
Co-authored-by: posthog[bot] <206114724+posthog[bot]@users.noreply.github.com> Co-authored-by: richardsolomou <2622273+richardsolomou@users.noreply.github.com>
1 parent 32e516b commit c531d06

5 files changed

Lines changed: 233 additions & 15 deletions

File tree

packages/ui/src/features/archive/ArchivedTasksView.tsx

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import type { RestoreOutcome } from "@posthog/core/archive/archivedTasksControll
1111
import {
1212
type ArchivedTaskWithDetails,
1313
deriveUniqueRepos,
14-
filterAndSortArchivedTasks,
1514
formatRelativeDate,
1615
mergeArchivedWithTasks,
1716
type ArchiveSortColumn as SortColumn,
@@ -35,12 +34,17 @@ import {
3534
} from "@radix-ui/themes";
3635
import { useQuery, useQueryClient } from "@tanstack/react-query";
3736
import { useVirtualizer } from "@tanstack/react-virtual";
38-
import { useMemo, useRef, useState } from "react";
37+
import { useEffect, useMemo, useRef, useState } from "react";
3938
import { useSetHeaderContent } from "../../hooks/useSetHeaderContent";
4039
import { DotsCircleSpinner } from "../../primitives/DotsCircleSpinner";
4140
import { Tooltip } from "../../primitives/Tooltip";
4241
import { toast } from "../../primitives/toast";
43-
import { useTaskSummaries, useTasks } from "../tasks/useTasks";
42+
import { useTasks } from "../tasks/useTasks";
43+
import {
44+
getVisibleArchivedTasks,
45+
shouldLoadMoreArchivedTasks,
46+
} from "./archiveListPagination";
47+
import { useArchivedTaskSummaries } from "./useArchivedTaskSummaries";
4448
import { useUnarchiveTask } from "./useUnarchiveTask";
4549

4650
const ICON_SIZE = 12;
@@ -187,23 +191,31 @@ export type { ArchivedTaskWithDetails };
187191
export interface ArchivedTasksViewPresentationProps {
188192
items: ArchivedTaskWithDetails[];
189193
isLoading: boolean;
194+
loadedCount?: number;
190195
branchNotFound: BranchNotFoundPrompt | null;
191196
onUnarchive: (taskId: string) => void;
192197
onDelete: (taskId: string) => void;
193198
onContextMenu: (item: ArchivedTaskWithDetails, e: React.MouseEvent) => void;
194199
onBranchNotFoundClose: () => void;
195200
onRecreateBranch: () => void;
201+
hasNextPage?: boolean;
202+
isFetchingNextPage?: boolean;
203+
onLoadMore?: () => void;
196204
}
197205

198206
export function ArchivedTasksViewPresentation({
199207
items,
200208
isLoading,
209+
loadedCount = items.length,
201210
branchNotFound,
202211
onUnarchive,
203212
onDelete,
204213
onContextMenu,
205214
onBranchNotFoundClose,
206215
onRecreateBranch,
216+
hasNextPage = false,
217+
isFetchingNextPage = false,
218+
onLoadMore,
207219
}: ArchivedTasksViewPresentationProps) {
208220
const [searchQuery, setSearchQuery] = useState("");
209221
const [sort, setSort] = useState<SortState>({
@@ -232,22 +244,44 @@ export function ArchivedTasksViewPresentation({
232244
[itemsWithRepo],
233245
);
234246

235-
const filteredItems = useMemo(
247+
const visibleItems = useMemo(
236248
() =>
237-
filterAndSortArchivedTasks(itemsWithRepo, {
238-
searchQuery,
239-
repoFilter,
240-
sort,
241-
}),
242-
[itemsWithRepo, searchQuery, repoFilter, sort],
249+
getVisibleArchivedTasks(
250+
itemsWithRepo,
251+
{ searchQuery, repoFilter, sort },
252+
loadedCount,
253+
),
254+
[itemsWithRepo, searchQuery, repoFilter, sort, loadedCount],
243255
);
244256
const rowVirtualizer = useVirtualizer({
245-
count: filteredItems.length,
257+
count: visibleItems.length,
246258
getScrollElement: () => tableViewportRef.current,
247259
estimateSize: () => 37,
248260
overscan: 12,
249261
});
250262
const virtualRows = rowVirtualizer.getVirtualItems();
263+
const lastVirtualRowIndex = virtualRows[virtualRows.length - 1]?.index;
264+
const hasActiveFilter = searchQuery.trim() !== "" || repoFilter !== null;
265+
useEffect(() => {
266+
if (
267+
shouldLoadMoreArchivedTasks(
268+
lastVirtualRowIndex,
269+
visibleItems.length,
270+
hasActiveFilter,
271+
) &&
272+
hasNextPage &&
273+
!isFetchingNextPage
274+
) {
275+
onLoadMore?.();
276+
}
277+
}, [
278+
visibleItems.length,
279+
hasActiveFilter,
280+
hasNextPage,
281+
isFetchingNextPage,
282+
lastVirtualRowIndex,
283+
onLoadMore,
284+
]);
251285
const topSpacerHeight = virtualRows[0]?.start ?? 0;
252286
const bottomSpacerHeight =
253287
rowVirtualizer.getTotalSize() -
@@ -294,7 +328,7 @@ export function ArchivedTasksViewPresentation({
294328
Loading archived tasks...
295329
</Text>
296330
</Flex>
297-
) : filteredItems.length === 0 ? (
331+
) : visibleItems.length === 0 ? (
298332
<Flex align="center" justify="center" py="8">
299333
<Text className="text-[13px] text-gray-10">
300334
{items.length === 0 ? "No archived tasks" : "No matching tasks"}
@@ -337,7 +371,7 @@ export function ArchivedTasksViewPresentation({
337371
</Table.Row>
338372
)}
339373
{virtualRows.map((virtualRow) => {
340-
const item = filteredItems[virtualRow.index];
374+
const item = visibleItems[virtualRow.index];
341375
return (
342376
<Table.Row
343377
key={item.archived.taskId}
@@ -496,8 +530,14 @@ export function ArchivedTasksView() {
496530
() => archivedTasks.map((task) => task.taskId),
497531
[archivedTasks],
498532
);
499-
const { data: archivedTaskDetails = [], isLoading: isLoadingTasks } =
500-
useTaskSummaries(archivedTaskIds);
533+
const {
534+
summaries: archivedTaskDetails,
535+
loadedCount,
536+
isLoading: isLoadingTasks,
537+
hasNextPage,
538+
isFetchingNextPage,
539+
fetchNextPage,
540+
} = useArchivedTaskSummaries(archivedTaskIds);
501541
const { restore, remove, runContextMenuAction } = useUnarchiveTask();
502542

503543
useSetHeaderContent(
@@ -594,12 +634,16 @@ export function ArchivedTasksView() {
594634
<ArchivedTasksViewPresentation
595635
items={items}
596636
isLoading={isLoading}
637+
loadedCount={loadedCount}
597638
branchNotFound={branchNotFound}
598639
onUnarchive={onUnarchive}
599640
onDelete={onDelete}
600641
onContextMenu={handleContextMenu}
601642
onBranchNotFoundClose={() => setBranchNotFound(null)}
602643
onRecreateBranch={handleRecreateBranch}
644+
hasNextPage={hasNextPage}
645+
isFetchingNextPage={isFetchingNextPage}
646+
onLoadMore={() => void fetchNextPage({ cancelRefetch: false })}
603647
/>
604648
);
605649
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import type { ArchivedTaskWithRepo } from "@posthog/core/archive/archiveListView";
2+
import { describe, expect, it } from "vitest";
3+
import {
4+
getVisibleArchivedTasks,
5+
shouldLoadMoreArchivedTasks,
6+
} from "./archiveListPagination";
7+
8+
function item(
9+
id: string,
10+
title: string,
11+
archivedAt: string,
12+
): ArchivedTaskWithRepo {
13+
return {
14+
archived: {
15+
taskId: id,
16+
archivedAt,
17+
folderId: "",
18+
mode: "cloud",
19+
worktreeName: null,
20+
branchName: null,
21+
checkpointId: null,
22+
},
23+
task: {
24+
id,
25+
title,
26+
created_at: archivedAt,
27+
repository: "posthog/code",
28+
},
29+
repoName: "code",
30+
};
31+
}
32+
33+
const defaultSort = { column: "archived", direction: "desc" } as const;
34+
35+
describe("getVisibleArchivedTasks", () => {
36+
it("finds matches outside the unfiltered page prefix", () => {
37+
const items = [
38+
item("first", "First task", "2026-01-03T00:00:00Z"),
39+
item("second", "Matching task", "2026-01-02T00:00:00Z"),
40+
];
41+
42+
expect(
43+
getVisibleArchivedTasks(
44+
items,
45+
{ searchQuery: "matching", repoFilter: null, sort: defaultSort },
46+
1,
47+
).map((entry) => entry.archived.taskId),
48+
).toEqual(["second"]);
49+
});
50+
51+
it("sorts the complete archive before limiting rows", () => {
52+
const items = [
53+
item("older", "Older task", "2026-01-01T00:00:00Z"),
54+
item("newer", "Newer task", "2026-01-03T00:00:00Z"),
55+
];
56+
57+
expect(
58+
getVisibleArchivedTasks(
59+
items,
60+
{ searchQuery: "", repoFilter: null, sort: defaultSort },
61+
1,
62+
).map((entry) => entry.archived.taskId),
63+
).toEqual(["newer"]);
64+
});
65+
});
66+
67+
describe("shouldLoadMoreArchivedTasks", () => {
68+
it("loads near the unfiltered boundary", () => {
69+
expect(shouldLoadMoreArchivedTasks(40, 50, false)).toBe(true);
70+
});
71+
72+
it("does not load from the end of filtered results", () => {
73+
expect(shouldLoadMoreArchivedTasks(0, 1, true)).toBe(false);
74+
});
75+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import {
2+
type ArchivedTaskWithRepo,
3+
type ArchiveFilterSortInput,
4+
filterAndSortArchivedTasks,
5+
} from "@posthog/core/archive/archiveListView";
6+
7+
export function getVisibleArchivedTasks(
8+
items: ArchivedTaskWithRepo[],
9+
filters: ArchiveFilterSortInput,
10+
loadedCount: number,
11+
): ArchivedTaskWithRepo[] {
12+
return filterAndSortArchivedTasks(items, filters).slice(0, loadedCount);
13+
}
14+
15+
export function shouldLoadMoreArchivedTasks(
16+
lastVirtualRowIndex: number | undefined,
17+
visibleItemCount: number,
18+
hasActiveFilter: boolean,
19+
): boolean {
20+
return (
21+
!hasActiveFilter &&
22+
lastVirtualRowIndex !== undefined &&
23+
lastVirtualRowIndex >= visibleItemCount - 10
24+
);
25+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { describe, expect, it } from "vitest";
2+
import { getNextArchivedTaskPage } from "./useArchivedTaskSummaries";
3+
4+
describe("getNextArchivedTaskPage", () => {
5+
it("returns the number of requested tasks as the next offset", () => {
6+
expect(
7+
getNextArchivedTaskPage(
8+
[
9+
{ results: [], requested: 50 },
10+
{ results: [], requested: 50 },
11+
],
12+
125,
13+
),
14+
).toBe(100);
15+
});
16+
17+
it("stops after every archived task has been requested", () => {
18+
expect(
19+
getNextArchivedTaskPage(
20+
[
21+
{ results: [], requested: 50 },
22+
{ results: [], requested: 25 },
23+
],
24+
75,
25+
),
26+
).toBeUndefined();
27+
});
28+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { Schemas } from "@posthog/api-client";
2+
import { useAuthenticatedInfiniteQuery } from "@posthog/ui/hooks/useAuthenticatedInfiniteQuery";
3+
import { useMemo } from "react";
4+
5+
export const ARCHIVED_TASKS_PAGE_SIZE = 50;
6+
7+
export interface ArchivedTaskSummaryPage {
8+
results: Schemas.TaskSummary[];
9+
requested: number;
10+
}
11+
12+
export function getNextArchivedTaskPage(
13+
allPages: ArchivedTaskSummaryPage[],
14+
taskCount: number,
15+
): number | undefined {
16+
const loaded = allPages.reduce((total, page) => total + page.requested, 0);
17+
return loaded < taskCount ? loaded : undefined;
18+
}
19+
20+
export function useArchivedTaskSummaries(ids: string[]) {
21+
const query = useAuthenticatedInfiniteQuery<ArchivedTaskSummaryPage, number>(
22+
["tasks", "archived-summaries", ids],
23+
async (client, offset) => {
24+
const pageIds = ids.slice(offset, offset + ARCHIVED_TASKS_PAGE_SIZE);
25+
return {
26+
results: await client.getTaskSummaries(pageIds),
27+
requested: pageIds.length,
28+
};
29+
},
30+
{
31+
enabled: ids.length > 0,
32+
initialPageParam: 0,
33+
getNextPageParam: (_lastPage, allPages) =>
34+
getNextArchivedTaskPage(allPages, ids.length),
35+
},
36+
);
37+
38+
const summaries = useMemo(
39+
() => query.data?.pages.flatMap((page) => page.results) ?? [],
40+
[query.data?.pages],
41+
);
42+
const loadedCount =
43+
query.data?.pages.reduce((total, page) => total + page.requested, 0) ?? 0;
44+
45+
return { ...query, summaries, loadedCount };
46+
}

0 commit comments

Comments
 (0)