diff --git a/surfsense_web/components/documents/DocumentNode.tsx b/surfsense_web/components/documents/DocumentNode.tsx index 79cf2233b..dd7731f22 100644 --- a/surfsense_web/components/documents/DocumentNode.tsx +++ b/surfsense_web/components/documents/DocumentNode.tsx @@ -153,7 +153,7 @@ export const DocumentNode = React.memo(function DocumentNode({ - Pending - waiting to be synced + Pending — waiting to be synced ); } diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 4eff8d546..fd6b45c52 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -51,7 +51,6 @@ import { import { Input } from "@/components/ui/input"; import { Spinner } from "@/components/ui/spinner"; import { useAnnouncements } from "@/hooks/use-announcements"; -import { useDocumentsProcessing } from "@/hooks/use-documents-processing"; import { useInbox } from "@/hooks/use-inbox"; import { useIsMobile } from "@/hooks/use-mobile"; import { notificationsApiService } from "@/lib/apis/notifications-api.service"; @@ -194,9 +193,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid setStatusInboxItems(statusInbox.inboxItems); }, [statusInbox.inboxItems, setStatusInboxItems]); - // 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()); const isInitialLoad = useRef(true); @@ -366,7 +362,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid isActive: isMobile ? isDocumentsSidebarOpen : isDocumentsSidebarOpen && !isRightPanelCollapsed, - statusIndicator: documentsProcessingStatus, }, { title: "Announcements", @@ -384,7 +379,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid totalUnreadCount, isAnnouncementsSidebarOpen, announcementUnreadCount, - documentsProcessingStatus, ] ); diff --git a/surfsense_web/components/layout/ui/header/Header.tsx b/surfsense_web/components/layout/ui/header/Header.tsx index 3c83b0c0c..2c8e41a42 100644 --- a/surfsense_web/components/layout/ui/header/Header.tsx +++ b/surfsense_web/components/layout/ui/header/Header.tsx @@ -3,18 +3,13 @@ import { useAtomValue } from "jotai"; import { usePathname } from "next/navigation"; import { currentThreadAtom } from "@/atoms/chat/current-thread.atom"; -import { hitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom"; -import { reportPanelAtom } from "@/atoms/chat/report-panel.atom"; -import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms"; -import { editorPanelAtom } from "@/atoms/editor/editor-panel.atom"; -import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { activeTabAtom, tabsAtom } from "@/atoms/tabs/tabs.atom"; import { ChatHeader } from "@/components/new-chat/chat-header"; import { ChatShareButton } from "@/components/new-chat/chat-share-button"; import { useIsMobile } from "@/hooks/use-mobile"; import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence"; -import { cn } from "@/lib/utils"; +import { RightPanelExpandButton } from "../right-panel/RightPanel"; interface HeaderProps { mobileMenuTrigger?: React.ReactNode; @@ -26,19 +21,9 @@ export function Header({ mobileMenuTrigger }: HeaderProps) { const isMobile = useIsMobile(); const activeTab = useAtomValue(activeTabAtom); const tabs = useAtomValue(tabsAtom); - const collapsed = useAtomValue(rightPanelCollapsedAtom); - const documentsOpen = useAtomValue(documentsSidebarOpenAtom); - const reportState = useAtomValue(reportPanelAtom); - const editorState = useAtomValue(editorPanelAtom); - const hitlEditState = useAtomValue(hitlEditPanelAtom); const isChatPage = pathname?.includes("/new-chat") ?? false; const isDocumentTab = activeTab?.type === "document"; - const reportOpen = reportState.isOpen && !!reportState.reportId; - const editorOpen = editorState.isOpen && !!editorState.documentId; - const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave; - const showExpandButton = - !isMobile && collapsed && (documentsOpen || reportOpen || editorOpen || hitlEditOpen); const hasTabBar = tabs.length > 1; const currentThreadState = useAtomValue(currentThreadAtom); @@ -72,12 +57,11 @@ export function Header({ mobileMenuTrigger }: HeaderProps) { {/* Right side - Actions */} -
+
{hasThread && ( )} + {!isMobile && !hasTabBar && }
); diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index 476bda231..d880524bd 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -21,7 +21,6 @@ import type { DocumentNodeDoc } from "@/components/documents/DocumentNode"; import type { FolderDisplay } from "@/components/documents/FolderNode"; import { FolderPickerDialog } from "@/components/documents/FolderPickerDialog"; import { FolderTreeView } from "@/components/documents/FolderTreeView"; -import { MarkdownViewer } from "@/components/markdown-viewer"; import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems"; import { AlertDialog, diff --git a/surfsense_web/components/layout/ui/sidebar/NavSection.tsx b/surfsense_web/components/layout/ui/sidebar/NavSection.tsx index 9b0a115c6..ccbe8e738 100644 --- a/surfsense_web/components/layout/ui/sidebar/NavSection.tsx +++ b/surfsense_web/components/layout/ui/sidebar/NavSection.tsx @@ -1,6 +1,6 @@ "use client"; -import { CheckCircle2, CircleAlert } from "lucide-react"; +import { CheckCircle2, CircleAlert, RefreshCw } from "lucide-react"; import { Spinner } from "@/components/ui/spinner"; import { cn } from "@/lib/utils"; import type { NavItem } from "../../types/layout.types"; @@ -12,6 +12,46 @@ interface NavSectionProps { isCollapsed?: boolean; } +function getStatusInfo(status: NavItem["statusIndicator"]) { + switch (status) { + case "processing": + return { + tooltip: "New or updated documents are still being prepared for search.", + }; + case "background_sync": + return { + pillLabel: "Background sync", + tooltip: + "Periodic sync is checking for updates in the background. Existing documents stay searchable while this runs.", + }; + case "success": + return { + tooltip: "All document updates are fully synced.", + }; + case "error": + return { + pillLabel: "Needs attention", + tooltip: "Some documents failed to sync. Open Documents or Inbox for details.", + }; + default: + return {}; + } +} + +function StatusPill({ status }: { status: NavItem["statusIndicator"] }) { + const { pillLabel } = getStatusInfo(status); + + if (!pillLabel) { + return null; + } + + return ( + + {pillLabel} + + ); +} + function StatusBadge({ status }: { status: NavItem["statusIndicator"] }) { if (status === "processing") { return ( @@ -20,6 +60,13 @@ function StatusBadge({ status }: { status: NavItem["statusIndicator"] }) { ); } + if (status === "background_sync") { + return ( + + + + ); + } if (status === "success") { return ( @@ -49,6 +96,16 @@ function StatusIcon({ if (status === "processing") { return ; } + if (status === "background_sync") { + return ( + + ); + } if (status === "success") { return ( } + trailingContent={} + tooltipContent={tooltip} buttonProps={joyrideAttr} /> ); diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarButton.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarButton.tsx index 1247849f6..4410edafa 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarButton.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarButton.tsx @@ -16,6 +16,10 @@ interface SidebarButtonProps { collapsedOverlay?: React.ReactNode; /** Custom icon node for expanded mode — overrides the default rendering */ expandedIconNode?: React.ReactNode; + /** Optional inline trailing content shown in expanded mode */ + trailingContent?: React.ReactNode; + /** Optional tooltip content that replaces the default label tooltip */ + tooltipContent?: React.ReactNode; className?: string; /** Extra attributes spread onto the inner - - {label} - {typeof badge === "string" && ` (${badge})`} + + {tooltipContent ?? ( + <> + {label} + {typeof badge === "string" && ` (${badge})`} + + )} ); } - return ( + const button = ( ); + + if (!tooltipContent) { + return button; + } + + return ( + + {button} + + {tooltipContent} + + + ); } diff --git a/surfsense_web/hooks/use-documents-processing.ts b/surfsense_web/hooks/use-documents-processing.ts index e39c03de0..11a80b08e 100644 --- a/surfsense_web/hooks/use-documents-processing.ts +++ b/surfsense_web/hooks/use-documents-processing.ts @@ -4,18 +4,31 @@ import { useQuery } from "@rocicorp/zero/react"; import { useEffect, useRef, useState } from "react"; import { queries } from "@/zero/queries"; -export type DocumentsProcessingStatus = "idle" | "processing" | "success" | "error"; +export type DocumentsProcessingStatus = + | "idle" + | "processing" + | "background_sync" + | "success" + | "error"; const SUCCESS_LINGER_MS = 5000; +interface UseDocumentsProcessingOptions { + hasPeriodicSyncEnabled?: boolean; +} + /** * Returns the processing status of documents in the search space: - * - "processing" — at least one doc is pending/processing (show spinner) + * - "processing" — docs are queued or actively being prepared for search + * - "background_sync" — existing docs are being refreshed in the background * - "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): DocumentsProcessingStatus { +export function useDocumentsProcessing( + searchSpaceId: number | null, + { hasPeriodicSyncEnabled = false }: UseDocumentsProcessingOptions = {} +): DocumentsProcessingStatus { const [status, setStatus] = useState("idle"); const wasProcessingRef = useRef(false); const successTimerRef = useRef | null>(null); @@ -25,38 +38,56 @@ export function useDocumentsProcessing(searchSpaceId: number | null): DocumentsP useEffect(() => { if (!searchSpaceId || !documents) return; + const clearSuccessTimer = () => { + if (successTimerRef.current) { + clearTimeout(successTimerRef.current); + successTimerRef.current = null; + } + }; + + let pendingCount = 0; let processingCount = 0; let failedCount = 0; + let readyCount = 0; for (const doc of documents) { + // Keep the nav indicator aligned with what the Documents sidebar actually renders. + // Some connectors can create temporary untitled placeholder rows that remain hidden + // from the sidebar, and those should not keep the whole section looking "stuck". + if (!doc.title || doc.title.trim() === "") { + continue; + } + const state = (doc.status as { state?: string } | null)?.state; - if (state === "pending" || state === "processing") { + if (state === "pending") { + pendingCount++; + } else if (state === "processing") { processingCount++; } else if (state === "failed") { failedCount++; + } else { + readyCount++; } } - if (processingCount > 0) { + if (pendingCount > 0) { wasProcessingRef.current = true; - if (successTimerRef.current) { - clearTimeout(successTimerRef.current); - successTimerRef.current = null; - } + clearSuccessTimer(); setStatus("processing"); + } else if (processingCount > 0) { + wasProcessingRef.current = true; + clearSuccessTimer(); + + const isBackgroundSync = hasPeriodicSyncEnabled && readyCount > 0; + setStatus(isBackgroundSync ? "background_sync" : "processing"); } else if (failedCount > 0) { wasProcessingRef.current = false; - if (successTimerRef.current) { - clearTimeout(successTimerRef.current); - successTimerRef.current = null; - } + clearSuccessTimer(); setStatus("error"); } else if (wasProcessingRef.current) { wasProcessingRef.current = false; setStatus("success"); - if (successTimerRef.current) { - clearTimeout(successTimerRef.current); - } + clearSuccessTimer(); successTimerRef.current = setTimeout(() => { setStatus("idle"); successTimerRef.current = null; @@ -64,7 +95,7 @@ export function useDocumentsProcessing(searchSpaceId: number | null): DocumentsP } else { setStatus("idle"); } - }, [searchSpaceId, documents]); + }, [searchSpaceId, documents, hasPeriodicSyncEnabled]); useEffect(() => { return () => {