diff --git a/surfsense_backend/app/routes/documents_routes.py b/surfsense_backend/app/routes/documents_routes.py
index 865fdf7b3..bb5df0c13 100644
--- a/surfsense_backend/app/routes/documents_routes.py
+++ b/surfsense_backend/app/routes/documents_routes.py
@@ -320,6 +320,8 @@ async def read_documents(
page_size: int = 50,
search_space_id: int | None = None,
document_types: str | None = None,
+ sort_by: str = "created_at",
+ sort_order: str = "desc",
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
@@ -392,6 +394,19 @@ async def read_documents(
total_result = await session.execute(count_query)
total = total_result.scalar() or 0
+ # Apply sorting
+ from sqlalchemy import asc as sa_asc, desc as sa_desc
+
+ sort_column_map = {
+ "created_at": Document.created_at,
+ "title": Document.title,
+ "document_type": Document.document_type,
+ }
+ sort_col = sort_column_map.get(sort_by, Document.created_at)
+ query = query.order_by(
+ sa_desc(sort_col) if sort_order == "desc" else sa_asc(sort_col)
+ )
+
# Calculate offset
offset = 0
if skip is not None:
diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx
index 277b73f0f..736ea183f 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx
@@ -301,6 +301,7 @@ export function DocumentsTableShell({
hasMore = false,
loadingMore = false,
onLoadMore,
+ isSearchMode = false,
}: {
documents: Document[];
loading: boolean;
@@ -316,6 +317,7 @@ export function DocumentsTableShell({
hasMore?: boolean;
loadingMore?: boolean;
onLoadMore?: () => void;
+ isSearchMode?: boolean;
}) {
const t = useTranslations("documents");
const { openDialog } = useDocumentUploadDialog();
@@ -473,9 +475,9 @@ export function DocumentsTableShell({
- {[65, 80, 45, 72, 55, 88, 40, 60, 50, 75].map((widthPercent, index) => (
+ {[65, 80, 45, 72, 55, 88, 40, 60, 50, 75].map((widthPercent) => (
@@ -500,8 +502,8 @@ export function DocumentsTableShell({
{/* Mobile Skeleton */}
- {[70, 85, 55, 78, 62, 90].map((widthPercent, index) => (
-
+ {[70, 85, 55, 78, 62, 90].map((widthPercent) => (
+
@@ -602,9 +604,9 @@ export function DocumentsTableShell({
searchSpaceId={searchSpaceId}
>
(value: T, delay = 250) {
const [debounced, setDebounced] = useState(value);
@@ -60,17 +60,21 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
const [selectedIds, setSelectedIds] = useState>(new Set());
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
+ // Paginated realtime documents from the hook (server-side sorted)
const {
documents: realtimeDocuments,
typeCounts: realtimeTypeCounts,
loading: realtimeLoading,
+ loadingMore: realtimeLoadingMore,
+ hasMore: realtimeHasMore,
+ loadMore: realtimeLoadMore,
error: realtimeError,
- } = useDocuments(searchSpaceId, activeTypes);
+ } = useDocuments(searchSpaceId, activeTypes, sortKey, sortDesc ? "desc" : "asc");
const isSearchMode = !!debouncedSearch.trim();
- // --- Infinite scroll state ---
- const [visibleCount, setVisibleCount] = useState(INITIAL_LOAD_SIZE);
+ // --- Search mode state ---
+ const searchApiLoadedRef = useRef(0);
const [searchItems, setSearchItems] = useState>([]);
- const [searchTotal, setSearchTotal] = useState(0);
- const [searchPageIndex, setSearchPageIndex] = useState(0);
const [searchLoadingMore, setSearchLoadingMore] = useState(false);
const [searchInitialLoading, setSearchInitialLoading] = useState(false);
+ const [searchHasMore, setSearchHasMore] = useState(false);
const searchQueryRef = useRef(debouncedSearch);
- const sortedRealtimeDocuments = useMemo(() => {
- const docs = [...realtimeDocuments];
- docs.sort((a, b) => {
- const av = a[sortKey] ?? "";
- const bv = b[sortKey] ?? "";
- let cmp: number;
- if (sortKey === "created_at") {
- cmp = new Date(av as string).getTime() - new Date(bv as string).getTime();
- } else {
- cmp = String(av).localeCompare(String(bv));
- }
- return sortDesc ? -cmp : cmp;
- });
- return docs;
- }, [realtimeDocuments, sortKey, sortDesc]);
-
- // Reset visible count when sort/filter changes
- // biome-ignore lint/correctness/useExhaustiveDependencies: intentional reset
- useEffect(() => {
- setVisibleCount(INITIAL_LOAD_SIZE);
- }, [sortKey, sortDesc, activeTypes]);
-
// Initial search fetch when search query changes
useEffect(() => {
if (!isSearchMode || !searchSpaceId || !open) {
setSearchItems([]);
- setSearchTotal(0);
- setSearchPageIndex(0);
+ setSearchHasMore(false);
+ searchApiLoadedRef.current = 0;
return;
}
searchQueryRef.current = debouncedSearch;
setSearchInitialLoading(true);
- const queryParams = {
- search_space_id: searchSpaceId,
- page: 0,
- page_size: INITIAL_LOAD_SIZE,
- title: debouncedSearch.trim(),
- ...(activeTypes.length > 0 && { document_types: activeTypes }),
- };
-
documentsApiService
- .searchDocuments({ queryParams })
+ .searchDocuments({
+ queryParams: {
+ search_space_id: searchSpaceId,
+ page: 0,
+ page_size: SEARCH_INITIAL_SIZE,
+ title: debouncedSearch.trim(),
+ ...(activeTypes.length > 0 && { document_types: activeTypes }),
+ },
+ })
.then((response) => {
if (searchQueryRef.current !== debouncedSearch) return;
const mapped = response.items.map((item) => ({
@@ -150,8 +131,8 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
).status ?? { state: "ready" as const },
}));
setSearchItems(mapped);
- setSearchTotal(response.total);
- setSearchPageIndex(0);
+ setSearchHasMore(response.has_more);
+ searchApiLoadedRef.current = response.items.length;
})
.catch((err) => {
console.error("[DocumentsSidebar] Search failed:", err);
@@ -161,22 +142,21 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
});
}, [debouncedSearch, searchSpaceId, open, isSearchMode, activeTypes]);
- // Load more search results
+ // Load more search results (uses skip for correct offset with mixed page sizes)
const loadMoreSearch = useCallback(async () => {
- if (searchLoadingMore || !isSearchMode) return;
- const nextPage = searchPageIndex + 1;
- if (searchItems.length >= searchTotal) return;
+ if (searchLoadingMore || !isSearchMode || !searchHasMore) return;
setSearchLoadingMore(true);
try {
- const queryParams = {
- search_space_id: searchSpaceId,
- page: nextPage,
- page_size: SCROLL_LOAD_SIZE,
- title: debouncedSearch.trim(),
- ...(activeTypes.length > 0 && { document_types: activeTypes }),
- };
- const response = await documentsApiService.searchDocuments({ queryParams });
+ const response = await documentsApiService.searchDocuments({
+ queryParams: {
+ search_space_id: searchSpaceId,
+ skip: searchApiLoadedRef.current,
+ page_size: SEARCH_SCROLL_SIZE,
+ title: debouncedSearch.trim(),
+ ...(activeTypes.length > 0 && { document_types: activeTypes }),
+ },
+ });
if (searchQueryRef.current !== debouncedSearch) return;
const mapped = response.items.map((item) => ({
@@ -195,36 +175,22 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
).status ?? { state: "ready" as const },
}));
setSearchItems((prev) => [...prev, ...mapped]);
- setSearchTotal(response.total);
- setSearchPageIndex(nextPage);
+ setSearchHasMore(response.has_more);
+ searchApiLoadedRef.current += response.items.length;
} catch (err) {
console.error("[DocumentsSidebar] Load more search failed:", err);
} finally {
setSearchLoadingMore(false);
}
- }, [searchLoadingMore, isSearchMode, searchPageIndex, searchItems.length, searchTotal, searchSpaceId, debouncedSearch, activeTypes]);
+ }, [searchLoadingMore, isSearchMode, searchHasMore, searchSpaceId, debouncedSearch, activeTypes]);
- // Load more for realtime (client-side, just increase visible count)
- const loadMoreRealtime = useCallback(() => {
- setVisibleCount((prev) => Math.min(prev + SCROLL_LOAD_SIZE, sortedRealtimeDocuments.length));
- }, [sortedRealtimeDocuments.length]);
-
- const visibleRealtimeDocs = useMemo(
- () => sortedRealtimeDocuments.slice(0, visibleCount),
- [sortedRealtimeDocuments, visibleCount]
- );
-
- const displayDocs = isSearchMode ? searchItems : visibleRealtimeDocs;
+ // Unified interface — pick between realtime and search mode
+ const displayDocs = isSearchMode ? searchItems : realtimeDocuments;
const loading = isSearchMode ? searchInitialLoading : realtimeLoading;
const error = isSearchMode ? false : realtimeError;
-
- const hasMore = isSearchMode
- ? searchItems.length < searchTotal
- : visibleCount < sortedRealtimeDocuments.length;
-
- const loadingMore = isSearchMode ? searchLoadingMore : false;
-
- const onLoadMore = isSearchMode ? loadMoreSearch : loadMoreRealtime;
+ const hasMore = isSearchMode ? searchHasMore : realtimeHasMore;
+ const loadingMore = isSearchMode ? searchLoadingMore : realtimeLoadingMore;
+ const onLoadMore = isSearchMode ? loadMoreSearch : realtimeLoadMore;
const onToggleType = (type: DocumentTypeEnum, checked: boolean) => {
setActiveTypes((prev) => {
@@ -244,7 +210,7 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
const allDocs = isSearchMode
? searchItems.map((item) => ({ id: item.id, status: item.status }))
- : sortedRealtimeDocuments.map((doc) => ({ id: doc.id, status: doc.status }));
+ : realtimeDocuments.map((doc) => ({ id: doc.id, status: doc.status }));
const selectedDocs = allDocs.filter((doc) => selectedIds.has(doc.id));
const deletableIds = selectedDocs
@@ -286,7 +252,6 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
}
if (isSearchMode) {
setSearchItems((prev) => prev.filter((item) => !deletableIds.includes(item.id)));
- setSearchTotal((prev) => prev - okCount);
}
setSelectedIds(new Set());
} catch (e) {
@@ -302,7 +267,6 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
toast.success(t("delete_success") || "Document deleted");
if (isSearchMode) {
setSearchItems((prev) => prev.filter((item) => item.id !== id));
- setSearchTotal((prev) => prev - 1);
}
return true;
} catch (e) {
@@ -391,6 +355,7 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
hasMore={hasMore}
loadingMore={loadingMore}
onLoadMore={onLoadMore}
+ isSearchMode={isSearchMode}
/>
>
diff --git a/surfsense_web/contracts/types/document.types.ts b/surfsense_web/contracts/types/document.types.ts
index 8f4e4c2fd..ea22b1241 100644
--- a/surfsense_web/contracts/types/document.types.ts
+++ b/surfsense_web/contracts/types/document.types.ts
@@ -92,11 +92,16 @@ export const surfsenseDocsDocumentWithChunks = surfsenseDocsDocument.extend({
/**
* Get documents
*/
+export const documentSortByEnum = z.enum(["created_at", "title", "document_type"]);
+export const sortOrderEnum = z.enum(["asc", "desc"]);
+
export const getDocumentsRequest = z.object({
queryParams: paginationQueryParams
.extend({
search_space_id: z.number().or(z.string()).optional(),
document_types: z.array(documentTypeEnum).optional(),
+ sort_by: documentSortByEnum.optional(),
+ sort_order: sortOrderEnum.optional(),
})
.nullish(),
});
@@ -311,6 +316,8 @@ export type UpdateDocumentResponse = z.infer
;
export type DeleteDocumentRequest = z.infer;
export type DeleteDocumentResponse = z.infer;
export type DocumentTypeEnum = z.infer;
+export type DocumentSortBy = z.infer;
+export type SortOrder = z.infer;
export type SurfsenseDocsChunk = z.infer;
export type SurfsenseDocsDocument = z.infer;
export type SurfsenseDocsDocumentWithChunks = z.infer;
diff --git a/surfsense_web/hooks/use-documents.ts b/surfsense_web/hooks/use-documents.ts
index 36a359696..96243049a 100644
--- a/surfsense_web/hooks/use-documents.ts
+++ b/surfsense_web/hooks/use-documents.ts
@@ -1,22 +1,20 @@
"use client";
-import { useQuery } from "@tanstack/react-query";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import type { DocumentTypeEnum } from "@/contracts/types/document.types";
+import { useCallback, useEffect, useRef, useState } from "react";
+import type {
+ DocumentSortBy,
+ DocumentTypeEnum,
+ SortOrder,
+} from "@/contracts/types/document.types";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import type { SyncHandle } from "@/lib/electric/client";
import { useElectricClient } from "@/lib/electric/context";
-// Stable empty array to prevent infinite re-renders when no typeFilter is provided
-const EMPTY_TYPE_FILTER: DocumentTypeEnum[] = [];
-
-// Document status type (matches backend DocumentStatus JSONB)
export interface DocumentStatusType {
state: "ready" | "pending" | "processing" | "failed";
reason?: string;
}
-// Document from Electric sync (lightweight table columns - NO content/metadata)
interface DocumentElectric {
id: number;
search_space_id: number;
@@ -27,7 +25,6 @@ interface DocumentElectric {
status: DocumentStatusType | null;
}
-// Document for display (with resolved user name and email)
export interface DocumentDisplay {
id: number;
search_space_id: number;
@@ -40,87 +37,57 @@ export interface DocumentDisplay {
status: DocumentStatusType;
}
-/**
- * Deduplicate by ID and sort by created_at descending (newest first)
- */
-function deduplicateAndSort(items: T[]): T[] {
- const seen = new Map();
- for (const item of items) {
- // Keep the most recent version if duplicate
- const existing = seen.get(item.id);
- if (!existing || new Date(item.created_at) > new Date(existing.created_at)) {
- seen.set(item.id, item);
- }
- }
- return Array.from(seen.values()).sort(
- (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
- );
-}
+const EMPTY_TYPE_FILTER: DocumentTypeEnum[] = [];
+const INITIAL_PAGE_SIZE = 20;
+const SCROLL_PAGE_SIZE = 5;
-/**
- * Check if a document has valid/complete data
- */
function isValidDocument(doc: DocumentElectric): boolean {
return doc.id != null && doc.title != null && doc.title !== "";
}
/**
- * Real-time documents hook with Electric SQL
+ * Paginated documents hook with Electric SQL real-time updates.
*
- * Architecture (100% Reliable):
- * 1. API is the PRIMARY source of truth - always loads first
- * 2. Electric provides REAL-TIME updates for additions and deletions
- * 3. Use syncHandle.isUpToDate to determine if deletions can be trusted
- * 4. Handles bulk deletions correctly by checking sync state
+ * Architecture:
+ * 1. API is the PRIMARY data source — fetches pages on demand
+ * 2. Type counts come from a dedicated lightweight API endpoint
+ * 3. Electric provides REAL-TIME updates (new docs, deletions, status changes)
+ * 4. Server-side sorting via sort_by + sort_order params
*
- * Filtering strategy:
- * - Internal state always stores ALL documents (unfiltered)
- * - typeFilter is applied client-side when returning documents
- * - typeCounts always reflect the full dataset so the filter sidebar stays complete
- * - Changing filters is instant (no API re-fetch or Electric re-sync)
- *
- * @param searchSpaceId - The search space ID to filter documents
- * @param typeFilter - Optional document types to filter by (applied client-side)
+ * @param searchSpaceId - The search space to load documents for
+ * @param typeFilter - Document types to filter by (server-side)
+ * @param sortBy - Column to sort by (server-side)
+ * @param sortOrder - Sort direction (server-side)
*/
export function useDocuments(
searchSpaceId: number | null,
- typeFilter: DocumentTypeEnum[] = EMPTY_TYPE_FILTER
+ typeFilter: DocumentTypeEnum[] = EMPTY_TYPE_FILTER,
+ sortBy: DocumentSortBy = "created_at",
+ sortOrder: SortOrder = "desc"
) {
const electricClient = useElectricClient();
- // Internal state: ALL documents (unfiltered)
- const [allDocuments, setAllDocuments] = useState([]);
+ const [documents, setDocuments] = useState([]);
+ const [typeCounts, setTypeCounts] = useState>({});
+ const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [hasMore, setHasMore] = useState(false);
const [error, setError] = useState(null);
- // Track if initial API load is complete (source of truth)
- const apiLoadedRef = useRef(false);
-
- // User cache: userId → displayName / email
+ const apiLoadedCountRef = useRef(0);
+ const initialLoadDoneRef = useRef(false);
+ // Snapshot of all doc IDs from Electric's first callback after initial load.
+ // Anything appearing in subsequent callbacks NOT in this set is genuinely new.
+ const electricBaselineIdsRef = useRef | null>(null);
+ const knownApiIdsRef = useRef>(new Set());
const userCacheRef = useRef