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
This commit is contained in:
LUIS NOVO 2025-12-14 10:56:38 -03:00
parent 53cbea6809
commit e5eeb90341
3 changed files with 113 additions and 11 deletions

View file

@ -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}
/>
</div>

View file

@ -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<string, ContextMode>
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<HTMLDivElement>(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({
</div>
</CardHeader>
<CardContent className="flex-1 overflow-y-auto min-h-0">
<CardContent ref={scrollContainerRef} className="flex-1 overflow-y-auto min-h-0">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<LoadingSpinner />
@ -179,6 +210,12 @@ export function SourcesColumn({
}
/>
))}
{/* Loading indicator for infinite scroll */}
{isFetchingNextPage && (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
</div>
)}
</CardContent>

View file

@ -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),