From e5eeb9034149f99123de451ac7e4740b0aed62a4 Mon Sep 17 00:00:00 2001 From: LUIS NOVO Date: Sun, 14 Dec 2025 10:56:38 -0300 Subject: [PATCH] feat(ui): add infinite scroll for notebook sources Previously, notebook sources were limited to 50 items due to API default. This adds pagination with infinite scroll to the sources column: - Add useNotebookSources hook with useInfiniteQuery for paginated fetching - Update SourcesColumn with scroll detection to load more sources - Fetch 30 sources per page, loading more as user scrolls near bottom --- .../app/(dashboard)/notebooks/[id]/page.tsx | 17 ++++- .../notebooks/components/SourcesColumn.tsx | 45 ++++++++++++-- frontend/src/lib/hooks/use-sources.ts | 62 +++++++++++++++++-- 3 files changed, 113 insertions(+), 11 deletions(-) diff --git a/frontend/src/app/(dashboard)/notebooks/[id]/page.tsx b/frontend/src/app/(dashboard)/notebooks/[id]/page.tsx index 7f598e2..351e264 100644 --- a/frontend/src/app/(dashboard)/notebooks/[id]/page.tsx +++ b/frontend/src/app/(dashboard)/notebooks/[id]/page.tsx @@ -8,7 +8,7 @@ import { SourcesColumn } from '../components/SourcesColumn' import { NotesColumn } from '../components/NotesColumn' import { ChatColumn } from '../components/ChatColumn' import { useNotebook } from '@/lib/hooks/use-notebooks' -import { useSources } from '@/lib/hooks/use-sources' +import { useNotebookSources } from '@/lib/hooks/use-sources' import { useNotes } from '@/lib/hooks/use-notes' import { LoadingSpinner } from '@/components/common/LoadingSpinner' import { useNotebookColumnsStore } from '@/lib/stores/notebook-columns-store' @@ -31,7 +31,14 @@ export default function NotebookPage() { const notebookId = decodeURIComponent(params.id as string) const { data: notebook, isLoading: notebookLoading } = useNotebook(notebookId) - const { data: sources, isLoading: sourcesLoading, refetch: refetchSources } = useSources(notebookId) + const { + sources, + isLoading: sourcesLoading, + refetch: refetchSources, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = useNotebookSources(notebookId) const { data: notes, isLoading: notesLoading } = useNotes(notebookId) // Get collapse states for dynamic layout @@ -153,6 +160,9 @@ export default function NotebookPage() { onRefresh={refetchSources} contextSelections={contextSelections.sources} onContextModeChange={(sourceId, mode) => handleContextModeChange(sourceId, mode, 'source')} + hasNextPage={hasNextPage} + isFetchingNextPage={isFetchingNextPage} + fetchNextPage={fetchNextPage} /> )} {mobileActiveTab === 'notes' && ( @@ -192,6 +202,9 @@ export default function NotebookPage() { onRefresh={refetchSources} contextSelections={contextSelections.sources} onContextModeChange={(sourceId, mode) => handleContextModeChange(sourceId, mode, 'source')} + hasNextPage={hasNextPage} + isFetchingNextPage={isFetchingNextPage} + fetchNextPage={fetchNextPage} /> diff --git a/frontend/src/app/(dashboard)/notebooks/components/SourcesColumn.tsx b/frontend/src/app/(dashboard)/notebooks/components/SourcesColumn.tsx index 04bd305..58f6add 100644 --- a/frontend/src/app/(dashboard)/notebooks/components/SourcesColumn.tsx +++ b/frontend/src/app/(dashboard)/notebooks/components/SourcesColumn.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useMemo } from 'react' +import { useState, useMemo, useRef, useCallback, useEffect } from 'react' import { SourceListResponse } from '@/lib/types/api' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' @@ -10,7 +10,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' -import { Plus, FileText, Link2, ChevronDown } from 'lucide-react' +import { Plus, FileText, Link2, ChevronDown, Loader2 } from 'lucide-react' import { LoadingSpinner } from '@/components/common/LoadingSpinner' import { EmptyState } from '@/components/common/EmptyState' import { AddSourceDialog } from '@/components/sources/AddSourceDialog' @@ -31,6 +31,10 @@ interface SourcesColumnProps { onRefresh?: () => void contextSelections?: Record onContextModeChange?: (sourceId: string, mode: ContextMode) => void + // Pagination props + hasNextPage?: boolean + isFetchingNextPage?: boolean + fetchNextPage?: () => void } export function SourcesColumn({ @@ -39,7 +43,10 @@ export function SourcesColumn({ notebookId, onRefresh, contextSelections, - onContextModeChange + onContextModeChange, + hasNextPage, + isFetchingNextPage, + fetchNextPage, }: SourcesColumnProps) { const [dropdownOpen, setDropdownOpen] = useState(false) const [addDialogOpen, setAddDialogOpen] = useState(false) @@ -60,6 +67,30 @@ export function SourcesColumn({ () => createCollapseButton(toggleSources, 'Sources'), [toggleSources] ) + + // Scroll container ref for infinite scroll + const scrollContainerRef = useRef(null) + + // Handle scroll for infinite loading + const handleScroll = useCallback(() => { + const container = scrollContainerRef.current + if (!container || !hasNextPage || isFetchingNextPage || !fetchNextPage) return + + const { scrollTop, scrollHeight, clientHeight } = container + // Load more when user scrolls within 200px of the bottom + if (scrollHeight - scrollTop - clientHeight < 200) { + fetchNextPage() + } + }, [hasNextPage, isFetchingNextPage, fetchNextPage]) + + // Attach scroll listener + useEffect(() => { + const container = scrollContainerRef.current + if (!container) return + + container.addEventListener('scroll', handleScroll) + return () => container.removeEventListener('scroll', handleScroll) + }, [handleScroll]) const handleDeleteClick = (sourceId: string) => { setSourceToDelete(sourceId) @@ -149,7 +180,7 @@ export function SourcesColumn({ - + {isLoading ? (
@@ -179,6 +210,12 @@ export function SourcesColumn({ } /> ))} + {/* Loading indicator for infinite scroll */} + {isFetchingNextPage && ( +
+ +
+ )}
)}
diff --git a/frontend/src/lib/hooks/use-sources.ts b/frontend/src/lib/hooks/use-sources.ts index ab2ba42..f2710af 100644 --- a/frontend/src/lib/hooks/use-sources.ts +++ b/frontend/src/lib/hooks/use-sources.ts @@ -1,14 +1,18 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query' +import { useCallback } from 'react' import { sourcesApi } from '@/lib/api/sources' import { QUERY_KEYS } from '@/lib/api/query-client' import { useToast } from '@/lib/hooks/use-toast' -import { - CreateSourceRequest, - UpdateSourceRequest, +import { + CreateSourceRequest, + UpdateSourceRequest, SourceResponse, - SourceStatusResponse + SourceStatusResponse, + SourceListResponse } from '@/lib/types/api' +const NOTEBOOK_SOURCES_PAGE_SIZE = 30 + export function useSources(notebookId?: string) { return useQuery({ queryKey: QUERY_KEYS.sources(notebookId), @@ -19,6 +23,54 @@ export function useSources(notebookId?: string) { }) } +/** + * Hook for fetching notebook sources with infinite scroll pagination. + * Returns flattened sources array and pagination controls. + */ +export function useNotebookSources(notebookId: string) { + const queryClient = useQueryClient() + + const query = useInfiniteQuery({ + queryKey: ['notebookSources', notebookId], + queryFn: async ({ pageParam = 0 }) => { + const data = await sourcesApi.list({ + notebook_id: notebookId, + limit: NOTEBOOK_SOURCES_PAGE_SIZE, + offset: pageParam, + sort_by: 'updated', + sort_order: 'desc', + }) + return { + sources: data, + nextOffset: data.length === NOTEBOOK_SOURCES_PAGE_SIZE ? pageParam + data.length : undefined, + } + }, + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextOffset, + enabled: !!notebookId, + staleTime: 5 * 1000, + refetchOnWindowFocus: true, + }) + + // Flatten all pages into a single array + const sources: SourceListResponse[] = query.data?.pages.flatMap(page => page.sources) ?? [] + + // Refetch function that resets to first page + const refetch = useCallback(() => { + queryClient.invalidateQueries({ queryKey: ['notebookSources', notebookId] }) + }, [queryClient, notebookId]) + + return { + sources, + isLoading: query.isLoading, + isFetchingNextPage: query.isFetchingNextPage, + hasNextPage: query.hasNextPage, + fetchNextPage: query.fetchNextPage, + refetch, + error: query.error, + } +} + export function useSource(id: string) { return useQuery({ queryKey: QUERY_KEYS.source(id),