diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 6ec028b2f..2c473018d 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -68,10 +68,10 @@ import { cn } from "@/lib/utils"; /** Placeholder texts that cycle in new chats when input is empty */ const CYCLING_PLACEHOLDERS = [ "Ask SurfSense anything or @mention docs.", - "Generate a podcast from marketing tips in the company handbook.", - "Sum up our vacation policy from Drive.", + "Generate a podcast from my vacation ideas in Notion.", + "Sum up last week's meeting notes from Drive in a bulleted list.", "Give me a brief overview of the most urgent tickets in Jira and Linear.", - "Create a concise table of today's top ten emails and calendar events.", + "Briefly, what are today's top ten important emails and calendar events?", "Check if this week's Slack messages reference any GitHub issues.", ]; diff --git a/surfsense_web/components/homepage/hero-section.tsx b/surfsense_web/components/homepage/hero-section.tsx index f30e7b9d7..e5b17d9e4 100644 --- a/surfsense_web/components/homepage/hero-section.tsx +++ b/surfsense_web/components/homepage/hero-section.tsx @@ -34,8 +34,8 @@ const GoogleLogo = ({ className }: { className?: string }) => ( export function HeroSection() { const containerRef = useRef(null); const parentRef = useRef(null); - const heroVariant = useFeatureFlagVariantKey("notebooklm_flag"); - const isNotebookLMVariant = heroVariant === "notebooklm"; + const heroVariant = useFeatureFlagVariantKey("notebooklm_superpowers_flag"); + const isNotebookLMVariant = heroVariant === "superpowers"; return (
- {isNotebookLMVariant ? ( -
-
- NotebookLM for Teams -
+ {isNotebookLMVariant ? ( +
+
+ NotebookLM with Superpowers
- ) : ( - <> - The AI Workspace{" "} -
-
- Built for Teams -
-
- - )} +
+ ) : ( +
+
+ NotebookLM for Teams +
+
+ )} {/* // TODO:aCTUAL DESCRITION */}

- Connect any LLM to your internal knowledge sources and chat with it in real time alongside - your team. + Connect any AI to your documents and knowledge sources. +

+

+ Then chat with it in real-time, even alongside your team.

diff --git a/surfsense_web/components/layout/hooks/SidebarContext.tsx b/surfsense_web/components/layout/hooks/SidebarContext.tsx index 35f76929d..bfb5b5aeb 100644 --- a/surfsense_web/components/layout/hooks/SidebarContext.tsx +++ b/surfsense_web/components/layout/hooks/SidebarContext.tsx @@ -6,6 +6,7 @@ interface SidebarContextValue { isCollapsed: boolean; setIsCollapsed: (collapsed: boolean) => void; toggleCollapsed: () => void; + sidebarWidth: number; } const SidebarContext = createContext(null); diff --git a/surfsense_web/components/layout/hooks/useSidebarResize.ts b/surfsense_web/components/layout/hooks/useSidebarResize.ts new file mode 100644 index 000000000..887c86dce --- /dev/null +++ b/surfsense_web/components/layout/hooks/useSidebarResize.ts @@ -0,0 +1,101 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +const SIDEBAR_WIDTH_COOKIE_NAME = "sidebar_width"; +const SIDEBAR_WIDTH_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year + +export const SIDEBAR_MIN_WIDTH = 240; +export const SIDEBAR_MAX_WIDTH = 480; + +interface UseSidebarResizeReturn { + sidebarWidth: number; + handleMouseDown: (e: React.MouseEvent) => void; + isDragging: boolean; +} + +export function useSidebarResize(defaultWidth = SIDEBAR_MIN_WIDTH): UseSidebarResizeReturn { + const [sidebarWidth, setSidebarWidth] = useState(defaultWidth); + const [isDragging, setIsDragging] = useState(false); + + const startXRef = useRef(0); + const startWidthRef = useRef(defaultWidth); + + // Initialize from cookie on mount + useEffect(() => { + try { + const match = document.cookie.match(/(?:^|; )sidebar_width=([^;]+)/); + if (match) { + const parsed = Number(match[1]); + if (!Number.isNaN(parsed) && parsed >= SIDEBAR_MIN_WIDTH && parsed <= SIDEBAR_MAX_WIDTH) { + setSidebarWidth(parsed); + } + } + } catch { + // Ignore cookie read errors + } + }, []); + + // Persist width to cookie + const persistWidth = useCallback((width: number) => { + try { + document.cookie = `${SIDEBAR_WIDTH_COOKIE_NAME}=${width}; path=/; max-age=${SIDEBAR_WIDTH_COOKIE_MAX_AGE}`; + } catch { + // Ignore cookie write errors + } + }, []); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + startXRef.current = e.clientX; + startWidthRef.current = sidebarWidth; + setIsDragging(true); + + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }, + [sidebarWidth] + ); + + useEffect(() => { + if (!isDragging) return; + + const handleMouseMove = (e: MouseEvent) => { + const delta = e.clientX - startXRef.current; + const newWidth = Math.min( + SIDEBAR_MAX_WIDTH, + Math.max(SIDEBAR_MIN_WIDTH, startWidthRef.current + delta) + ); + setSidebarWidth(newWidth); + }; + + const handleMouseUp = () => { + setIsDragging(false); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + + // Persist the final width + setSidebarWidth((currentWidth) => { + persistWidth(currentWidth); + return currentWidth; + }); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + }, [isDragging, persistWidth]); + + return { + sidebarWidth, + handleMouseDown, + isDragging, + }; +} diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index ead017a3e..e40fe28ad 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -33,9 +33,6 @@ import { cacheKeys } from "@/lib/query-client/cache-keys"; import type { ChatItem, NavItem, SearchSpace } from "../types/layout.types"; import { CreateSearchSpaceDialog } from "../ui/dialogs"; import { LayoutShell } from "../ui/shell"; -import { AllPrivateChatsSidebar } from "../ui/sidebar/AllPrivateChatsSidebar"; -import { AllSharedChatsSidebar } from "../ui/sidebar/AllSharedChatsSidebar"; - interface LayoutDataProviderProps { searchSpaceId: string; children: React.ReactNode; @@ -390,7 +387,13 @@ export function LayoutDataProvider({ (item: NavItem) => { // Handle inbox specially - toggle sidebar instead of navigating if (item.url === "#inbox") { - setIsInboxSidebarOpen((prev) => !prev); + setIsInboxSidebarOpen((prev) => { + if (!prev) { + setIsAllSharedChatsSidebarOpen(false); + setIsAllPrivateChatsSidebarOpen(false); + } + return !prev; + }); return; } router.push(item.url); @@ -490,10 +493,14 @@ export function LayoutDataProvider({ const handleViewAllSharedChats = useCallback(() => { setIsAllSharedChatsSidebarOpen(true); + setIsAllPrivateChatsSidebarOpen(false); + setIsInboxSidebarOpen(false); }, []); const handleViewAllPrivateChats = useCallback(() => { setIsAllPrivateChatsSidebarOpen(true); + setIsAllSharedChatsSidebarOpen(false); + setIsInboxSidebarOpen(false); }, []); // Delete handlers @@ -614,6 +621,16 @@ export function LayoutDataProvider({ isDocked: isInboxDocked, onDockedChange: setIsInboxDocked, }} + allSharedChatsPanel={{ + open: isAllSharedChatsSidebarOpen, + onOpenChange: setIsAllSharedChatsSidebarOpen, + searchSpaceId, + }} + allPrivateChatsPanel={{ + open: isAllPrivateChatsSidebarOpen, + onOpenChange: setIsAllPrivateChatsSidebarOpen, + searchSpaceId, + }} > {children} @@ -796,20 +813,6 @@ export function LayoutDataProvider({ - {/* All Shared Chats Sidebar */} - - - {/* All Private Chats Sidebar */} - - {/* Create Search Space Dialog */} void; searchSpaceId: string }; + allPrivateChatsPanel?: { open: boolean; onOpenChange: (open: boolean) => void; searchSpaceId: string }; } export function LayoutShell({ @@ -112,15 +123,18 @@ export function LayoutShell({ className, inbox, isLoadingChats = false, + allSharedChatsPanel, + allPrivateChatsPanel, }: LayoutShellProps) { const isMobile = useIsMobile(); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const { isCollapsed, setIsCollapsed, toggleCollapsed } = useSidebarState(defaultCollapsed); + const { sidebarWidth, handleMouseDown: onResizeMouseDown, isDragging: isResizing } = useSidebarResize(); // Memoize context value to prevent unnecessary re-renders const sidebarContextValue = useMemo( - () => ({ isCollapsed, setIsCollapsed, toggleCollapsed }), - [isCollapsed, setIsCollapsed, toggleCollapsed] + () => ({ isCollapsed, setIsCollapsed, toggleCollapsed, sidebarWidth }), + [isCollapsed, setIsCollapsed, toggleCollapsed, sidebarWidth] ); // Mobile layout @@ -236,6 +250,9 @@ export function LayoutShell({ setTheme={setTheme} className="hidden md:flex border-r shrink-0" isLoadingChats={isLoadingChats} + sidebarWidth={sidebarWidth} + onResizeMouseDown={onResizeMouseDown} + isResizing={isResizing} /> {/* Docked Inbox Sidebar - renders as flex sibling between sidebar and content */} @@ -275,6 +292,24 @@ export function LayoutShell({ onDockedChange={inbox.onDockedChange} /> )} + + {/* All Shared Chats - slide-out panel */} + {allSharedChatsPanel && ( + + )} + + {/* All Private Chats - slide-out panel */} + {allPrivateChatsPanel && ( + + )}
diff --git a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx index 1d4f590bd..c78adfef1 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx @@ -12,11 +12,9 @@ import { User, X, } from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { createPortal } from "react-dom"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { @@ -40,6 +38,7 @@ import { updateThread, } from "@/lib/chat/thread-persistence"; import { cn } from "@/lib/utils"; +import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel"; interface AllPrivateChatsSidebarProps { open: boolean; @@ -69,16 +68,11 @@ export function AllPrivateChatsSidebar({ const [archivingThreadId, setArchivingThreadId] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [showArchived, setShowArchived] = useState(false); - const [mounted, setMounted] = useState(false); const [openDropdownId, setOpenDropdownId] = useState(null); const debouncedSearchQuery = useDebouncedValue(searchQuery, 300); const isSearchMode = !!debouncedSearchQuery.trim(); - useEffect(() => { - setMounted(true); - }, []); - useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape" && open) { @@ -89,17 +83,6 @@ export function AllPrivateChatsSidebar({ return () => document.removeEventListener("keydown", handleEscape); }, [open, onOpenChange]); - useEffect(() => { - if (open) { - document.body.style.overflow = "hidden"; - } else { - document.body.style.overflow = ""; - } - return () => { - document.body.style.overflow = ""; - }; - }, [open]); - const { data: threadsData, error: threadsError, @@ -214,33 +197,13 @@ export function AllPrivateChatsSidebar({ const activeCount = activeChats.length; const archivedCount = archivedChats.length; - if (!mounted) return null; - - return createPortal( - - {open && ( - <> - onOpenChange(false)} - aria-hidden="true" - /> - - -
+ return ( + +

{t("chats") || "Private Chats"}

@@ -451,11 +414,7 @@ export function AllPrivateChatsSidebar({ )}
)} -
- - - )} - , - document.body +
+ ); } diff --git a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx index db7ca73b2..ed22b819d 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx @@ -12,11 +12,9 @@ import { Users, X, } from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { createPortal } from "react-dom"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { @@ -40,6 +38,7 @@ import { updateThread, } from "@/lib/chat/thread-persistence"; import { cn } from "@/lib/utils"; +import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel"; interface AllSharedChatsSidebarProps { open: boolean; @@ -69,16 +68,11 @@ export function AllSharedChatsSidebar({ const [archivingThreadId, setArchivingThreadId] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [showArchived, setShowArchived] = useState(false); - const [mounted, setMounted] = useState(false); const [openDropdownId, setOpenDropdownId] = useState(null); const debouncedSearchQuery = useDebouncedValue(searchQuery, 300); const isSearchMode = !!debouncedSearchQuery.trim(); - useEffect(() => { - setMounted(true); - }, []); - useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape" && open) { @@ -89,17 +83,6 @@ export function AllSharedChatsSidebar({ return () => document.removeEventListener("keydown", handleEscape); }, [open, onOpenChange]); - useEffect(() => { - if (open) { - document.body.style.overflow = "hidden"; - } else { - document.body.style.overflow = ""; - } - return () => { - document.body.style.overflow = ""; - }; - }, [open]); - const { data: threadsData, error: threadsError, @@ -214,33 +197,13 @@ export function AllSharedChatsSidebar({ const activeCount = activeChats.length; const archivedCount = archivedChats.length; - if (!mounted) return null; - - return createPortal( - - {open && ( - <> - onOpenChange(false)} - aria-hidden="true" - /> - - -
+ return ( + +

{t("shared_chats") || "Shared Chats"}

@@ -451,11 +414,7 @@ export function AllSharedChatsSidebar({ )}
)} -
- - - )} - , - document.body +
+ ); } diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index b6caed330..c459141d6 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -19,7 +19,6 @@ import { Search, X, } from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -59,11 +58,7 @@ import { useMediaQuery } from "@/hooks/use-media-query"; import { notificationsApiService } from "@/lib/apis/notifications-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cn } from "@/lib/utils"; -import { useSidebarContextSafe } from "../../hooks"; - -// Sidebar width constants -const SIDEBAR_COLLAPSED_WIDTH = 60; -const SIDEBAR_EXPANDED_WIDTH = 240; +import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel"; /** * Get initials from name or email for avatar fallback @@ -561,13 +556,6 @@ 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; // Shared content component for both docked and floating modes @@ -1126,49 +1114,8 @@ export function InboxSidebar({ // 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} - -
- - )} -
+ + {inboxContent} + ); } diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index 883fa5890..f5589ba1c 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -7,6 +7,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types"; +import { SIDEBAR_MIN_WIDTH } from "../../hooks/useSidebarResize"; import { ChatListItem } from "./ChatListItem"; import { NavSection } from "./NavSection"; import { PageUsageDisplay } from "./PageUsageDisplay"; @@ -51,6 +52,9 @@ interface SidebarProps { className?: string; isLoadingChats?: boolean; disableTooltips?: boolean; + sidebarWidth?: number; + onResizeMouseDown?: (e: React.MouseEvent) => void; + isResizing?: boolean; } export function Sidebar({ @@ -80,17 +84,29 @@ export function Sidebar({ className, isLoadingChats = false, disableTooltips = false, + sidebarWidth = SIDEBAR_MIN_WIDTH, + onResizeMouseDown, + isResizing = false, }: SidebarProps) { const t = useTranslations("sidebar"); return (
+ {/* Resize handle on right border */} + {!isCollapsed && onResizeMouseDown && ( +
+ )} {/* Header - search space name or collapse button when collapsed */} {isCollapsed ? (
diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarSlideOutPanel.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarSlideOutPanel.tsx new file mode 100644 index 000000000..9d74c01f1 --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/SidebarSlideOutPanel.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { AnimatePresence, motion } from "motion/react"; +import { cn } from "@/lib/utils"; +import { useMediaQuery } from "@/hooks/use-media-query"; +import { useSidebarContextSafe } from "../../hooks"; + +const SIDEBAR_COLLAPSED_WIDTH = 60; + +interface SidebarSlideOutPanelProps { + open: boolean; + onOpenChange: (open: boolean) => void; + ariaLabel: string; + width?: number; + children: React.ReactNode; +} + +/** + * Reusable slide-out panel that appears from the right edge of the sidebar. + * Used by InboxSidebar (floating mode), AllSharedChatsSidebar, and AllPrivateChatsSidebar. + * + * Must be rendered inside a positioned container (the LayoutShell's relative flex container) + * and within the SidebarProvider context. + */ +export function SidebarSlideOutPanel({ + open, + onOpenChange, + ariaLabel, + width = 360, + children, +}: SidebarSlideOutPanelProps) { + const isMobile = !useMediaQuery("(min-width: 640px)"); + const sidebarContext = useSidebarContextSafe(); + const isCollapsed = sidebarContext?.isCollapsed ?? false; + const sidebarWidth = isCollapsed + ? SIDEBAR_COLLAPSED_WIDTH + : (sidebarContext?.sidebarWidth ?? 240); + + return ( + + {open && ( + <> + {/* Click-away layer - covers the full container including the sidebar */} + onOpenChange(false)} + aria-hidden="true" + /> + + {/* Clip container - positioned at sidebar edge with overflow hidden */} +
+ + {children} + +
+ + )} +
+ ); +}