diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 65143669e..22bbf6015 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -132,8 +132,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid const totalUnreadCount = commentsInbox.unreadCount + statusInbox.unreadCount; - // Whether any documents are currently being uploaded/indexed — drives sidebar spinner - const isDocumentsProcessing = useDocumentsProcessing(numericSpaceId); + // Document processing status — drives sidebar status indicator (spinner / check / error) + const documentsProcessingStatus = useDocumentsProcessing(numericSpaceId); // Track seen notification IDs to detect new page_limit_exceeded notifications const seenPageLimitNotifications = useRef>(new Set()); @@ -271,7 +271,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid url: "#documents", icon: SquareLibrary, isActive: isDocumentsSidebarOpen, - showSpinner: isDocumentsProcessing, + statusIndicator: documentsProcessingStatus, }, { title: "Announcements", @@ -287,7 +287,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid totalUnreadCount, isAnnouncementsSidebarOpen, announcementUnreadCount, - isDocumentsProcessing, + documentsProcessingStatus, ] ); diff --git a/surfsense_web/components/layout/types/layout.types.ts b/surfsense_web/components/layout/types/layout.types.ts index 063a2d38f..720aaecf1 100644 --- a/surfsense_web/components/layout/types/layout.types.ts +++ b/surfsense_web/components/layout/types/layout.types.ts @@ -1,4 +1,5 @@ import type { LucideIcon } from "lucide-react"; +import type { DocumentsProcessingStatus } from "@/hooks/use-documents-processing"; export interface SearchSpace { id: number; @@ -21,7 +22,7 @@ export interface NavItem { icon: LucideIcon; isActive?: boolean; badge?: string | number; - showSpinner?: boolean; + statusIndicator?: DocumentsProcessingStatus; } export interface ChatItem { diff --git a/surfsense_web/components/layout/ui/sidebar/NavSection.tsx b/surfsense_web/components/layout/ui/sidebar/NavSection.tsx index fa35b16f7..2ab1e6148 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 { CheckCircle2, CircleAlert } from "lucide-react"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; @@ -11,13 +12,55 @@ interface NavSectionProps { isCollapsed?: boolean; } +function StatusBadge({ status }: { status: NavItem["statusIndicator"] }) { + if (status === "processing") { + return ( + + + + ); + } + if (status === "success") { + return ( + + + + ); + } + if (status === "error") { + return ( + + + + ); + } + return null; +} + +function StatusIcon({ status, FallbackIcon, className }: { + status: NavItem["statusIndicator"]; + FallbackIcon: NavItem["icon"]; + className?: string; +}) { + if (status === "processing") { + return ; + } + if (status === "success") { + return ; + } + if (status === "error") { + return ; + } + return ; +} + export function NavSection({ items, onItemClick, isCollapsed = false }: NavSectionProps) { return (
{items.map((item) => { const Icon = item.icon; + const indicator = item.statusIndicator; - // Add data-joyride for onboarding tour const joyrideAttr = item.title === "Documents" || item.title.toLowerCase().includes("documents") ? { "data-joyride": "documents-sidebar" } @@ -40,10 +83,8 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti {...joyrideAttr} > - {item.showSpinner ? ( - - - + {indicator && indicator !== "idle" ? ( + ) : item.badge ? ( {item.badge} @@ -72,11 +113,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 index d1788a1b5..f7a09c36a 100644 --- a/surfsense_web/hooks/use-documents-processing.ts +++ b/surfsense_web/hooks/use-documents-processing.ts @@ -3,20 +3,23 @@ import { useEffect, useRef, useState } from "react"; import { useElectricClient } from "@/lib/electric/context"; +export type DocumentsProcessingStatus = "idle" | "processing" | "success" | "error"; + +const SUCCESS_LINGER_MS = 5000; + /** - * 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. + * Returns the processing status of documents in the search space: + * - "processing" — at least one doc is pending/processing (show spinner) + * - "error" — nothing processing, but failed docs exist (show red icon) + * - "success" — just transitioned from processing → all clear (green check, auto-dismisses) + * - "idle" — nothing noteworthy (show normal icon) */ -export function useDocumentsProcessing(searchSpaceId: number | null): boolean { +export function useDocumentsProcessing(searchSpaceId: number | null): DocumentsProcessingStatus { const electricClient = useElectricClient(); - const [isProcessing, setIsProcessing] = useState(false); + const [status, setStatus] = useState("idle"); const liveQueryRef = useRef<{ unsubscribe?: () => void } | null>(null); + const wasProcessingRef = useRef(false); + const successTimerRef = useRef | null>(null); useEffect(() => { if (!searchSpaceId || !electricClient) return; @@ -76,10 +79,15 @@ export function useDocumentsProcessing(searchSpaceId: number | null): boolean { 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')`, + const liveQuery = await db.live.query<{ + processing_count: number | string; + failed_count: number | string; + }>( + `SELECT + SUM(CASE WHEN status->>'state' IN ('pending', 'processing') THEN 1 ELSE 0 END) AS processing_count, + SUM(CASE WHEN status->>'state' = 'failed' THEN 1 ELSE 0 END) AS failed_count + FROM documents + WHERE search_space_id = $1`, [spaceId] ); @@ -88,10 +96,44 @@ export function useDocumentsProcessing(searchSpaceId: number | null): boolean { return; } - liveQuery.subscribe((result: { rows: Array<{ count: number | string }> }) => { - if (!mounted || !result.rows?.[0]) return; - setIsProcessing((Number(result.rows[0].count) || 0) > 0); - }); + liveQuery.subscribe( + (result: { rows: Array<{ processing_count: number | string; failed_count: number | string }> }) => { + if (!mounted || !result.rows?.[0]) return; + + const processingCount = Number(result.rows[0].processing_count) || 0; + const failedCount = Number(result.rows[0].failed_count) || 0; + + if (processingCount > 0) { + wasProcessingRef.current = true; + if (successTimerRef.current) { + clearTimeout(successTimerRef.current); + successTimerRef.current = null; + } + setStatus("processing"); + } else if (failedCount > 0) { + wasProcessingRef.current = false; + if (successTimerRef.current) { + clearTimeout(successTimerRef.current); + successTimerRef.current = null; + } + setStatus("error"); + } else if (wasProcessingRef.current) { + wasProcessingRef.current = false; + setStatus("success"); + if (successTimerRef.current) { + clearTimeout(successTimerRef.current); + } + successTimerRef.current = setTimeout(() => { + if (mounted) { + setStatus("idle"); + successTimerRef.current = null; + } + }, SUCCESS_LINGER_MS); + } else { + setStatus("idle"); + } + } + ); liveQueryRef.current = liveQuery; } catch (err) { @@ -103,6 +145,10 @@ export function useDocumentsProcessing(searchSpaceId: number | null): boolean { return () => { mounted = false; + if (successTimerRef.current) { + clearTimeout(successTimerRef.current); + successTimerRef.current = null; + } if (liveQueryRef.current) { try { liveQueryRef.current.unsubscribe?.(); @@ -114,5 +160,5 @@ export function useDocumentsProcessing(searchSpaceId: number | null): boolean { }; }, [searchSpaceId, electricClient]); - return isProcessing; + return status; }