From 93a09e1978def3c428389411a74c2f7783a24839 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:47:48 +0530 Subject: [PATCH 01/27] refactor(layout): remove Inbox component and integrate NotificationsDropdown - Eliminated the Inbox component and its related logic from FreeLayoutDataProvider and LayoutDataProvider. - Introduced NotificationsDropdown to handle notifications in the sidebar and mobile sidebar. - Updated Sidebar and LayoutShell components to accommodate the new notifications structure, enhancing user experience and code clarity. --- .../providers/FreeLayoutDataProvider.tsx | 30 +- .../layout/providers/LayoutDataProvider.tsx | 57 +-- .../layout/ui/icon-rail/IconRail.tsx | 9 + .../layout/ui/shell/LayoutShell.tsx | 112 +----- .../layout/ui/sidebar/MobileSidebar.tsx | 11 + .../ui/sidebar/NotificationsDropdown.tsx | 378 ++++++++++++++++++ .../components/layout/ui/sidebar/Sidebar.tsx | 36 +- .../layout/ui/sidebar/SidebarUserProfile.tsx | 4 + .../components/layout/ui/sidebar/index.ts | 2 +- 9 files changed, 420 insertions(+), 219 deletions(-) create mode 100644 surfsense_web/components/layout/ui/sidebar/NotificationsDropdown.tsx diff --git a/surfsense_web/components/layout/providers/FreeLayoutDataProvider.tsx b/surfsense_web/components/layout/providers/FreeLayoutDataProvider.tsx index 3a74d77b9..059872bac 100644 --- a/surfsense_web/components/layout/providers/FreeLayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/FreeLayoutDataProvider.tsx @@ -1,14 +1,13 @@ "use client"; -import { Inbox } from "lucide-react"; import { useRouter } from "next/navigation"; import type { ReactNode } from "react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useAnonymousMode } from "@/contexts/anonymous-mode"; import { useLoginGate } from "@/contexts/login-gate"; import { useAnnouncements } from "@/hooks/use-announcements"; import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service"; -import type { ChatItem, NavItem, PageUsage, Workspace } from "../types/layout.types"; +import type { ChatItem, PageUsage, Workspace } from "../types/layout.types"; import { LayoutShell } from "../ui/shell"; interface FreeLayoutDataProviderProps { @@ -47,34 +46,12 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps const gatedAction = useCallback((feature: string) => () => gate(feature), [gate]); - const navItems: NavItem[] = useMemo( - () => - ( - [ - { - title: "Inbox", - url: "#inbox", - icon: Inbox, - isActive: false, - }, - ] as (NavItem | null)[] - ).filter((item): item is NavItem => item !== null), - [] - ); - const pageUsage: PageUsage | undefined = quota ? { pagesUsed: quota.used, pagesLimit: quota.limit } : undefined; const handleChatSelect = useCallback((_chat: ChatItem) => gate("view chat history"), [gate]); - const handleNavItemClick = useCallback( - (item: NavItem) => { - if (item.title === "Inbox") gate("use the inbox"); - }, - [gate] - ); - const handleAnnouncements = useCallback(() => gate("see what's new"), [gate]); const handleWorkspaceSelect = useCallback((_id: number) => gate("switch workspaces"), [gate]); @@ -87,8 +64,7 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps onWorkspaceSettings={gatedAction("workspace settings")} onAddWorkspace={gatedAction("create workspaces")} workspace={GUEST_SPACE} - navItems={navItems} - onNavItemClick={handleNavItemClick} + navItems={[]} chats={[]} activeChatId={null} onNewChat={resetChat} diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 639965d38..bac179816 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; -import { AlarmClock, AlertTriangle, Boxes, Inbox, SquareTerminal } from "lucide-react"; +import { AlarmClock, AlertTriangle, Boxes, SquareTerminal } from "lucide-react"; import { useParams, usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useTheme } from "next-themes"; @@ -60,17 +60,6 @@ interface LayoutDataProviderProps { children: React.ReactNode; } -/** - * Format count for display: shows numbers up to 999, then "1k+", "2k+", etc. - */ -function formatInboxCount(count: number): string { - if (count <= 999) { - return count.toString(); - } - const thousands = Math.floor(count / 1000); - return `${thousands}k+`; -} - export function LayoutDataProvider({ workspaceId, children }: LayoutDataProviderProps) { const t = useTranslations("dashboard"); const tCommon = useTranslations("common"); @@ -125,12 +114,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider enabled: !!workspaceId, }); - // Unified slide-out panel state (only one can be open at a time) - type SlideoutPanel = "inbox" | null; - const [activeSlideoutPanel, setActiveSlideoutPanel] = useState(null); - - const isInboxSidebarOpen = activeSlideoutPanel === "inbox"; - // Search space dialog state const [isCreateWorkspaceDialogOpen, setIsCreateWorkspaceDialogOpen] = useState(false); @@ -228,17 +211,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider const [isDeletingWorkspace, setIsDeletingWorkspace] = useState(false); const [isLeavingWorkspace, setIsLeavingWorkspace] = useState(false); - // Reset transient slide-out panels when switching workspaces. - // Tabs intentionally persist across spaces — opening tabs from multiple - // workspaces is a supported flow (browser-tab semantics). - const prevWorkspaceIdRef = useRef(workspaceId); - useEffect(() => { - if (prevWorkspaceIdRef.current !== workspaceId) { - prevWorkspaceIdRef.current = workspaceId; - setActiveSlideoutPanel(null); - } - }, [workspaceId]); - const workspaces: Workspace[] = useMemo(() => { if (!workspacesData || !Array.isArray(workspacesData)) return []; return workspacesData.map((space) => ({ @@ -318,9 +290,9 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider }, [threadsData, workspaceId]); // Navigation items - // Inbox, Automations, and Artifacts are rendered explicitly below "New chat" - // in the sidebar (also surfaced in the icon rail's collapsed mode via this - // list). Documents is embedded below Recents; announcements live in the avatar dropdown. + // Automations and Artifacts are rendered explicitly below "New chat" + // in the sidebar. Documents is embedded below Recents; notifications and + // announcements live in the avatar rail/dropdown. const isAutomationsActive = pathname?.includes("/automations") === true; const isArtifactsActive = pathname?.endsWith("/artifacts") === true; const isPlaygroundRoute = pathname?.includes("/playground") === true; @@ -328,13 +300,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider () => ( [ - { - title: "Inbox", - url: "#inbox", - icon: Inbox, - isActive: isInboxSidebarOpen, - badge: totalUnreadCount > 0 ? formatInboxCount(totalUnreadCount) : undefined, - }, { title: "Automations", url: `/dashboard/${workspaceId}/automations`, @@ -358,8 +323,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider ] as (NavItem | null)[] ).filter((item): item is NavItem => item !== null), [ - isInboxSidebarOpen, - totalUnreadCount, workspaceId, isAutomationsActive, isArtifactsActive, @@ -503,10 +466,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider const handleNavItemClick = useCallback( (item: NavItem) => { - if (item.url === "#inbox") { - setActiveSlideoutPanel((prev) => (prev === "inbox" ? null : "inbox")); - return; - } // Desktop: Playground is a persistent toggle, not a plain link — it just // opens the second-level sidebar (which holds the whole API playground) // and only closes on a second click, never navigating away from the @@ -518,7 +477,7 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider } router.push(item.url); }, - [router, setPlaygroundSidebarOpen, setActiveSlideoutPanel, isMobile] + [router, setPlaygroundSidebarOpen, isMobile] ); const handleNewChat = useCallback(() => { @@ -688,7 +647,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider const isPlaygroundPage = pathname?.includes("/playground") === true; const isAllChatsPage = pathname?.endsWith("/chats") === true; const handleViewAllChats = useCallback(() => { - setActiveSlideoutPanel(null); router.push( isAllChatsPage ? `/dashboard/${workspaceId}/new-chat` : `/dashboard/${workspaceId}/chats` ); @@ -764,10 +722,7 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider : undefined } isLoadingChats={isLoadingThreads} - activeSlideoutPanel={activeSlideoutPanel} - onSlideoutPanelChange={setActiveSlideoutPanel} - inbox={{ - isOpen: isInboxSidebarOpen, + notifications={{ totalUnreadCount, comments: { items: commentsInbox.inboxItems, diff --git a/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx b/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx index 5170fa770..1f700c89f 100644 --- a/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx +++ b/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx @@ -6,6 +6,10 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import type { NavItem, User, Workspace } from "../../types/layout.types"; +import { + NotificationsDropdown, + type NotificationsDropdownData, +} from "../sidebar/NotificationsDropdown"; import { SidebarUserProfile } from "../sidebar/SidebarUserProfile"; import { WorkspaceAvatar } from "./WorkspaceAvatar"; @@ -24,6 +28,7 @@ interface IconRailProps { onUserSettings?: () => void; onAnnouncements?: () => void; announcementUnreadCount?: number; + notifications?: NotificationsDropdownData; onLogout?: () => void; theme?: string; setTheme?: (theme: "light" | "dark" | "system") => void; @@ -45,6 +50,7 @@ export function IconRail({ onUserSettings, onAnnouncements, announcementUnreadCount = 0, + notifications, onLogout, theme, setTheme, @@ -145,6 +151,9 @@ export function IconRail({ isCollapsed theme={theme} setTheme={setTheme} + topContent={ + notifications ? : undefined + } /> ); diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index 7adad0cc3..00e606024 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -1,14 +1,12 @@ "use client"; import { useAtomValue } from "jotai"; -import { AnimatePresence, motion } from "motion/react"; import dynamic from "next/dynamic"; -import { useCallback, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { activeTabAtom, type Tab } from "@/atoms/tabs/tabs.atom"; import { Logo } from "@/components/Logo"; import { Spinner } from "@/components/ui/spinner"; import { TooltipProvider } from "@/components/ui/tooltip"; -import type { InboxItem } from "@/hooks/use-inbox"; import { useIsMobile } from "@/hooks/use-mobile"; import { useElectronAPI } from "@/hooks/use-platform"; import { cn } from "@/lib/utils"; @@ -27,14 +25,13 @@ import { RightPanelToggleButton, } from "../right-panel/RightPanel"; import { - InboxSidebarContent, MobileSidebar, MobileSidebarTrigger, PlaygroundSidebar, Sidebar, SidebarCollapseButton, } from "../sidebar"; -import { SidebarSlideOutPanel } from "../sidebar/SidebarSlideOutPanel"; +import type { NotificationsDropdownData } from "../sidebar/NotificationsDropdown"; import { TabBar } from "../tabs/TabBar"; import { WorkspacePanel } from "./WorkspacePanel"; @@ -80,28 +77,6 @@ function MacDesktopTitleBar({ ); } -// Per-tab data source -interface TabDataSource { - items: InboxItem[]; - unreadCount: number; - loading: boolean; - loadingMore: boolean; - hasMore: boolean; - loadMore: () => void; - markAsRead: (id: number) => Promise; - markAllAsRead: () => Promise; -} - -export type ActiveSlideoutPanel = "inbox" | null; - -// Inbox-related props — per-tab data sources with independent loading/pagination -interface InboxProps { - isOpen: boolean; - totalUnreadCount: number; - comments: TabDataSource; - status: TabDataSource; -} - interface LayoutShellProps { workspaces: Workspace[]; activeWorkspaceId: number | null; @@ -140,11 +115,7 @@ interface LayoutShellProps { workspacePanelContentClassName?: string; children: React.ReactNode; className?: string; - // Unified slide-out panel state - activeSlideoutPanel?: ActiveSlideoutPanel; - onSlideoutPanelChange?: (panel: ActiveSlideoutPanel) => void; - // Inbox props - inbox?: InboxProps; + notifications?: NotificationsDropdownData; isLoadingChats?: boolean; onTabSwitch?: (tab: Tab) => void; onTabPrefetch?: (tab: Tab) => void; @@ -245,9 +216,7 @@ export function LayoutShell({ workspacePanelContentClassName, children, className, - activeSlideoutPanel = null, - onSlideoutPanelChange, - inbox, + notifications, isLoadingChats = false, onTabSwitch, onTabPrefetch, @@ -269,17 +238,6 @@ export function LayoutShell({ [isCollapsed, setIsCollapsed, toggleCollapsed] ); - const closeSlideout = useCallback( - (open: boolean) => { - if (!open) onSlideoutPanelChange?.(null); - }, - [onSlideoutPanelChange] - ); - - const anySlideOutOpen = activeSlideoutPanel !== null; - - const panelAriaLabel = activeSlideoutPanel === "inbox" ? "Inbox" : "Panel"; - // Mobile layout if (isMobile) { return ( @@ -316,6 +274,7 @@ export function LayoutShell({ onUserSettings={onUserSettings} onAnnouncements={onAnnouncements} announcementUnreadCount={announcementUnreadCount} + notifications={notifications} onLogout={onLogout} pageUsage={pageUsage} theme={theme} @@ -335,34 +294,6 @@ export function LayoutShell({ {children} )} - - {/* Mobile unified slide-out panel */} - - - {activeSlideoutPanel === "inbox" && inbox && ( - - closeSlideout(open)} - comments={inbox.comments} - status={inbox.status} - totalUnreadCount={inbox.totalUnreadCount} - onCloseMobileSidebar={() => setMobileMenuOpen(false)} - /> - - )} - - @@ -400,6 +331,7 @@ export function LayoutShell({ onUserSettings={onUserSettings} onAnnouncements={onAnnouncements} announcementUnreadCount={announcementUnreadCount} + notifications={notifications} onLogout={onLogout} theme={theme} setTheme={setTheme} @@ -425,10 +357,7 @@ export function LayoutShell({ className={cn( "relative hidden md:flex shrink-0 z-20 -mr-2 bg-panel", isMacDesktop - ? cn( - "border-t border-r", - !showPlaygroundSidebar && "rounded-tl-xl border-l" - ) + ? cn("border-t border-r", !showPlaygroundSidebar && "rounded-tl-xl border-l") : "border-r" )} > @@ -491,33 +420,6 @@ export function LayoutShell({ )} /> )} - - {/* Unified slide-out panel — shell stays open, content cross-fades */} - - - {activeSlideoutPanel === "inbox" && inbox && ( - - closeSlideout(open)} - comments={inbox.comments} - status={inbox.status} - totalUnreadCount={inbox.totalUnreadCount} - /> - - )} - - diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx index f9901d7cb..94fe2d413 100644 --- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx @@ -6,6 +6,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; import type { ChatItem, NavItem, PageUsage, User, Workspace } from "../../types/layout.types"; import { WorkspaceAvatar } from "../icon-rail/WorkspaceAvatar"; +import { NotificationsDropdown, type NotificationsDropdownData } from "./NotificationsDropdown"; import { Sidebar } from "./Sidebar"; import { SidebarUserProfile } from "./SidebarUserProfile"; @@ -35,6 +36,7 @@ interface MobileSidebarProps { onUserSettings?: () => void; onAnnouncements?: () => void; announcementUnreadCount?: number; + notifications?: NotificationsDropdownData; onLogout?: () => void; pageUsage?: PageUsage; theme?: string; @@ -83,6 +85,7 @@ export function MobileSidebar({ onUserSettings, onAnnouncements, announcementUnreadCount = 0, + notifications, onLogout, pageUsage, theme, @@ -161,6 +164,14 @@ export function MobileSidebar({ isCollapsed theme={theme} setTheme={setTheme} + topContent={ + notifications ? ( + onOpenChange(false)} + /> + ) : undefined + } /> diff --git a/surfsense_web/components/layout/ui/sidebar/NotificationsDropdown.tsx b/surfsense_web/components/layout/ui/sidebar/NotificationsDropdown.tsx new file mode 100644 index 000000000..e432e077a --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/NotificationsDropdown.tsx @@ -0,0 +1,378 @@ +"use client"; + +import { useAtom } from "jotai"; +import { Bell } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useCallback, useMemo, useState } from "react"; +import { setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Spinner } from "@/components/ui/spinner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { + isCommentReplyMetadata, + isInsufficientCreditsMetadata, + isNewMentionMetadata, +} from "@/contracts/types/inbox.types"; +import type { InboxItem } from "@/hooks/use-inbox"; +import { cn } from "@/lib/utils"; + +export interface NotificationsDataSource { + items: InboxItem[]; + unreadCount: number; + loading: boolean; + loadingMore?: boolean; + hasMore?: boolean; + loadMore?: () => void; + markAsRead: (id: number) => Promise; + markAllAsRead: () => Promise; +} + +export interface NotificationsDropdownData { + totalUnreadCount: number; + comments: NotificationsDataSource; + status: NotificationsDataSource; +} + +interface NotificationsDropdownProps { + notifications: NotificationsDropdownData; + onCloseMobileSidebar?: () => void; +} + +type NotificationFilter = "mentions" | "status" | null; + +function formatNotificationCount(count: number): string { + if (count <= 999) { + return count.toString(); + } + const thousands = Math.floor(count / 1000); + return `${thousands}k+`; +} + +function formatTime(dateString: string): string { + try { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / (1000 * 60)); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffMins < 1) return "now"; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return `${Math.floor(diffDays / 7)}w ago`; + } catch { + return "now"; + } +} + +function isCommentNotification(item: InboxItem): boolean { + return item.type === "new_mention" || item.type === "comment_reply"; +} + +export function NotificationsDropdown({ + notifications, + onCloseMobileSidebar, +}: NotificationsDropdownProps) { + const router = useRouter(); + const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom); + const [open, setOpen] = useState(false); + const [activeFilter, setActiveFilter] = useState(null); + const [markingAsReadId, setMarkingAsReadId] = useState(null); + const [markingAllAsRead, setMarkingAllAsRead] = useState(false); + + const unreadLabel = formatNotificationCount(notifications.totalUnreadCount); + const visibleUnreadCount = + activeFilter === "mentions" + ? notifications.comments.unreadCount + : activeFilter === "status" + ? notifications.status.unreadCount + : notifications.totalUnreadCount; + const visibleUnreadLabel = formatNotificationCount(visibleUnreadCount); + const isLoading = + activeFilter === "mentions" + ? notifications.comments.loading + : activeFilter === "status" + ? notifications.status.loading + : notifications.comments.loading || notifications.status.loading; + const items = useMemo(() => { + const sourceItems = + activeFilter === "mentions" + ? notifications.comments.items + : activeFilter === "status" + ? notifications.status.items + : [...notifications.comments.items, ...notifications.status.items]; + + return sourceItems + .toSorted((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) + .slice(0, 8); + }, [activeFilter, notifications.comments.items, notifications.status.items]); + + const markItemAsRead = useCallback( + async (item: InboxItem) => { + if (item.read) return; + setMarkingAsReadId(item.id); + try { + await (isCommentNotification(item) + ? notifications.comments.markAsRead(item.id) + : notifications.status.markAsRead(item.id)); + } finally { + setMarkingAsReadId(null); + } + }, + [notifications.comments, notifications.status] + ); + + const handleItemClick = useCallback( + async (item: InboxItem) => { + await markItemAsRead(item); + + if (item.type === "new_mention" && isNewMentionMetadata(item.metadata)) { + const threadId = item.metadata.thread_id; + const commentId = item.metadata.comment_id; + if (item.workspace_id && threadId) { + if (commentId) setTargetCommentId(commentId); + setOpen(false); + onCloseMobileSidebar?.(); + router.push( + commentId + ? `/dashboard/${item.workspace_id}/new-chat/${threadId}?commentId=${commentId}` + : `/dashboard/${item.workspace_id}/new-chat/${threadId}` + ); + } + return; + } + + if (item.type === "comment_reply" && isCommentReplyMetadata(item.metadata)) { + const threadId = item.metadata.thread_id; + const replyId = item.metadata.reply_id; + if (item.workspace_id && threadId) { + if (replyId) setTargetCommentId(replyId); + setOpen(false); + onCloseMobileSidebar?.(); + router.push( + replyId + ? `/dashboard/${item.workspace_id}/new-chat/${threadId}?commentId=${replyId}` + : `/dashboard/${item.workspace_id}/new-chat/${threadId}` + ); + } + return; + } + + if (item.type === "insufficient_credits" && isInsufficientCreditsMetadata(item.metadata)) { + if (item.metadata.action_url) { + setOpen(false); + onCloseMobileSidebar?.(); + router.push(item.metadata.action_url); + } + } + }, + [markItemAsRead, onCloseMobileSidebar, router, setTargetCommentId] + ); + + const handleMarkAllAsRead = useCallback(async () => { + if (visibleUnreadCount === 0 || markingAllAsRead) return; + setMarkingAllAsRead(true); + try { + if (activeFilter === "mentions") { + await notifications.comments.markAllAsRead(); + } else if (activeFilter === "status") { + await notifications.status.markAllAsRead(); + } else { + await Promise.all([ + notifications.comments.markAllAsRead(), + notifications.status.markAllAsRead(), + ]); + } + } finally { + setMarkingAllAsRead(false); + } + }, [activeFilter, markingAllAsRead, notifications, visibleUnreadCount]); + + const handleFilterClick = useCallback((filter: Exclude) => { + setActiveFilter((current) => (current === filter ? null : filter)); + }, []); + + const emptyStateCopy = + activeFilter === "mentions" + ? { + title: "No mentions", + description: "Mentions and replies will appear here.", + } + : activeFilter === "status" + ? { + title: "No status updates", + description: "Connector and document updates will appear here.", + } + : { + title: "No notifications", + description: "Mentions, replies, and status updates will appear here.", + }; + + return ( + + + + + + + + + Notifications + + + +
+
+

