+
{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 () => {