feat(notifications): enhance inbox functionality with type filtering and independent pagination for mentions and status notifications

This commit is contained in:
Anish Sarkar 2026-01-28 02:14:36 +05:30
parent 5690ac09ec
commit 79f7dfbbed
12 changed files with 600 additions and 532 deletions

View file

@ -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(

View file

@ -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) => {

View file

@ -254,10 +254,7 @@ const defaultComponents = memoizeMarkdownComponents({
table: ({ className, ...props }) => (
<div className="aui-md-table-wrapper my-5 w-full overflow-x-auto">
<table
className={cn(
"aui-md-table w-full min-w-max border-separate border-spacing-0",
className
)}
className={cn("aui-md-table w-full min-w-max border-separate border-spacing-0", className)}
{...props}
/>
</div>

View file

@ -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 {
</span>
);
lastIndex = match.index + match[0].length;
lastIndex = (match.index ?? 0) + match[0].length;
}
if (lastIndex < content.length) {

View file

@ -34,4 +34,3 @@ export function useSidebarContext(): SidebarContextValue {
export function useSidebarContextSafe(): SidebarContextValue | null {
return useContext(SidebarContext);
}

View file

@ -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,

View file

@ -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<boolean>;
markAllAsRead: () => Promise<boolean>;
/** Whether the inbox is docked (permanent) */
@ -151,26 +161,23 @@ export function LayoutShell({
setTheme={setTheme}
/>
<main className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
{children}
</main>
<main className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
{children}
</main>
{/* Mobile Inbox Sidebar - only render when open to avoid scroll blocking */}
{inbox?.isOpen && (
<InboxSidebar
open={inbox.isOpen}
onOpenChange={inbox.onOpenChange}
inboxItems={inbox.items}
unreadCount={inbox.unreadCount}
loading={inbox.loading}
loadingMore={inbox.loadingMore}
hasMore={inbox.hasMore}
loadMore={inbox.loadMore}
markAsRead={inbox.markAsRead}
markAllAsRead={inbox.markAllAsRead}
onCloseMobileSidebar={() => setMobileMenuOpen(false)}
/>
)}
{/* Mobile Inbox Sidebar - only render when open to avoid scroll blocking */}
{inbox?.isOpen && (
<InboxSidebar
open={inbox.isOpen}
onOpenChange={inbox.onOpenChange}
mentions={inbox.mentions}
status={inbox.status}
totalUnreadCount={inbox.totalUnreadCount}
markAsRead={inbox.markAsRead}
markAllAsRead={inbox.markAllAsRead}
onCloseMobileSidebar={() => setMobileMenuOpen(false)}
/>
)}
</div>
</TooltipProvider>
</SidebarProvider>
@ -181,7 +188,9 @@ export function LayoutShell({
return (
<SidebarProvider value={sidebarContextValue}>
<TooltipProvider delayDuration={0}>
<div className={cn("flex h-screen w-full gap-2 p-2 overflow-hidden bg-muted/40", className)}>
<div
className={cn("flex h-screen w-full gap-2 p-2 overflow-hidden bg-muted/40", className)}
>
<div className="hidden md:flex overflow-hidden">
<IconRail
searchSpaces={searchSpaces}
@ -226,12 +235,9 @@ export function LayoutShell({
<InboxSidebar
open={inbox.isOpen}
onOpenChange={inbox.onOpenChange}
inboxItems={inbox.items}
unreadCount={inbox.unreadCount}
loading={inbox.loading}
loadingMore={inbox.loadingMore}
hasMore={inbox.hasMore}
loadMore={inbox.loadMore}
mentions={inbox.mentions}
status={inbox.status}
totalUnreadCount={inbox.totalUnreadCount}
markAsRead={inbox.markAsRead}
markAllAsRead={inbox.markAllAsRead}
isDocked={inbox.isDocked}
@ -252,12 +258,9 @@ export function LayoutShell({
<InboxSidebar
open={inbox.isOpen}
onOpenChange={inbox.onOpenChange}
inboxItems={inbox.items}
unreadCount={inbox.unreadCount}
loading={inbox.loading}
loadingMore={inbox.loadingMore}
hasMore={inbox.hasMore}
loadMore={inbox.loadMore}
mentions={inbox.mentions}
status={inbox.status}
totalUnreadCount={inbox.totalUnreadCount}
markAsRead={inbox.markAsRead}
markAllAsRead={inbox.markAllAsRead}
isDocked={false}

View file

@ -44,12 +44,7 @@ import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import {
type ConnectorIndexingMetadata,
isConnectorIndexingMetadata,
isNewMentionMetadata,
type NewMentionMetadata,
} from "@/contracts/types/inbox.types";
import { isConnectorIndexingMetadata, isNewMentionMetadata } from "@/contracts/types/inbox.types";
import type { InboxItem } from "@/hooks/use-inbox";
import { useMediaQuery } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils";
@ -136,15 +131,25 @@ function getConnectorTypeDisplayName(connectorType: string): string {
type InboxTab = "mentions" | "status";
type InboxFilter = "all" | "unread";
interface InboxSidebarProps {
open: boolean;
onOpenChange: (open: boolean) => 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<boolean>;
markAllAsRead: () => Promise<boolean>;
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 = (
<>
<div className="shrink-0 p-4 pb-2 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">{t("inbox") || "Inbox"}</h2>
</div>
<div className="flex items-center gap-1">
{/* Mobile: Button that opens bottom drawer */}
{isMobile ? (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => setFilterDrawerOpen(true)}
>
<ListFilter className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{t("filter") || "Filter"}</span>
</Button>
</TooltipTrigger>
<TooltipContent className="z-80">{t("filter") || "Filter"}</TooltipContent>
</Tooltip>
<Drawer
open={filterDrawerOpen}
onOpenChange={setFilterDrawerOpen}
shouldScaleBackground={false}
>
<DrawerContent className="max-h-[70vh] z-80" overlayClassName="z-80">
<DrawerHandle />
<DrawerHeader className="px-4 pb-3 pt-2">
<DrawerTitle className="flex items-center gap-2 text-base font-semibold">
<ListFilter className="size-5" />
{t("filter") || "Filter"}
</DrawerTitle>
</DrawerHeader>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Filter section */}
<div className="space-y-2">
<p className="text-xs text-muted-foreground/80 font-medium px-1">
{t("filter") || "Filter"}
</p>
<div className="space-y-1">
<button
type="button"
onClick={() => {
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"
)}
>
<span className="flex items-center gap-2">
<Inbox className="h-4 w-4" />
<span>{t("all") || "All"}</span>
</span>
{activeFilter === "all" && <Check className="h-4 w-4" />}
</button>
<button
type="button"
onClick={() => {
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"
)}
>
<span className="flex items-center gap-2">
<BellDot className="h-4 w-4" />
<span>{t("unread") || "Unread"}</span>
</span>
{activeFilter === "unread" && <Check className="h-4 w-4" />}
</button>
</div>
</div>
{/* Connectors section - only for status tab */}
{activeTab === "status" && uniqueConnectorTypes.length > 0 && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground/80 font-medium px-1">
{t("connectors") || "Connectors"}
</p>
<div className="space-y-1">
<button
type="button"
onClick={() => {
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"
)}
>
<span className="flex items-center gap-2">
<LayoutGrid className="h-4 w-4" />
<span>{t("all_connectors") || "All connectors"}</span>
</span>
{selectedConnector === null && <Check className="h-4 w-4" />}
</button>
{uniqueConnectorTypes.map((connector) => (
<button
key={connector.type}
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"
)}
>
<span className="flex items-center gap-2">
{getConnectorIcon(connector.type, "h-4 w-4")}
<span>{connector.displayName}</span>
</span>
{selectedConnector === connector.type && (
<Check className="h-4 w-4" />
)}
</button>
))}
</div>
</div>
)}
</div>
</DrawerContent>
</Drawer>
</>
) : (
/* Desktop: Dropdown menu */
<DropdownMenu
open={openDropdown === "filter"}
onOpenChange={(isOpen) => setOpenDropdown(isOpen ? "filter" : null)}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">{t("inbox") || "Inbox"}</h2>
</div>
<div className="flex items-center gap-1">
{/* Mobile: Button that opens bottom drawer */}
{isMobile ? (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => setFilterDrawerOpen(true)}
>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
<ListFilter className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{t("filter") || "Filter"}</span>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent className="z-80">{t("filter") || "Filter"}</TooltipContent>
</Tooltip>
<DropdownMenuContent
align="end"
className={cn("z-80", activeTab === "status" ? "w-52" : "w-44")}
>
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal">
<ListFilter className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{t("filter") || "Filter"}</span>
</Button>
</TooltipTrigger>
<TooltipContent className="z-80">{t("filter") || "Filter"}</TooltipContent>
</Tooltip>
<Drawer
open={filterDrawerOpen}
onOpenChange={setFilterDrawerOpen}
shouldScaleBackground={false}
>
<DrawerContent className="max-h-[70vh] z-80" overlayClassName="z-80">
<DrawerHandle />
<DrawerHeader className="px-4 pb-3 pt-2">
<DrawerTitle className="flex items-center gap-2 text-base font-semibold">
<ListFilter className="size-5" />
{t("filter") || "Filter"}
</DrawerTitle>
</DrawerHeader>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Filter section */}
<div className="space-y-2">
<p className="text-xs text-muted-foreground/80 font-medium px-1">
{t("filter") || "Filter"}
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => setActiveFilter("all")}
className="flex items-center justify-between"
>
<span className="flex items-center gap-2">
<Inbox className="h-4 w-4" />
<span>{t("all") || "All"}</span>
</span>
{activeFilter === "all" && <Check className="h-4 w-4" />}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setActiveFilter("unread")}
className="flex items-center justify-between"
>
<span className="flex items-center gap-2">
<BellDot className="h-4 w-4" />
<span>{t("unread") || "Unread"}</span>
</span>
{activeFilter === "unread" && <Check className="h-4 w-4" />}
</DropdownMenuItem>
{activeTab === "status" && uniqueConnectorTypes.length > 0 && (
<>
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal mt-2">
{t("connectors") || "Connectors"}
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => setSelectedConnector(null)}
className="flex items-center justify-between"
</p>
<div className="space-y-1">
<button
type="button"
onClick={() => {
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"
)}
>
<span className="flex items-center gap-2">
<Inbox className="h-4 w-4" />
<span>{t("all") || "All"}</span>
</span>
{activeFilter === "all" && <Check className="h-4 w-4" />}
</button>
<button
type="button"
onClick={() => {
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"
)}
>
<span className="flex items-center gap-2">
<BellDot className="h-4 w-4" />
<span>{t("unread") || "Unread"}</span>
</span>
{activeFilter === "unread" && <Check className="h-4 w-4" />}
</button>
</div>
</div>
{/* Connectors section - only for status tab */}
{activeTab === "status" && uniqueConnectorTypes.length > 0 && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground/80 font-medium px-1">
{t("connectors") || "Connectors"}
</p>
<div className="space-y-1">
<button
type="button"
onClick={() => {
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"
)}
>
<span className="flex items-center gap-2">
<LayoutGrid className="h-4 w-4" />
<span>{t("all_connectors") || "All connectors"}</span>
</span>
{selectedConnector === null && <Check className="h-4 w-4" />}
</DropdownMenuItem>
</button>
{uniqueConnectorTypes.map((connector) => (
<DropdownMenuItem
<button
key={connector.type}
onClick={() => 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"
)}
>
<span className="flex items-center gap-2">
{getConnectorIcon(connector.type, "h-4 w-4")}
@ -692,240 +608,311 @@ export function InboxSidebar({
{selectedConnector === connector.type && (
<Check className="h-4 w-4" />
)}
</DropdownMenuItem>
</button>
))}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={handleMarkAllAsRead}
disabled={unreadCount === 0}
>
<CheckCheck className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{t("mark_all_read") || "Mark all as read"}</span>
</div>
</div>
)}
</div>
</DrawerContent>
</Drawer>
</>
) : (
/* Desktop: Dropdown menu */
<DropdownMenu
open={openDropdown === "filter"}
onOpenChange={(isOpen) => setOpenDropdown(isOpen ? "filter" : null)}
>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
<ListFilter className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{t("filter") || "Filter"}</span>
</Button>
</TooltipTrigger>
<TooltipContent className="z-80">
{t("mark_all_read") || "Mark all as read"}
</TooltipContent>
</Tooltip>
{/* Close button - mobile only */}
{isMobile && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => onOpenChange(false)}
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent className="z-80">{t("filter") || "Filter"}</TooltipContent>
</Tooltip>
<DropdownMenuContent
align="end"
className={cn("z-80", activeTab === "status" ? "w-52" : "w-44")}
>
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal">
{t("filter") || "Filter"}
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => setActiveFilter("all")}
className="flex items-center justify-between"
>
<span className="flex items-center gap-2">
<Inbox className="h-4 w-4" />
<span>{t("all") || "All"}</span>
</span>
{activeFilter === "all" && <Check className="h-4 w-4" />}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setActiveFilter("unread")}
className="flex items-center justify-between"
>
<span className="flex items-center gap-2">
<BellDot className="h-4 w-4" />
<span>{t("unread") || "Unread"}</span>
</span>
{activeFilter === "unread" && <Check className="h-4 w-4" />}
</DropdownMenuItem>
{activeTab === "status" && uniqueConnectorTypes.length > 0 && (
<>
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal mt-2">
{t("connectors") || "Connectors"}
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => setSelectedConnector(null)}
className="flex items-center justify-between"
>
<span className="flex items-center gap-2">
<LayoutGrid className="h-4 w-4" />
<span>{t("all_connectors") || "All connectors"}</span>
</span>
{selectedConnector === null && <Check className="h-4 w-4" />}
</DropdownMenuItem>
{uniqueConnectorTypes.map((connector) => (
<DropdownMenuItem
key={connector.type}
onClick={() => setSelectedConnector(connector.type)}
className="flex items-center justify-between"
>
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{t("close") || "Close"}</span>
</Button>
</TooltipTrigger>
<TooltipContent className="z-80">
{t("close") || "Close"}
</TooltipContent>
</Tooltip>
)}
{/* Dock/Undock button - desktop only */}
{!isMobile && onDockedChange && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => {
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 ? (
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<span className="sr-only">
{isDocked ? "Collapse panel" : "Expand panel"}
<span className="flex items-center gap-2">
{getConnectorIcon(connector.type, "h-4 w-4")}
<span>{connector.displayName}</span>
</span>
</Button>
</TooltipTrigger>
<TooltipContent className="z-80">
{isDocked ? "Collapse panel" : "Expand panel"}
</TooltipContent>
</Tooltip>
{selectedConnector === connector.type && <Check className="h-4 w-4" />}
</DropdownMenuItem>
))}
</>
)}
</div>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder={t("search_inbox") || "Search inbox"}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-8 h-9"
/>
{searchQuery && (
</DropdownMenuContent>
</DropdownMenu>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={handleMarkAllAsRead}
disabled={totalUnreadCount === 0}
>
<CheckCheck className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{t("mark_all_read") || "Mark all as read"}</span>
</Button>
</TooltipTrigger>
<TooltipContent className="z-80">
{t("mark_all_read") || "Mark all as read"}
</TooltipContent>
</Tooltip>
{/* Close button - mobile only */}
{isMobile && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6"
onClick={handleClearSearch}
className="h-8 w-8 rounded-full"
onClick={() => onOpenChange(false)}
>
<X className="h-3.5 w-3.5" />
<span className="sr-only">{t("clear_search") || "Clear search"}</span>
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{t("close") || "Close"}</span>
</Button>
)}
</div>
</div>
</TooltipTrigger>
<TooltipContent className="z-80">{t("close") || "Close"}</TooltipContent>
</Tooltip>
)}
{/* Dock/Undock button - desktop only */}
{!isMobile && onDockedChange && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => {
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 ? (
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<span className="sr-only">{isDocked ? "Collapse panel" : "Expand panel"}</span>
</Button>
</TooltipTrigger>
<TooltipContent className="z-80">
{isDocked ? "Collapse panel" : "Expand panel"}
</TooltipContent>
</Tooltip>
)}
</div>
</div>
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as InboxTab)}
className="shrink-0 mx-4"
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder={t("search_inbox") || "Search inbox"}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-8 h-9"
/>
{searchQuery && (
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6"
onClick={handleClearSearch}
>
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
<TabsTrigger
value="mentions"
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
<AtSign className="h-4 w-4" />
<span>{t("mentions") || "Mentions"}</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(unreadMentionsCount)}
</span>
</span>
</TabsTrigger>
<TabsTrigger
value="status"
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
<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)}
</span>
</span>
</TabsTrigger>
</TabsList>
</Tabs>
<X className="h-3.5 w-3.5" />
<span className="sr-only">{t("clear_search") || "Clear search"}</span>
</Button>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
{loading ? (
<div className="flex items-center justify-center py-8">
<Spinner size="md" className="text-muted-foreground" />
</div>
) : filteredItems.length > 0 ? (
<div className="space-y-2">
{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;
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as InboxTab)}
className="shrink-0 mx-4"
>
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
<TabsTrigger
value="mentions"
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
<AtSign className="h-4 w-4" />
<span>{t("mentions") || "Mentions"}</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(unreadMentionsCount)}
</span>
</span>
</TabsTrigger>
<TabsTrigger
value="status"
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
<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)}
</span>
</span>
</TabsTrigger>
</TabsList>
</Tabs>
return (
<div
key={item.id}
ref={isPrefetchTrigger ? prefetchTriggerRef : undefined}
className={cn(
"group flex items-center gap-3 rounded-lg px-3 py-3 text-sm h-[80px] overflow-hidden",
"hover:bg-accent hover:text-accent-foreground",
"transition-colors cursor-pointer",
isMarkingAsRead && "opacity-50 pointer-events-none"
)}
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
{loading ? (
<div className="flex items-center justify-center py-8">
<Spinner size="md" className="text-muted-foreground" />
</div>
) : filteredItems.length > 0 ? (
<div className="space-y-2">
{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 (
<div
key={item.id}
ref={isPrefetchTrigger ? prefetchTriggerRef : undefined}
className={cn(
"group flex items-center gap-3 rounded-lg px-3 py-3 text-sm h-[80px] overflow-hidden",
"hover:bg-accent hover:text-accent-foreground",
"transition-colors cursor-pointer",
isMarkingAsRead && "opacity-50 pointer-events-none"
)}
>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => handleItemClick(item)}
disabled={isMarkingAsRead}
className="flex items-center gap-3 flex-1 min-w-0 text-left overflow-hidden"
>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => handleItemClick(item)}
disabled={isMarkingAsRead}
className="flex items-center gap-3 flex-1 min-w-0 text-left overflow-hidden"
>
<div className="shrink-0">{getStatusIcon(item)}</div>
<div className="flex-1 min-w-0 overflow-hidden">
<p
className={cn(
"text-xs font-medium line-clamp-2",
!item.read && "font-semibold"
)}
>
{item.title}
</p>
<p className="text-[11px] text-muted-foreground line-clamp-2 mt-0.5">
{convertRenderedToDisplay(item.message)}
</p>
</div>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" align="start" className="max-w-[250px]">
<p className="font-medium">{item.title}</p>
<p className="text-muted-foreground mt-1">
{convertRenderedToDisplay(item.message)}
</p>
</TooltipContent>
</Tooltip>
{/* Time and unread dot - fixed width to prevent content shift */}
<div className="flex items-center justify-end gap-1.5 shrink-0 w-10">
<span className="text-[10px] text-muted-foreground">
{formatTime(item.created_at)}
</span>
{!item.read && (
<span className="h-2 w-2 rounded-full bg-blue-500 shrink-0" />
)}
<div className="shrink-0">{getStatusIcon(item)}</div>
<div className="flex-1 min-w-0 overflow-hidden">
<p
className={cn(
"text-xs font-medium line-clamp-2",
!item.read && "font-semibold"
)}
>
{item.title}
</p>
<p className="text-[11px] text-muted-foreground line-clamp-2 mt-0.5">
{convertRenderedToDisplay(item.message)}
</p>
</div>
</div>
);
})}
{/* Fallback trigger at the very end if less than 5 items and not searching */}
{!searchQuery && filteredItems.length < 5 && hasMore && (
<div ref={prefetchTriggerRef} className="h-1" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" align="start" className="max-w-[250px]">
<p className="font-medium">{item.title}</p>
<p className="text-muted-foreground mt-1">
{convertRenderedToDisplay(item.message)}
</p>
</TooltipContent>
</Tooltip>
{/* Time and unread dot - fixed width to prevent content shift */}
<div className="flex items-center justify-end gap-1.5 shrink-0 w-10">
<span className="text-[10px] text-muted-foreground">
{formatTime(item.created_at)}
</span>
{!item.read && <span className="h-2 w-2 rounded-full bg-blue-500 shrink-0" />}
</div>
</div>
) : searchQuery ? (
<div className="text-center py-8">
<Search className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">
{t("no_results_found") || "No results found"}
</p>
<p className="text-xs text-muted-foreground/70 mt-1">
{t("try_different_search") || "Try a different search term"}
</p>
</div>
) : (
<div className="text-center py-8">
{activeTab === "mentions" ? (
<AtSign className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
) : (
<History className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
)}
<p className="text-sm text-muted-foreground">{getEmptyStateMessage().title}</p>
<p className="text-xs text-muted-foreground/70 mt-1">
{getEmptyStateMessage().hint}
</p>
</div>
)}
);
})}
{/* Fallback trigger at the very end if less than 5 items and not searching */}
{!searchQuery && filteredItems.length < 5 && hasMore && (
<div ref={prefetchTriggerRef} className="h-1" />
)}
</div>
) : searchQuery ? (
<div className="text-center py-8">
<Search className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">
{t("no_results_found") || "No results found"}
</p>
<p className="text-xs text-muted-foreground/70 mt-1">
{t("try_different_search") || "Try a different search term"}
</p>
</div>
) : (
<div className="text-center py-8">
{activeTab === "mentions" ? (
<AtSign className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
) : (
<History className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
)}
<p className="text-sm text-muted-foreground">{getEmptyStateMessage().title}</p>
<p className="text-xs text-muted-foreground/70 mt-1">{getEmptyStateMessage().hint}</p>
</div>
)}
</div>
</>
);
@ -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")}
>
<motion.div
initial={{ x: "-100%" }}