Notifications

+

+ {visibleUnreadCount > 0 ? `${visibleUnreadLabel} unread` : "You're all caught up"} +

+
+ +
+ +
+ + +
+ +
+ {isLoading ? ( +
+ {[82, 64, 74].map((width) => ( +
+
+ + +
+
+ ))} +
+ ) : items.length > 0 ? ( +
+ {items.map((item) => { + const isMarkingAsRead = markingAsReadId === item.id; + return ( + + ); + })} +
+ ) : ( +
+

{emptyStateCopy.title}

+

{emptyStateCopy.description}

+
+ )} +
+
+
+ ); +} diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index 35746acf1..28264a77f 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -46,21 +46,6 @@ function ChatListSkeletonRows() { ); } -function CollapsedInboxIcon({ item }: { item: NavItem }) { - const Icon = item.icon; - - return ( - - - {typeof item.badge === "string" ? ( - - {item.badge} - - ) : null} - - ); -} - interface SidebarProps { workspace: Workspace | null; isCollapsed?: boolean; @@ -138,10 +123,9 @@ export function Sidebar({ const [openDropdownChatId, setOpenDropdownChatId] = useState(null); const [isSidebarNavScrolled, setIsSidebarNavScrolled] = useState(false); - // Inbox, Automations, and Artifacts are rendered explicitly right below + // Automations, Artifacts, and Playground are rendered explicitly right below // New Chat. Pull them out of the nav items list so they don't also appear // in the bottom NavSection. Documents is embedded below Recents. - const inboxItem = useMemo(() => navItems.find((item) => item.url === "#inbox"), [navItems]); const automationsItem = useMemo( () => navItems.find((item) => item.url.endsWith("/automations")), [navItems] @@ -158,7 +142,6 @@ export function Sidebar({ () => navItems.filter( (item) => - item.url !== "#inbox" && !item.url.endsWith("/automations") && !item.url.endsWith("/artifacts") && !item.url.endsWith("/playground") @@ -235,23 +218,6 @@ export function Sidebar({ onScroll={(event) => setIsSidebarNavScrolled(event.currentTarget.scrollTop > 0)} >
- {inboxItem && ( - onNavItemClick?.(inboxItem)} - isCollapsed={isCollapsed} - isActive={inboxItem.isActive} - badge={inboxItem.badge} - collapsedIconNode={} - tooltipContent={isCollapsed ? inboxItem.title : undefined} - buttonProps={ - { - "data-joyride": "inbox-sidebar", - } as React.ButtonHTMLAttributes - } - /> - )} {automationsItem && ( void; + topContent?: React.ReactNode; } function formatAnnouncementCount(count: number): string { @@ -134,6 +136,7 @@ export function SidebarUserProfile({ isCollapsed = false, theme, setTheme, + topContent, }: SidebarUserProfileProps) { const t = useTranslations("sidebar"); const { locale, setLocale } = useLocaleContext(); @@ -171,6 +174,7 @@ export function SidebarUserProfile({ return (
+ {topContent} {showDownloadCta && ( diff --git a/surfsense_web/components/layout/ui/sidebar/index.ts b/surfsense_web/components/layout/ui/sidebar/index.ts index d1a1f85e5..b74e31b53 100644 --- a/surfsense_web/components/layout/ui/sidebar/index.ts +++ b/surfsense_web/components/layout/ui/sidebar/index.ts @@ -2,9 +2,9 @@ export { AllChatsWorkspaceContent } from "./AllChatsSidebar"; export { ChatListItem } from "./ChatListItem"; export { CreditBalanceDisplay } from "./CreditBalanceDisplay"; export { DocumentsSidebar } from "./DocumentsSidebar"; -export { InboxSidebar, InboxSidebarContent } from "./InboxSidebar"; export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar"; export { NavSection } from "./NavSection"; +export { NotificationsDropdown } from "./NotificationsDropdown"; export { PlaygroundSidebar } from "./PlaygroundSidebar"; export { Sidebar } from "./Sidebar"; export { SidebarCollapseButton } from "./SidebarCollapseButton"; From 469d7b10a6c7eca3180dbe37aaa4144f58110766 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:48:03 +0530 Subject: [PATCH 02/27] refactor(sidebar): remove InboxSidebar and SidebarSlideOutPanel components - Deleted InboxSidebar and SidebarSlideOutPanel components to streamline the sidebar structure. - This change simplifies the layout and prepares for future enhancements in notification handling. --- .../layout/ui/sidebar/InboxSidebar.tsx | 1082 ----------------- .../ui/sidebar/SidebarSlideOutPanel.tsx | 115 -- 2 files changed, 1197 deletions(-) delete mode 100644 surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx delete mode 100644 surfsense_web/components/layout/ui/sidebar/SidebarSlideOutPanel.tsx diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx deleted file mode 100644 index 7cf909112..000000000 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ /dev/null @@ -1,1082 +0,0 @@ -"use client"; - -import { useQuery } from "@tanstack/react-query"; -import { useAtom } from "jotai"; -import { - AlertCircle, - AlertTriangle, - BellDot, - Check, - CheckCheck, - CheckCircle2, - ChevronLeft, - History, - Inbox, - LayoutGrid, - ListFilter, - MessageCircleReply, - Search, - X, -} from "lucide-react"; -import { useParams, useRouter } from "next/navigation"; -import { useTranslations } from "next-intl"; -import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; -import { setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/animated-tabs"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Button } from "@/components/ui/button"; -import { - Drawer, - DrawerContent, - DrawerHandle, - DrawerHeader, - DrawerTitle, -} from "@/components/ui/drawer"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Input } from "@/components/ui/input"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Spinner } from "@/components/ui/spinner"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; -import { - isCommentReplyMetadata, - isConnectorIndexingMetadata, - isDocumentProcessingMetadata, - isInsufficientCreditsMetadata, - isNewMentionMetadata, -} from "@/contracts/types/inbox.types"; -import { useDebouncedValue } from "@/hooks/use-debounced-value"; -import type { InboxItem } from "@/hooks/use-inbox"; -import { useMediaQuery } from "@/hooks/use-media-query"; -import { notificationsApiService } from "@/lib/apis/notifications-api.service"; -import { convertRenderedToDisplay } from "@/lib/comments/utils"; -import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels"; -import { cacheKeys } from "@/lib/query-client/cache-keys"; -import { getWorkspaceIdNumber } from "@/lib/route-params"; -import { cn } from "@/lib/utils"; -import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel"; - -function getInitials(name: string | null | undefined, email: string | null | undefined): string { - if (name) { - return name - .split(" ") - .map((n) => n[0]) - .join("") - .toUpperCase() - .slice(0, 2); - } - if (email) { - const localPart = email.split("@")[0]; - return localPart.slice(0, 2).toUpperCase(); - } - return "U"; -} - -function formatInboxCount(count: number): string { - if (count <= 999) { - return count.toString(); - } - const thousands = Math.floor(count / 1000); - return `${thousands}k+`; -} - -function getConnectorTypeDisplayName(connectorType: string): string { - const displayNames: Record = { - GITHUB_CONNECTOR: "GitHub", - GOOGLE_CALENDAR_CONNECTOR: "Google Calendar", - GOOGLE_GMAIL_CONNECTOR: "Gmail", - GOOGLE_DRIVE_CONNECTOR: "Google Drive", - COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "Composio Google Drive", - COMPOSIO_GMAIL_CONNECTOR: "Composio Gmail", - COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: "Composio Google Calendar", - LINEAR_CONNECTOR: "Linear", - NOTION_CONNECTOR: "Notion", - SLACK_CONNECTOR: "Slack", - TEAMS_CONNECTOR: "Microsoft Teams", - DISCORD_CONNECTOR: "Discord", - JIRA_CONNECTOR: "Jira", - CONFLUENCE_CONNECTOR: "Confluence", - BOOKSTACK_CONNECTOR: "BookStack", - CLICKUP_CONNECTOR: "ClickUp", - AIRTABLE_CONNECTOR: "Airtable", - LUMA_CONNECTOR: "Luma", - ELASTICSEARCH_CONNECTOR: "Elasticsearch", - WEBCRAWLER_CONNECTOR: "Web Crawler", - YOUTUBE_CONNECTOR: "YouTube", - CIRCLEBACK_CONNECTOR: "Circleback", - MCP_CONNECTOR: "MCP", - OBSIDIAN_CONNECTOR: "Obsidian", - ONEDRIVE_CONNECTOR: "OneDrive", - DROPBOX_CONNECTOR: "Dropbox", - TAVILY_API: "Tavily", - SEARXNG_API: "SearXNG", - LINKUP_API: "Linkup", - BAIDU_SEARCH_API: "Baidu", - }; - - return ( - displayNames[connectorType] || - connectorType - .replace(/_/g, " ") - .replace(/CONNECTOR|API/gi, "") - .trim() - ); -} - -type InboxTab = "comments" | "status"; -type InboxFilter = "all" | "unread" | "errors"; - -interface TabDataSource { - items: InboxItem[]; - unreadCount: number; - loading: boolean; - loadingMore: boolean; - hasMore: boolean; - loadMore: () => void; - markAsRead: (id: number) => Promise; - markAllAsRead: () => Promise; -} - -export interface InboxSidebarContentProps { - onOpenChange: (open: boolean) => void; - comments: TabDataSource; - status: TabDataSource; - totalUnreadCount: number; - onCloseMobileSidebar?: () => void; -} - -interface InboxSidebarProps extends InboxSidebarContentProps { - open: boolean; -} - -export function InboxSidebarContent({ - onOpenChange, - comments, - status, - totalUnreadCount, - onCloseMobileSidebar, -}: InboxSidebarContentProps) { - const t = useTranslations("sidebar"); - const router = useRouter(); - const params = useParams(); - const isMobile = !useMediaQuery("(min-width: 640px)"); - const workspaceId = getWorkspaceIdNumber(params) ?? null; - - const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom); - - const [searchQuery, setSearchQuery] = useState(""); - const debouncedSearch = useDebouncedValue(searchQuery, 300); - const isSearchMode = !!debouncedSearch.trim(); - const [activeTab, setActiveTab] = useState("comments"); - const [activeFilter, setActiveFilter] = useState("all"); - const [selectedSource, setSelectedSource] = useState(null); - const [mounted, setMounted] = useState(false); - const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null); - const [connectorScrollPos, setConnectorScrollPos] = useState<"top" | "middle" | "bottom">("top"); - const connectorRafRef = useRef(null); - const handleConnectorScroll = useCallback((e: React.UIEvent) => { - const el = e.currentTarget; - if (connectorRafRef.current) return; - connectorRafRef.current = requestAnimationFrame(() => { - const atTop = el.scrollTop <= 2; - const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2; - setConnectorScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle"); - connectorRafRef.current = null; - }); - }, []); - useEffect( - () => () => { - if (connectorRafRef.current) cancelAnimationFrame(connectorRafRef.current); - }, - [] - ); - const [filterDrawerOpen, setFilterDrawerOpen] = useState(false); - const [markingAsReadId, setMarkingAsReadId] = useState(null); - - const prefetchTriggerRef = useRef(null); - - // Server-side search query - const searchTypeFilter = activeTab === "comments" ? ("new_mention" as const) : undefined; - const { data: searchResponse, isLoading: isSearchLoading } = useQuery({ - queryKey: cacheKeys.notifications.search(workspaceId, debouncedSearch.trim(), activeTab), - queryFn: () => - notificationsApiService.getNotifications({ - queryParams: { - workspace_id: workspaceId ?? undefined, - type: searchTypeFilter, - search: debouncedSearch.trim(), - limit: 50, - }, - }), - staleTime: 30 * 1000, - enabled: isSearchMode, - }); - - useEffect(() => { - setMounted(true); - }, []); - - useEffect(() => { - if (!isMobile) return; - const originalOverflow = document.body.style.overflow; - document.body.style.overflow = "hidden"; - return () => { - document.body.style.overflow = originalOverflow; - }; - }, [isMobile]); - - useEffect(() => { - if (activeTab !== "status") { - setSelectedSource(null); - } - }, [activeTab]); - - // 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({ - queryKey: cacheKeys.notifications.sourceTypes(workspaceId), - queryFn: () => notificationsApiService.getSourceTypes(workspaceId ?? undefined), - staleTime: 60 * 1000, - enabled: activeTab === "status", - }); - - const statusSourceOptions = useMemo(() => { - if (!sourceTypesData?.sources) return []; - - return sourceTypesData.sources.map((source) => ({ - key: source.key, - type: source.type, - category: source.category, - displayName: - source.category === "connector" - ? getConnectorTypeDisplayName(source.type) - : getDocumentTypeLabel(source.type), - })); - }, [sourceTypesData]); - - // Client-side filter: source type - const matchesSourceFilter = useCallback( - (item: InboxItem): boolean => { - if (!selectedSource) return true; - if (selectedSource.startsWith("connector:")) { - const connectorType = selectedSource.slice("connector:".length); - return ( - item.type === "connector_indexing" && - isConnectorIndexingMetadata(item.metadata) && - item.metadata.connector_type === connectorType - ); - } - if (selectedSource.startsWith("doctype:")) { - const docType = selectedSource.slice("doctype:".length); - return ( - item.type === "document_processing" && - isDocumentProcessingMetadata(item.metadata) && - item.metadata.document_type === docType - ); - } - return true; - }, - [selectedSource] - ); - - // Client-side filter: unread / errors - const matchesActiveFilter = useCallback( - (item: InboxItem): boolean => { - if (activeFilter === "unread") return !item.read; - if (activeFilter === "errors") { - if (item.type === "insufficient_credits") return true; - const meta = item.metadata as Record | undefined; - return typeof meta?.status === "string" && meta.status === "failed"; - } - return true; - }, - [activeFilter] - ); - - // Defer non-urgent list updates so the search input stays responsive. - // The deferred snapshot lags one render behind the live value intentionally. - const deferredTabItems = useDeferredValue(activeSource.items); - const deferredSearchItems = useDeferredValue(searchResponse?.items ?? []); - - // Two data paths: search mode (API) or default (per-tab data source) - const filteredItems = useMemo(() => { - const tabItems: InboxItem[] = isSearchMode ? deferredSearchItems : deferredTabItems; - - let result = tabItems; - if (activeFilter !== "all") { - result = result.filter(matchesActiveFilter); - } - if (activeTab === "status" && selectedSource) { - result = result.filter(matchesSourceFilter); - } - - return result; - }, [ - isSearchMode, - deferredSearchItems, - deferredTabItems, - activeTab, - activeFilter, - selectedSource, - matchesActiveFilter, - matchesSourceFilter, - ]); - - // Infinite scroll — uses active tab's pagination - useEffect(() => { - if (!activeSource.hasMore || activeSource.loadingMore || isSearchMode) return; - - const observer = new IntersectionObserver( - (entries) => { - if (entries[0]?.isIntersecting) { - activeSource.loadMore(); - } - }, - { - root: null, - rootMargin: "100px", - threshold: 0, - } - ); - - if (prefetchTriggerRef.current) { - observer.observe(prefetchTriggerRef.current); - } - - return () => observer.disconnect(); - }, [activeSource.hasMore, activeSource.loadingMore, activeSource.loadMore, isSearchMode]); - - const handleItemClick = useCallback( - async (item: InboxItem) => { - if (!item.read) { - setMarkingAsReadId(item.id); - await activeSource.markAsRead(item.id); - setMarkingAsReadId(null); - } - - if (item.type === "new_mention") { - if (isNewMentionMetadata(item.metadata)) { - const workspaceId = item.workspace_id; - const threadId = item.metadata.thread_id; - const commentId = item.metadata.comment_id; - - if (workspaceId && threadId) { - if (commentId) { - setTargetCommentId(commentId); - } - const url = commentId - ? `/dashboard/${workspaceId}/new-chat/${threadId}?commentId=${commentId}` - : `/dashboard/${workspaceId}/new-chat/${threadId}`; - onOpenChange(false); - onCloseMobileSidebar?.(); - router.push(url); - } - } - } else if (item.type === "comment_reply") { - if (isCommentReplyMetadata(item.metadata)) { - const workspaceId = item.workspace_id; - const threadId = item.metadata.thread_id; - const replyId = item.metadata.reply_id; - - if (workspaceId && threadId) { - if (replyId) { - setTargetCommentId(replyId); - } - const url = replyId - ? `/dashboard/${workspaceId}/new-chat/${threadId}?commentId=${replyId}` - : `/dashboard/${workspaceId}/new-chat/${threadId}`; - onOpenChange(false); - onCloseMobileSidebar?.(); - router.push(url); - } - } - } else if (item.type === "insufficient_credits") { - if (isInsufficientCreditsMetadata(item.metadata)) { - const actionUrl = item.metadata.action_url; - if (actionUrl) { - onOpenChange(false); - onCloseMobileSidebar?.(); - router.push(actionUrl); - } - } - } - }, - [activeSource.markAsRead, router, onOpenChange, onCloseMobileSidebar, setTargetCommentId] - ); - - const handleMarkAllAsRead = useCallback(async () => { - await Promise.all([comments.markAllAsRead(), status.markAllAsRead()]); - }, [comments.markAllAsRead, status.markAllAsRead]); - - const handleClearSearch = useCallback(() => { - setSearchQuery(""); - }, []); - - const formatTime = (dateString: string) => { - try { - const date = new Date(dateString); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / (1000 * 60)); - const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); - const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); - - if (diffMins < 1) return "now"; - if (diffMins < 60) return `${diffMins}m`; - if (diffHours < 24) return `${diffHours}h`; - if (diffDays < 7) return `${diffDays}d`; - return `${Math.floor(diffDays / 7)}w`; - } catch { - return "now"; - } - }; - - const getStatusIcon = (item: InboxItem) => { - if (item.type === "new_mention" || item.type === "comment_reply") { - const metadata = - item.type === "new_mention" - ? isNewMentionMetadata(item.metadata) - ? item.metadata - : null - : isCommentReplyMetadata(item.metadata) - ? item.metadata - : null; - - if (metadata) { - return ( - - {metadata.author_avatar_url && ( - - )} - - {getInitials(metadata.author_name, metadata.author_email)} - - - ); - } - return ( - - - {getInitials(null, null)} - - - ); - } - - if (item.type === "insufficient_credits") { - return ( -
- -
- ); - } - - const metadata = item.metadata as Record; - const status = typeof metadata?.status === "string" ? metadata.status : undefined; - - switch (status) { - case "in_progress": - return ( -
- -
- ); - case "completed": - return ( -
- -
- ); - case "failed": - return ( -
- -
- ); - default: - return ( -
- -
- ); - } - }; - - const getEmptyStateMessage = () => { - if (activeTab === "comments") { - return { - title: t("no_comments") || "No comments", - hint: t("no_comments_hint") || "You'll see mentions and replies here", - }; - } - return { - title: t("no_status_updates") || "No status updates", - hint: t("no_status_updates_hint") || "Document and connector updates will appear here", - }; - }; - - if (!mounted) return null; - - const isLoading = isSearchMode ? isSearchLoading : activeSource.loading; - - return ( - <> -
-
-
- {isMobile && ( - - )} -

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

-
-
- {isMobile ? ( - <> - - - - - - - - {t("filter") || "Filter"} - - -
-
-

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

-
- - - {activeTab === "status" && ( - - )} -
-
- {activeTab === "status" && statusSourceOptions.length > 0 && ( -
-

- {t("sources") || "Sources"} -

-
- - {statusSourceOptions.map((source) => ( - - ))} -
-
- )} -
-
-
- - ) : ( - setOpenDropdown(isOpen ? "filter" : null)} - > - - - - - - - {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" && ( - setActiveFilter("errors")} - className="flex items-center justify-between" - > - - - {t("errors_only") || "Errors only"} - - {activeFilter === "errors" && } - - )} - {activeTab === "status" && statusSourceOptions.length > 0 && ( - <> - - {t("sources") || "Sources"} - -
- setSelectedSource(null)} - className="flex items-center justify-between" - > - - - {t("all_sources") || "All sources"} - - {selectedSource === null && } - - {statusSourceOptions.map((source) => ( - setSelectedSource(source.key)} - className="flex items-center justify-between" - > - - {getConnectorIcon(source.type, "h-4 w-4")} - {source.displayName} - - {selectedSource === source.key && } - - ))} -
- - )} -
-
- )} - - - - - - {t("mark_all_read") || "Mark all as read"} - - -
-
- -
- - setSearchQuery(e.target.value)} - className="h-8 border-0 bg-muted pl-8 pr-7 text-sm shadow-none" - /> - {searchQuery && ( - - )} -
-
- - { - const tab = value as InboxTab; - setActiveTab(tab); - if (tab !== "status" && activeFilter === "errors") { - setActiveFilter("all"); - } - }} - className="shrink-0 mx-3 mt-1.5" - > - - - - - {t("comments") || "Comments"} - - {formatInboxCount(comments.unreadCount)} - - - - - - - {t("status") || "Status"} - - {formatInboxCount(status.unreadCount)} - - - - - - -
- {isLoading ? ( -
- {activeTab === "comments" - ? [85, 60, 90, 70, 50, 75].map((titleWidth) => ( -
- -
- - -
- -
- )) - : [75, 90, 55, 80, 65, 85].map((titleWidth) => ( -
- -
- - -
-
- - -
-
- ))} -
- ) : filteredItems.length > 0 ? ( -
- {filteredItems.map((item, index) => { - const isMarkingAsRead = markingAsReadId === item.id; - const isPrefetchTrigger = - !isSearchMode && activeSource.hasMore && index === filteredItems.length - 5; - - return ( -
- {activeTab === "status" ? ( - - - - - -

{item.title}

-

- {convertRenderedToDisplay(item.message)} -

-
-
- ) : ( - - )} - -
- - {formatTime(item.created_at)} - - {!item.read && } -
-
- ); - })} - {!isSearchMode && filteredItems.length < 5 && activeSource.hasMore && ( -
- )} - {activeSource.loadingMore && - (activeTab === "comments" - ? [80, 60, 90].map((titleWidth) => ( -
- -
- - -
- -
- )) - : [70, 85, 55].map((titleWidth) => ( -
- -
- - -
-
- - -
-
- )))} -
- ) : isSearchMode ? ( -
- -

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

-

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

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

{getEmptyStateMessage().title}

-

- {getEmptyStateMessage().hint} -

-
- )} -
- - ); -} - -export function InboxSidebar({ - open, - onOpenChange, - comments, - status, - totalUnreadCount, - onCloseMobileSidebar, -}: InboxSidebarProps) { - const t = useTranslations("sidebar"); - - return ( - - - - ); -} diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarSlideOutPanel.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarSlideOutPanel.tsx deleted file mode 100644 index 52b2cf998..000000000 --- a/surfsense_web/components/layout/ui/sidebar/SidebarSlideOutPanel.tsx +++ /dev/null @@ -1,115 +0,0 @@ -"use client"; - -import { useSetAtom } from "jotai"; -import { AnimatePresence, motion } from "motion/react"; -import { useCallback, useEffect } from "react"; -import { useIsMobile } from "@/hooks/use-mobile"; -import { slideoutOpenedTickAtom } from "@/lib/layout-events"; - -interface SidebarSlideOutPanelProps { - open: boolean; - onOpenChange: (open: boolean) => void; - ariaLabel: string; - width?: number; - children: React.ReactNode; -} - -/** - * Reusable slide-out panel that extends from the sidebar. - * - * Desktop: absolutely positioned at the sidebar's right edge, overlaying the main - * content with a blur backdrop. Does not push/shrink the main content. - * - * Mobile: full-width absolute overlay (unchanged). - */ -export function SidebarSlideOutPanel({ - open, - onOpenChange, - ariaLabel, - width = 360, - children, -}: SidebarSlideOutPanelProps) { - const isMobile = useIsMobile(); - const bumpSlideoutOpenedTick = useSetAtom(slideoutOpenedTickAtom); - - useEffect(() => { - if (open) { - bumpSlideoutOpenedTick((tick) => tick + 1); - } - }, [open, bumpSlideoutOpenedTick]); - - const handleEscape = useCallback( - (e: KeyboardEvent) => { - if (e.key === "Escape") onOpenChange(false); - }, - [onOpenChange] - ); - - useEffect(() => { - if (!open) return; - document.addEventListener("keydown", handleEscape); - return () => document.removeEventListener("keydown", handleEscape); - }, [open, handleEscape]); - - if (isMobile) { - return ( - - {open && ( -
- - {children} - -
- )} -
- ); - } - - return ( - - {open && ( - <> - {/* Blur backdrop covering the main content area (right of sidebar) */} - onOpenChange(false)} - aria-hidden="true" - /> - - {/* Panel extending from sidebar's right edge, flush with the wrapper border */} - -
- {children} -
-
- - )} -
- ); -} From 335e37e1d3ebae5f418f89412df3ed770c1cbf22 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:48:16 +0530 Subject: [PATCH 03/27] feat(notifications): enhance notification handling with total counts and improved filtering - Added totalCount to comments and status notifications for better tracking. - Updated NotificationsDropdown to support new filters and improved loading behavior. - Refactored notification display logic to accommodate the new structure, enhancing user experience. --- .../layout/providers/LayoutDataProvider.tsx | 2 + .../ui/sidebar/NotificationsDropdown.tsx | 188 ++++++++++++------ surfsense_web/hooks/use-inbox.ts | 5 + 3 files changed, 130 insertions(+), 65 deletions(-) diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index bac179816..a70e8f59e 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -727,6 +727,7 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider comments: { items: commentsInbox.inboxItems, unreadCount: commentsInbox.unreadCount, + totalCount: commentsInbox.totalCount, loading: commentsInbox.loading, loadingMore: commentsInbox.loadingMore, hasMore: commentsInbox.hasMore, @@ -737,6 +738,7 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider status: { items: statusInbox.inboxItems, unreadCount: statusInbox.unreadCount, + totalCount: statusInbox.totalCount, loading: statusInbox.loading, loadingMore: statusInbox.loadingMore, hasMore: statusInbox.hasMore, diff --git a/surfsense_web/components/layout/ui/sidebar/NotificationsDropdown.tsx b/surfsense_web/components/layout/ui/sidebar/NotificationsDropdown.tsx index e432e077a..3ada5de46 100644 --- a/surfsense_web/components/layout/ui/sidebar/NotificationsDropdown.tsx +++ b/surfsense_web/components/layout/ui/sidebar/NotificationsDropdown.tsx @@ -3,7 +3,7 @@ import { useAtom } from "jotai"; import { Bell } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; @@ -21,6 +21,7 @@ import { cn } from "@/lib/utils"; export interface NotificationsDataSource { items: InboxItem[]; unreadCount: number; + totalCount?: number; loading: boolean; loadingMore?: boolean; hasMore?: boolean; @@ -40,7 +41,7 @@ interface NotificationsDropdownProps { onCloseMobileSidebar?: () => void; } -type NotificationFilter = "mentions" | "status" | null; +type NotificationFilter = "all" | "mentions" | "unread"; function formatNotificationCount(count: number): string { if (count <= 999) { @@ -80,37 +81,84 @@ export function NotificationsDropdown({ const router = useRouter(); const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom); const [open, setOpen] = useState(false); - const [activeFilter, setActiveFilter] = useState(null); + const [activeFilter, setActiveFilter] = useState("all"); const [markingAsReadId, setMarkingAsReadId] = useState(null); const [markingAllAsRead, setMarkingAllAsRead] = useState(false); + const scrollContainerRef = useRef(null); + const loadMoreTriggerRef = useRef(null); const unreadLabel = formatNotificationCount(notifications.totalUnreadCount); + const allCount = + (notifications.comments.totalCount ?? 0) + (notifications.status.totalCount ?? 0); + const mentionsCount = notifications.comments.totalCount ?? notifications.comments.items.length; const visibleUnreadCount = activeFilter === "mentions" ? notifications.comments.unreadCount - : activeFilter === "status" - ? notifications.status.unreadCount - : notifications.totalUnreadCount; - const visibleUnreadLabel = formatNotificationCount(visibleUnreadCount); + : notifications.totalUnreadCount; const isLoading = activeFilter === "mentions" ? notifications.comments.loading - : activeFilter === "status" - ? notifications.status.loading - : notifications.comments.loading || notifications.status.loading; + : notifications.comments.loading || notifications.status.loading; + const isLoadingMore = + activeFilter === "mentions" + ? !!notifications.comments.loadingMore + : !!notifications.comments.loadingMore || !!notifications.status.loadingMore; + const hasMore = + activeFilter === "mentions" + ? !!notifications.comments.hasMore + : !!notifications.comments.hasMore || !!notifications.status.hasMore; const items = useMemo(() => { const sourceItems = activeFilter === "mentions" ? notifications.comments.items - : activeFilter === "status" - ? notifications.status.items - : [...notifications.comments.items, ...notifications.status.items]; + : [...notifications.comments.items, ...notifications.status.items]; return sourceItems - .toSorted((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) - .slice(0, 8); + .filter((item) => activeFilter !== "unread" || !item.read) + .toSorted((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); }, [activeFilter, notifications.comments.items, notifications.status.items]); + const loadMoreForActiveFilter = useCallback(() => { + if (isLoadingMore) return; + + if (activeFilter === "mentions") { + if (notifications.comments.hasMore) { + notifications.comments.loadMore?.(); + } + return; + } + + if (notifications.comments.hasMore) { + notifications.comments.loadMore?.(); + } + if (notifications.status.hasMore) { + notifications.status.loadMore?.(); + } + }, [activeFilter, isLoadingMore, notifications.comments, notifications.status]); + + useEffect(() => { + if (!open || isLoading || isLoadingMore || !hasMore) return; + const root = scrollContainerRef.current; + const target = loadMoreTriggerRef.current; + if (!root || !target) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + loadMoreForActiveFilter(); + } + }, + { + root, + rootMargin: "120px", + threshold: 0, + } + ); + + observer.observe(target); + return () => observer.disconnect(); + }, [hasMore, isLoading, isLoadingMore, loadMoreForActiveFilter, open]); + const markItemAsRead = useCallback( async (item: InboxItem) => { if (item.read) return; @@ -179,8 +227,6 @@ export function NotificationsDropdown({ try { if (activeFilter === "mentions") { await notifications.comments.markAllAsRead(); - } else if (activeFilter === "status") { - await notifications.status.markAllAsRead(); } else { await Promise.all([ notifications.comments.markAllAsRead(), @@ -192,26 +238,28 @@ export function NotificationsDropdown({ } }, [activeFilter, markingAllAsRead, notifications, visibleUnreadCount]); - const handleFilterClick = useCallback((filter: Exclude) => { - setActiveFilter((current) => (current === filter ? null : filter)); - }, []); - const emptyStateCopy = activeFilter === "mentions" ? { title: "No mentions", description: "Mentions and replies will appear here.", } - : activeFilter === "status" + : activeFilter === "unread" ? { - title: "No status updates", - description: "Connector and document updates will appear here.", + title: "No unread notifications", + description: "New mentions and status updates will appear here.", } : { title: "No notifications", description: "Mentions, replies, and status updates will appear here.", }; + const tabs: { value: NotificationFilter; label: string; count: number }[] = [ + { value: "all", label: "All", count: allCount }, + { value: "mentions", label: "Mentions", count: mentionsCount }, + { value: "unread", label: "Unread", count: notifications.totalUnreadCount }, + ]; + return ( @@ -253,9 +301,6 @@ export function NotificationsDropdown({

Notifications

-

- {visibleUnreadCount > 0 ? `${visibleUnreadLabel} unread` : "You're all caught up"} -

-
- - +
+ {tabs.map((tab) => { + const isActive = activeFilter === tab.value; + return ( + + ); + })}
-
+
{isLoading ? (
{[82, 64, 74].map((width) => ( @@ -364,11 +401,32 @@ export function NotificationsDropdown({ ); })} + {hasMore ? ( +
+ {isLoadingMore ? : null} +
+ ) : null}
) : (

{emptyStateCopy.title}

{emptyStateCopy.description}

+ {hasMore ? ( + + ) : null}
)}
diff --git a/surfsense_web/hooks/use-inbox.ts b/surfsense_web/hooks/use-inbox.ts index 1caf73b1d..eb33f84f7 100644 --- a/surfsense_web/hooks/use-inbox.ts +++ b/surfsense_web/hooks/use-inbox.ts @@ -54,6 +54,7 @@ export function useInbox( const [hasMore, setHasMore] = useState(false); const [error, setError] = useState(null); const [unreadCount, setUnreadCount] = useState(0); + const [totalCount, setTotalCount] = useState(0); const initialLoadDoneRef = useRef(false); const olderUnreadOffsetRef = useRef(null); @@ -71,6 +72,7 @@ export function useInbox( setLoading(true); setInboxItems([]); setHasMore(false); + setTotalCount(0); initialLoadDoneRef.current = false; olderUnreadOffsetRef.current = null; apiUnreadTotalRef.current = 0; @@ -97,6 +99,7 @@ export function useInbox( if (cancelled) return; setInboxItems(notificationsResponse.items); + setTotalCount(notificationsResponse.total); setHasMore(notificationsResponse.has_more); setUnreadCount(unreadResponse.total_unread); apiUnreadTotalRef.current = unreadResponse.total_unread; @@ -222,6 +225,7 @@ export function useInbox( const deduped = newItems.filter((d) => !existingIds.has(d.id)); return [...prev, ...deduped]; }); + setTotalCount(response.total); setHasMore(response.has_more); } catch (err) { console.error(`[useInbox:${category}] Load more failed:`, err); @@ -299,6 +303,7 @@ export function useInbox( return { inboxItems, unreadCount, + totalCount, markAsRead, markAllAsRead, loading, From c12790f091b184c4f29888772daf1460b2b6379a Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:55:58 +0530 Subject: [PATCH 04/27] feat(notifications): integrate Drawer for mobile notifications and enhance UI components - Added Drawer component to NotificationsDropdown for improved mobile experience. - Refactored notification trigger button and panel content for better accessibility and usability. - Enhanced loading states and notification filtering options to streamline user interaction. --- .../ui/sidebar/NotificationsDropdown.tsx | 341 ++++++++++-------- 1 file changed, 186 insertions(+), 155 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/NotificationsDropdown.tsx b/surfsense_web/components/layout/ui/sidebar/NotificationsDropdown.tsx index 3ada5de46..bf3ce4322 100644 --- a/surfsense_web/components/layout/ui/sidebar/NotificationsDropdown.tsx +++ b/surfsense_web/components/layout/ui/sidebar/NotificationsDropdown.tsx @@ -6,6 +6,13 @@ import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom"; import { Button } from "@/components/ui/button"; +import { + Drawer, + DrawerContent, + DrawerHandle, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; @@ -16,6 +23,7 @@ import { isNewMentionMetadata, } from "@/contracts/types/inbox.types"; import type { InboxItem } from "@/hooks/use-inbox"; +import { useIsMobile } from "@/hooks/use-mobile"; import { cn } from "@/lib/utils"; export interface NotificationsDataSource { @@ -79,6 +87,7 @@ export function NotificationsDropdown({ onCloseMobileSidebar, }: NotificationsDropdownProps) { const router = useRouter(); + const isMobile = useIsMobile(); const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom); const [open, setOpen] = useState(false); const [activeFilter, setActiveFilter] = useState("all"); @@ -260,33 +269,186 @@ export function NotificationsDropdown({ { value: "unread", label: "Unread", count: notifications.totalUnreadCount }, ]; + const triggerButton = ( + + ); + + const panelContent = ( + <> +
+
+

Notifications

+
+ +
+ +
+ {tabs.map((tab) => { + const isActive = activeFilter === tab.value; + return ( + + ); + })} +
+ +
+ {isLoading ? ( +
+ {[82, 64, 74].map((width) => ( +
+
+ + +
+
+ ))} +
+ ) : items.length > 0 ? ( +
+ {items.map((item) => { + const isMarkingAsRead = markingAsReadId === item.id; + return ( + + ); + })} + {hasMore ? ( +
+ {isLoadingMore ? : null} +
+ ) : null} +
+ ) : ( +
+

{emptyStateCopy.title}

+

{emptyStateCopy.description}

+ {hasMore ? ( + + ) : null} +
+ )} +
+ + ); + + if (isMobile) { + return ( + + {triggerButton} + + + Notifications +
{panelContent}
+
+
+ ); + } + return ( - - - + {triggerButton} Notifications @@ -298,138 +460,7 @@ export function NotificationsDropdown({ sideOffset={10} className="z-80 flex max-h-[min(520px,calc(100vh-2rem))] w-[360px] flex-col overflow-hidden rounded-xl border bg-popover p-0 text-popover-foreground shadow-lg" > -
-
-

Notifications

-
- -
- -
- {tabs.map((tab) => { - const isActive = activeFilter === tab.value; - return ( - - ); - })} -
- -
- {isLoading ? ( -
- {[82, 64, 74].map((width) => ( -
-
- - -
-
- ))} -
- ) : items.length > 0 ? ( -
- {items.map((item) => { - const isMarkingAsRead = markingAsReadId === item.id; - return ( - - ); - })} - {hasMore ? ( -
- {isLoadingMore ? : null} -
- ) : null} -
- ) : ( -
-

{emptyStateCopy.title}

-

{emptyStateCopy.description}

- {hasMore ? ( - - ) : null} -
- )} -
+ {panelContent}
); From 80a6b054781091e959a6962aa360525359c81dfa Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:28:32 +0530 Subject: [PATCH 05/27] refactor(sidebar): enhance MobileSidebar and SidebarUserProfile UI - Updated MobileSidebar to include a right border for improved visual separation. - Modified SidebarUserProfile to add a relative positioning and a top border for better layout consistency. --- surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx | 2 +- .../components/layout/ui/sidebar/SidebarUserProfile.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx index 94fe2d413..1d02b56f4 100644 --- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx @@ -176,7 +176,7 @@ export function MobileSidebar({
{/* Sidebar Content - right side */} -
+
+
{topContent} {showDownloadCta && ( From 24613533123ddb34c1d3290eb25ea258e6561c14 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:36:18 +0530 Subject: [PATCH 06/27] feat(sidebar): implement mobile submenus in SidebarUserProfile - Added mobile submenus for theme, language, and learn more options in SidebarUserProfile. - Integrated Drawer component for improved mobile navigation experience. - Enhanced UI with new dropdown items and improved accessibility for user settings. --- .../layout/ui/sidebar/SidebarUserProfile.tsx | 414 +++++++++++------- 1 file changed, 251 insertions(+), 163 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx index a2cf4f3e3..09fb3f6d0 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx @@ -2,6 +2,7 @@ import { Check, + ChevronRight, ChevronUp, Download, ExternalLink, @@ -17,8 +18,9 @@ import { import Image from "next/image"; import { useTranslations } from "next-intl"; import type React from "react"; -import { useState } from "react"; +import { Fragment, useState } from "react"; import { Button } from "@/components/ui/button"; +import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer"; import { DropdownMenu, DropdownMenuContent, @@ -64,6 +66,8 @@ const LEARN_MORE_LINKS = [ { key: "github" as const, href: "https://github.com/MODSetter/SurfSense" }, ]; +type MobileProfileSubmenu = "theme" | "language" | "learn_more"; + interface SidebarUserProfileProps { user: User; onUserSettings?: () => void; @@ -144,12 +148,14 @@ export function SidebarUserProfile({ const isDesktopViewport = useMediaQuery("(min-width: 768px)"); const { os, primary, isMobileOS } = usePrimaryDownload(); const [isLoggingOut, setIsLoggingOut] = useState(false); + const [mobileSubmenu, setMobileSubmenu] = useState(null); const bgColor = getUserAvatarColor(user.email); const initials = getUserInitials(user.email); const displayName = user.name || user.email.split("@")[0]; const downloadUrl = primary?.url ?? GITHUB_RELEASES_URL; const downloadLabel = t("download_for_os", { os }); const showDownloadCta = !isDesktop && !isMobileOS && isDesktopViewport; + const useMobileSubmenus = !isDesktopViewport; const handleLanguageChange = (newLocale: "en" | "es" | "pt" | "hi" | "zh") => { setLocale(newLocale); @@ -169,6 +175,242 @@ export function SidebarUserProfile({ } }; + const submenuTriggerClassName = "cursor-default"; + const drawerItemClassName = cn( + "flex h-12 w-full items-center gap-3 rounded-lg px-3 text-left text-sm transition-colors", + "hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + ); + const drawerSeparatorClassName = "mx-3 h-px bg-popover-border"; + + const renderThemeSubmenu = () => { + if (!setTheme) return null; + + if (useMobileSubmenus) { + return ( + setMobileSubmenu("theme")} + > + + {t("theme")} + + + ); + } + + return ( + + + + {t("theme")} + + + + {THEMES.map((themeOption) => { + const Icon = themeOption.icon; + const isSelected = theme === themeOption.value; + return ( + handleThemeChange(themeOption.value)} + className={cn( + "mb-1 last:mb-0 transition-all", + "hover:bg-accent hover:text-accent-foreground", + isSelected && "text-primary" + )} + > + + {t(themeOption.value)} + {isSelected && } + + ); + })} + + + + ); + }; + + const renderLanguageSubmenu = () => { + if (useMobileSubmenus) { + return ( + setMobileSubmenu("language")} + > + + {t("language")} + + + ); + } + + return ( + + + + {t("language")} + + + + {LANGUAGES.map((language) => { + const isSelected = locale === language.code; + return ( + handleLanguageChange(language.code)} + className={cn( + "mb-1 last:mb-0 transition-all", + "hover:bg-accent hover:text-accent-foreground", + isSelected && "text-primary" + )} + > + {language.flag} + {language.name} + {isSelected && } + + ); + })} + + + + ); + }; + + const renderLearnMoreSubmenu = () => { + if (useMobileSubmenus) { + return ( + setMobileSubmenu("learn_more")} + > + + {t("learn_more")} + + + ); + } + + return ( + + + + {t("learn_more")} + + + + {LEARN_MORE_LINKS.map((link) => ( + + + {t(link.key)} + + + + ))} + +

+ v{APP_VERSION} +

+
+
+
+ ); + }; + + const mobileSubmenuDrawer = ( + { + if (!open) setMobileSubmenu(null); + }} + shouldScaleBackground={false} + > + + + + {mobileSubmenu === "theme" + ? t("theme") + : mobileSubmenu === "language" + ? t("language") + : t("learn_more")} + +
+ {mobileSubmenu === "theme" && + setTheme && + THEMES.map((themeOption, index) => { + const Icon = themeOption.icon; + const isSelected = theme === themeOption.value; + return ( + + {index > 0 &&
} + + + ); + })} + + {mobileSubmenu === "language" && + LANGUAGES.map((language, index) => { + const isSelected = locale === language.code; + return ( + + {index > 0 &&
} + + + ); + })} + + {mobileSubmenu === "learn_more" && ( + <> + {LEARN_MORE_LINKS.map((link, index) => ( + + {index > 0 &&
} + setMobileSubmenu(null)} + > + {t(link.key)} + + + + ))} +

+ v{APP_VERSION} +

+ + )} +
+ + + ); + // Collapsed view - just show avatar with dropdown if (isCollapsed) { return ( @@ -251,89 +493,11 @@ export function SidebarUserProfile({ )} - {setTheme && ( - - - - {t("theme")} - - - - {THEMES.map((themeOption) => { - const Icon = themeOption.icon; - const isSelected = theme === themeOption.value; - return ( - handleThemeChange(themeOption.value)} - className={cn( - "mb-1 last:mb-0 transition-all", - "hover:bg-accent hover:text-accent-foreground", - isSelected && "text-primary" - )} - > - - {t(themeOption.value)} - {isSelected && } - - ); - })} - - - - )} + {renderThemeSubmenu()} - - - - {t("language")} - - - - {LANGUAGES.map((language) => { - const isSelected = locale === language.code; - return ( - handleLanguageChange(language.code)} - className={cn( - "mb-1 last:mb-0 transition-all", - "hover:bg-accent hover:text-accent-foreground", - isSelected && "text-primary" - )} - > - {language.flag} - {language.name} - {isSelected && } - - ); - })} - - - + {renderLanguageSubmenu()} - - - - {t("learn_more")} - - - - {LEARN_MORE_LINKS.map((link) => ( - - - {t(link.key)} - - - - ))} - -

- v{APP_VERSION} -

-
-
-
+ {renderLearnMoreSubmenu()} {!isDesktop && !isMobileOS && ( @@ -356,6 +520,7 @@ export function SidebarUserProfile({ + {mobileSubmenuDrawer}
); @@ -433,89 +598,11 @@ export function SidebarUserProfile({ )} - {setTheme && ( - - - - {t("theme")} - - - - {THEMES.map((themeOption) => { - const Icon = themeOption.icon; - const isSelected = theme === themeOption.value; - return ( - handleThemeChange(themeOption.value)} - className={cn( - "mb-1 last:mb-0 transition-all", - "hover:bg-accent hover:text-accent-foreground", - isSelected && "text-primary" - )} - > - - {t(themeOption.value)} - {isSelected && } - - ); - })} - - - - )} + {renderThemeSubmenu()} - - - - {t("language")} - - - - {LANGUAGES.map((language) => { - const isSelected = locale === language.code; - return ( - handleLanguageChange(language.code)} - className={cn( - "mb-1 last:mb-0 transition-all", - "hover:bg-accent hover:text-accent-foreground", - isSelected && "text-primary" - )} - > - {language.flag} - {language.name} - {isSelected && } - - ); - })} - - - + {renderLanguageSubmenu()} - - - - {t("learn_more")} - - - - {LEARN_MORE_LINKS.map((link) => ( - - - {t(link.key)} - - - - ))} - -

- v{APP_VERSION} -

-
-
-
+ {renderLearnMoreSubmenu()} {!isDesktop && ( @@ -534,6 +621,7 @@ export function SidebarUserProfile({ + {mobileSubmenuDrawer}
); } From b911a36bcf454174bc70fb173d6a1a10733d3971 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:58:47 +0530 Subject: [PATCH 07/27] feat(sidebar): enhance document filtering with Drawer component - Integrated Drawer component in EmbeddedDocumentsMenu for mobile-friendly document type filtering. - Updated UI to improve accessibility and user experience with new button interactions and layout adjustments. - Refactored document type display logic to streamline filtering options and enhance visual clarity. --- .../layout/ui/sidebar/DocumentsSidebar.tsx | 140 ++++++++++++------ .../components/layout/ui/sidebar/Sidebar.tsx | 9 +- 2 files changed, 105 insertions(+), 44 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index a5b67ae7d..5d4efac81 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -3,6 +3,7 @@ import { useQuery } from "@rocicorp/zero/react"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { + Check, FolderInput, FolderPlus, FolderSync, @@ -50,6 +51,7 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer"; import { DropdownMenu, DropdownMenuCheckboxItem, @@ -69,6 +71,7 @@ import { EnumConnectorName } from "@/contracts/enums/connector"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import type { DocumentTypeEnum } from "@/contracts/types/document.types"; +import { useIsMobile } from "@/hooks/use-mobile"; import { useElectronAPI, usePlatform } from "@/hooks/use-platform"; import { documentsApiService } from "@/lib/apis/documents-api.service"; import { foldersApiService } from "@/lib/apis/folders-api.service"; @@ -127,60 +130,111 @@ export function EmbeddedDocumentsMenu({ onToggleType: (type: DocumentTypeEnum, checked: boolean) => void; onCreateFolder: () => void; }) { + const isMobile = useIsMobile(); + const [filterDrawerOpen, setFilterDrawerOpen] = useState(false); const documentTypes = useMemo( () => Object.keys(typeCounts).sort() as DocumentTypeEnum[], [typeCounts] ); return ( - - - + + + + + New folder + + {isMobile ? ( + setFilterDrawerOpen(true)}> + + Filter by type + + ) : ( + + + + Filter by type + + + {documentTypes.length > 0 ? ( + documentTypes.map((type) => ( + onToggleType(type, checked === true)} + onSelect={(event) => event.preventDefault()} + > + {getDocumentTypeIcon(type, "h-4 w-4")} + {getDocumentTypeLabel(type)} + + {typeCounts[type] ?? 0} + + + )) + ) : ( + No document types + )} + + + )} + + + + + - - {activeTypes.length > 0 ? ( - - ) : null} - - - - - - New folder - - - - + + Filter by type - - + +
{documentTypes.length > 0 ? ( - documentTypes.map((type) => ( - onToggleType(type, checked === true)} - onSelect={(event) => event.preventDefault()} - > - {getDocumentTypeIcon(type, "h-4 w-4")} - {getDocumentTypeLabel(type)} - - {typeCounts[type] ?? 0} - - - )) + documentTypes.map((type, index) => { + const isActive = activeTypes.includes(type); + return ( +
+ {index > 0 &&
} + +
+ ); + }) ) : ( - No document types +

No document types

)} - - - - +
+ + + ); } diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index 28264a77f..edd978283 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { useParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { type ReactNode, useMemo, useState } from "react"; +import { ConnectAgentDialog } from "@/components/mcp/connect-agent-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; @@ -322,10 +323,16 @@ export function Sidebar({ /> )} + {!isCollapsed && ( +
+ +
+ )} + 0} + hasNavSectionAbove={footerNavItems.length > 0 || !isCollapsed} onNavigate={onNavigate} /> From ac41671467322ec947dbf90de07f5152bc3348a1 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:03:31 +0530 Subject: [PATCH 08/27] refactor(notifications): streamline NotificationsDropdown UI and remove unused components - Simplified the styling of notification count indicators for better visual consistency. - Enhanced the empty state layout for improved user experience. - Removed the ConnectAgentDialog from PlaygroundSidebar to declutter the UI. --- .../layout/ui/sidebar/NotificationsDropdown.tsx | 11 ++++------- .../layout/ui/sidebar/PlaygroundSidebar.tsx | 4 ---- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/NotificationsDropdown.tsx b/surfsense_web/components/layout/ui/sidebar/NotificationsDropdown.tsx index bf3ce4322..f9441d2c3 100644 --- a/surfsense_web/components/layout/ui/sidebar/NotificationsDropdown.tsx +++ b/surfsense_web/components/layout/ui/sidebar/NotificationsDropdown.tsx @@ -329,10 +329,7 @@ export function NotificationsDropdown({ > {tab.label} {formatNotificationCount(tab.count)} @@ -406,7 +403,7 @@ export function NotificationsDropdown({ ) : null}
) : ( -
+

{emptyStateCopy.title}

{emptyStateCopy.description}

{hasMore ? ( @@ -438,7 +435,7 @@ export function NotificationsDropdown({ > Notifications -
{panelContent}
+
{panelContent}
); @@ -458,7 +455,7 @@ export function NotificationsDropdown({ side="right" align="end" sideOffset={10} - className="z-80 flex max-h-[min(520px,calc(100vh-2rem))] w-[360px] flex-col overflow-hidden rounded-xl border bg-popover p-0 text-popover-foreground shadow-lg" + className="z-80 flex h-[min(420px,calc(100vh-2rem))] w-[360px] select-none flex-col overflow-hidden rounded-xl border bg-popover p-0 text-popover-foreground shadow-lg" > {panelContent} diff --git a/surfsense_web/components/layout/ui/sidebar/PlaygroundSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/PlaygroundSidebar.tsx index 1c2f96212..ffdab8004 100644 --- a/surfsense_web/components/layout/ui/sidebar/PlaygroundSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/PlaygroundSidebar.tsx @@ -3,7 +3,6 @@ import { History, KeyRound } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { ConnectAgentDialog } from "@/components/mcp/connect-agent-dialog"; import { PLAYGROUND_PLATFORMS, type PlatformIcon } from "@/lib/playground/catalog"; import { cn } from "@/lib/utils"; @@ -90,9 +89,6 @@ export function PlaygroundSidebar({ workspaceId }: PlaygroundSidebarProps) { ))}
-
- -
); } From f2b326b63bcad62114237954d47c737dbf1889f6 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:17:51 +0530 Subject: [PATCH 09/27] feat(playground): implement Playground layout and navigation structure - Introduced PlaygroundLayoutShell component for managing the layout and navigation of the API Playground. - Added PlaygroundLayout component to handle workspace-specific rendering. - Removed PlaygroundSidebar and related state management to simplify the UI and enhance mobile responsiveness. - Updated LayoutDataProvider and LayoutShell components to reflect the new Playground structure. --- .../playground/layout-shell.tsx | 192 ++++++++++++++++++ .../[workspace_id]/playground/layout.tsx | 15 ++ surfsense_web/atoms/layout/playground.atom.ts | 10 - .../layout/providers/LayoutDataProvider.tsx | 39 +--- .../layout/ui/shell/LayoutShell.tsx | 23 +-- .../layout/ui/sidebar/PlaygroundSidebar.tsx | 94 --------- .../components/layout/ui/sidebar/index.ts | 1 - 7 files changed, 214 insertions(+), 160 deletions(-) create mode 100644 surfsense_web/app/dashboard/[workspace_id]/playground/layout-shell.tsx create mode 100644 surfsense_web/app/dashboard/[workspace_id]/playground/layout.tsx delete mode 100644 surfsense_web/atoms/layout/playground.atom.ts delete mode 100644 surfsense_web/components/layout/ui/sidebar/PlaygroundSidebar.tsx diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/layout-shell.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/layout-shell.tsx new file mode 100644 index 000000000..0d0211f99 --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/playground/layout-shell.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { History, KeyRound, LayoutGrid } from "lucide-react"; +import Link from "next/link"; +import { useSelectedLayoutSegments } from "next/navigation"; +import type React from "react"; +import { useCallback, useMemo, useState } from "react"; +import { Separator } from "@/components/ui/separator"; +import { PLAYGROUND_PLATFORMS, type PlatformIcon } from "@/lib/playground/catalog"; +import { cn } from "@/lib/utils"; + +interface PlaygroundLayoutShellProps { + workspaceId: string; + children: React.ReactNode; +} + +type PlaygroundNavItem = + | { + type: "item"; + value: string; + label: string; + href: string; + icon: React.ReactNode; + indented?: boolean; + } + | { + type: "section"; + value: string; + label: string; + icon: PlatformIcon; + }; + +function PlaygroundNavLink({ + item, + activeValue, +}: { + item: Extract; + activeValue: string; +}) { + const isActive = activeValue === item.value; + + return ( + + {item.icon} + {item.label} + + ); +} + +export function PlaygroundLayoutShell({ workspaceId, children }: PlaygroundLayoutShellProps) { + const segments = useSelectedLayoutSegments(); + const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start"); + const base = `/dashboard/${workspaceId}/playground`; + + const handleTabScroll = useCallback((e: React.UIEvent) => { + const el = e.currentTarget; + const atStart = el.scrollLeft <= 2; + const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2; + setTabScrollPos(atStart ? "start" : atEnd ? "end" : "middle"); + }, []); + + const navItems = useMemo( + () => [ + { + type: "item", + value: "overview", + label: "Overview", + href: base, + icon: , + }, + { + type: "item", + value: "runs", + label: "Runs", + href: `${base}/runs`, + icon: , + }, + { + type: "item", + value: "api-keys", + label: "API Keys", + href: `${base}/api-keys`, + icon: , + }, + ...PLAYGROUND_PLATFORMS.flatMap((platform) => [ + { + type: "section", + value: platform.id, + label: platform.label, + icon: platform.icon, + }, + ...platform.verbs.map((verb) => ({ + type: "item" as const, + value: `${platform.id}/${verb.verb}`, + label: verb.label, + href: `${base}/${platform.id}/${verb.verb}`, + icon: , + indented: true, + })), + ]), + ], + [base] + ); + + const activeValue = + segments.length >= 2 + ? `${segments[0]}/${segments[1]}` + : segments[0] && navItems.some((item) => item.type === "item" && item.value === segments[0]) + ? segments[0] + : "overview"; + const selectedLabel = + navItems.find((item) => item.type === "item" && item.value === activeValue)?.label ?? + "API Playground"; + const mobileItems = navItems.filter( + (item): item is Extract => item.type === "item" + ); + + return ( +
+
+

API Playground

+ +
+
+ {mobileItems.map((item) => ( + + {item.icon} + {item.label} + + ))} +
+
+
+ +
+
+

{selectedLabel}

+ +
+
{children}
+
+
+ ); +} diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/layout.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/layout.tsx new file mode 100644 index 000000000..318886e31 --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/playground/layout.tsx @@ -0,0 +1,15 @@ +import type React from "react"; +import { use } from "react"; +import { PlaygroundLayoutShell } from "./layout-shell"; + +export default function PlaygroundLayout({ + params, + children, +}: { + params: Promise<{ workspace_id: string }>; + children: React.ReactNode; +}) { + const { workspace_id } = use(params); + + return {children}; +} diff --git a/surfsense_web/atoms/layout/playground.atom.ts b/surfsense_web/atoms/layout/playground.atom.ts deleted file mode 100644 index 6cbe8d276..000000000 --- a/surfsense_web/atoms/layout/playground.atom.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { atom } from "jotai"; - -/** - * Whether the second-level API Playground sidebar is open. Toggled by the - * Playground nav button and kept in memory for the session, so it survives - * in-app navigation (opening a new chat won't close it) and only closes when - * the user clicks the toggle. It defaults to open, so a fresh app load — a new - * signup or a relogin — always starts with the playground visible. - */ -export const playgroundSidebarOpenAtom = atom(true); diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index a70e8f59e..9821a9f83 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -1,7 +1,7 @@ "use client"; import { useQuery } from "@tanstack/react-query"; -import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { useAtomValue, useSetAtom } from "jotai"; import { AlarmClock, AlertTriangle, Boxes, SquareTerminal } from "lucide-react"; import { useParams, usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; @@ -11,7 +11,6 @@ import { toast } from "sonner"; import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom"; import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom"; import { announcementsDialogAtom } from "@/atoms/layout/dialogs.atom"; -import { playgroundSidebarOpenAtom } from "@/atoms/layout/playground.atom"; import { removeChatTabAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { deleteWorkspaceMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms"; @@ -43,7 +42,6 @@ import { Spinner } from "@/components/ui/spinner"; import { useActivateChatThread } from "@/hooks/use-activate-chat-thread"; import { useAnnouncements } from "@/hooks/use-announcements"; import { useInbox } from "@/hooks/use-inbox"; -import { useIsMobile } from "@/hooks/use-mobile"; import { useArchiveThread, useDeleteThread, useRenameThread } from "@/hooks/use-thread-mutations"; import { notificationsApiService } from "@/lib/apis/notifications-api.service"; import { workspacesApiService } from "@/lib/apis/workspaces-api.service"; @@ -68,8 +66,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider const params = useParams(); const pathname = usePathname(); const { theme, setTheme } = useTheme(); - const isMobile = useIsMobile(); - const [playgroundSidebarOpen, setPlaygroundSidebarOpen] = useAtom(playgroundSidebarOpenAtom); // Announcements const { unreadCount: announcementUnreadCount } = useAnnouncements(); @@ -316,20 +312,11 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider title: "Playground", url: `/dashboard/${workspaceId}/playground`, icon: SquareTerminal, - // Mobile has no second-level sidebar: Playground is a plain link - // there, so highlight by route. Desktop highlights the toggle state. - isActive: isMobile ? isPlaygroundRoute : playgroundSidebarOpen, + isActive: isPlaygroundRoute, }, ] as (NavItem | null)[] ).filter((item): item is NavItem => item !== null), - [ - workspaceId, - isAutomationsActive, - isArtifactsActive, - isPlaygroundRoute, - playgroundSidebarOpen, - isMobile, - ] + [workspaceId, isAutomationsActive, isArtifactsActive, isPlaygroundRoute] ); // Handlers @@ -466,18 +453,9 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider const handleNavItemClick = useCallback( (item: NavItem) => { - // Desktop: Playground is a persistent toggle, not a plain link — it just - // opens the second-level sidebar (which holds the whole API playground) - // and only closes on a second click, never navigating away from the - // current page (e.g. a new chat). Mobile has no second-level sidebar, - // so there it navigates to the playground index page instead. - if (item.url.endsWith("/playground") && !isMobile) { - setPlaygroundSidebarOpen((prev) => !prev); - return; - } router.push(item.url); }, - [router, setPlaygroundSidebarOpen, isMobile] + [router] ); const handleNewChat = useCallback(() => { @@ -699,7 +677,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider setTheme={setTheme} isChatPage={isChatPage} isAllChatsPage={isAllChatsPage} - showPlaygroundSidebar={playgroundSidebarOpen} useWorkspacePanel={useWorkspacePanel} workspacePanelViewportClassName={ isUserSettingsPage || @@ -713,13 +690,7 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider : undefined } workspacePanelContentClassName={ - isAutomationsPage || isPlaygroundPage - ? "max-w-none select-none" - : isAllChatsPage - ? "max-w-5xl" - : isUserSettingsPage || isWorkspaceSettingsPage || isTeamPage || isArtifactsPage - ? "max-w-5xl" - : undefined + useWorkspacePanel ? "max-w-5xl select-none" : undefined } isLoadingChats={isLoadingThreads} notifications={{ diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index 00e606024..06ccaab80 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -27,7 +27,6 @@ import { import { MobileSidebar, MobileSidebarTrigger, - PlaygroundSidebar, Sidebar, SidebarCollapseButton, } from "../sidebar"; @@ -109,7 +108,6 @@ interface LayoutShellProps { defaultCollapsed?: boolean; isChatPage?: boolean; isAllChatsPage?: boolean; - showPlaygroundSidebar?: boolean; useWorkspacePanel?: boolean; workspacePanelViewportClassName?: string; workspacePanelContentClassName?: string; @@ -210,7 +208,6 @@ export function LayoutShell({ defaultCollapsed = false, isChatPage = false, isAllChatsPage = false, - showPlaygroundSidebar = false, useWorkspacePanel = false, workspacePanelViewportClassName, workspacePanelContentClassName, @@ -338,27 +335,11 @@ export function LayoutShell({ />
- {/* Playground second-level sidebar — contextual, desktop only. Sits - between the icon rail and the main sidebar. On Mac it becomes the - leftmost panel, so it takes the rounded-corner/left-border treatment. */} - {showPlaygroundSidebar && activeWorkspaceId != null && ( - - )} - {/* Sidebar + slide-out panels share one container; overflow visible so panels can overlay main content. Negative right margin closes the flex gap so the sidebar sits flush against the main panel, separated only by a border. */}