diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 37f101f79..076256e25 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -34,6 +34,7 @@ import { Input } from "@/components/ui/input"; import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types"; import { useAnnouncements } from "@/hooks/use-announcements"; import { useInbox } from "@/hooks/use-inbox"; +import { useDocumentsProcessing } from "@/hooks/use-documents-processing"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { logout } from "@/lib/auth-utils"; import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-persistence"; @@ -129,6 +130,9 @@ export function LayoutDataProvider({ const totalUnreadCount = commentsInbox.unreadCount + statusInbox.unreadCount; + // Whether any documents are currently being uploaded/indexed — drives sidebar spinner + const isDocumentsProcessing = useDocumentsProcessing(numericSpaceId); + // Track seen notification IDs to detect new page_limit_exceeded notifications const seenPageLimitNotifications = useRef>(new Set()); const isInitialLoad = useRef(true); @@ -266,6 +270,7 @@ export function LayoutDataProvider({ url: "#documents", icon: SquareLibrary, isActive: isDocumentsSidebarOpen, + showSpinner: isDocumentsProcessing, }, { title: "Announcements", @@ -281,6 +286,7 @@ export function LayoutDataProvider({ isDocumentsSidebarOpen, totalUnreadCount, announcementUnreadCount, + isDocumentsProcessing, ] ); diff --git a/surfsense_web/components/layout/types/layout.types.ts b/surfsense_web/components/layout/types/layout.types.ts index c58fb5b54..063a2d38f 100644 --- a/surfsense_web/components/layout/types/layout.types.ts +++ b/surfsense_web/components/layout/types/layout.types.ts @@ -21,6 +21,7 @@ export interface NavItem { icon: LucideIcon; isActive?: boolean; badge?: string | number; + showSpinner?: boolean; } export interface ChatItem { diff --git a/surfsense_web/components/layout/ui/header/Header.tsx b/surfsense_web/components/layout/ui/header/Header.tsx index 166bf6ed0..4063ffa63 100644 --- a/surfsense_web/components/layout/ui/header/Header.tsx +++ b/surfsense_web/components/layout/ui/header/Header.tsx @@ -39,7 +39,7 @@ export function Header({ mobileMenuTrigger }: HeaderProps) { const handleVisibilityChange = (_visibility: ChatVisibility) => {}; return ( -
+
{/* Left side - Mobile menu trigger + Model selector */}
{mobileMenuTrigger} diff --git a/surfsense_web/components/layout/ui/sidebar/NavSection.tsx b/surfsense_web/components/layout/ui/sidebar/NavSection.tsx index 8bf83ac11..fa35b16f7 100644 --- a/surfsense_web/components/layout/ui/sidebar/NavSection.tsx +++ b/surfsense_web/components/layout/ui/sidebar/NavSection.tsx @@ -1,5 +1,6 @@ "use client"; +import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import type { NavItem } from "../../types/layout.types"; @@ -39,11 +40,15 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti {...joyrideAttr} > - {item.badge && ( + {item.showSpinner ? ( + + + + ) : item.badge ? ( {item.badge} - )} + ) : null} {item.title} @@ -67,7 +72,11 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti )} {...joyrideAttr} > - + {item.showSpinner ? ( + + ) : ( + + )} {item.title} {item.badge && ( diff --git a/surfsense_web/hooks/use-documents-processing.ts b/surfsense_web/hooks/use-documents-processing.ts new file mode 100644 index 000000000..8d95f65d8 --- /dev/null +++ b/surfsense_web/hooks/use-documents-processing.ts @@ -0,0 +1,118 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useElectricClient } from "@/lib/electric/context"; + +/** + * Returns whether any documents in the search space are currently being + * uploaded or indexed (status = "pending" | "processing"). + * + * Covers both manual file uploads (2-phase pattern) and all connector indexers, + * since both create documents with status = pending before processing. + * + * The sync shape uses the same columns as useDocuments so Electric can share + * the subscription when both hooks are active simultaneously. + */ +export function useDocumentsProcessing(searchSpaceId: number | null): boolean { + const electricClient = useElectricClient(); + const [isProcessing, setIsProcessing] = useState(false); + const liveQueryRef = useRef<{ unsubscribe?: () => void } | null>(null); + + useEffect(() => { + if (!searchSpaceId || !electricClient) return; + + const spaceId = searchSpaceId; + const client = electricClient; + let mounted = true; + + async function setup() { + if (liveQueryRef.current) { + try { + liveQueryRef.current.unsubscribe?.(); + } catch { + /* PGlite may be closed */ + } + liveQueryRef.current = null; + } + + try { + const handle = await client.syncShape({ + table: "documents", + where: `search_space_id = ${spaceId}`, + columns: [ + "id", + "document_type", + "search_space_id", + "title", + "created_by_id", + "created_at", + "status", + ], + primaryKey: ["id"], + }); + + if (!mounted) return; + + if (!handle.isUpToDate && handle.initialSyncPromise) { + await Promise.race([ + handle.initialSyncPromise, + new Promise((resolve) => setTimeout(resolve, 5000)), + ]); + } + + if (!mounted) return; + + const db = client.db as { + live?: { + query: ( + sql: string, + params?: (number | string)[], + ) => Promise<{ + subscribe: (cb: (result: { rows: T[] }) => void) => void; + unsubscribe?: () => void; + }>; + }; + }; + + if (!db.live?.query) return; + + const liveQuery = await db.live.query<{ count: number | string }>( + `SELECT COUNT(*) as count FROM documents + WHERE search_space_id = $1 + AND (status->>'state' = 'pending' OR status->>'state' = 'processing')`, + [spaceId], + ); + + if (!mounted) { + liveQuery.unsubscribe?.(); + return; + } + + liveQuery.subscribe((result: { rows: Array<{ count: number | string }> }) => { + if (!mounted || !result.rows?.[0]) return; + setIsProcessing((Number(result.rows[0].count) || 0) > 0); + }); + + liveQueryRef.current = liveQuery; + } catch (err) { + console.error("[useDocumentsProcessing] Electric setup failed:", err); + } + } + + setup(); + + return () => { + mounted = false; + if (liveQueryRef.current) { + try { + liveQueryRef.current.unsubscribe?.(); + } catch { + /* PGlite may be closed */ + } + liveQueryRef.current = null; + } + }; + }, [searchSpaceId, electricClient]); + + return isProcessing; +}