View file

@ -197,7 +197,9 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
<div className="min-w-0">
<h3 className="truncate font-semibold text-foreground text-sm sm:text-base">{title}</h3>
{description && (
<p className="mt-0.5 line-clamp-1 text-muted-foreground text-xs sm:text-sm">{description}</p>
<p className="mt-0.5 line-clamp-1 text-muted-foreground text-xs sm:text-sm">
{description}
</p>
)}
</div>
@ -243,7 +245,11 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
{/* Volume control */}
<div className="flex items-center gap-1 sm:gap-1.5">
<Button variant="ghost" size="icon" onClick={toggleMute} className="size-7 sm:size-8">
{isMuted ? <VolumeXIcon className="size-3.5 sm:size-4" /> : <Volume2Icon className="size-3.5 sm:size-4" />}
{isMuted ? (
<VolumeXIcon className="size-3.5 sm:size-4" />
) : (
<Volume2Icon className="size-3.5 sm:size-4" />
)}
</Button>
{/* Custom volume bar - visually distinct from progress slider */}
<div className="relative flex h-6 w-12 sm:w-16 items-center">
@ -268,7 +274,12 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
</div>
{/* Download button */}
<Button variant="outline" size="sm" onClick={handleDownload} className="gap-1.5 sm:gap-2 h-7 sm:h-8 px-2.5 sm:px-3 text-xs sm:text-sm">
<Button
variant="outline"
size="sm"
onClick={handleDownload}
className="gap-1.5 sm:gap-2 h-7 sm:h-8 px-2.5 sm:px-3 text-xs sm:text-sm"
>
<DownloadIcon className="size-3 sm:size-4" />
Download
</Button>

