Add pagination to task list (#156)

Co-authored-by: matt <matt@roocode.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Matt Rubens 2025-06-30 15:17:22 -04:00 committed by GitHub
parent 44ee729540
commit fb3df3a976
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 568 additions and 34 deletions

View file

@ -395,19 +395,29 @@ const taskSchema = z.object({
export type TaskWithUser = z.infer<typeof taskSchema> & { user: User };
export type TasksResult = {
tasks: TaskWithUser[];
hasMore: boolean;
nextCursor?: number;
};
export const getTasks = async ({
orgId,
userId,
taskId,
allowCrossUserAccess = false,
skipAuth = false,
limit = 20,
cursor,
}: {
orgId?: string | null;
userId?: string | null;
taskId?: string | null;
allowCrossUserAccess?: boolean;
skipAuth?: boolean;
}): Promise<TaskWithUser[]> => {
limit?: number;
cursor?: number;
}): Promise<TasksResult> => {
let effectiveUserId = userId;
if (!skipAuth) {
@ -422,7 +432,7 @@ export const getTasks = async ({
// For personal accounts, query by userId instead of orgId
// Exception: when skipAuth is true (for public shares), we can query without userId
if (!orgId && !effectiveUserId && !skipAuth) {
return []; // Personal accounts must have a userId unless we're skipping auth
return { tasks: [], hasMore: false }; // Personal accounts must have a userId unless we're skipping auth
}
const userFilter = effectiveUserId ? 'AND e.userId = {userId: String}' : '';
@ -441,12 +451,13 @@ export const getTasks = async ({
? 'orgId IS NULL'
: 'orgId = {orgId: String}';
const queryParams: Record<string, string | string[]> = {
const queryParams: Record<string, string | string[] | number> = {
types: [
TelemetryEventName.TASK_CREATED,
TelemetryEventName.TASK_COMPLETED,
TelemetryEventName.LLM_COMPLETION,
],
limit: limit + 1, // Request one extra to determine hasMore
};
if (orgId) {
@ -461,6 +472,18 @@ export const getTasks = async ({
queryParams.taskId = taskId;
}
if (cursor) {
queryParams.cursor = cursor;
}
// TODO: Handle same-timestamp edge cases
// Currently using only timestamp as cursor, but this can miss/duplicate tasks
// if multiple tasks have the same timestamp at page boundaries.
// Future improvement: use composite cursor (timestamp, taskId, userId)
const havingFilter = cursor
? 'HAVING MIN(e.timestamp) < {cursor: Int32}'
: '';
const results = await analytics.query({
query: `
WITH first_messages AS (
@ -497,7 +520,9 @@ export const getTasks = async ({
${userFilter}
${taskFilter}
GROUP BY 1, 2
${havingFilter}
ORDER BY timestamp DESC
LIMIT {limit: Int32}
`,
format: 'JSONEachRow',
query_params: queryParams,
@ -507,9 +532,25 @@ export const getTasks = async ({
const users = await getUsersById(tasks.map(({ userId }) => userId));
return tasks
const taskWithUsers = tasks
.map((usage) => ({ ...usage, user: users[usage.userId] }))
.filter((usage): usage is TaskWithUser => !!usage.user);
// Calculate hasMore and nextCursor using limit + 1 pattern
const hasMore = taskWithUsers.length === limit + 1;
const nextCursor =
hasMore && taskWithUsers.length > 0
? taskWithUsers[limit - 1]?.timestamp // Use the last item we'll return, not the extra one
: undefined;
// Slice down to the requested limit
const finalTasks = hasMore ? taskWithUsers.slice(0, limit) : taskWithUsers;
return {
tasks: finalTasks,
hasMore,
nextCursor,
};
};
/**

View file

@ -60,8 +60,8 @@ export async function canShareTask(taskId: string): Promise<{
// Handle personal context
if (!orgId) {
// Personal users can only share tasks they created
const tasks = await getTasks({ taskId, orgId: null, userId });
const task = tasks[0];
const result = await getTasks({ taskId, orgId: null, userId });
const task = result.tasks[0];
if (!task || task.userId !== userId) {
return {
@ -77,13 +77,13 @@ export async function canShareTask(taskId: string): Promise<{
// Organization context - existing logic
// Admins can share any task in the organization
if (orgRole === 'org:admin') {
const tasks = await getTasks({
const result = await getTasks({
taskId,
orgId,
allowCrossUserAccess: true,
});
const task = tasks[0];
const task = result.tasks[0];
if (!task) {
return { canShare: false, error: 'Task not found' };
@ -93,8 +93,8 @@ export async function canShareTask(taskId: string): Promise<{
}
// Members can only share tasks they created
const tasks = await getTasks({ taskId, orgId });
const task = tasks[0];
const result = await getTasks({ taskId, orgId });
const task = result.tasks[0];
// Additional check: ensure the task belongs to the requesting user
if (task && task.userId !== userId) {
@ -295,13 +295,13 @@ export async function getTaskByShareToken(token: string): Promise<{
// Get task data based on visibility
// For organization shares, we need to skip auth since the viewer might be a different user
// but they're authorized to view this share within their organization
const tasks = await getTasks({
const result = await getTasks({
taskId: share.taskId,
orgId: share.orgId, // Will be null for personal shares
allowCrossUserAccess: true,
skipAuth: true, // Skip auth for both public and organization shares since we've already validated access above
});
const task = tasks[0];
const task = result.tasks[0];
if (!task) {
return null;
@ -528,13 +528,13 @@ export async function getSharedTaskMessages(
// For public shares, no auth check needed
// Get the task to get the userId
const tasks = await getTasks({
const result = await getTasks({
taskId: share.taskId,
orgId: share.orgId,
allowCrossUserAccess: true,
skipAuth: true, // We've already validated access above
});
const task = tasks[0];
const task = result.tasks[0];
if (!task) {
throw new Error('Task not found');

View file

@ -1,12 +1,14 @@
import { useMemo } from 'react';
import { useMemo, useEffect } from 'react';
import { useAuth } from '@clerk/nextjs';
import { useQuery } from '@tanstack/react-query';
import type { TaskWithUser } from '@/actions/analytics';
import { getTasks } from '@/actions/analytics';
import { useRealtimePolling } from '@/hooks/useRealtimePolling';
import { Skeleton } from '@/components/ui';
import { getTasks } from '@/actions/analytics';
import { Skeleton, CursorPaginationControls } from '@/components/ui';
import { TaskCard } from '@/components/usage';
import { useCursorPagination } from '@/hooks/usePagination';
import type { Filter } from './types';
@ -26,28 +28,46 @@ export const Tasks = ({
const { orgId } = useAuth();
const polling = useRealtimePolling({ enabled: true, interval: 5000 });
const { data = [], isPending } = useQuery({
// Initialize cursor-based pagination
const pagination = useCursorPagination(100);
const { data, isPending } = useQuery({
queryKey: [
'getTasks',
'getTasksPaginated',
orgId,
userRole === 'member' ? currentUserId : null,
!orgId,
pagination.currentCursor,
pagination.pageSize,
],
queryFn: () =>
getTasks({
orgId,
userId: userRole === 'member' ? currentUserId : undefined,
limit: pagination.pageSize,
cursor: pagination.currentCursor,
}),
enabled: true, // Run for both personal and organization context
...polling,
});
// Update cursor when we get new data
useEffect(() => {
if (data?.nextCursor) {
pagination.setNextCursor(data.nextCursor);
}
}, [data?.nextCursor, pagination]);
// Note: The pagination hook automatically handles total updates via the pagination controls
const tasks = useMemo(() => {
const allTasks = data?.tasks || [];
if (!filter) {
return data;
return allTasks;
}
return data.filter((task) => {
return allTasks.filter((task) => {
if (filter.type === 'userId') {
return task.userId === filter.value;
} else if (filter.type === 'model') {
@ -57,7 +77,7 @@ export const Tasks = ({
}
return false;
});
}, [filter, data]);
}, [filter, data?.tasks]);
if (isPending) {
return (
@ -81,15 +101,22 @@ export const Tasks = ({
}
return (
<div className="space-y-3 sm:space-y-4">
{tasks.map((task) => (
<TaskCard
key={task.taskId}
task={task}
onFilter={userRole === 'member' ? undefined : onFilter}
onTaskSelected={onTaskSelected}
/>
))}
<div className="space-y-4">
<div className="space-y-3 sm:space-y-4">
{tasks.map((task) => (
<TaskCard
key={task.taskId}
task={task}
onFilter={userRole === 'member' ? undefined : onFilter}
onTaskSelected={onTaskSelected}
/>
))}
</div>
{/* Cursor Pagination Controls */}
<div className="flex justify-center mt-6">
<CursorPaginationControls pagination={pagination} />
</div>
</div>
);
};

View file

@ -53,10 +53,9 @@ export async function POST(request: NextRequest) {
}
// Verify user has access to the task
const tasks = await getTasks({ orgId, userId });
const task = tasks.find((t) => t.taskId === taskId);
const tasksResult = await getTasks({ orgId, userId, taskId });
if (!task) {
if (tasksResult.tasks.length === 0) {
return NextResponse.json(
{ success: false, error: 'Task not found or access denied' },
{ status: 404 },

View file

@ -0,0 +1,56 @@
import * as React from 'react';
import { Button } from './button';
import type { CursorPaginationControls as CursorPaginationHook } from '@/hooks/usePagination';
interface CursorPaginationControlsProps {
pagination: CursorPaginationHook;
className?: string;
showPageInfo?: boolean;
}
export const CursorPaginationControls: React.FC<
CursorPaginationControlsProps
> = ({ pagination, className = '', showPageInfo = true }) => {
const {
hasNextPage,
hasPreviousPage,
nextPage,
previousPage,
currentPageIndex,
} = pagination;
// Don't show pagination if we're on the first page and there's no next page
if (currentPageIndex === 0 && !hasNextPage) {
return null;
}
return (
<div className={`flex items-center justify-center space-x-2 ${className}`}>
<Button
variant="outline"
size="sm"
onClick={previousPage}
disabled={!hasPreviousPage}
className="px-3 py-2"
>
Previous
</Button>
{showPageInfo && (
<span className="text-sm text-muted-foreground px-4">
Page {currentPageIndex + 1}
</span>
)}
<Button
variant="outline"
size="sm"
onClick={nextPage}
disabled={!hasNextPage}
className="px-3 py-2"
>
Next
</Button>
</div>
);
};

View file

@ -0,0 +1,127 @@
import * as React from 'react';
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from './pagination';
import type { PaginationControls as PaginationHook } from '@/hooks/usePagination';
interface PaginationControlsProps {
pagination: PaginationHook;
className?: string;
}
export const PaginationControls: React.FC<PaginationControlsProps> = ({
pagination,
className,
}) => {
const {
page,
totalPages,
hasNextPage,
hasPreviousPage,
goToPage,
nextPage,
previousPage,
} = pagination;
if (totalPages <= 1) {
return null;
}
// Generate page numbers to display
const getPageNumbers = () => {
const delta = 2; // Number of pages to show on each side of current page
const range = [];
const rangeWithDots = [];
// Always include first page
range.push(1);
// Add pages around current page
for (
let i = Math.max(2, page - delta);
i <= Math.min(totalPages - 1, page + delta);
i++
) {
range.push(i);
}
// Always include last page if it's not already included
if (totalPages > 1) {
range.push(totalPages);
}
// Remove duplicates and sort
const uniqueRange = Array.from(new Set(range)).sort((a, b) => a - b);
// Add ellipsis where there are gaps
let lastPage = 0;
for (const pageNum of uniqueRange) {
if (pageNum - lastPage > 1) {
rangeWithDots.push('ellipsis');
}
rangeWithDots.push(pageNum);
lastPage = pageNum;
}
return rangeWithDots;
};
const pageNumbers = getPageNumbers();
return (
<Pagination className={className}>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => {
e.preventDefault();
if (hasPreviousPage) {
previousPage();
}
}}
className={!hasPreviousPage ? 'pointer-events-none opacity-50' : ''}
/>
</PaginationItem>
{pageNumbers.map((pageNum, index) => (
<PaginationItem key={index}>
{pageNum === 'ellipsis' ? (
<PaginationEllipsis />
) : (
<PaginationLink
href="#"
onClick={(e) => {
e.preventDefault();
goToPage(pageNum as number);
}}
isActive={pageNum === page}
>
{pageNum}
</PaginationLink>
)}
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => {
e.preventDefault();
if (hasNextPage) {
nextPage();
}
}}
className={!hasNextPage ? 'pointer-events-none opacity-50' : ''}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
);
};

View file

@ -9,6 +9,9 @@ export * from './dropdown-menu';
export * from './form';
export * from './input';
export * from './label';
export * from './pagination';
export * from './PaginationControls';
export * from './CursorPaginationControls';
export * from './popover';
export * from './separator';
export * from './skeleton';

View file

@ -0,0 +1,114 @@
import * as React from 'react';
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils';
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
<nav
role="navigation"
aria-label="pagination"
className={cn('mx-auto flex w-full justify-center', className)}
{...props}
/>
);
Pagination.displayName = 'Pagination';
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<'ul'>
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn('flex flex-row items-center gap-1', className)}
{...props}
/>
));
PaginationContent.displayName = 'PaginationContent';
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<'li'>
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn('', className)} {...props} />
));
PaginationItem.displayName = 'PaginationItem';
type PaginationLinkProps = {
isActive?: boolean;
size?: 'default' | 'sm' | 'lg' | 'icon';
} & React.ComponentProps<'a'>;
const PaginationLink = ({
className,
isActive,
size: _size = 'icon',
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? 'page' : undefined}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 w-10',
isActive && 'bg-primary text-primary-foreground hover:bg-primary/90',
className,
)}
{...props}
/>
);
PaginationLink.displayName = 'PaginationLink';
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn('gap-1 pl-2.5', className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
);
PaginationPrevious.displayName = 'PaginationPrevious';
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn('gap-1 pr-2.5', className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
);
PaginationNext.displayName = 'PaginationNext';
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<'span'>) => (
<span
aria-hidden
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
);
PaginationEllipsis.displayName = 'PaginationEllipsis';
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
};

View file

@ -0,0 +1,167 @@
import { useState, useMemo, useCallback } from 'react';
export interface CursorPaginationControls {
pageSize: number;
cursors: number[];
currentPageIndex: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
currentCursor: number | undefined;
nextPage: () => void;
previousPage: () => void;
reset: () => void;
setNextCursor: (cursor: number | undefined) => void;
setPageSize: (size: number) => void;
}
export const useCursorPagination = (
initialPageSize: number = 20,
): CursorPaginationControls => {
const [pageSize, setPageSize] = useState(initialPageSize);
const [cursors, setCursors] = useState<number[]>([]);
const [currentPageIndex, setCurrentPageIndex] = useState(0);
const currentCursor = cursors[currentPageIndex];
const hasNextPage = currentPageIndex < cursors.length - 1;
const hasPreviousPage = currentPageIndex > 0;
const nextPage = useCallback(() => {
if (hasNextPage) {
setCurrentPageIndex((prev) => prev + 1);
}
}, [hasNextPage]);
const previousPage = useCallback(() => {
if (hasPreviousPage) {
setCurrentPageIndex((prev) => prev - 1);
}
}, [hasPreviousPage]);
const setNextCursor = useCallback(
(cursor: number | undefined) => {
if (cursor !== undefined) {
setCursors((prev) => {
// Only add the cursor if it's not already the next one
const nextIndex = currentPageIndex + 1;
if (prev[nextIndex] !== cursor) {
const newCursors = [...prev];
newCursors[nextIndex] = cursor;
// Remove any cursors beyond this point (in case data changed)
return newCursors.slice(0, nextIndex + 1);
}
return prev;
});
}
},
[currentPageIndex],
);
const reset = useCallback(() => {
setCursors([]);
setCurrentPageIndex(0);
}, []);
const handleSetPageSize = useCallback(
(size: number) => {
setPageSize(size);
reset(); // Reset pagination when page size changes
},
[reset],
);
return useMemo(
() => ({
pageSize,
cursors,
currentPageIndex,
hasNextPage,
hasPreviousPage,
currentCursor,
nextPage,
previousPage,
reset,
setNextCursor,
setPageSize: handleSetPageSize,
}),
[
pageSize,
cursors,
currentPageIndex,
hasNextPage,
hasPreviousPage,
currentCursor,
nextPage,
previousPage,
reset,
setNextCursor,
handleSetPageSize,
],
);
};
// Keep the original hook for backward compatibility
export interface PaginationConfig {
page: number;
pageSize: number;
total: number;
}
export interface PaginationControls {
page: number;
pageSize: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
offset: number;
goToPage: (page: number) => void;
nextPage: () => void;
previousPage: () => void;
setPageSize: (size: number) => void;
}
export const usePagination = (
initialPageSize: number = 20,
total: number = 0,
): PaginationControls => {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(initialPageSize);
const pagination = useMemo(() => {
const totalPages = Math.ceil(total / pageSize);
const hasNextPage = page < totalPages;
const hasPreviousPage = page > 1;
const offset = (page - 1) * pageSize;
return {
page,
pageSize,
total,
totalPages,
hasNextPage,
hasPreviousPage,
offset,
goToPage: (newPage: number) => {
if (newPage >= 1 && newPage <= totalPages) {
setPage(newPage);
}
},
nextPage: () => {
if (hasNextPage) {
setPage(page + 1);
}
},
previousPage: () => {
if (hasPreviousPage) {
setPage(page - 1);
}
},
setPageSize: (size: number) => {
setPageSize(size);
setPage(1); // Reset to first page when changing page size
},
};
}, [page, pageSize, total]);
return pagination;
};