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/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 38501fcab..f6f70f83b 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -13,7 +13,11 @@ import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { z } from "zod"; -import { currentThreadAtom } from "@/atoms/chat/current-thread.atom"; +import { + clearTargetCommentIdAtom, + currentThreadAtom, + setTargetCommentIdAtom, +} from "@/atoms/chat/current-thread.atom"; import { type MentionedDocumentInfo, mentionedDocumentIdsAtom, @@ -158,6 +162,8 @@ export default function NewChatPage() { const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom); const hydratePlanState = useSetAtom(hydratePlanStateAtom); const setCurrentThreadState = useSetAtom(currentThreadAtom); + const setTargetCommentId = useSetAtom(setTargetCommentIdAtom); + const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom); // Get current user for author info in shared chats const { data: currentUser } = useAtomValue(currentUserAtom); @@ -355,46 +361,33 @@ export default function NewChatPage() { // Handle scroll to comment from URL query params (e.g., from inbox item click) const searchParams = useSearchParams(); - const targetCommentId = searchParams.get("commentId"); + const targetCommentIdParam = searchParams.get("commentId"); + // Set target comment ID from URL param - the AssistantMessage and CommentItem + // components will handle scrolling and highlighting once comments are loaded useEffect(() => { - if (!targetCommentId || isInitializing || messages.length === 0) return; - - const tryScroll = () => { - const el = document.querySelector(`[data-comment-id="${targetCommentId}"]`); - if (el) { - el.scrollIntoView({ behavior: "smooth", block: "center" }); - return true; + if (targetCommentIdParam && !isInitializing) { + const commentId = Number.parseInt(targetCommentIdParam, 10); + if (!Number.isNaN(commentId)) { + setTargetCommentId(commentId); } - return false; - }; + } - // Try immediately - if (tryScroll()) return; - - // Retry every 200ms for up to 10 seconds - const intervalId = setInterval(() => { - if (tryScroll()) clearInterval(intervalId); - }, 200); - - const timeoutId = setTimeout(() => clearInterval(intervalId), 10000); - - return () => { - clearInterval(intervalId); - clearTimeout(timeoutId); - }; - }, [targetCommentId, isInitializing, messages.length]); + // Cleanup on unmount or when navigating away + return () => clearTargetCommentId(); + }, [targetCommentIdParam, isInitializing, setTargetCommentId, clearTargetCommentId]); // Sync current thread state to atom useEffect(() => { - setCurrentThreadState({ + setCurrentThreadState((prev) => ({ + ...prev, id: currentThread?.id ?? null, visibility: currentThread?.visibility ?? null, hasComments: currentThread?.has_comments ?? false, addingCommentToMessageId: null, publicShareEnabled: currentThread?.public_share_enabled ?? false, publicShareToken: currentThread?.public_share_token ?? null, - }); + })); }, [currentThread, setCurrentThreadState]); // Cancel ongoing request diff --git a/surfsense_web/atoms/chat/current-thread.atom.ts b/surfsense_web/atoms/chat/current-thread.atom.ts index 7d6ccb0db..54f2190fe 100644 --- a/surfsense_web/atoms/chat/current-thread.atom.ts +++ b/surfsense_web/atoms/chat/current-thread.atom.ts @@ -17,6 +17,8 @@ interface CurrentThreadState { visibility: ChatVisibility | null; hasComments: boolean; addingCommentToMessageId: number | null; + /** Whether the right-side comments panel is collapsed (desktop only) */ + commentsCollapsed: boolean; publicShareEnabled: boolean; publicShareToken: string | null; } @@ -26,6 +28,7 @@ const initialState: CurrentThreadState = { visibility: null, hasComments: false, addingCommentToMessageId: null, + commentsCollapsed: false, publicShareEnabled: false, publicShareToken: null, }; @@ -38,6 +41,8 @@ export const commentsEnabledAtom = atom( export const showCommentsGutterAtom = atom((get) => { const thread = get(currentThreadAtom); + // Hide gutter if comments are collapsed + if (thread.commentsCollapsed) return false; return ( thread.visibility === "SEARCH_SPACE" && (thread.hasComments || thread.addingCommentToMessageId !== null) @@ -59,3 +64,34 @@ export const setThreadVisibilityAtom = atom(null, (get, set, newVisibility: Chat export const resetCurrentThreadAtom = atom(null, (_, set) => { set(currentThreadAtom, initialState); }); + +/** Atom to read whether comments panel is collapsed */ +export const commentsCollapsedAtom = atom((get) => get(currentThreadAtom).commentsCollapsed); + +/** Atom to toggle the comments collapsed state */ +export const toggleCommentsCollapsedAtom = atom(null, (get, set) => { + const current = get(currentThreadAtom); + set(currentThreadAtom, { ...current, commentsCollapsed: !current.commentsCollapsed }); +}); + +/** Atom to explicitly set the comments collapsed state */ +export const setCommentsCollapsedAtom = atom(null, (get, set, collapsed: boolean) => { + set(currentThreadAtom, { ...get(currentThreadAtom), commentsCollapsed: collapsed }); +}); + +/** Target comment ID to scroll to (from URL navigation or inbox click) */ +export const targetCommentIdAtom = atom(null); + +/** Setter for target comment ID - also ensures comments are not collapsed */ +export const setTargetCommentIdAtom = atom(null, (get, set, commentId: number | null) => { + // Ensure comments are not collapsed when navigating to a comment + if (commentId !== null) { + set(currentThreadAtom, { ...get(currentThreadAtom), commentsCollapsed: false }); + } + set(targetCommentIdAtom, commentId); +}); + +/** Clear target after navigation completes */ +export const clearTargetCommentIdAtom = atom(null, (_, set) => { + set(targetCommentIdAtom, null); +}); diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index b3cfc4476..4fd2446c3 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -5,13 +5,16 @@ import { MessagePrimitive, useAssistantState, } from "@assistant-ui/react"; -import { useAtom, useAtomValue } from "jotai"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { CheckIcon, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react"; import type { FC } from "react"; -import { useContext, useEffect, useRef, useState } from "react"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { addingCommentToMessageIdAtom, + clearTargetCommentIdAtom, + commentsCollapsedAtom, commentsEnabledAtom, + targetCommentIdAtom, } from "@/atoms/chat/current-thread.atom"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { BranchPicker } from "@/components/assistant-ui/branch-picker"; @@ -102,6 +105,7 @@ export const AssistantMessage: FC = () => { const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const dbMessageId = parseMessageId(messageId); const commentsEnabled = useAtomValue(commentsEnabledAtom); + const commentsCollapsed = useAtomValue(commentsCollapsedAtom); const [addingCommentToMessageId, setAddingCommentToMessageId] = useAtom( addingCommentToMessageIdAtom ); @@ -115,11 +119,23 @@ export const AssistantMessage: FC = () => { const isLastMessage = useAssistantState(({ message }) => message?.isLast ?? false); const isMessageStreaming = isThreadRunning && isLastMessage; - const { data: commentsData } = useComments({ + const { data: commentsData, isSuccess: commentsLoaded } = useComments({ messageId: dbMessageId ?? 0, enabled: !!dbMessageId, }); + // Target comment navigation - read target from global atom + const targetCommentId = useAtomValue(targetCommentIdAtom); + const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom); + + // Check if target comment belongs to this message (including replies) + const hasTargetComment = useMemo(() => { + if (!targetCommentId || !commentsData?.comments) return false; + return commentsData.comments.some( + (c) => c.id === targetCommentId || c.replies?.some((r) => r.id === targetCommentId) + ); + }, [targetCommentId, commentsData]); + const commentCount = commentsData?.total_count ?? 0; const hasComments = commentCount > 0; const isAddingComment = dbMessageId !== null && addingCommentToMessageId === dbMessageId; @@ -144,6 +160,24 @@ export const AssistantMessage: FC = () => { return () => observer.disconnect(); }, []); + // Auto-open sheet on mobile/tablet when this message has the target comment + useEffect(() => { + if (hasTargetComment && !isDesktop && commentsLoaded) { + setIsSheetOpen(true); + } + }, [hasTargetComment, isDesktop, commentsLoaded]); + + // Scroll message into view when it contains target comment (desktop) + useEffect(() => { + if (hasTargetComment && isDesktop && commentsLoaded && messageRef.current) { + // Small delay to ensure DOM is ready after comments render + const timeoutId = setTimeout(() => { + messageRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); + }, 100); + return () => clearTimeout(timeoutId); + } + }, [hasTargetComment, isDesktop, commentsLoaded]); + const showCommentTrigger = searchSpaceId && commentsEnabled && !isMessageStreaming && dbMessageId; // Determine sheet side based on screen size @@ -157,8 +191,8 @@ export const AssistantMessage: FC = () => { > - {/* Desktop comment panel - only on lg screens and above */} - {searchSpaceId && commentsEnabled && !isMessageStreaming && ( + {/* Desktop comment panel - only on lg screens and above, hidden when collapsed */} + {searchSpaceId && commentsEnabled && !isMessageStreaming && !commentsCollapsed && (
), table: ({ className, ...props }) => ( - +
+
+ ), th: ({ className, children, ...props }) => (
= ({ header }) => { }} /> - + !thread.isEmpty}>
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 8b2503e8c..4996fe01b 100644 --- a/surfsense_web/components/chat-comments/comment-item/comment-item.tsx +++ b/surfsense_web/components/chat-comments/comment-item/comment-item.tsx @@ -1,6 +1,9 @@ "use client"; +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 { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -76,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)); } @@ -90,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) { @@ -113,6 +115,37 @@ export function CommentItem({ members = [], membersLoading = false, }: CommentItemProps) { + const commentRef = useRef(null); + const [isHighlighted, setIsHighlighted] = useState(false); + + // Target comment navigation + const targetCommentId = useAtomValue(targetCommentIdAtom); + const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom); + + const isTarget = targetCommentId === comment.id; + + // Scroll into view and highlight when this is the target comment + useEffect(() => { + if (isTarget && commentRef.current) { + // Small delay to ensure DOM is ready + const scrollTimeoutId = setTimeout(() => { + commentRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); + setIsHighlighted(true); + }, 150); + + // Remove highlight and clear target after delay + const clearTimeoutId = setTimeout(() => { + setIsHighlighted(false); + clearTargetCommentId(); + }, 3000); + + return () => { + clearTimeout(scrollTimeoutId); + clearTimeout(clearTimeoutId); + }; + } + }, [isTarget, clearTargetCommentId]); + const displayName = comment.author?.displayName || comment.author?.email.split("@")[0] || "Unknown"; const email = comment.author?.email || ""; @@ -122,7 +155,14 @@ export function CommentItem({ }; return ( -
+
{comment.author?.avatarUrl && ( diff --git a/surfsense_web/components/layout/hooks/SidebarContext.tsx b/surfsense_web/components/layout/hooks/SidebarContext.tsx new file mode 100644 index 000000000..7aa24d5d0 --- /dev/null +++ b/surfsense_web/components/layout/hooks/SidebarContext.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { createContext, useContext, type ReactNode } from "react"; + +interface SidebarContextValue { + isCollapsed: boolean; + setIsCollapsed: (collapsed: boolean) => void; + toggleCollapsed: () => void; +} + +const SidebarContext = createContext(null); + +interface SidebarProviderProps { + children: ReactNode; + value: SidebarContextValue; +} + +export function SidebarProvider({ children, value }: SidebarProviderProps) { + return {children}; +} + +export function useSidebarContext(): SidebarContextValue { + const context = useContext(SidebarContext); + if (!context) { + throw new Error("useSidebarContext must be used within a SidebarProvider"); + } + return context; +} + +/** + * Safe version that returns null if not within provider + * Useful for components that may be rendered outside the sidebar context + */ +export function useSidebarContextSafe(): SidebarContextValue | null { + return useContext(SidebarContext); +} diff --git a/surfsense_web/components/layout/hooks/index.ts b/surfsense_web/components/layout/hooks/index.ts index 51cf8f7a0..557cfc992 100644 --- a/surfsense_web/components/layout/hooks/index.ts +++ b/surfsense_web/components/layout/hooks/index.ts @@ -1 +1,2 @@ export { useSidebarState } from "./useSidebarState"; +export { SidebarProvider, useSidebarContext, useSidebarContextSafe } from "./SidebarContext"; diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 0baf1dcfa..537ae09ea 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -32,7 +32,6 @@ import { CreateSearchSpaceDialog } from "../ui/dialogs"; import { LayoutShell } from "../ui/shell"; import { AllPrivateChatsSidebar } from "../ui/sidebar/AllPrivateChatsSidebar"; import { AllSharedChatsSidebar } from "../ui/sidebar/AllSharedChatsSidebar"; -import { InboxSidebar } from "../ui/sidebar/InboxSidebar"; interface LayoutDataProviderProps { searchSpaceId: string; @@ -100,23 +99,60 @@ export function LayoutDataProvider({ // Inbox sidebar state const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false); + const [isInboxDocked, setIsInboxDocked] = useState(false); // 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 @@ -298,9 +334,9 @@ export function LayoutDataProvider({ const handleNavItemClick = useCallback( (item: NavItem) => { - // Handle inbox specially - open sidebar instead of navigating + // Handle inbox specially - toggle sidebar instead of navigating if (item.url === "#inbox") { - setIsInboxSidebarOpen(true); + setIsInboxSidebarOpen((prev) => !prev); return; } router.push(item.url); @@ -462,6 +498,32 @@ export function LayoutDataProvider({ theme={theme} setTheme={setTheme} isChatPage={isChatPage} + inbox={{ + isOpen: isInboxSidebarOpen, + onOpenChange: setIsInboxSidebarOpen, + // 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, + onDockedChange: setIsInboxDocked, + }} > {children} @@ -607,20 +669,6 @@ export function LayoutDataProvider({ searchSpaceId={searchSpaceId} /> - {/* Inbox Sidebar */} - - {/* Create Search Space Dialog */} 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) */ + isDocked?: boolean; + /** Callback to change docked state */ + onDockedChange?: (docked: boolean) => void; +} interface LayoutShellProps { searchSpaces: SearchSpace[]; @@ -42,6 +71,8 @@ interface LayoutShellProps { isChatPage?: boolean; children: React.ReactNode; className?: string; + // Inbox props + inbox?: InboxProps; } export function LayoutShell({ @@ -76,111 +107,169 @@ export function LayoutShell({ isChatPage = false, children, className, + inbox, }: LayoutShellProps) { const isMobile = useIsMobile(); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); - const { isCollapsed, toggleCollapsed } = useSidebarState(defaultCollapsed); + const { isCollapsed, setIsCollapsed, toggleCollapsed } = useSidebarState(defaultCollapsed); + + // Memoize context value to prevent unnecessary re-renders + const sidebarContextValue = useMemo( + () => ({ isCollapsed, setIsCollapsed, toggleCollapsed }), + [isCollapsed, setIsCollapsed, toggleCollapsed] + ); // Mobile layout if (isMobile) { return ( - -
-
setMobileMenuOpen(true)} />} - /> + + +
+
setMobileMenuOpen(true)} />} + /> - + -
- {children} -
-
-
+
+ {children} +
+ + {/* Mobile Inbox Sidebar - only render when open to avoid scroll blocking */} + {inbox?.isOpen && ( + setMobileMenuOpen(false)} + /> + )} +
+
+ ); } // Desktop layout return ( - -
-
- + + +
+
+ +
+ + {/* Main container with sidebar and content - relative for inbox positioning */} +
+ + + {/* Docked Inbox Sidebar - renders as flex sibling between sidebar and content */} + {inbox?.isDocked && ( + + )} + +
+
+ +
+ {children} +
+
+ + {/* Floating Inbox Sidebar - positioned absolutely on top of content */} + {inbox && !inbox.isDocked && ( + + )} +
- -
- - -
-
- -
- {children} -
-
-
-
- + + ); } diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index b47634b8d..bddaeb9b2 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -7,6 +7,8 @@ import { Check, CheckCheck, CheckCircle2, + ChevronLeft, + ChevronRight, History, Inbox, LayoutGrid, @@ -14,11 +16,12 @@ import { Search, X, } from "lucide-react"; +import { useAtom } from "jotai"; import { AnimatePresence, motion } from "motion/react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { createPortal } from "react-dom"; +import { setCommentsCollapsedAtom, setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom"; import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; @@ -45,6 +48,11 @@ import { isConnectorIndexingMetadata, isNewMentionMetadata } from "@/contracts/t import type { InboxItem } from "@/hooks/use-inbox"; import { useMediaQuery } from "@/hooks/use-media-query"; import { cn } from "@/lib/utils"; +import { useSidebarContextSafe } from "../../hooks"; + +// Sidebar width constants +const SIDEBAR_COLLAPSED_WIDTH = 60; +const SIDEBAR_EXPANDED_WIDTH = 240; /** * Get initials from name or email for avatar fallback @@ -104,6 +112,7 @@ function getConnectorTypeDisplayName(connectorType: string): string { YOUTUBE_CONNECTOR: "YouTube", CIRCLEBACK_CONNECTOR: "Circleback", MCP_CONNECTOR: "MCP", + OBSIDIAN_CONNECTOR: "Obsidian", TAVILY_API: "Tavily", SEARXNG_API: "SearXNG", LINKUP_API: "Linkup", @@ -122,37 +131,55 @@ 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; markAllAsRead: () => Promise; onCloseMobileSidebar?: () => void; + /** Whether the inbox is docked (permanent) or floating */ + isDocked?: boolean; + /** Callback to toggle docked state */ + onDockedChange?: (docked: boolean) => void; } export function InboxSidebar({ open, onOpenChange, - inboxItems, - unreadCount, - loading, - loadingMore = false, - hasMore = false, - loadMore, + mentions, + status, + totalUnreadCount, markAsRead, markAllAsRead, onCloseMobileSidebar, + isDocked = false, + onDockedChange, }: InboxSidebarProps) { const t = useTranslations("sidebar"); const router = useRouter(); const isMobile = !useMediaQuery("(min-width: 640px)"); + // Comments collapsed state (desktop only, when docked) + const [, setCommentsCollapsed] = useAtom(setCommentsCollapsedAtom); + // Target comment for navigation - also ensures comments panel is visible + const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom); + const [searchQuery, setSearchQuery] = useState(""); const [activeTab, setActiveTab] = useState("mentions"); const [activeFilter, setActiveFilter] = useState("all"); @@ -181,16 +208,18 @@ export function InboxSidebar({ return () => document.removeEventListener("keydown", handleEscape); }, [open, onOpenChange]); + // Only lock body scroll on mobile when inbox is open useEffect(() => { - if (open) { - document.body.style.overflow = "hidden"; - } else { - document.body.style.overflow = ""; - } + 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 = ""; + document.body.style.overflow = originalOverflow; }; - }, [open]); + }, [open, isMobile]); // Reset connector filter when switching away from status tab useEffect(() => { @@ -199,19 +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; // Status tab includes: connector indexing, document processing + // Filter to only show status notification types 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 @@ -233,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") { @@ -269,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) @@ -295,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) => { @@ -322,6 +345,12 @@ export function InboxSidebar({ const commentId = item.metadata.comment_id; if (searchSpaceId && threadId) { + // Pre-set target comment ID before navigation + // This also ensures comments panel is not collapsed + if (commentId) { + setTargetCommentId(commentId); + } + const url = commentId ? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}` : `/dashboard/${searchSpaceId}/new-chat/${threadId}`; @@ -332,7 +361,7 @@ export function InboxSidebar({ } } }, - [markAsRead, router, onOpenChange, onCloseMobileSidebar] + [markAsRead, router, onOpenChange, onCloseMobileSidebar, setTargetCommentId] ); const handleMarkAllAsRead = useCallback(async () => { @@ -436,237 +465,141 @@ export function InboxSidebar({ }; }; + // Get sidebar collapsed state from context (provided by LayoutShell) + const sidebarContext = useSidebarContextSafe(); + const isCollapsed = sidebarContext?.isCollapsed ?? false; + + // Calculate the left position for the inbox panel (relative to sidebar) + const sidebarWidth = isCollapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_EXPANDED_WIDTH; + if (!mounted) return null; - return createPortal( - - {open && ( - <> - onOpenChange(false)} - aria-hidden="true" - /> - - -
-
-
- -

{t("inbox") || "Inbox"}

-
-
- {/* Mobile: Button that opens bottom drawer */} - {isMobile ? ( - <> - - - - - {t("filter") || "Filter"} - - - - - - - - {t("filter") || "Filter"} - - -
- {/* Filter section */} -
-

- {t("filter") || "Filter"} -

-
- - -
-
- {/* Connectors section - only for status tab */} - {activeTab === "status" && uniqueConnectorTypes.length > 0 && ( -
-

- {t("connectors") || "Connectors"} -

-
- - {uniqueConnectorTypes.map((connector) => ( - - ))} -
-
- )} -
-
-
- - ) : ( - /* Desktop: Dropdown menu */ - setOpenDropdown(isOpen ? "filter" : null)} + // Shared content component for both docked and floating modes + const inboxContent = ( + <> +
+
+
+

{t("inbox") || "Inbox"}

+
+
+ {/* Mobile: Button that opens bottom drawer */} + {isMobile ? ( + <> + + + - - - {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" +

+
+ + +
+
+ {/* Connectors section - only for status tab */} + {activeTab === "status" && uniqueConnectorTypes.length > 0 && ( +
+

+ {t("connectors") || "Connectors"} +

+
+ {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")} @@ -675,190 +608,372 @@ export function InboxSidebar({ {selectedConnector === connector.type && ( )} - + ))} - - )} - - - )} - - -
+
+ )} +
+
+
+ + ) : ( + /* Desktop: Dropdown menu */ + setOpenDropdown(isOpen ? "filter" : null)} + > + + + + - - - {t("mark_all_read") || "Mark all as read"} - - -
-
- -
- - setSearchQuery(e.target.value)} - className="pl-9 pr-8 h-9" - /> - {searchQuery && ( + + + {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" + > + + {getConnectorIcon(connector.type, "h-4 w-4")} + {connector.displayName} + + {selectedConnector === connector.type && } + + ))} + + )} + + + )} + + + + + + {t("mark_all_read") || "Mark all as read"} + + + {/* Close button - mobile only */} + {isMobile && ( + + - )} -
-
+ + {t("close") || "Close"} + + )} + {/* Dock/Undock button - desktop only */} + {!isMobile && onDockedChange && ( + + + + + + {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 && ( + + )} +
+
-
- {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 ( +
+ + + - - -

{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 && ( -
- )} -
- ) : searchQuery ? ( -
- -

- {t("no_results_found") || "No results found"} -

-

- {t("try_different_search") || "Try a different search term"} -

-
- ) : ( -
- {activeTab === "mentions" ? ( - - ) : ( - - )} -

{getEmptyStateMessage().title}

-

- {getEmptyStateMessage().hint} -

+ + + +

{item.title}

+

+ {convertRenderedToDisplay(item.message)} +

+
+ + + {/* Time and unread dot - fixed width to prevent content shift */} +
+ + {formatTime(item.created_at)} + + {!item.read && } +
+ ); + })} + {/* 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}

+
+ )} +
+ + ); + + // DOCKED MODE: Render as a static flex child (no animation, no click-away) + if (isDocked && open && !isMobile) { + return ( + + ); + } + + // FLOATING MODE: Render with animation and click-away layer + return ( + + {open && ( + <> + {/* Click-away layer - only covers the content area, not the sidebar */} + onOpenChange(false)} + aria-hidden="true" + /> + + {/* Clip container - positioned at sidebar edge with overflow hidden */} +
+ - + role="dialog" + aria-modal="true" + aria-label={t("inbox") || "Inbox"} + > + {inboxContent} + +
)} -
, - document.body + ); } diff --git a/surfsense_web/components/tool-ui/audio.tsx b/surfsense_web/components/tool-ui/audio.tsx index 4b7679cd6..acf6054fb 100644 --- a/surfsense_web/components/tool-ui/audio.tsx +++ b/surfsense_web/components/tool-ui/audio.tsx @@ -149,16 +149,16 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN return (
-
- +
+
-
-

{title}

-

{error}

+
+

{title}

+

{error}

); @@ -168,7 +168,7 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
@@ -177,15 +177,15 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN -
+
{/* Artwork */}
-
+
{artwork ? ( {title} ) : (
- +
)}
@@ -195,14 +195,16 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
{/* Title and description */}
-

{title}

+

{title}

{description && ( -

{description}

+

+ {description} +

)}
{/* Progress bar */} -
+
-
+
{formatTime(currentTime)} {formatTime(duration)}
@@ -220,33 +222,37 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
{/* Controls */} -
-
+
+
{/* Play/Pause button */} {/* Volume control */} -
- {/* Custom volume bar - visually distinct from progress slider */} -
+
{/* Download button */} -
diff --git a/surfsense_web/components/tool-ui/generate-podcast.tsx b/surfsense_web/components/tool-ui/generate-podcast.tsx index 67eabbc90..3ae0755ef 100644 --- a/surfsense_web/components/tool-ui/generate-podcast.tsx +++ b/surfsense_web/components/tool-ui/generate-podcast.tsx @@ -96,23 +96,27 @@ function parsePodcastDetails(data: unknown): { podcast_transcript?: PodcastTrans */ function PodcastGeneratingState({ title }: { title: string }) { return ( -
-
-
-
- +
+
+
+
+
{/* Animated rings */}
-
-

{title}

-
- - Generating podcast. This may take a few minutes. +
+

+ {title} +

+
+ + + Generating podcast. This may take a few minutes. +
-
-
+
+
@@ -127,15 +131,17 @@ function PodcastGeneratingState({ title }: { title: string }) { */ function PodcastErrorState({ title, error }: { title: string; error: string }) { return ( -
-
-
- +
+
+
+
-
-

{title}

-

Failed to generate podcast

-

{error}

+
+

+ {title} +

+

Failed to generate podcast

+

{error}

@@ -147,16 +153,18 @@ function PodcastErrorState({ title, error }: { title: string; error: string }) { */ function AudioLoadingState({ title }: { title: string }) { return ( -
-
-
- +
+
+
+
-
-

{title}

-
- - Loading audio... +
+

+ {title} +

+
+ + Loading audio...
@@ -274,13 +282,13 @@ function PodcastPlayer({ /> {/* Transcript section */} {transcript && transcript.length > 0 && ( -
- +
+ View transcript ({transcript.length} entries) -
+
{transcript.map((entry, idx) => ( -
+
Speaker {entry.speaker_id + 1}:{" "} {entry.dialog}
@@ -404,9 +412,9 @@ export const GeneratePodcastToolUI = makeAssistantToolUI< if (status.type === "incomplete") { if (status.reason === "cancelled") { return ( -
-

- +

+

+ Podcast generation cancelled

@@ -437,16 +445,16 @@ export const GeneratePodcastToolUI = makeAssistantToolUI< // (new: "generating", legacy: "already_generating") if (result.status === "generating" || result.status === "already_generating") { return ( -
-
-
- +
+
+
+
-
-

+

+

Podcast already in progress

-

+

Please wait for the current podcast to complete.

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( diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 578bb9002..a9a75d8dc 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -708,7 +708,8 @@ "all": "All", "unread": "Unread", "connectors": "Connectors", - "all_connectors": "All connectors" + "all_connectors": "All connectors", + "close": "Close" }, "errors": { "something_went_wrong": "Something went wrong", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index 9bbbe1ecf..7c0fd8400 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -679,7 +679,7 @@ "system": "系统", "logout": "退出登录", "inbox": "收件箱", - "search_inbox": "搜索收件箱...", + "search_inbox": "搜索收件箱", "mark_all_read": "全部标记为已读", "mark_as_read": "标记为已读", "mentions": "提及", @@ -693,7 +693,8 @@ "all": "全部", "unread": "未读", "connectors": "连接器", - "all_connectors": "所有连接器" + "all_connectors": "所有连接器", + "close": "关闭" }, "errors": { "something_went_wrong": "出错了",