View file

@ -96,10 +96,14 @@ function PodcastGeneratingState({ title }: { title: string }) {
<div className="absolute inset-1 animate-ping rounded-full bg-primary/20" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground text-sm sm:text-lg leading-tight">{title}</h3>
<h3 className="font-semibold text-foreground text-sm sm:text-lg leading-tight">
{title}
</h3>
<div className="mt-1.5 sm:mt-2 flex items-center gap-1.5 sm:gap-2 text-muted-foreground">
<Spinner size="sm" className="size-3 sm:size-4" />
<span className="text-xs sm:text-sm">Generating podcast. This may take a few minutes.</span>
<span className="text-xs sm:text-sm">
Generating podcast. This may take a few minutes.
</span>
</div>
<div className="mt-2 sm:mt-3">
<div className="h-1 sm:h-1.5 w-full overflow-hidden rounded-full bg-primary/10">
@ -123,7 +127,9 @@ function PodcastErrorState({ title, error }: { title: string; error: string }) {
<AlertCircleIcon className="size-6 sm:size-8 text-destructive" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground text-sm sm:text-base leading-tight">{title}</h3>
<h3 className="font-semibold text-foreground text-sm sm:text-base leading-tight">
{title}
</h3>
<p className="mt-1 text-destructive text-xs sm:text-sm">Failed to generate podcast</p>
<p className="mt-1.5 sm:mt-2 text-muted-foreground text-xs sm:text-sm">{error}</p>
</div>
@ -143,7 +149,9 @@ function AudioLoadingState({ title }: { title: string }) {
<MicIcon className="size-6 sm:size-8 text-primary/50" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground text-sm sm:text-base leading-tight">{title}</h3>
<h3 className="font-semibold text-foreground text-sm sm:text-base leading-tight">
{title}
</h3>
<div className="mt-1.5 sm:mt-2 flex items-center gap-1.5 sm:gap-2 text-muted-foreground">
<Spinner size="sm" className="size-3 sm:size-4" />
<span className="text-xs sm:text-sm">Loading audio...</span>

View file

@ -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) {

View file

@ -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<GetUnreadCountResponse> => {
getUnreadCount = async (
searchSpaceId?: number,
type?: InboxItemTypeEnum
): Promise<GetUnreadCountResponse> => {
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(