Skip to content
Merged
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
2 changes: 1 addition & 1 deletion apps/code/snapshots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
74 changes: 59 additions & 15 deletions packages/ui/src/features/archive/ArchivedTasksView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import type { RestoreOutcome } from "@posthog/core/archive/archivedTasksControll
import {
type ArchivedTaskWithDetails,
deriveUniqueRepos,
filterAndSortArchivedTasks,
formatRelativeDate,
mergeArchivedWithTasks,
type ArchiveSortColumn as SortColumn,
Expand All @@ -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;
Expand Down Expand Up @@ -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<SortState>({
Expand Down Expand Up @@ -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
Comment thread
richardsolomou marked this conversation as resolved.
) {
onLoadMore?.();
}
}, [
visibleItems.length,
hasActiveFilter,
hasNextPage,
isFetchingNextPage,
lastVirtualRowIndex,
onLoadMore,
]);
const topSpacerHeight = virtualRows[0]?.start ?? 0;
const bottomSpacerHeight =
rowVirtualizer.getTotalSize() -
Expand Down Expand Up @@ -294,7 +328,7 @@ export function ArchivedTasksViewPresentation({
Loading archived tasks...
</Text>
</Flex>
) : filteredItems.length === 0 ? (
) : visibleItems.length === 0 ? (
<Flex align="center" justify="center" py="8">
<Text className="text-[13px] text-gray-10">
{items.length === 0 ? "No archived tasks" : "No matching tasks"}
Expand Down Expand Up @@ -337,7 +371,7 @@ export function ArchivedTasksViewPresentation({
</Table.Row>
)}
{virtualRows.map((virtualRow) => {
const item = filteredItems[virtualRow.index];
const item = visibleItems[virtualRow.index];
return (
<Table.Row
key={item.archived.taskId}
Expand Down Expand Up @@ -496,8 +530,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(
Expand Down Expand Up @@ -594,12 +634,16 @@ export function ArchivedTasksView() {
<ArchivedTasksViewPresentation
items={items}
isLoading={isLoading}
loadedCount={loadedCount}
branchNotFound={branchNotFound}
onUnarchive={onUnarchive}
onDelete={onDelete}
onContextMenu={handleContextMenu}
onBranchNotFoundClose={() => setBranchNotFound(null)}
onRecreateBranch={handleRecreateBranch}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onLoadMore={() => void fetchNextPage({ cancelRefetch: false })}
/>
);
}
75 changes: 75 additions & 0 deletions packages/ui/src/features/archive/archiveListPagination.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
25 changes: 25 additions & 0 deletions packages/ui/src/features/archive/archiveListPagination.ts
Original file line number Diff line number Diff line change
@@ -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
);
}
28 changes: 28 additions & 0 deletions packages/ui/src/features/archive/useArchivedTaskSummaries.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
46 changes: 46 additions & 0 deletions packages/ui/src/features/archive/useArchivedTaskSummaries.ts
Original file line number Diff line number Diff line change
@@ -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<ArchivedTaskSummaryPage, number>(
["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 };
}
Loading