diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 042b932a4..db5015023 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -110,7 +110,7 @@ const ThreadContent: FC<{ header?: React.ReactNode }> = ({ header }) => { }} /> - + !thread.isEmpty}>
diff --git a/surfsense_web/components/layout/hooks/SidebarContext.tsx b/surfsense_web/components/layout/hooks/SidebarContext.tsx new file mode 100644 index 000000000..70e9311f9 --- /dev/null +++ b/surfsense_web/components/layout/hooks/SidebarContext.tsx @@ -0,0 +1,37 @@ +"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 702014050..8710fdb79 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,6 +99,7 @@ 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); @@ -298,9 +298,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 +462,20 @@ export function LayoutDataProvider({ theme={theme} setTheme={setTheme} isChatPage={isChatPage} + inbox={{ + isOpen: isInboxSidebarOpen, + onOpenChange: setIsInboxSidebarOpen, + items: inboxItems, + unreadCount, + loading: inboxLoading, + loadingMore: inboxLoadingMore, + hasMore: inboxHasMore, + loadMore: inboxLoadMore, + markAsRead, + markAllAsRead, + isDocked: isInboxDocked, + onDockedChange: setIsInboxDocked, + }} > {children} @@ -607,20 +621,6 @@ export function LayoutDataProvider({ searchSpaceId={searchSpaceId} /> - {/* Inbox Sidebar */} - - {/* Create Search Space Dialog */} void; + items: InboxItem[]; + unreadCount: number; + loading: boolean; + loadingMore?: boolean; + hasMore?: boolean; + loadMore?: () => void; + 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 +61,8 @@ interface LayoutShellProps { isChatPage?: boolean; children: React.ReactNode; className?: string; + // Inbox props + inbox?: InboxProps; } export function LayoutShell({ @@ -76,111 +97,176 @@ 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 */} + {inbox && ( + 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 e80c6e62d..db6d22cba 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -11,6 +11,8 @@ import { Inbox, LayoutGrid, ListFilter, + PanelLeftClose, + PanelLeft, Search, X, } from "lucide-react"; @@ -18,7 +20,6 @@ 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 { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; @@ -50,6 +51,11 @@ import { 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 @@ -109,6 +115,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", @@ -139,6 +146,10 @@ interface InboxSidebarProps { 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({ @@ -153,6 +164,8 @@ export function InboxSidebar({ markAsRead, markAllAsRead, onCloseMobileSidebar, + isDocked = false, + onDockedChange, }: InboxSidebarProps) { const t = useTranslations("sidebar"); const router = useRouter(); @@ -186,8 +199,9 @@ export function InboxSidebar({ return () => document.removeEventListener("keydown", handleEscape); }, [open, onOpenChange]); + // Only lock body scroll on mobile (Notion-style keeps desktop content scrollable) useEffect(() => { - if (open) { + if (open && isMobile) { document.body.style.overflow = "hidden"; } else { document.body.style.overflow = ""; @@ -195,7 +209,7 @@ export function InboxSidebar({ return () => { document.body.style.overflow = ""; }; - }, [open]); + }, [open, isMobile]); // Reset connector filter when switching away from status tab useEffect(() => { @@ -440,36 +454,21 @@ 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" - /> - - -
+ // Shared content component for both docked and floating modes + const inboxContent = ( + <> +
-

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

@@ -703,6 +702,40 @@ export function InboxSidebar({ {t("mark_all_read") || "Mark all as read"} + {/* Dock/Undock button - desktop only */} + {!isMobile && onDockedChange && ( + + + + + + {isDocked ? "Close inbox" : "Dock inbox"} + + + )}
@@ -858,11 +891,70 @@ export function InboxSidebar({

)} -
-
+
+ + ); + + // 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 */} +
+ + {inboxContent} + +
)} -
, - document.body + ); } diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index 6838b0f52..b11e43bbc 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -686,7 +686,7 @@ "system": "系统", "logout": "退出登录", "inbox": "收件箱", - "search_inbox": "搜索收件箱...", + "search_inbox": "搜索收件箱", "mark_all_read": "全部标记为已读", "mark_as_read": "标记为已读", "mentions": "提及",