diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-indexing-connectors.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-indexing-connectors.ts index e82a8eb29..289da475d 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-indexing-connectors.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-indexing-connectors.ts @@ -10,8 +10,9 @@ import { isConnectorIndexingMetadata } from "@/contracts/types/inbox.types"; * * This provides a better UX than polling by: * 1. Setting indexing state immediately when user triggers indexing (optimistic) - * 2. Clearing indexing state when Electric SQL detects last_indexed_at changed - * 3. Clearing indexing state when a failed notification is detected + * 2. Detecting in_progress notifications from Electric SQL to restore state after remounts + * 3. Clearing indexing state when notifications become completed or failed + * 4. Clearing indexing state when Electric SQL detects last_indexed_at changed * * The actual `last_indexed_at` value comes from Electric SQL/PGlite, not local state. */ @@ -28,65 +29,73 @@ export function useIndexingConnectors( // Detect when last_indexed_at changes (indexing completed) via Electric SQL useEffect(() => { const previousValues = previousLastIndexedAtRef.current; - const newIndexingIds = new Set(indexingConnectorIds); - let hasChanges = false; for (const connector of connectors) { const previousValue = previousValues.get(connector.id); const currentValue = connector.last_indexed_at; - // If last_indexed_at changed and connector was in indexing state, clear it + // If last_indexed_at changed, clear it from indexing state if ( previousValue !== undefined && // We've seen this connector before - previousValue !== currentValue && // Value changed - indexingConnectorIds.has(connector.id) // It was marked as indexing + previousValue !== currentValue // Value changed ) { - newIndexingIds.delete(connector.id); - hasChanges = true; + // Use functional update to access current state + setIndexingConnectorIds((prev) => { + if (prev.has(connector.id)) { + const next = new Set(prev); + next.delete(connector.id); + return next; + } + return prev; + }); } // Update previous value tracking previousValues.set(connector.id, currentValue); } + }, [connectors]); - if (hasChanges) { - setIndexingConnectorIds(newIndexingIds); - } - }, [connectors, indexingConnectorIds]); - - // Detect failed notifications and stop indexing state + // Detect notification status changes and update indexing state accordingly + // This restores spinner state after component remounts and handles all status transitions useEffect(() => { if (!inboxItems || inboxItems.length === 0) return; - const newIndexingIds = new Set(indexingConnectorIds); - let hasChanges = false; + setIndexingConnectorIds((prev) => { + const newIndexingIds = new Set(prev); + let hasChanges = false; - for (const item of inboxItems) { - // Only check connector_indexing notifications - if (item.type !== "connector_indexing") continue; + for (const item of inboxItems) { + // Only check connector_indexing notifications + if (item.type !== "connector_indexing") continue; - // Check if this notification indicates a failure - const metadata = isConnectorIndexingMetadata(item.metadata) - ? item.metadata - : null; - if (!metadata) continue; + const metadata = isConnectorIndexingMetadata(item.metadata) + ? item.metadata + : null; + if (!metadata) continue; - // Check if status is "failed" or if there's an error_message - const isFailed = - metadata.status === "failed" || - (metadata.error_message && metadata.error_message.trim().length > 0); - - // If failed and connector is in indexing state, clear it - if (isFailed && indexingConnectorIds.has(metadata.connector_id)) { - newIndexingIds.delete(metadata.connector_id); - hasChanges = true; + // If status is "in_progress", add connector to indexing set + if (metadata.status === "in_progress") { + if (!newIndexingIds.has(metadata.connector_id)) { + newIndexingIds.add(metadata.connector_id); + hasChanges = true; + } + } + // If status is "completed" or "failed", remove connector from indexing set + else if ( + metadata.status === "completed" || + metadata.status === "failed" || + (metadata.error_message && metadata.error_message.trim().length > 0) + ) { + if (newIndexingIds.has(metadata.connector_id)) { + newIndexingIds.delete(metadata.connector_id); + hasChanges = true; + } + } } - } - if (hasChanges) { - setIndexingConnectorIds(newIndexingIds); - } - }, [inboxItems, indexingConnectorIds]); + return hasChanges ? newIndexingIds : prev; + }); + }, [inboxItems]); // Add a connector to the indexing set (called when indexing starts) const startIndexing = useCallback((connectorId: number) => { diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 52dc7196a..9e3f55c97 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -38,6 +38,17 @@ interface LayoutDataProviderProps { breadcrumb?: React.ReactNode; } +/** + * Format count for display: shows numbers up to 999, then "1k+", "2k+", etc. + */ +function formatInboxCount(count: number): string { + if (count <= 999) { + return count.toString(); + } + const thousands = Math.floor(count / 1000); + return `${thousands}k+`; +} + export function LayoutDataProvider({ searchSpaceId, children, @@ -172,7 +183,7 @@ export function LayoutDataProvider({ url: "#inbox", // Special URL to indicate this is handled differently icon: Inbox, isActive: isInboxSidebarOpen, - badge: unreadCount > 0 ? (unreadCount > 99 ? "99+" : unreadCount) : undefined, + badge: unreadCount > 0 ? formatInboxCount(unreadCount) : undefined, }, ], [searchSpaceId, pathname, isInboxSidebarOpen, unreadCount] diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index bb06d6a56..e80c6e62d 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -70,6 +70,17 @@ function getInitials(name: string | null | undefined, email: string | null | und return "U"; } +/** + * Format count for display: shows numbers up to 999, then "1k+", "2k+", etc. + */ +function formatInboxCount(count: number): string { + if (count <= 999) { + return count.toString(); + } + const thousands = Math.floor(count / 1000); + return `${thousands}k+`; +} + /** * Get display name for connector type */ @@ -732,7 +743,7 @@ export function InboxSidebar({ {t("mentions") || "Mentions"} - {unreadMentionsCount} + {formatInboxCount(unreadMentionsCount)} @@ -744,7 +755,7 @@ export function InboxSidebar({ {t("status") || "Status"} - {unreadStatusCount} + {formatInboxCount(unreadStatusCount)} diff --git a/surfsense_web/components/layout/ui/sidebar/NavSection.tsx b/surfsense_web/components/layout/ui/sidebar/NavSection.tsx index d2d926de8..742a27bbc 100644 --- a/surfsense_web/components/layout/ui/sidebar/NavSection.tsx +++ b/surfsense_web/components/layout/ui/sidebar/NavSection.tsx @@ -39,7 +39,7 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti > {item.badge && ( - + {item.badge} )} @@ -70,7 +70,7 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti {item.title} {item.badge && ( - + {item.badge} )}