feat: enhance notifications system by introducing category-based filtering for comments and status, improving user experience in the inbox and API interactions

This commit is contained in:
Anish Sarkar 2026-03-06 19:35:35 +05:30
parent eb775fea11
commit 1a688c7161
8 changed files with 180 additions and 165 deletions

View file

@ -4,7 +4,7 @@ import { useAtomValue } from "jotai";
import { AlertTriangle, Cable, Settings } from "lucide-react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import type { FC } from "react";
import { type FC, useMemo } from "react";
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
import {
globalNewLLMConfigsAtom,
@ -66,11 +66,15 @@ export const ConnectorIndicator: FC = () => {
const { data: documentTypeCounts, isFetching: documentTypesLoading } =
useAtomValue(documentTypeCountsAtom);
// Fetch notifications to detect indexing failures
const { inboxItems = [] } = useInbox(
// Fetch status notifications to detect indexing failures
const { inboxItems: statusInboxItems = [] } = useInbox(
currentUser?.id ?? null,
searchSpaceId ? Number(searchSpaceId) : null,
"connector_indexing"
"status"
);
const inboxItems = useMemo(
() => statusInboxItems.filter((item) => item.type === "connector_indexing"),
[statusInboxItems]
);
// Check if YouTube view is active

View file

@ -121,19 +121,15 @@ export function LayoutDataProvider({
// Search space dialog state
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
// Single inbox hook - API-first with Electric real-time deltas
// Per-tab inbox hooks — each has independent API loading, pagination,
// and Electric live queries. The Electric sync shape is shared (client-level cache).
const userId = user?.id ? String(user.id) : null;
const numericSpaceId = Number(searchSpaceId) || null;
const {
inboxItems,
unreadCount: totalUnreadCount,
loading: inboxLoading,
loadingMore: inboxLoadingMore,
hasMore: inboxHasMore,
loadMore: inboxLoadMore,
markAsRead,
markAllAsRead,
} = useInbox(userId, Number(searchSpaceId) || null);
const commentsInbox = useInbox(userId, numericSpaceId, "comments");
const statusInbox = useInbox(userId, numericSpaceId, "status");
const totalUnreadCount = commentsInbox.unreadCount + statusInbox.unreadCount;
// Track seen notification IDs to detect new page_limit_exceeded notifications
const seenPageLimitNotifications = useRef<Set<number>>(new Set());
@ -141,9 +137,9 @@ export function LayoutDataProvider({
// Effect to show toast for new page_limit_exceeded notifications
useEffect(() => {
if (inboxLoading) return;
if (statusInbox.loading) return;
const pageLimitNotifications = inboxItems.filter(
const pageLimitNotifications = statusInbox.inboxItems.filter(
(item) => item.type === "page_limit_exceeded"
);
@ -176,7 +172,7 @@ export function LayoutDataProvider({
},
});
}
}, [inboxItems, inboxLoading, searchSpaceId, router]);
}, [statusInbox.inboxItems, statusInbox.loading, searchSpaceId, router]);
// Delete dialogs state
@ -607,14 +603,27 @@ export function LayoutDataProvider({
inbox={{
isOpen: isInboxSidebarOpen,
onOpenChange: setIsInboxSidebarOpen,
items: inboxItems,
totalUnreadCount,
loading: inboxLoading,
loadingMore: inboxLoadingMore,
hasMore: inboxHasMore,
loadMore: inboxLoadMore,
markAsRead,
markAllAsRead,
comments: {
items: commentsInbox.inboxItems,
unreadCount: commentsInbox.unreadCount,
loading: commentsInbox.loading,
loadingMore: commentsInbox.loadingMore,
hasMore: commentsInbox.hasMore,
loadMore: commentsInbox.loadMore,
markAsRead: commentsInbox.markAsRead,
markAllAsRead: commentsInbox.markAllAsRead,
},
status: {
items: statusInbox.inboxItems,
unreadCount: statusInbox.unreadCount,
loading: statusInbox.loading,
loadingMore: statusInbox.loadingMore,
hasMore: statusInbox.hasMore,
loadMore: statusInbox.loadMore,
markAsRead: statusInbox.markAsRead,
markAllAsRead: statusInbox.markAllAsRead,
},
isDocked: isInboxDocked,
onDockedChange: setIsInboxDocked,
}}

View file

@ -20,21 +20,26 @@ import {
Sidebar,
} from "../sidebar";
// Inbox-related props — single data source, tab split done in InboxSidebar
// Per-tab data source
interface TabDataSource {
items: InboxItem[];
unreadCount: number;
loading: boolean;
loadingMore: boolean;
hasMore: boolean;
loadMore: () => void;
markAsRead: (id: number) => Promise<boolean>;
markAllAsRead: () => Promise<boolean>;
}
// Inbox-related props — per-tab data sources with independent loading/pagination
interface InboxProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
items: InboxItem[];
totalUnreadCount: number;
loading: boolean;
loadingMore?: boolean;
hasMore?: boolean;
loadMore?: () => void;
markAsRead: (id: number) => Promise<boolean>;
markAllAsRead: () => Promise<boolean>;
/** Whether the inbox is docked (permanent) */
comments: TabDataSource;
status: TabDataSource;
isDocked?: boolean;
/** Callback to change docked state */
onDockedChange?: (docked: boolean) => void;
}
@ -198,11 +203,9 @@ export function LayoutShell({
<InboxSidebar
open={inbox.isOpen}
onOpenChange={inbox.onOpenChange}
mentions={inbox.mentions}
comments={inbox.comments}
status={inbox.status}
totalUnreadCount={inbox.totalUnreadCount}
markAsRead={inbox.markAsRead}
markAllAsRead={inbox.markAllAsRead}
onCloseMobileSidebar={() => setMobileMenuOpen(false)}
/>
)}
@ -296,14 +299,9 @@ export function LayoutShell({
<InboxSidebar
open={inbox.isOpen}
onOpenChange={inbox.onOpenChange}
items={inbox.items}
comments={inbox.comments}
status={inbox.status}
totalUnreadCount={inbox.totalUnreadCount}
loading={inbox.loading}
loadingMore={inbox.loadingMore}
hasMore={inbox.hasMore}
loadMore={inbox.loadMore}
markAsRead={inbox.markAsRead}
markAllAsRead={inbox.markAllAsRead}
isDocked={inbox.isDocked}
onDockedChange={inbox.onDockedChange}
/>
@ -322,14 +320,9 @@ export function LayoutShell({
<InboxSidebar
open={inbox.isOpen}
onOpenChange={inbox.onOpenChange}
items={inbox.items}
comments={inbox.comments}
status={inbox.status}
totalUnreadCount={inbox.totalUnreadCount}
loading={inbox.loading}
loadingMore={inbox.loadingMore}
hasMore={inbox.hasMore}
loadMore={inbox.loadMore}
markAsRead={inbox.markAsRead}
markAllAsRead={inbox.markAllAsRead}
isDocked={false}
onDockedChange={inbox.onDockedChange}
/>

View file

@ -130,20 +130,23 @@ function getConnectorTypeDisplayName(connectorType: string): string {
type InboxTab = "comments" | "status";
type InboxFilter = "all" | "unread" | "errors";
const COMMENT_TYPES = new Set(["new_mention", "comment_reply"]);
const STATUS_TYPES = new Set(["connector_indexing", "document_processing", "page_limit_exceeded", "connector_deletion"]);
interface TabDataSource {
items: InboxItem[];
unreadCount: number;
loading: boolean;
loadingMore: boolean;
hasMore: boolean;
loadMore: () => void;
markAsRead: (id: number) => Promise<boolean>;
markAllAsRead: () => Promise<boolean>;
}
interface InboxSidebarProps {
open: boolean;
onOpenChange: (open: boolean) => void;
items: InboxItem[];
comments: TabDataSource;
status: TabDataSource;
totalUnreadCount: number;
loading: boolean;
loadingMore?: boolean;
hasMore?: boolean;
loadMore?: () => void;
markAsRead: (id: number) => Promise<boolean>;
markAllAsRead: () => Promise<boolean>;
onCloseMobileSidebar?: () => void;
isDocked?: boolean;
onDockedChange?: (docked: boolean) => void;
@ -152,14 +155,9 @@ interface InboxSidebarProps {
export function InboxSidebar({
open,
onOpenChange,
items,
comments,
status,
totalUnreadCount,
loading,
loadingMore: loadingMoreProp = false,
hasMore: hasMoreProp = false,
loadMore,
markAsRead,
markAllAsRead,
onCloseMobileSidebar,
isDocked = false,
onDockedChange,
@ -239,26 +237,8 @@ export function InboxSidebar({
}
}, [activeTab]);
// Split items by tab type (client-side from single data source)
const commentsItems = useMemo(
() => items.filter((item) => COMMENT_TYPES.has(item.type)),
[items]
);
const statusItems = useMemo(
() => items.filter((item) => STATUS_TYPES.has(item.type)),
[items]
);
// Derive unread counts per tab from the items array
const unreadCommentsCount = useMemo(
() => commentsItems.filter((item) => !item.read).length,
[commentsItems]
);
const unreadStatusCount = useMemo(
() => statusItems.filter((item) => !item.read).length,
[statusItems]
);
// Active tab's data source — fully independent loading, pagination, and counts
const activeSource = activeTab === "comments" ? comments : status;
// Fetch source types for the status tab filter
const { data: sourceTypesData } = useQuery({
@ -321,22 +301,16 @@ export function InboxSidebar({
[activeFilter]
);
// Two data paths: search mode (API) or default (client-side filtered)
// Two data paths: search mode (API) or default (per-tab data source)
const filteredItems = useMemo(() => {
let tabItems: InboxItem[];
if (isSearchMode) {
tabItems = searchResponse?.items ?? [];
if (activeTab === "status") {
tabItems = tabItems.filter((item) => STATUS_TYPES.has(item.type));
} else {
tabItems = tabItems.filter((item) => COMMENT_TYPES.has(item.type));
}
} else {
tabItems = activeTab === "comments" ? commentsItems : statusItems;
tabItems = activeSource.items;
}
// Apply filters
let result = tabItems;
if (activeFilter !== "all") {
result = result.filter(matchesActiveFilter);
@ -349,23 +323,22 @@ export function InboxSidebar({
}, [
isSearchMode,
searchResponse,
activeSource.items,
activeTab,
commentsItems,
statusItems,
activeFilter,
selectedSource,
matchesActiveFilter,
matchesSourceFilter,
]);
// Infinite scroll
// Infinite scroll — uses active tab's pagination
useEffect(() => {
if (!loadMore || !hasMoreProp || loadingMoreProp || !open || isSearchMode) return;
if (!activeSource.hasMore || activeSource.loadingMore || !open || isSearchMode) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
loadMore();
activeSource.loadMore();
}
},
{
@ -380,13 +353,13 @@ export function InboxSidebar({
}
return () => observer.disconnect();
}, [loadMore, hasMoreProp, loadingMoreProp, open, isSearchMode]);
}, [activeSource.hasMore, activeSource.loadingMore, activeSource.loadMore, open, isSearchMode]);
const handleItemClick = useCallback(
async (item: InboxItem) => {
if (!item.read) {
setMarkingAsReadId(item.id);
await markAsRead(item.id);
await activeSource.markAsRead(item.id);
setMarkingAsReadId(null);
}
@ -437,12 +410,12 @@ export function InboxSidebar({
}
}
},
[markAsRead, router, onOpenChange, onCloseMobileSidebar, setTargetCommentId]
[activeSource.markAsRead, router, onOpenChange, onCloseMobileSidebar, setTargetCommentId]
);
const handleMarkAllAsRead = useCallback(async () => {
await markAllAsRead();
}, [markAllAsRead]);
await Promise.all([comments.markAllAsRead(), status.markAllAsRead()]);
}, [comments.markAllAsRead, status.markAllAsRead]);
const handleClearSearch = useCallback(() => {
setSearchQuery("");
@ -553,7 +526,7 @@ export function InboxSidebar({
if (!mounted) return null;
const isLoading = isSearchMode ? isSearchLoading : loading;
const isLoading = isSearchMode ? isSearchLoading : activeSource.loading;
const inboxContent = (
<>
@ -925,7 +898,7 @@ export function InboxSidebar({
<MessageSquare className="h-4 w-4" />
<span>{t("comments") || "Comments"}</span>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
{formatInboxCount(unreadCommentsCount)}
{formatInboxCount(comments.unreadCount)}
</span>
</span>
</TabsTrigger>
@ -937,7 +910,7 @@ export function InboxSidebar({
<History className="h-4 w-4" />
<span>{t("status") || "Status"}</span>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
{formatInboxCount(unreadStatusCount)}
{formatInboxCount(status.unreadCount)}
</span>
</span>
</TabsTrigger>
@ -983,7 +956,7 @@ export function InboxSidebar({
{filteredItems.map((item, index) => {
const isMarkingAsRead = markingAsReadId === item.id;
const isPrefetchTrigger =
!isSearchMode && hasMoreProp && index === filteredItems.length - 5;
!isSearchMode && activeSource.hasMore && index === filteredItems.length - 5;
return (
<div
@ -1061,10 +1034,10 @@ export function InboxSidebar({
</div>
);
})}
{!isSearchMode && filteredItems.length < 5 && hasMoreProp && (
{!isSearchMode && filteredItems.length < 5 && activeSource.hasMore && (
<div ref={prefetchTriggerRef} className="h-1" />
)}
{loadingMoreProp &&
{activeSource.loadingMore &&
(activeTab === "comments"
? [80, 60, 90].map((titleWidth, i) => (
<div