diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 439e50f79..2a0428209 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -32,7 +32,7 @@ import { closeReportPanelAtom } from "@/atoms/chat/report-panel.atom"; import { membersAtom } from "@/atoms/members/members-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { Thread } from "@/components/assistant-ui/thread"; -import { ReportPanel } from "@/components/report-panel/report-panel"; +import { MobileReportPanel } from "@/components/report-panel/report-panel"; import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; import { DisplayImageToolUI } from "@/components/tool-ui/display-image"; import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; @@ -1668,7 +1668,7 @@ export default function NewChatPage() {
- + ); diff --git a/surfsense_web/atoms/chat/report-panel.atom.ts b/surfsense_web/atoms/chat/report-panel.atom.ts index 89eaf0229..8092e623b 100644 --- a/surfsense_web/atoms/chat/report-panel.atom.ts +++ b/surfsense_web/atoms/chat/report-panel.atom.ts @@ -1,5 +1,6 @@ import { atom } from "jotai"; import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms"; +import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom"; interface ReportPanelState { isOpen: boolean; @@ -44,15 +45,14 @@ export const openReportPanelAtom = atom( wordCount: wordCount ?? null, shareToken: shareToken ?? null, }); - set(documentsSidebarOpenAtom, false); + set(rightPanelTabAtom, "report"); + set(rightPanelCollapsedAtom, false); + set(documentsSidebarOpenAtom, true); } ); /** Action atom to close the report panel */ -export const closeReportPanelAtom = atom(null, (get, set) => { - const wasOpen = get(reportPanelAtom).isOpen; +export const closeReportPanelAtom = atom(null, (_get, set) => { set(reportPanelAtom, initialState); - if (wasOpen) { - set(documentsSidebarOpenAtom, true); - } + set(rightPanelTabAtom, "sources"); }); diff --git a/surfsense_web/atoms/layout/right-panel.atom.ts b/surfsense_web/atoms/layout/right-panel.atom.ts new file mode 100644 index 000000000..69335e026 --- /dev/null +++ b/surfsense_web/atoms/layout/right-panel.atom.ts @@ -0,0 +1,8 @@ +import { atom } from "jotai"; + +export type RightPanelTab = "sources" | "report"; + +export const rightPanelTabAtom = atom("sources"); + +/** Whether the right panel is collapsed (hidden but state preserved) */ +export const rightPanelCollapsedAtom = atom(false); diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index fa767fbab..51a9b9275 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -22,7 +22,6 @@ import { PlusIcon, RefreshCwIcon, SquareIcon, - SquareLibrary, Unplug, Upload, X, @@ -37,7 +36,6 @@ import { } from "@/atoms/chat/mentioned-documents.atom"; import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms"; import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms"; -import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms"; import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms"; import { membersAtom } from "@/atoms/members/members-query.atoms"; import { @@ -77,7 +75,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; @@ -570,12 +567,6 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false const { openDialog: openUploadDialog } = useDocumentUploadDialog(); const { data: connectors } = useAtomValue(connectorsAtom); const connectorCount = connectors?.length ?? 0; - const { data: typeCounts } = useAtomValue(documentTypeCountsAtom); - const totalDocuments = useMemo( - () => (typeCounts ? Object.values(typeCounts).reduce((sum, n) => sum + n, 0) : 0), - [typeCounts] - ); - const isComposerTextEmpty = useAssistantState(({ composer }) => { const text = composer.text?.trim() || ""; return text.length === 0; @@ -631,18 +622,6 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false Upload files - {totalDocuments > 0 && ( - { - setAddMenuOpen(false); - setDocumentsSidebarOpen(true); - }} - > - - Manage Documents - - )} - { setAddMenuOpen(false); @@ -650,7 +629,7 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false }} > - {connectorCount > 0 ? "Manage connectors" : "Connect your tools"} + {connectorCount > 0 ? "Manage tools" : "Connect your tools"} {connectorCount > 0 && ( {connectorCount} )} diff --git a/surfsense_web/components/layout/ui/header/Header.tsx b/surfsense_web/components/layout/ui/header/Header.tsx index 166bf6ed0..5e5201883 100644 --- a/surfsense_web/components/layout/ui/header/Header.tsx +++ b/surfsense_web/components/layout/ui/header/Header.tsx @@ -1,11 +1,18 @@ "use client"; -import { useAtomValue } from "jotai"; +import { useAtom, useAtomValue } from "jotai"; +import { PanelRight } from "lucide-react"; import { usePathname } from "next/navigation"; import { currentThreadAtom } from "@/atoms/chat/current-thread.atom"; +import { reportPanelAtom } from "@/atoms/chat/report-panel.atom"; +import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms"; +import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { ChatHeader } from "@/components/new-chat/chat-header"; import { ChatShareButton } from "@/components/new-chat/chat-share-button"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useIsMobile } from "@/hooks/use-mobile"; import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence"; interface HeaderProps { @@ -15,6 +22,7 @@ interface HeaderProps { export function Header({ mobileMenuTrigger }: HeaderProps) { const pathname = usePathname(); const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); + const isMobile = useIsMobile(); const isChatPage = pathname?.includes("/new-chat") ?? false; @@ -38,6 +46,13 @@ export function Header({ mobileMenuTrigger }: HeaderProps) { const handleVisibilityChange = (_visibility: ChatVisibility) => {}; + const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom); + const documentsOpen = useAtomValue(documentsSidebarOpenAtom); + const reportState = useAtomValue(reportPanelAtom); + const reportOpen = reportState.isOpen && !!reportState.reportId; + const hasRightPanelContent = documentsOpen || reportOpen; + const showExpandButton = !isMobile && collapsed && hasRightPanelContent; + return (
{/* Left side - Mobile menu trigger + Model selector */} @@ -49,10 +64,26 @@ export function Header({ mobileMenuTrigger }: HeaderProps) { {/* Right side - Actions */} -
+
{hasThread && ( )} + {showExpandButton && ( + + + + + Expand panel + + )}
); diff --git a/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx b/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx new file mode 100644 index 000000000..98f14b247 --- /dev/null +++ b/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { PanelRight, PanelRightClose } from "lucide-react"; +import { useEffect } from "react"; +import { closeReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom"; +import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms"; +import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom"; +import { ReportPanelContent } from "@/components/report-panel/report-panel"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { DocumentsSidebar } from "../sidebar"; + +interface RightPanelProps { + documentsPanel?: { + open: boolean; + onOpenChange: (open: boolean) => void; + }; +} + +function CollapseButton({ onClick }: { onClick: () => void }) { + return ( + + + + + Collapse panel + + ); +} + +/** + * Absolutely positioned expand button — renders at top-right of the main + * container so it occupies the same screen position as the collapse button + * inside the Documents header. + */ +export function RightPanelExpandButton() { + const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom); + const documentsOpen = useAtomValue(documentsSidebarOpenAtom); + const reportState = useAtomValue(reportPanelAtom); + const reportOpen = reportState.isOpen && !!reportState.reportId; + const hasContent = documentsOpen || reportOpen; + + if (!collapsed || !hasContent) return null; + + return ( +
+ + + + + Expand panel + +
+ ); +} + +export function RightPanel({ documentsPanel }: RightPanelProps) { + const [activeTab] = useAtom(rightPanelTabAtom); + const reportState = useAtomValue(reportPanelAtom); + const closeReport = useSetAtom(closeReportPanelAtom); + const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom); + + const documentsOpen = documentsPanel?.open ?? false; + const reportOpen = reportState.isOpen && !!reportState.reportId; + + // Close report on Escape key (works on Windows, macOS, and Linux) + // Must be called before early returns to satisfy Rules of Hooks. + useEffect(() => { + if (!reportOpen) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + closeReport(); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [reportOpen, closeReport]); + + if (!documentsOpen && !reportOpen) return null; + if (collapsed) return null; + + const effectiveTab = + activeTab === "report" && !reportOpen + ? "sources" + : activeTab === "sources" && !documentsOpen + ? "report" + : activeTab; + + const collapseButton = setCollapsed(true)} />; + + const panelWidth = effectiveTab === "sources" ? "w-[420px]" : "w-[640px]"; + + return ( + + ); +} diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index 5bfbcfee0..8ac897fd4 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -10,6 +10,7 @@ import { useSidebarResize } from "../../hooks/useSidebarResize"; import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types"; import { Header } from "../header"; import { IconRail } from "../icon-rail"; +import { RightPanel } from "../right-panel/RightPanel"; import { AllPrivateChatsSidebar, AllSharedChatsSidebar, @@ -275,46 +276,36 @@ export function LayoutShell({ {/* Main container with sidebar and content - relative for inbox positioning */}
- - - {/* Docked Documents Sidebar - renders as flex sibling between sidebar and content */} - {documentsPanel?.isDocked && ( - - )} +
@@ -324,6 +315,16 @@ export function LayoutShell({
+ {/* Right panel — tabbed Sources/Report (desktop only) */} + {documentsPanel && ( + + )} + {/* Inbox Sidebar - slide-out panel */} {inbox && ( )} - {/* Documents Sidebar - floating slide-out panel (non-docked mode) */} - {documentsPanel && !documentsPanel.isDocked && ( - - )} - {/* Announcements Sidebar */} {announcementsPanel && ( void; isDocked?: boolean; onDockedChange?: (docked: boolean) => void; + /** When true, renders content without any wrapper — parent provides the container */ + embedded?: boolean; + /** Optional action element rendered in the header row (e.g. collapse button) */ + headerAction?: React.ReactNode; } export function DocumentsSidebar({ @@ -45,6 +53,8 @@ export function DocumentsSidebar({ onOpenChange, isDocked = false, onDockedChange, + embedded = false, + headerAction, }: DocumentsSidebarProps) { const t = useTranslations("documents"); const tSidebar = useTranslations("sidebar"); @@ -168,8 +178,8 @@ export function DocumentsSidebar({ const documentsContent = ( <> -
-
+
+
{isMobile && (
{/* Connected tools strip */} -
+