diff --git a/surfsense_backend/tests/integration/document_upload/conftest.py b/surfsense_backend/tests/integration/document_upload/conftest.py index 45cfef7ac..1f1c7df59 100644 --- a/surfsense_backend/tests/integration/document_upload/conftest.py +++ b/surfsense_backend/tests/integration/document_upload/conftest.py @@ -183,7 +183,7 @@ async def _cleanup_documents( for doc_id in cleanup_doc_ids: try: resp = await delete_document(client, headers, doc_id) - if resp.status_code == 409: + if resp.status_code != 200: remaining_ids.append(doc_id) except Exception: remaining_ids.append(doc_id) @@ -274,6 +274,15 @@ def _mock_external_apis(monkeypatch): ) +@pytest.fixture(autouse=True) +def _mock_celery_delete_task(monkeypatch): + """Mock Celery delete dispatch — no broker is available in CI.""" + monkeypatch.setattr( + "app.tasks.celery_tasks.document_tasks.delete_document_task.delay", + lambda *args, **kwargs: None, + ) + + @pytest.fixture(autouse=True) def _mock_redis_heartbeat(monkeypatch): """Mock Redis heartbeat — Redis is an external infrastructure boundary.""" diff --git a/surfsense_web/atoms/agent-tools/agent-tools.atoms.ts b/surfsense_web/atoms/agent-tools/agent-tools.atoms.ts index 3e05fc006..2c9e01fb2 100644 --- a/surfsense_web/atoms/agent-tools/agent-tools.atoms.ts +++ b/surfsense_web/atoms/agent-tools/agent-tools.atoms.ts @@ -1,6 +1,6 @@ import { atom } from "jotai"; import { atomWithQuery } from "jotai-tanstack-query"; -import { agentToolsApiService, type AgentToolInfo } from "@/lib/apis/agent-tools-api.service"; +import { type AgentToolInfo, agentToolsApiService } from "@/lib/apis/agent-tools-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms"; diff --git a/surfsense_web/atoms/chat/report-panel.atom.ts b/surfsense_web/atoms/chat/report-panel.atom.ts index 8092e623b..edae8979d 100644 --- a/surfsense_web/atoms/chat/report-panel.atom.ts +++ b/surfsense_web/atoms/chat/report-panel.atom.ts @@ -1,5 +1,4 @@ import { atom } from "jotai"; -import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms"; import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom"; interface ReportPanelState { @@ -25,11 +24,14 @@ export const reportPanelAtom = atom(initialState); /** Derived read-only atom for checking if panel is open */ export const reportPanelOpenAtom = atom((get) => get(reportPanelAtom).isOpen); +/** Snapshot of `rightPanelCollapsedAtom` taken before the report opens */ +const preReportCollapsedAtom = atom(null); + /** Action atom to open the report panel with a specific report */ export const openReportPanelAtom = atom( null, ( - _get, + get, set, { reportId, @@ -38,6 +40,9 @@ export const openReportPanelAtom = atom( shareToken, }: { reportId: number; title: string; wordCount?: number; shareToken?: string | null } ) => { + if (!get(reportPanelAtom).isOpen) { + set(preReportCollapsedAtom, get(rightPanelCollapsedAtom)); + } set(reportPanelAtom, { isOpen: true, reportId, @@ -47,12 +52,16 @@ export const openReportPanelAtom = atom( }); set(rightPanelTabAtom, "report"); set(rightPanelCollapsedAtom, false); - set(documentsSidebarOpenAtom, true); } ); /** Action atom to close the report panel */ -export const closeReportPanelAtom = atom(null, (_get, set) => { +export const closeReportPanelAtom = atom(null, (get, set) => { set(reportPanelAtom, initialState); set(rightPanelTabAtom, "sources"); + const prev = get(preReportCollapsedAtom); + if (prev !== null) { + set(rightPanelCollapsedAtom, prev); + set(preReportCollapsedAtom, null); + } }); diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/composio-drive-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/composio-drive-config.tsx index ce6845c77..6c2cc4ecb 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/composio-drive-config.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/composio-drive-config.tsx @@ -235,39 +235,39 @@ export const ComposioDriveConfig: FC = ({ )} - {isEditMode ? ( -
- + {isFolderTreeOpen && ( + )} - Change Selection - - {isFolderTreeOpen && ( - - )} -
- ) : ( - - )} + + ) : ( + + )} {/* Indexing Options */} diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts index 8cbf08bb9..7d2b3682b 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts @@ -123,11 +123,7 @@ export const useConnectorDialog = () => { }, []); const handleAutoIndex = useCallback( - async ( - connector: SearchSourceConnector, - connectorTitle: string, - connectorType: string - ) => { + async (connector: SearchSourceConnector, connectorTitle: string, connectorType: string) => { if (!searchSpaceId || isAutoIndexingRef.current) return; isAutoIndexingRef.current = true; @@ -159,12 +155,10 @@ export const useConnectorDialog = () => { }, }); - trackIndexWithDateRangeStarted( - Number(searchSpaceId), - connectorType, - connector.id, - { hasStartDate: true, hasEndDate: true } - ); + trackIndexWithDateRangeStarted(Number(searchSpaceId), connectorType, connector.id, { + hasStartDate: true, + hasEndDate: true, + }); toast.success(`${connectorTitle} connected!`, { id: toastId, @@ -317,8 +311,14 @@ export const useConnectorDialog = () => { } } } else { - setIsOpen(false); - // Clear indexing config when modal is closed + // Do NOT call setIsOpen(false) here. Closing the dialog is handled + // explicitly by handleOpenChange and the individual action handlers. + // Relying on URL params to close the dialog caused a race condition + // where Next.js router updates from tab switches briefly produced + // stale searchParams without the "modal" key, closing the popup. + + // Still clean up sub-view state when the modal param is gone + // (e.g. after browser back navigation or explicit handler URL cleanup). if (indexingConfig) { setIndexingConfig(null); setIndexingConnector(null); @@ -331,7 +331,6 @@ export const useConnectorDialog = () => { setIsScrolled(false); setSearchQuery(""); } - // Clear editing connector when modal is closed if (editingConnector) { setEditingConnector(null); setConnectorName(null); @@ -344,15 +343,12 @@ export const useConnectorDialog = () => { setIsScrolled(false); setSearchQuery(""); } - // Clear connecting connector type when modal is closed if (connectingConnectorType) { setConnectingConnectorType(null); } - // Clear viewing accounts type when modal is closed if (viewingAccountsType) { setViewingAccountsType(null); } - // Clear YouTube view when modal is closed (handled by view param check) } } catch (error) { // Invalid query params - log but don't crash @@ -412,6 +408,7 @@ export const useConnectorDialog = () => { if (earlyConnector && AUTO_INDEX_CONNECTOR_TYPES.has(earlyConnector.connectorType)) { toast.loading(`Setting up ${earlyConnector.title}...`, { id: "auto-index" }); + setIsOpen(false); const url = new URL(window.location.href); url.searchParams.delete("success"); url.searchParams.delete("connector"); @@ -795,6 +792,8 @@ export const useConnectorDialog = () => { : `${connectorTitle} connected and syncing started!`; toast.success(successMessage); + // Close dialog and clean up URL + setIsOpen(false); const url = new URL(window.location.href); url.searchParams.delete("modal"); url.searchParams.delete("tab"); @@ -860,7 +859,8 @@ export const useConnectorDialog = () => { // Refresh connectors list before closing modal await refetchAllConnectors(); - // Close modal and return to main view + // Close dialog and clean up URL + setIsOpen(false); const url = new URL(window.location.href); url.searchParams.delete("modal"); url.searchParams.delete("tab"); @@ -894,6 +894,7 @@ export const useConnectorDialog = () => { updateConnector, indexConnector, router, + setIsOpen, ] ); @@ -1124,7 +1125,8 @@ export const useConnectorDialog = () => { toast.success(`${indexingConfig.connectorTitle} indexing started`); - // Update URL - the effect will handle closing the modal and clearing state + // Close dialog and clean up URL + setIsOpen(false); const url = new URL(window.location.href); url.searchParams.delete("modal"); url.searchParams.delete("tab"); @@ -1156,12 +1158,14 @@ export const useConnectorDialog = () => { enableSummary, router, indexingConnectorConfig, + setIsOpen, ] ); // Handle skipping indexing const handleSkipIndexing = useCallback(() => { - // Update URL - the effect will handle closing the modal and clearing state + // Close dialog and clean up URL + setIsOpen(false); const url = new URL(window.location.href); url.searchParams.delete("modal"); url.searchParams.delete("tab"); @@ -1169,7 +1173,7 @@ export const useConnectorDialog = () => { url.searchParams.delete("connector"); url.searchParams.delete("view"); router.replace(url.pathname + url.search, { scroll: false }); - }, [router]); + }, [router, setIsOpen]); // Handle starting edit mode const handleStartEdit = useCallback( @@ -1411,7 +1415,8 @@ export const useConnectorDialog = () => { : indexingDescription, }); - // Update URL - the effect will handle closing the modal and clearing state + // Close dialog and clean up URL + setIsOpen(false); const url = new URL(window.location.href); url.searchParams.delete("modal"); url.searchParams.delete("tab"); @@ -1445,6 +1450,7 @@ export const useConnectorDialog = () => { router, connectorConfig, connectorName, + setIsOpen, ] ); @@ -1481,7 +1487,8 @@ export const useConnectorDialog = () => { url.searchParams.set("view", "mcp-list"); url.searchParams.delete("connectorId"); } else { - // Close modal for all other cases + // Close dialog for all other cases + setIsOpen(false); url.searchParams.delete("modal"); url.searchParams.delete("tab"); url.searchParams.delete("view"); @@ -1500,7 +1507,7 @@ export const useConnectorDialog = () => { setIsDisconnecting(false); } }, - [editingConnector, searchSpaceId, deleteConnector, router, cameFromMCPList] + [editingConnector, searchSpaceId, deleteConnector, router, cameFromMCPList, setIsOpen] ); // Handle quick index (index with selected date range, or backend defaults if none selected) diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index d25b5fe9e..38426a47b 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -28,6 +28,13 @@ import { import { useParams } from "next/navigation"; import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; +import { + agentToolsAtom, + disabledToolsAtom, + enabledToolCountAtom, + hydrateDisabledToolsAtom, + toggleToolAtom, +} from "@/atoms/agent-tools/agent-tools.atoms"; import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom"; import { mentionedDocumentsAtom, @@ -66,20 +73,14 @@ import { import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Switch } from "@/components/ui/switch"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { Document } from "@/contracts/types/document.types"; import { useBatchCommentsPreload } from "@/hooks/use-comments"; import { useCommentsElectric } from "@/hooks/use-comments-electric"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Switch } from "@/components/ui/switch"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { - agentToolsAtom, - disabledToolsAtom, - enabledToolCountAtom, - hydrateDisabledToolsAtom, - toggleToolAtom, -} from "@/atoms/agent-tools/agent-tools.atoms"; +import { useMediaQuery } from "@/hooks/use-media-query"; import { cn } from "@/lib/utils"; /** Placeholder texts that cycle in new chats when input is empty */ @@ -562,7 +563,16 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false const mentionedDocuments = useAtomValue(mentionedDocumentsAtom); const sidebarDocs = useAtomValue(sidebarSelectedDocumentsAtom); const setDocumentsSidebarOpen = useSetAtom(documentsSidebarOpenAtom); + const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom); const [toolsPopoverOpen, setToolsPopoverOpen] = useState(false); + const isDesktop = useMediaQuery("(min-width: 640px)"); + const [toolsScrollPos, setToolsScrollPos] = useState<"top" | "middle" | "bottom">("top"); + const handleToolsScroll = useCallback((e: React.UIEvent) => { + const el = e.currentTarget; + const atTop = el.scrollTop <= 2; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2; + setToolsScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle"); + }, []); const isComposerTextEmpty = useAssistantState(({ composer }) => { const text = composer.text?.trim() || ""; return text.length === 0; @@ -614,32 +624,46 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false e.preventDefault()} > -
- Agent Tools - +
+ Agent Tools + {enabledCount}/{agentTools?.length ?? 0} enabled
-
+
{agentTools?.map((tool) => { const isDisabled = disabledTools.includes(tool.name); + const row = ( + + ); + if (!isDesktop) { + return
{row}
; + } return ( - - - + {row} {tool.description} @@ -654,6 +678,19 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false
+ {!isDesktop && ( + setConnectorDialogOpen(true)} + > + + + )} {sidebarDocs.length > 0 && ( @@ -118,7 +123,6 @@ export function ChatListItem({ )} )} - {onArchive && onDelete && } {onDelete && ( { diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index 65a1317f5..aaaae61f3 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -233,34 +233,69 @@ export function DocumentsSidebar({
{/* Connected tools strip */} -
+
diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index 4e95c381f..20a3b5b7d 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -2,6 +2,7 @@ import { FolderOpen, PenSquare } from "lucide-react"; import { useTranslations } from "next-intl"; +import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -89,6 +90,7 @@ export function Sidebar({ isResizing = false, }: SidebarProps) { const t = useTranslations("sidebar"); + const [openDropdownChatId, setOpenDropdownChatId] = useState(null); return (
@@ -215,6 +223,8 @@ export function Sidebar({ name={chat.name} isActive={chat.id === activeChatId} archived={chat.archived} + dropdownOpen={openDropdownChatId === chat.id} + onDropdownOpenChange={(open) => setOpenDropdownChatId(open ? chat.id : null)} onClick={() => onChatSelect(chat)} onRename={() => onChatRename?.(chat)} onArchive={() => onChatArchive?.(chat)} @@ -287,6 +297,8 @@ export function Sidebar({ name={chat.name} isActive={chat.id === activeChatId} archived={chat.archived} + dropdownOpen={openDropdownChatId === chat.id} + onDropdownOpenChange={(open) => setOpenDropdownChatId(open ? chat.id : null)} onClick={() => onChatSelect(chat)} onRename={() => onChatRename?.(chat)} onArchive={() => onChatArchive?.(chat)} diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx index 0c7181ee0..02541eab6 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx @@ -9,8 +9,8 @@ import { Laptop, LogOut, Moon, - Settings, Sun, + UserCog, } from "lucide-react"; import Image from "next/image"; import { useTranslations } from "next-intl"; @@ -206,7 +206,7 @@ export function SidebarUserProfile({ - + {t("user_settings")} @@ -351,7 +351,7 @@ export function SidebarUserProfile({ - + {t("user_settings")} diff --git a/surfsense_web/hooks/use-google-picker.ts b/surfsense_web/hooks/use-google-picker.ts index fa2a159b9..3b125827a 100644 --- a/surfsense_web/hooks/use-google-picker.ts +++ b/surfsense_web/hooks/use-google-picker.ts @@ -154,17 +154,17 @@ export function useGooglePicker({ connectorId, onPicked }: UseGooglePickerOption } } - if (action === google.picker.Action.ERROR) { - setError("Google Drive encountered an error. Please try again."); - } + if (action === google.picker.Action.ERROR) { + setError("Google Drive encountered an error. Please try again."); + } - if ( - action === google.picker.Action.PICKED || - action === google.picker.Action.CANCEL || - action === google.picker.Action.ERROR - ) { - closePicker(); - } + if ( + action === google.picker.Action.PICKED || + action === google.picker.Action.CANCEL || + action === google.picker.Action.ERROR + ) { + closePicker(); + } }) .build();