diff --git a/apps/web/src/actions/analytics/events.ts b/apps/web/src/actions/analytics/events.ts index ab48f09f60..3c32079bf2 100644 --- a/apps/web/src/actions/analytics/events.ts +++ b/apps/web/src/actions/analytics/events.ts @@ -395,19 +395,29 @@ const taskSchema = z.object({ export type TaskWithUser = z.infer & { 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 => { + limit?: number; + cursor?: number; +}): Promise => { 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 = { + const queryParams: Record = { 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, + }; }; /** diff --git a/apps/web/src/actions/taskSharing.ts b/apps/web/src/actions/taskSharing.ts index 92ec0a8a21..9d108b9da1 100644 --- a/apps/web/src/actions/taskSharing.ts +++ b/apps/web/src/actions/taskSharing.ts @@ -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'); diff --git a/apps/web/src/app/(authenticated)/usage/Tasks.tsx b/apps/web/src/app/(authenticated)/usage/Tasks.tsx index 68056a99f6..9509a0fd7e 100644 --- a/apps/web/src/app/(authenticated)/usage/Tasks.tsx +++ b/apps/web/src/app/(authenticated)/usage/Tasks.tsx @@ -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 ( -
- {tasks.map((task) => ( - - ))} +
+
+ {tasks.map((task) => ( + + ))} +
+ + {/* Cursor Pagination Controls */} +
+ +
); }; diff --git a/apps/web/src/app/api/extension/share/route.ts b/apps/web/src/app/api/extension/share/route.ts index 6824f831dd..4b9f1a4b27 100644 --- a/apps/web/src/app/api/extension/share/route.ts +++ b/apps/web/src/app/api/extension/share/route.ts @@ -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 }, diff --git a/apps/web/src/components/ui/CursorPaginationControls.tsx b/apps/web/src/components/ui/CursorPaginationControls.tsx new file mode 100644 index 0000000000..d05f2d663f --- /dev/null +++ b/apps/web/src/components/ui/CursorPaginationControls.tsx @@ -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 ( +
+ + + {showPageInfo && ( + + Page {currentPageIndex + 1} + + )} + + +
+ ); +}; diff --git a/apps/web/src/components/ui/PaginationControls.tsx b/apps/web/src/components/ui/PaginationControls.tsx new file mode 100644 index 0000000000..0cbb5448a2 --- /dev/null +++ b/apps/web/src/components/ui/PaginationControls.tsx @@ -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 = ({ + 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 ( + + + + { + e.preventDefault(); + if (hasPreviousPage) { + previousPage(); + } + }} + className={!hasPreviousPage ? 'pointer-events-none opacity-50' : ''} + /> + + + {pageNumbers.map((pageNum, index) => ( + + {pageNum === 'ellipsis' ? ( + + ) : ( + { + e.preventDefault(); + goToPage(pageNum as number); + }} + isActive={pageNum === page} + > + {pageNum} + + )} + + ))} + + + { + e.preventDefault(); + if (hasNextPage) { + nextPage(); + } + }} + className={!hasNextPage ? 'pointer-events-none opacity-50' : ''} + /> + + + + ); +}; diff --git a/apps/web/src/components/ui/index.ts b/apps/web/src/components/ui/index.ts index 8b74d2277f..edd4fd1db9 100644 --- a/apps/web/src/components/ui/index.ts +++ b/apps/web/src/components/ui/index.ts @@ -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'; diff --git a/apps/web/src/components/ui/pagination.tsx b/apps/web/src/components/ui/pagination.tsx new file mode 100644 index 0000000000..67e37aa820 --- /dev/null +++ b/apps/web/src/components/ui/pagination.tsx @@ -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'>) => ( +