diff --git a/surfsense_backend/app/routes/notifications_routes.py b/surfsense_backend/app/routes/notifications_routes.py
index 6bc945643..84591f001 100644
--- a/surfsense_backend/app/routes/notifications_routes.py
+++ b/surfsense_backend/app/routes/notifications_routes.py
@@ -6,6 +6,7 @@ For older items (beyond the sync window), use the list endpoint.
"""
from datetime import UTC, datetime, timedelta
+from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
@@ -20,6 +21,9 @@ router = APIRouter(prefix="/notifications", tags=["notifications"])
# Must match frontend SYNC_WINDOW_DAYS in use-inbox.ts
SYNC_WINDOW_DAYS = 14
+# Valid notification types - must match frontend InboxItemTypeEnum
+NotificationType = Literal["connector_indexing", "document_processing", "new_mention"]
+
class NotificationResponse(BaseModel):
"""Response model for a single notification."""
@@ -73,6 +77,9 @@ class UnreadCountResponse(BaseModel):
@router.get("/unread-count", response_model=UnreadCountResponse)
async def get_unread_count(
search_space_id: int | None = Query(None, description="Filter by search space ID"),
+ type_filter: NotificationType | None = Query(
+ None, alias="type", description="Filter by notification type"
+ ),
user: User = Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
) -> UnreadCountResponse:
@@ -103,6 +110,10 @@ async def get_unread_count(
| (Notification.search_space_id.is_(None))
)
+ # Filter by notification type if provided
+ if type_filter:
+ base_filter.append(Notification.type == type_filter)
+
# Total unread count (all time)
total_query = select(func.count(Notification.id)).where(*base_filter)
total_result = await session.execute(total_query)
@@ -125,7 +136,7 @@ async def get_unread_count(
@router.get("", response_model=NotificationListResponse)
async def list_notifications(
search_space_id: int | None = Query(None, description="Filter by search space ID"),
- type_filter: str | None = Query(
+ type_filter: NotificationType | None = Query(
None, alias="type", description="Filter by notification type"
),
before_date: str | None = Query(
diff --git a/surfsense_web/atoms/chat/current-thread.atom.ts b/surfsense_web/atoms/chat/current-thread.atom.ts
index 18afb8ff3..5de11eb92 100644
--- a/surfsense_web/atoms/chat/current-thread.atom.ts
+++ b/surfsense_web/atoms/chat/current-thread.atom.ts
@@ -62,9 +62,7 @@ export const resetCurrentThreadAtom = atom(null, (_, set) => {
});
/** Atom to read whether comments panel is collapsed */
-export const commentsCollapsedAtom = atom(
- (get) => get(currentThreadAtom).commentsCollapsed
-);
+export const commentsCollapsedAtom = atom((get) => get(currentThreadAtom).commentsCollapsed);
/** Atom to toggle the comments collapsed state */
export const toggleCommentsCollapsedAtom = atom(null, (get, set) => {
diff --git a/surfsense_web/components/assistant-ui/markdown-text.tsx b/surfsense_web/components/assistant-ui/markdown-text.tsx
index dd4ce6b75..cc8cec5d9 100644
--- a/surfsense_web/components/assistant-ui/markdown-text.tsx
+++ b/surfsense_web/components/assistant-ui/markdown-text.tsx
@@ -254,10 +254,7 @@ const defaultComponents = memoizeMarkdownComponents({
table: ({ className, ...props }) => (
diff --git a/surfsense_web/components/chat-comments/comment-item/comment-item.tsx b/surfsense_web/components/chat-comments/comment-item/comment-item.tsx
index 847886fc8..4996fe01b 100644
--- a/surfsense_web/components/chat-comments/comment-item/comment-item.tsx
+++ b/surfsense_web/components/chat-comments/comment-item/comment-item.tsx
@@ -3,10 +3,7 @@
import { useAtomValue, useSetAtom } from "jotai";
import { MessageSquare } from "lucide-react";
import { useEffect, useRef, useState } from "react";
-import {
- clearTargetCommentIdAtom,
- targetCommentIdAtom,
-} from "@/atoms/chat/current-thread.atom";
+import { clearTargetCommentIdAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -82,10 +79,9 @@ function renderMentions(content: string): React.ReactNode {
const mentionPattern = /@\{([^}]+)\}/g;
const parts: React.ReactNode[] = [];
let lastIndex = 0;
- let match: RegExpExecArray | null;
- while ((match = mentionPattern.exec(content)) !== null) {
- if (match.index > lastIndex) {
+ for (const match of content.matchAll(mentionPattern)) {
+ if (match.index !== undefined && match.index > lastIndex) {
parts.push(content.slice(lastIndex, match.index));
}
@@ -96,7 +92,7 @@ function renderMentions(content: string): React.ReactNode {
);
- lastIndex = match.index + match[0].length;
+ lastIndex = (match.index ?? 0) + match[0].length;
}
if (lastIndex < content.length) {
diff --git a/surfsense_web/components/layout/hooks/SidebarContext.tsx b/surfsense_web/components/layout/hooks/SidebarContext.tsx
index 70e9311f9..7aa24d5d0 100644
--- a/surfsense_web/components/layout/hooks/SidebarContext.tsx
+++ b/surfsense_web/components/layout/hooks/SidebarContext.tsx
@@ -34,4 +34,3 @@ export function useSidebarContext(): SidebarContextValue {
export function useSidebarContextSafe(): SidebarContextValue | null {
return useContext(SidebarContext);
}
-
diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
index 8710fdb79..ed8f28916 100644
--- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
+++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
@@ -104,19 +104,55 @@ export function LayoutDataProvider({
// Search space dialog state
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
- // Inbox hook
+ // Inbox hooks - separate data sources for mentions and status tabs
+ // This ensures each tab has independent pagination and data loading
const userId = user?.id ? String(user.id) : null;
+
+ // Mentions: Only fetch "new_mention" type notifications
const {
- inboxItems,
- unreadCount,
- loading: inboxLoading,
- loadingMore: inboxLoadingMore,
- hasMore: inboxHasMore,
- loadMore: inboxLoadMore,
- markAsRead,
- markAllAsRead,
+ inboxItems: mentionItems,
+ unreadCount: mentionUnreadCount,
+ loading: mentionLoading,
+ loadingMore: mentionLoadingMore,
+ hasMore: mentionHasMore,
+ loadMore: mentionLoadMore,
+ markAsRead: markMentionAsRead,
+ markAllAsRead: markAllMentionsAsRead,
+ } = useInbox(userId, Number(searchSpaceId) || null, "new_mention");
+
+ // Status: Fetch all types (will be filtered client-side to status types)
+ // We pass null to get all, then InboxSidebar filters to status types
+ const {
+ inboxItems: statusItems,
+ unreadCount: statusUnreadCount,
+ loading: statusLoading,
+ loadingMore: statusLoadingMore,
+ hasMore: statusHasMore,
+ loadMore: statusLoadMore,
+ markAsRead: markStatusAsRead,
+ markAllAsRead: markAllStatusAsRead,
} = useInbox(userId, Number(searchSpaceId) || null, null);
+ // Combined unread count for nav badge (mentions take priority for visibility)
+ const totalUnreadCount = mentionUnreadCount + statusUnreadCount;
+
+ // Unified mark as read that delegates to the correct hook
+ const markAsRead = useCallback(
+ async (id: number) => {
+ // Try both - one will succeed based on which list has the item
+ const mentionResult = await markMentionAsRead(id);
+ if (mentionResult) return true;
+ return markStatusAsRead(id);
+ },
+ [markMentionAsRead, markStatusAsRead]
+ );
+
+ // Mark all as read for both types
+ const markAllAsRead = useCallback(async () => {
+ await Promise.all([markAllMentionsAsRead(), markAllStatusAsRead()]);
+ return true;
+ }, [markAllMentionsAsRead, markAllStatusAsRead]);
+
// Delete dialogs state
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
@@ -197,7 +233,7 @@ export function LayoutDataProvider({
url: "#inbox", // Special URL to indicate this is handled differently
icon: Inbox,
isActive: isInboxSidebarOpen,
- badge: unreadCount > 0 ? formatInboxCount(unreadCount) : undefined,
+ badge: totalUnreadCount > 0 ? formatInboxCount(totalUnreadCount) : undefined,
},
{
title: "Documents",
@@ -206,7 +242,7 @@ export function LayoutDataProvider({
isActive: pathname?.includes("/documents"),
},
],
- [searchSpaceId, pathname, isInboxSidebarOpen, unreadCount]
+ [searchSpaceId, pathname, isInboxSidebarOpen, totalUnreadCount]
);
// Handlers
@@ -465,12 +501,24 @@ export function LayoutDataProvider({
inbox={{
isOpen: isInboxSidebarOpen,
onOpenChange: setIsInboxSidebarOpen,
- items: inboxItems,
- unreadCount,
- loading: inboxLoading,
- loadingMore: inboxLoadingMore,
- hasMore: inboxHasMore,
- loadMore: inboxLoadMore,
+ // Separate data sources for each tab
+ mentions: {
+ items: mentionItems,
+ unreadCount: mentionUnreadCount,
+ loading: mentionLoading,
+ loadingMore: mentionLoadingMore,
+ hasMore: mentionHasMore,
+ loadMore: mentionLoadMore,
+ },
+ status: {
+ items: statusItems,
+ unreadCount: statusUnreadCount,
+ loading: statusLoading,
+ loadingMore: statusLoadingMore,
+ hasMore: statusHasMore,
+ loadMore: statusLoadMore,
+ },
+ totalUnreadCount,
markAsRead,
markAllAsRead,
isDocked: isInboxDocked,
diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx
index d84b9cdce..3624c90a3 100644
--- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx
+++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx
@@ -11,16 +11,26 @@ import { Header } from "../header";
import { IconRail } from "../icon-rail";
import { InboxSidebar, MobileSidebar, MobileSidebarTrigger, Sidebar } from "../sidebar";
-// Inbox-related props
-interface InboxProps {
- isOpen: boolean;
- onOpenChange: (open: boolean) => void;
+// Tab-specific data source props
+interface TabDataSource {
items: InboxItem[];
unreadCount: number;
loading: boolean;
loadingMore?: boolean;
hasMore?: boolean;
loadMore?: () => void;
+}
+
+// Inbox-related props with separate data sources per tab
+interface InboxProps {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ /** Mentions tab data source with independent pagination */
+ mentions: TabDataSource;
+ /** Status tab data source with independent pagination */
+ status: TabDataSource;
+ /** Combined unread count for nav badge */
+ totalUnreadCount: number;
markAsRead: (id: number) => Promise;
markAllAsRead: () => Promise;
/** Whether the inbox is docked (permanent) */
@@ -151,26 +161,23 @@ export function LayoutShell({
setTheme={setTheme}
/>
-
- {children}
-
+
+ {children}
+
- {/* Mobile Inbox Sidebar - only render when open to avoid scroll blocking */}
- {inbox?.isOpen && (
- setMobileMenuOpen(false)}
- />
- )}
+ {/* Mobile Inbox Sidebar - only render when open to avoid scroll blocking */}
+ {inbox?.isOpen && (
+ setMobileMenuOpen(false)}
+ />
+ )}
@@ -181,7 +188,9 @@ export function LayoutShell({
return (
-
+
void;
- inboxItems: InboxItem[];
+// Tab-specific data source with independent pagination
+interface TabDataSource {
+ items: InboxItem[];
unreadCount: number;
loading: boolean;
loadingMore?: boolean;
hasMore?: boolean;
loadMore?: () => void;
+}
+
+interface InboxSidebarProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ /** Mentions tab data source with independent pagination */
+ mentions: TabDataSource;
+ /** Status tab data source with independent pagination */
+ status: TabDataSource;
+ /** Combined unread count for mark all as read */
+ totalUnreadCount: number;
markAsRead: (id: number) => Promise;
markAllAsRead: () => Promise;
onCloseMobileSidebar?: () => void;
@@ -157,12 +162,9 @@ interface InboxSidebarProps {
export function InboxSidebar({
open,
onOpenChange,
- inboxItems,
- unreadCount,
- loading,
- loadingMore = false,
- hasMore = false,
- loadMore,
+ mentions,
+ status,
+ totalUnreadCount,
markAsRead,
markAllAsRead,
onCloseMobileSidebar,
@@ -209,11 +211,11 @@ export function InboxSidebar({
// Only lock body scroll on mobile when inbox is open
useEffect(() => {
if (!open || !isMobile) return;
-
+
// Store original overflow to restore on cleanup
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
-
+
return () => {
document.body.style.overflow = originalOverflow;
};
@@ -226,18 +228,18 @@ export function InboxSidebar({
}
}, [activeTab]);
- // Split items by type
- const mentionItems = useMemo(
- () => inboxItems.filter((item) => item.type === "new_mention"),
- [inboxItems]
- );
+ // Get current tab's data source - each tab has independent data and pagination
+ const currentDataSource = activeTab === "mentions" ? mentions : status;
+ const { loading, loadingMore = false, hasMore = false, loadMore } = currentDataSource;
+ // For status items, filter to only show status notification types
+ // (the status data source may include all types from API)
const statusItems = useMemo(
() =>
- inboxItems.filter(
+ status.items.filter(
(item) => item.type === "connector_indexing" || item.type === "document_processing"
),
- [inboxItems]
+ [status.items]
);
// Get unique connector types from status items for filtering
@@ -259,12 +261,12 @@ export function InboxSidebar({
}));
}, [statusItems]);
- // Get items for current tab
- const currentTabItems = activeTab === "mentions" ? mentionItems : statusItems;
+ // Get items for current tab - mentions use their source directly, status uses filtered items
+ const displayItems = activeTab === "mentions" ? mentions.items : statusItems;
// Filter items based on filter type, connector filter, and search query
const filteredItems = useMemo(() => {
- let items = currentTabItems;
+ let items = displayItems;
// Apply read/unread filter
if (activeFilter === "unread") {
@@ -295,7 +297,7 @@ export function InboxSidebar({
}
return items;
- }, [currentTabItems, activeFilter, activeTab, selectedConnector, searchQuery]);
+ }, [displayItems, activeFilter, activeTab, selectedConnector, searchQuery]);
// Intersection Observer for infinite scroll with prefetching
// Only active when not searching (search results are client-side filtered)
@@ -321,16 +323,11 @@ export function InboxSidebar({
}
return () => observer.disconnect();
- }, [loadMore, hasMore, loadingMore, open, searchQuery, filteredItems.length]);
+ }, [loadMore, hasMore, loadingMore, open, searchQuery]);
- // Count unread items per tab
- const unreadMentionsCount = useMemo(() => {
- return mentionItems.filter((item) => !item.read).length;
- }, [mentionItems]);
-
- const unreadStatusCount = useMemo(() => {
- return statusItems.filter((item) => !item.read).length;
- }, [statusItems]);
+ // Use unread counts from data sources (more accurate than client-side counting)
+ const unreadMentionsCount = mentions.unreadCount;
+ const unreadStatusCount = status.unreadCount;
const handleItemClick = useCallback(
async (item: InboxItem) => {
@@ -481,209 +478,128 @@ export function InboxSidebar({
const inboxContent = (
<>
-
-
-
{t("inbox") || "Inbox"}
-
-
- {/* Mobile: Button that opens bottom drawer */}
- {isMobile ? (
- <>
-
-
- setFilterDrawerOpen(true)}
- >
-
- {t("filter") || "Filter"}
-
-
- {t("filter") || "Filter"}
-
-
-
-
-
-
-
- {t("filter") || "Filter"}
-
-
-
- {/* Filter section */}
-
-
- {t("filter") || "Filter"}
-
-
- {
- setActiveFilter("all");
- setFilterDrawerOpen(false);
- }}
- className={cn(
- "flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
- activeFilter === "all"
- ? "bg-primary/10 text-primary"
- : "hover:bg-muted"
- )}
- >
-
-
- {t("all") || "All"}
-
- {activeFilter === "all" && }
-
- {
- setActiveFilter("unread");
- setFilterDrawerOpen(false);
- }}
- className={cn(
- "flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
- activeFilter === "unread"
- ? "bg-primary/10 text-primary"
- : "hover:bg-muted"
- )}
- >
-
-
- {t("unread") || "Unread"}
-
- {activeFilter === "unread" && }
-
-
-
- {/* Connectors section - only for status tab */}
- {activeTab === "status" && uniqueConnectorTypes.length > 0 && (
-
-
- {t("connectors") || "Connectors"}
-
-
- {
- setSelectedConnector(null);
- setFilterDrawerOpen(false);
- }}
- className={cn(
- "flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
- selectedConnector === null
- ? "bg-primary/10 text-primary"
- : "hover:bg-muted"
- )}
- >
-
-
- {t("all_connectors") || "All connectors"}
-
- {selectedConnector === null && }
-
- {uniqueConnectorTypes.map((connector) => (
- {
- setSelectedConnector(connector.type);
- setFilterDrawerOpen(false);
- }}
- className={cn(
- "flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
- selectedConnector === connector.type
- ? "bg-primary/10 text-primary"
- : "hover:bg-muted"
- )}
- >
-
- {getConnectorIcon(connector.type, "h-4 w-4")}
- {connector.displayName}
-
- {selectedConnector === connector.type && (
-
- )}
-
- ))}
-
-
- )}
-
-
-
- >
- ) : (
- /* Desktop: Dropdown menu */
-
setOpenDropdown(isOpen ? "filter" : null)}
+
+
+
{t("inbox") || "Inbox"}
+
+
+ {/* Mobile: Button that opens bottom drawer */}
+ {isMobile ? (
+ <>
+
+
+ setFilterDrawerOpen(true)}
>
-
-
-
-
-
- {t("filter") || "Filter"}
-
-
-
- {t("filter") || "Filter"}
-
-
-
+
+ {t("filter") || "Filter"}
+
+
+ {t("filter") || "Filter"}
+
+
+
+
+
+
+
+ {t("filter") || "Filter"}
+
+
+
+ {/* Filter section */}
+
+
{t("filter") || "Filter"}
-
- setActiveFilter("all")}
- className="flex items-center justify-between"
- >
-
-
- {t("all") || "All"}
-
- {activeFilter === "all" && }
-
- setActiveFilter("unread")}
- className="flex items-center justify-between"
- >
-
-
- {t("unread") || "Unread"}
-
- {activeFilter === "unread" && }
-
- {activeTab === "status" && uniqueConnectorTypes.length > 0 && (
- <>
-
- {t("connectors") || "Connectors"}
-
- setSelectedConnector(null)}
- className="flex items-center justify-between"
+
+
+ {
+ setActiveFilter("all");
+ setFilterDrawerOpen(false);
+ }}
+ className={cn(
+ "flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
+ activeFilter === "all"
+ ? "bg-primary/10 text-primary"
+ : "hover:bg-muted"
+ )}
+ >
+
+
+ {t("all") || "All"}
+
+ {activeFilter === "all" && }
+
+ {
+ setActiveFilter("unread");
+ setFilterDrawerOpen(false);
+ }}
+ className={cn(
+ "flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
+ activeFilter === "unread"
+ ? "bg-primary/10 text-primary"
+ : "hover:bg-muted"
+ )}
+ >
+
+
+ {t("unread") || "Unread"}
+
+ {activeFilter === "unread" && }
+
+
+
+ {/* Connectors section - only for status tab */}
+ {activeTab === "status" && uniqueConnectorTypes.length > 0 && (
+
+
+ {t("connectors") || "Connectors"}
+
+
+ {
+ setSelectedConnector(null);
+ setFilterDrawerOpen(false);
+ }}
+ className={cn(
+ "flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
+ selectedConnector === null
+ ? "bg-primary/10 text-primary"
+ : "hover:bg-muted"
+ )}
>
{t("all_connectors") || "All connectors"}
{selectedConnector === null && }
-
+
{uniqueConnectorTypes.map((connector) => (
- setSelectedConnector(connector.type)}
- className="flex items-center justify-between"
+ type="button"
+ onClick={() => {
+ setSelectedConnector(connector.type);
+ setFilterDrawerOpen(false);
+ }}
+ className={cn(
+ "flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
+ selectedConnector === connector.type
+ ? "bg-primary/10 text-primary"
+ : "hover:bg-muted"
+ )}
>
{getConnectorIcon(connector.type, "h-4 w-4")}
@@ -692,240 +608,311 @@ export function InboxSidebar({
{selectedConnector === connector.type && (
)}
-
+
))}
- >
- )}
-
-
- )}
-
-
-
-
- {t("mark_all_read") || "Mark all as read"}
+
+
+ )}
+
+
+
+ >
+ ) : (
+ /* Desktop: Dropdown menu */
+
setOpenDropdown(isOpen ? "filter" : null)}
+ >
+
+
+
+
+
+ {t("filter") || "Filter"}
-
-
- {t("mark_all_read") || "Mark all as read"}
-
-
- {/* Close button - mobile only */}
- {isMobile && (
-
-
- onOpenChange(false)}
+
+
+ {t("filter") || "Filter"}
+
+
+
+ {t("filter") || "Filter"}
+
+ setActiveFilter("all")}
+ className="flex items-center justify-between"
+ >
+
+
+ {t("all") || "All"}
+
+ {activeFilter === "all" && }
+
+ setActiveFilter("unread")}
+ className="flex items-center justify-between"
+ >
+
+
+ {t("unread") || "Unread"}
+
+ {activeFilter === "unread" && }
+
+ {activeTab === "status" && uniqueConnectorTypes.length > 0 && (
+ <>
+
+ {t("connectors") || "Connectors"}
+
+ setSelectedConnector(null)}
+ className="flex items-center justify-between"
+ >
+
+
+ {t("all_connectors") || "All connectors"}
+
+ {selectedConnector === null && }
+
+ {uniqueConnectorTypes.map((connector) => (
+ setSelectedConnector(connector.type)}
+ className="flex items-center justify-between"
>
-
- {t("close") || "Close"}
-
-
-
- {t("close") || "Close"}
-
-
- )}
- {/* Dock/Undock button - desktop only */}
- {!isMobile && onDockedChange && (
-
-
- {
- if (isDocked) {
- // Collapse: show comments immediately, then close inbox
- setCommentsCollapsed(false);
- onDockedChange(false);
- onOpenChange(false);
- } else {
- // Expand: hide comments immediately
- setCommentsCollapsed(true);
- onDockedChange(true);
- }
- }}
- >
- {isDocked ? (
-
- ) : (
-
- )}
-
- {isDocked ? "Collapse panel" : "Expand panel"}
+
+ {getConnectorIcon(connector.type, "h-4 w-4")}
+ {connector.displayName}
-
-
-
- {isDocked ? "Collapse panel" : "Expand panel"}
-
-
+ {selectedConnector === connector.type && }
+
+ ))}
+ >
)}
-
-
-
-
-
- setSearchQuery(e.target.value)}
- className="pl-9 pr-8 h-9"
- />
- {searchQuery && (
+
+
+ )}
+
+
+
+
+ {t("mark_all_read") || "Mark all as read"}
+
+
+
+ {t("mark_all_read") || "Mark all as read"}
+
+
+ {/* Close button - mobile only */}
+ {isMobile && (
+
+
onOpenChange(false)}
>
-
- {t("clear_search") || "Clear search"}
+
+ {t("close") || "Close"}
- )}
-
-
+
+
{t("close") || "Close"}
+
+ )}
+ {/* Dock/Undock button - desktop only */}
+ {!isMobile && onDockedChange && (
+
+
+ {
+ if (isDocked) {
+ // Collapse: show comments immediately, then close inbox
+ setCommentsCollapsed(false);
+ onDockedChange(false);
+ onOpenChange(false);
+ } else {
+ // Expand: hide comments immediately
+ setCommentsCollapsed(true);
+ onDockedChange(true);
+ }
+ }}
+ >
+ {isDocked ? (
+
+ ) : (
+
+ )}
+ {isDocked ? "Collapse panel" : "Expand panel"}
+
+
+
+ {isDocked ? "Collapse panel" : "Expand panel"}
+
+
+ )}
+
+
- setActiveTab(value as InboxTab)}
- className="shrink-0 mx-4"
+
+
+
setSearchQuery(e.target.value)}
+ className="pl-9 pr-8 h-9"
+ />
+ {searchQuery && (
+
-
-
-
-
- {t("mentions") || "Mentions"}
-
- {formatInboxCount(unreadMentionsCount)}
-
-
-
-
-
-
- {t("status") || "Status"}
-
- {formatInboxCount(unreadStatusCount)}
-
-
-
-
-
+
+ {t("clear_search") || "Clear search"}
+
+ )}
+
+
-
- {loading ? (
-
-
-
- ) : filteredItems.length > 0 ? (
-
- {filteredItems.map((item, index) => {
- const isMarkingAsRead = markingAsReadId === item.id;
- // Place prefetch trigger on 5th item from end (only if not searching)
- const isPrefetchTrigger =
- !searchQuery && hasMore && index === filteredItems.length - 5;
+
setActiveTab(value as InboxTab)}
+ className="shrink-0 mx-4"
+ >
+
+
+
+
+ {t("mentions") || "Mentions"}
+
+ {formatInboxCount(unreadMentionsCount)}
+
+
+
+
+
+
+ {t("status") || "Status"}
+
+ {formatInboxCount(unreadStatusCount)}
+
+
+
+
+
- return (
-
+ {loading ? (
+
+
+
+ ) : filteredItems.length > 0 ? (
+
+ {filteredItems.map((item, index) => {
+ const isMarkingAsRead = markingAsReadId === item.id;
+ // Place prefetch trigger on 5th item from end (only if not searching)
+ const isPrefetchTrigger =
+ !searchQuery && hasMore && index === filteredItems.length - 5;
+
+ return (
+
+
+
+ handleItemClick(item)}
+ disabled={isMarkingAsRead}
+ className="flex items-center gap-3 flex-1 min-w-0 text-left overflow-hidden"
>
-
-
- handleItemClick(item)}
- disabled={isMarkingAsRead}
- className="flex items-center gap-3 flex-1 min-w-0 text-left overflow-hidden"
- >
- {getStatusIcon(item)}
-
-
- {item.title}
-
-
- {convertRenderedToDisplay(item.message)}
-
-
-
-
-
- {item.title}
-
- {convertRenderedToDisplay(item.message)}
-
-
-
-
- {/* Time and unread dot - fixed width to prevent content shift */}
-
-
- {formatTime(item.created_at)}
-
- {!item.read && (
-
- )}
+
{getStatusIcon(item)}
+
+
+ {item.title}
+
+
+ {convertRenderedToDisplay(item.message)}
+
-
- );
- })}
- {/* Fallback trigger at the very end if less than 5 items and not searching */}
- {!searchQuery && filteredItems.length < 5 && hasMore && (
-
- )}
+
+
+
+ {item.title}
+
+ {convertRenderedToDisplay(item.message)}
+
+
+
+
+ {/* Time and unread dot - fixed width to prevent content shift */}
+
+
+ {formatTime(item.created_at)}
+
+ {!item.read && }
+
- ) : searchQuery ? (
-
-
-
- {t("no_results_found") || "No results found"}
-
-
- {t("try_different_search") || "Try a different search term"}
-
-
- ) : (
-
- {activeTab === "mentions" ? (
-
- ) : (
-
- )}
-
{getEmptyStateMessage().title}
-
- {getEmptyStateMessage().hint}
-
-
- )}
+ );
+ })}
+ {/* Fallback trigger at the very end if less than 5 items and not searching */}
+ {!searchQuery && filteredItems.length < 5 && hasMore && (
+
+ )}
+
+ ) : searchQuery ? (
+
+
+
+ {t("no_results_found") || "No results found"}
+
+
+ {t("try_different_search") || "Try a different search term"}
+
+
+ ) : (
+
+ {activeTab === "mentions" ? (
+
+ ) : (
+
+ )}
+
{getEmptyStateMessage().title}
+
{getEmptyStateMessage().hint}
+
+ )}
>
);
@@ -967,10 +954,7 @@ export function InboxSidebar({
left: isMobile ? 0 : sidebarWidth,
width: isMobile ? "100%" : 360,
}}
- className={cn(
- "absolute z-10 overflow-hidden pointer-events-none",
- "inset-y-0"
- )}
+ className={cn("absolute z-10 overflow-hidden pointer-events-none", "inset-y-0")}
>
{title}
{description && (
- {description}
+
+ {description}
+
)}
@@ -243,7 +245,11 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
{/* Volume control */}
- {isMuted ? : }
+ {isMuted ? (
+
+ ) : (
+
+ )}
{/* Custom volume bar - visually distinct from progress slider */}
@@ -268,7 +274,12 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
{/* Download button */}
-
+
Download
diff --git a/surfsense_web/components/tool-ui/generate-podcast.tsx b/surfsense_web/components/tool-ui/generate-podcast.tsx
index 513853c1a..d40024b7c 100644
--- a/surfsense_web/components/tool-ui/generate-podcast.tsx
+++ b/surfsense_web/components/tool-ui/generate-podcast.tsx
@@ -96,10 +96,14 @@ function PodcastGeneratingState({ title }: { title: string }) {
-
{title}
+
+ {title}
+
- Generating podcast. This may take a few minutes.
+
+ Generating podcast. This may take a few minutes.
+
@@ -123,7 +127,9 @@ function PodcastErrorState({ title, error }: { title: string; error: string }) {
-
{title}
+
+ {title}
+
Failed to generate podcast
{error}
@@ -143,7 +149,9 @@ function AudioLoadingState({ title }: { title: string }) {
-
{title}
+
+ {title}
+
Loading audio...
diff --git a/surfsense_web/hooks/use-inbox.ts b/surfsense_web/hooks/use-inbox.ts
index 4c26ddcb9..362feb747 100644
--- a/surfsense_web/hooks/use-inbox.ts
+++ b/surfsense_web/hooks/use-inbox.ts
@@ -318,9 +318,13 @@ export function useInbox(
try {
// STEP 1: Fetch server counts (total and recent) - guaranteed accurate
- console.log("[useInbox] Fetching unread count from server");
+ console.log(
+ "[useInbox] Fetching unread count from server",
+ typeFilter ? `for type: ${typeFilter}` : "for all types"
+ );
const serverCounts = await notificationsApiService.getUnreadCount(
- searchSpaceId ?? undefined
+ searchSpaceId ?? undefined,
+ typeFilter ?? undefined
);
if (mounted) {
diff --git a/surfsense_web/lib/apis/notifications-api.service.ts b/surfsense_web/lib/apis/notifications-api.service.ts
index a9e81a81f..941a347db 100644
--- a/surfsense_web/lib/apis/notifications-api.service.ts
+++ b/surfsense_web/lib/apis/notifications-api.service.ts
@@ -2,6 +2,7 @@ import {
type GetNotificationsRequest,
type GetNotificationsResponse,
type GetUnreadCountResponse,
+ type InboxItemTypeEnum,
getNotificationsRequest,
getNotificationsResponse,
getUnreadCountResponse,
@@ -92,12 +93,20 @@ class NotificationsApiService {
* Get unread notification count with split between total and recent
* - total_unread: All unread notifications
* - recent_unread: Unread within sync window (last 14 days)
+ * @param searchSpaceId - Optional search space ID to filter by
+ * @param type - Optional notification type to filter by (type-safe enum)
*/
- getUnreadCount = async (searchSpaceId?: number): Promise => {
+ getUnreadCount = async (
+ searchSpaceId?: number,
+ type?: InboxItemTypeEnum
+ ): Promise => {
const params = new URLSearchParams();
if (searchSpaceId !== undefined) {
params.append("search_space_id", String(searchSpaceId));
}
+ if (type) {
+ params.append("type", type);
+ }
const queryString = params.toString();
return baseApiService.get(