From 70c3f0926137866beb6825d1b43f200624f51957 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:37:50 +0530 Subject: [PATCH 1/6] fix: Refactor connector dialog state management and improve modal handling --- .../hooks/use-connector-dialog.ts | 311 +++++++++--------- .../layout/ui/sidebar/SidebarUserProfile.tsx | 6 +- 2 files changed, 165 insertions(+), 152 deletions(-) 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..731a55c3f 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 @@ -316,44 +316,46 @@ export const useConnectorDialog = () => { } } } - } else { - setIsOpen(false); - // Clear indexing config when modal is closed - if (indexingConfig) { - setIndexingConfig(null); - setIndexingConnector(null); - setIndexingConnectorConfig(null); - setStartDate(undefined); - setEndDate(undefined); - setPeriodicEnabled(false); - setFrequencyMinutes("1440"); - setEnableSummary(false); - setIsScrolled(false); - setSearchQuery(""); - } - // Clear editing connector when modal is closed - if (editingConnector) { - setEditingConnector(null); - setConnectorName(null); - setConnectorConfig(null); - setStartDate(undefined); - setEndDate(undefined); - setPeriodicEnabled(false); - setFrequencyMinutes("1440"); - setEnableSummary(false); - 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) + } else { + // 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); + setIndexingConnectorConfig(null); + setStartDate(undefined); + setEndDate(undefined); + setPeriodicEnabled(false); + setFrequencyMinutes("1440"); + setEnableSummary(false); + setIsScrolled(false); + setSearchQuery(""); } + if (editingConnector) { + setEditingConnector(null); + setConnectorName(null); + setConnectorConfig(null); + setStartDate(undefined); + setEndDate(undefined); + setPeriodicEnabled(false); + setFrequencyMinutes("1440"); + setEnableSummary(false); + setIsScrolled(false); + setSearchQuery(""); + } + if (connectingConnectorType) { + setConnectingConnectorType(null); + } + if (viewingAccountsType) { + setViewingAccountsType(null); + } + } } catch (error) { // Invalid query params - log but don't crash console.warn("Invalid connector popup query params:", error); @@ -410,17 +412,18 @@ export const useConnectorDialog = () => { COMPOSIO_CONNECTORS.find((c) => c.id === params.connector) : null; - if (earlyConnector && AUTO_INDEX_CONNECTOR_TYPES.has(earlyConnector.connectorType)) { - toast.loading(`Setting up ${earlyConnector.title}...`, { id: "auto-index" }); - const url = new URL(window.location.href); - url.searchParams.delete("success"); - url.searchParams.delete("connector"); - url.searchParams.delete("connectorId"); - url.searchParams.delete("view"); - url.searchParams.delete("modal"); - url.searchParams.delete("tab"); - router.replace(url.pathname + url.search, { scroll: false }); - } + 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"); + url.searchParams.delete("connectorId"); + url.searchParams.delete("view"); + url.searchParams.delete("modal"); + url.searchParams.delete("tab"); + router.replace(url.pathname + url.search, { scroll: false }); + } refetchAllConnectors().then(async (result) => { if (!result.data) { @@ -789,20 +792,22 @@ export const useConnectorDialog = () => { }, }); - const successMessage = - currentConnectorType === "MCP_CONNECTOR" - ? `${connector.name} added successfully` - : `${connectorTitle} connected and syncing started!`; - toast.success(successMessage); + const successMessage = + currentConnectorType === "MCP_CONNECTOR" + ? `${connector.name} added successfully` + : `${connectorTitle} connected and syncing started!`; + toast.success(successMessage); - const url = new URL(window.location.href); - url.searchParams.delete("modal"); - url.searchParams.delete("tab"); - url.searchParams.delete("view"); - url.searchParams.delete("connectorType"); - router.replace(url.pathname + url.search, { scroll: false }); + // Close dialog and clean up URL + setIsOpen(false); + const url = new URL(window.location.href); + url.searchParams.delete("modal"); + url.searchParams.delete("tab"); + url.searchParams.delete("view"); + url.searchParams.delete("connectorType"); + router.replace(url.pathname + url.search, { scroll: false }); - // Clear indexing config state since we're not showing the view + // Clear indexing config state since we're not showing the view setIndexingConfig(null); setIndexingConnector(null); setIndexingConnectorConfig(null); @@ -850,23 +855,24 @@ export const useConnectorDialog = () => { // Refresh connectors list await refetchAllConnectors(); } else { - // Other non-indexable connectors - just show success message and close - const successMessage = - currentConnectorType === "MCP_CONNECTOR" - ? `${connector.name} added successfully` - : `${connectorTitle} connected successfully!`; - toast.success(successMessage); + // Other non-indexable connectors - just show success message and close + const successMessage = + currentConnectorType === "MCP_CONNECTOR" + ? `${connector.name} added successfully` + : `${connectorTitle} connected successfully!`; + toast.success(successMessage); - // Refresh connectors list before closing modal - await refetchAllConnectors(); + // Refresh connectors list before closing modal + await refetchAllConnectors(); - // Close modal and return to main view - const url = new URL(window.location.href); - url.searchParams.delete("modal"); - url.searchParams.delete("tab"); - url.searchParams.delete("view"); - url.searchParams.delete("connectorType"); - router.replace(url.pathname + url.search, { scroll: false }); + // Close dialog and clean up URL + setIsOpen(false); + const url = new URL(window.location.href); + url.searchParams.delete("modal"); + url.searchParams.delete("tab"); + url.searchParams.delete("view"); + url.searchParams.delete("connectorType"); + router.replace(url.pathname + url.search, { scroll: false }); // Clear indexing config state setIndexingConfig(null); @@ -894,6 +900,7 @@ export const useConnectorDialog = () => { updateConnector, indexConnector, router, + setIsOpen, ] ); @@ -1122,18 +1129,19 @@ export const useConnectorDialog = () => { ); } - toast.success(`${indexingConfig.connectorTitle} indexing started`); + toast.success(`${indexingConfig.connectorTitle} indexing started`); - // Update URL - the effect will handle closing the modal and clearing state - const url = new URL(window.location.href); - url.searchParams.delete("modal"); - url.searchParams.delete("tab"); - url.searchParams.delete("success"); - url.searchParams.delete("connector"); - url.searchParams.delete("view"); - router.replace(url.pathname + url.search, { scroll: false }); + // Close dialog and clean up URL + setIsOpen(false); + const url = new URL(window.location.href); + url.searchParams.delete("modal"); + url.searchParams.delete("tab"); + url.searchParams.delete("success"); + url.searchParams.delete("connector"); + url.searchParams.delete("view"); + router.replace(url.pathname + url.search, { scroll: false }); - refreshConnectors(); + refreshConnectors(); queryClient.invalidateQueries({ queryKey: cacheKeys.logs.summary(Number(searchSpaceId)), }); @@ -1156,12 +1164,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 +1179,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,41 +1421,43 @@ export const useConnectorDialog = () => { : indexingDescription, }); - // Update URL - the effect will handle closing the modal and clearing state - const url = new URL(window.location.href); - url.searchParams.delete("modal"); - url.searchParams.delete("tab"); - url.searchParams.delete("view"); - url.searchParams.delete("connectorId"); - router.replace(url.pathname + url.search, { scroll: false }); + // Close dialog and clean up URL + setIsOpen(false); + const url = new URL(window.location.href); + url.searchParams.delete("modal"); + url.searchParams.delete("tab"); + url.searchParams.delete("view"); + url.searchParams.delete("connectorId"); + router.replace(url.pathname + url.search, { scroll: false }); - refreshConnectors(); - queryClient.invalidateQueries({ - queryKey: cacheKeys.logs.summary(Number(searchSpaceId)), - }); - } catch (error) { - console.error("Error saving connector:", error); - toast.error("Failed to save connector changes"); - } finally { - setIsSaving(false); - } - }, - [ - editingConnector, - searchSpaceId, - isSaving, - startDate, - endDate, - indexConnector, - updateConnector, - periodicEnabled, - frequencyMinutes, - enableSummary, - getFrequencyLabel, - router, - connectorConfig, - connectorName, - ] + refreshConnectors(); + queryClient.invalidateQueries({ + queryKey: cacheKeys.logs.summary(Number(searchSpaceId)), + }); + } catch (error) { + console.error("Error saving connector:", error); + toast.error("Failed to save connector changes"); + } finally { + setIsSaving(false); + } + }, + [ + editingConnector, + searchSpaceId, + isSaving, + startDate, + endDate, + indexConnector, + updateConnector, + periodicEnabled, + frequencyMinutes, + enableSummary, + getFrequencyLabel, + router, + connectorConfig, + connectorName, + setIsOpen, + ] ); // Handle disconnecting connector @@ -1472,35 +1484,36 @@ export const useConnectorDialog = () => { : `${editingConnector.name} disconnected successfully` ); - // Update URL - for MCP from list view, go back to list; otherwise close modal - const url = new URL(window.location.href); - if (editingConnector.connector_type === "MCP_CONNECTOR" && cameFromMCPList) { - // Go back to MCP list view only if we came from there - setViewingMCPList(true); - url.searchParams.set("modal", "connectors"); - url.searchParams.set("view", "mcp-list"); - url.searchParams.delete("connectorId"); - } else { - // Close modal for all other cases - url.searchParams.delete("modal"); - url.searchParams.delete("tab"); - url.searchParams.delete("view"); - url.searchParams.delete("connectorId"); - } - router.replace(url.pathname + url.search, { scroll: false }); - - refreshConnectors(); - queryClient.invalidateQueries({ - queryKey: cacheKeys.logs.summary(Number(searchSpaceId)), - }); - } catch (error) { - console.error("Error disconnecting connector:", error); - toast.error("Failed to disconnect connector"); - } finally { - setIsDisconnecting(false); + // Update URL - for MCP from list view, go back to list; otherwise close modal + const url = new URL(window.location.href); + if (editingConnector.connector_type === "MCP_CONNECTOR" && cameFromMCPList) { + // Go back to MCP list view only if we came from there + setViewingMCPList(true); + url.searchParams.set("modal", "connectors"); + url.searchParams.set("view", "mcp-list"); + url.searchParams.delete("connectorId"); + } else { + // Close dialog for all other cases + setIsOpen(false); + url.searchParams.delete("modal"); + url.searchParams.delete("tab"); + url.searchParams.delete("view"); + url.searchParams.delete("connectorId"); } - }, - [editingConnector, searchSpaceId, deleteConnector, router, cameFromMCPList] + router.replace(url.pathname + url.search, { scroll: false }); + + refreshConnectors(); + queryClient.invalidateQueries({ + queryKey: cacheKeys.logs.summary(Number(searchSpaceId)), + }); + } catch (error) { + console.error("Error disconnecting connector:", error); + toast.error("Failed to disconnect connector"); + } finally { + setIsDisconnecting(false); + } + }, + [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/layout/ui/sidebar/SidebarUserProfile.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx index 0c7181ee0..88bad2a27 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx @@ -9,7 +9,7 @@ import { Laptop, LogOut, Moon, - Settings, + UserCog, Sun, } from "lucide-react"; import Image from "next/image"; @@ -206,7 +206,7 @@ export function SidebarUserProfile({ - + {t("user_settings")} @@ -351,7 +351,7 @@ export function SidebarUserProfile({ - + {t("user_settings")} From 4a576f73471710104f0eb77761ffb5e76de845c1 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:04:22 +0530 Subject: [PATCH 2/6] feat: Enhance ComposerAction and DocumentsSidebar with improved UI and functionality --- .../components/assistant-ui/thread.tsx | 73 +++++++++++++----- .../layout/ui/sidebar/DocumentsSidebar.tsx | 74 +++++++++++++------ 2 files changed, 106 insertions(+), 41 deletions(-) diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index d25b5fe9e..93b296000 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -73,6 +73,7 @@ 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 { useMediaQuery } from "@/hooks/use-media-query"; import { agentToolsAtom, disabledToolsAtom, @@ -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; @@ -613,32 +623,46 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false - -
- Agent Tools - + e.preventDefault()} + > +
+ 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 && (
From f73c1d83a8fc792261ce8645b3eee49c24e3c5ea Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:25:04 +0530 Subject: [PATCH 3/6] feat: Enhance report panel state management and dropdown functionality in sidebar --- surfsense_web/atoms/chat/report-panel.atom.ts | 17 ++++-- .../components/assistant-ui/thread.tsx | 2 +- .../layout/ui/sidebar/ChatListItem.tsx | 20 ++++--- .../components/layout/ui/sidebar/Sidebar.tsx | 60 +++++++++++-------- 4 files changed, 62 insertions(+), 37 deletions(-) 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/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 93b296000..b77edb6b7 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -627,7 +627,7 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false side="bottom" align="start" sideOffset={12} - className="w-[calc(100vw-2rem)] max-w-56 sm:max-w-72 sm:w-72 p-0" + className="w-[calc(100vw-2rem)] max-w-56 sm:max-w-72 sm:w-72 p-0 select-none" onOpenAutoFocus={(e) => e.preventDefault()} >
diff --git a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx index 902c11b44..fecb85175 100644 --- a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx +++ b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx @@ -8,7 +8,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useLongPress } from "@/hooks/use-long-press"; @@ -20,6 +19,8 @@ interface ChatListItemProps { name: string; isActive?: boolean; archived?: boolean; + dropdownOpen?: boolean; + onDropdownOpenChange?: (open: boolean) => void; onClick?: () => void; onRename?: () => void; onArchive?: () => void; @@ -30,6 +31,8 @@ export function ChatListItem({ name, isActive, archived, + dropdownOpen: controlledOpen, + onDropdownOpenChange, onClick, onRename, onArchive, @@ -37,11 +40,13 @@ export function ChatListItem({ }: ChatListItemProps) { const t = useTranslations("sidebar"); const isMobile = useIsMobile(); - const [dropdownOpen, setDropdownOpen] = useState(false); + const [internalOpen, setInternalOpen] = useState(false); + const dropdownOpen = controlledOpen ?? internalOpen; + const setDropdownOpen = onDropdownOpenChange ?? setInternalOpen; const animatedName = useTypewriter(name); const { handlers: longPressHandlers, wasLongPress } = useLongPress( - useCallback(() => setDropdownOpen(true), []) + useCallback(() => setDropdownOpen(true), [setDropdownOpen]) ); const handleClick = useCallback(() => { @@ -68,12 +73,12 @@ export function ChatListItem({ {/* Actions dropdown - trigger hidden on mobile, long-press opens it instead */}
- @@ -118,8 +123,7 @@ export function ChatListItem({ )} )} - {onArchive && onDelete && } - {onDelete && ( + {onDelete && ( { e.stopPropagation(); diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index 4e95c381f..d13da7e47 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 (
@@ -209,18 +217,20 @@ export function Sidebar({
4 ? "pb-8" : ""}`} > - {sharedChats.slice(0, 20).map((chat) => ( - onChatSelect(chat)} - onRename={() => onChatRename?.(chat)} - onArchive={() => onChatArchive?.(chat)} - onDelete={() => onChatDelete?.(chat)} - /> - ))} + {sharedChats.slice(0, 20).map((chat) => ( + setOpenDropdownChatId(open ? chat.id : null)} + onClick={() => onChatSelect(chat)} + onRename={() => onChatRename?.(chat)} + onArchive={() => onChatArchive?.(chat)} + onDelete={() => onChatDelete?.(chat)} + /> + ))}
{/* Gradient fade indicator when more than 4 items */} {sharedChats.length > 4 && ( @@ -281,18 +291,20 @@ export function Sidebar({
4 ? "pb-8" : ""}`} > - {chats.slice(0, 20).map((chat) => ( - onChatSelect(chat)} - onRename={() => onChatRename?.(chat)} - onArchive={() => onChatArchive?.(chat)} - onDelete={() => onChatDelete?.(chat)} - /> - ))} + {chats.slice(0, 20).map((chat) => ( + setOpenDropdownChatId(open ? chat.id : null)} + onClick={() => onChatSelect(chat)} + onRename={() => onChatRename?.(chat)} + onArchive={() => onChatArchive?.(chat)} + onDelete={() => onChatDelete?.(chat)} + /> + ))}
{/* Gradient fade indicator when more than 4 items */} {chats.length > 4 && ( From 851856a54bae2f5b9669468a033af6d4f713aea3 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:27:32 +0530 Subject: [PATCH 4/6] fix: update document cleanup logic and mock Celery task in tests --- .../tests/integration/document_upload/conftest.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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.""" From 1173f6ba19b1d4d3583f5a5be2a8cc269a0e82fc Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:29:54 +0530 Subject: [PATCH 5/6] fix: adjust layout behavior during resizing in LayoutShell component --- surfsense_web/components/layout/ui/shell/LayoutShell.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index eae6fcad8..ab2945f61 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -309,7 +309,7 @@ export function LayoutShell({ /> From 9503d4ade1ce71fa7e0075121897b2a12b962988 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:30:20 +0530 Subject: [PATCH 6/6] chore: ran linting --- .../atoms/agent-tools/agent-tools.atoms.ts | 2 +- .../components/composio-drive-config.tsx | 64 ++-- .../hooks/use-connector-dialog.ts | 328 +++++++++--------- .../components/assistant-ui/thread.tsx | 42 +-- .../layout/ui/sidebar/ChatListItem.tsx | 2 +- .../layout/ui/sidebar/DocumentsSidebar.tsx | 117 ++++--- .../components/layout/ui/sidebar/Sidebar.tsx | 56 +-- .../layout/ui/sidebar/SidebarUserProfile.tsx | 2 +- surfsense_web/hooks/use-google-picker.ts | 20 +- 9 files changed, 317 insertions(+), 316 deletions(-) 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/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 731a55c3f..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, @@ -316,46 +310,46 @@ export const useConnectorDialog = () => { } } } - } else { - // 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. + } else { + // 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); - setIndexingConnectorConfig(null); - setStartDate(undefined); - setEndDate(undefined); - setPeriodicEnabled(false); - setFrequencyMinutes("1440"); - setEnableSummary(false); - setIsScrolled(false); - setSearchQuery(""); + // 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); + setIndexingConnectorConfig(null); + setStartDate(undefined); + setEndDate(undefined); + setPeriodicEnabled(false); + setFrequencyMinutes("1440"); + setEnableSummary(false); + setIsScrolled(false); + setSearchQuery(""); + } + if (editingConnector) { + setEditingConnector(null); + setConnectorName(null); + setConnectorConfig(null); + setStartDate(undefined); + setEndDate(undefined); + setPeriodicEnabled(false); + setFrequencyMinutes("1440"); + setEnableSummary(false); + setIsScrolled(false); + setSearchQuery(""); + } + if (connectingConnectorType) { + setConnectingConnectorType(null); + } + if (viewingAccountsType) { + setViewingAccountsType(null); + } } - if (editingConnector) { - setEditingConnector(null); - setConnectorName(null); - setConnectorConfig(null); - setStartDate(undefined); - setEndDate(undefined); - setPeriodicEnabled(false); - setFrequencyMinutes("1440"); - setEnableSummary(false); - setIsScrolled(false); - setSearchQuery(""); - } - if (connectingConnectorType) { - setConnectingConnectorType(null); - } - if (viewingAccountsType) { - setViewingAccountsType(null); - } - } } catch (error) { // Invalid query params - log but don't crash console.warn("Invalid connector popup query params:", error); @@ -412,18 +406,18 @@ export const useConnectorDialog = () => { COMPOSIO_CONNECTORS.find((c) => c.id === params.connector) : null; - 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"); - url.searchParams.delete("connectorId"); - url.searchParams.delete("view"); - url.searchParams.delete("modal"); - url.searchParams.delete("tab"); - router.replace(url.pathname + url.search, { scroll: false }); - } + 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"); + url.searchParams.delete("connectorId"); + url.searchParams.delete("view"); + url.searchParams.delete("modal"); + url.searchParams.delete("tab"); + router.replace(url.pathname + url.search, { scroll: false }); + } refetchAllConnectors().then(async (result) => { if (!result.data) { @@ -792,22 +786,22 @@ export const useConnectorDialog = () => { }, }); - const successMessage = - currentConnectorType === "MCP_CONNECTOR" - ? `${connector.name} added successfully` - : `${connectorTitle} connected and syncing started!`; - toast.success(successMessage); + const successMessage = + currentConnectorType === "MCP_CONNECTOR" + ? `${connector.name} added successfully` + : `${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"); - url.searchParams.delete("view"); - url.searchParams.delete("connectorType"); - router.replace(url.pathname + url.search, { scroll: false }); + // Close dialog and clean up URL + setIsOpen(false); + const url = new URL(window.location.href); + url.searchParams.delete("modal"); + url.searchParams.delete("tab"); + url.searchParams.delete("view"); + url.searchParams.delete("connectorType"); + router.replace(url.pathname + url.search, { scroll: false }); - // Clear indexing config state since we're not showing the view + // Clear indexing config state since we're not showing the view setIndexingConfig(null); setIndexingConnector(null); setIndexingConnectorConfig(null); @@ -855,24 +849,24 @@ export const useConnectorDialog = () => { // Refresh connectors list await refetchAllConnectors(); } else { - // Other non-indexable connectors - just show success message and close - const successMessage = - currentConnectorType === "MCP_CONNECTOR" - ? `${connector.name} added successfully` - : `${connectorTitle} connected successfully!`; - toast.success(successMessage); + // Other non-indexable connectors - just show success message and close + const successMessage = + currentConnectorType === "MCP_CONNECTOR" + ? `${connector.name} added successfully` + : `${connectorTitle} connected successfully!`; + toast.success(successMessage); - // Refresh connectors list before closing modal - await refetchAllConnectors(); + // Refresh connectors list before closing modal + await refetchAllConnectors(); - // Close dialog and clean up URL - setIsOpen(false); - const url = new URL(window.location.href); - url.searchParams.delete("modal"); - url.searchParams.delete("tab"); - url.searchParams.delete("view"); - url.searchParams.delete("connectorType"); - router.replace(url.pathname + url.search, { scroll: false }); + // Close dialog and clean up URL + setIsOpen(false); + const url = new URL(window.location.href); + url.searchParams.delete("modal"); + url.searchParams.delete("tab"); + url.searchParams.delete("view"); + url.searchParams.delete("connectorType"); + router.replace(url.pathname + url.search, { scroll: false }); // Clear indexing config state setIndexingConfig(null); @@ -1129,19 +1123,19 @@ export const useConnectorDialog = () => { ); } - toast.success(`${indexingConfig.connectorTitle} indexing started`); + toast.success(`${indexingConfig.connectorTitle} indexing started`); - // Close dialog and clean up URL - setIsOpen(false); - const url = new URL(window.location.href); - url.searchParams.delete("modal"); - url.searchParams.delete("tab"); - url.searchParams.delete("success"); - url.searchParams.delete("connector"); - url.searchParams.delete("view"); - router.replace(url.pathname + url.search, { scroll: false }); + // Close dialog and clean up URL + setIsOpen(false); + const url = new URL(window.location.href); + url.searchParams.delete("modal"); + url.searchParams.delete("tab"); + url.searchParams.delete("success"); + url.searchParams.delete("connector"); + url.searchParams.delete("view"); + router.replace(url.pathname + url.search, { scroll: false }); - refreshConnectors(); + refreshConnectors(); queryClient.invalidateQueries({ queryKey: cacheKeys.logs.summary(Number(searchSpaceId)), }); @@ -1421,43 +1415,43 @@ export const useConnectorDialog = () => { : indexingDescription, }); - // Close dialog and clean up URL - setIsOpen(false); - const url = new URL(window.location.href); - url.searchParams.delete("modal"); - url.searchParams.delete("tab"); - url.searchParams.delete("view"); - url.searchParams.delete("connectorId"); - router.replace(url.pathname + url.search, { scroll: false }); + // Close dialog and clean up URL + setIsOpen(false); + const url = new URL(window.location.href); + url.searchParams.delete("modal"); + url.searchParams.delete("tab"); + url.searchParams.delete("view"); + url.searchParams.delete("connectorId"); + router.replace(url.pathname + url.search, { scroll: false }); - refreshConnectors(); - queryClient.invalidateQueries({ - queryKey: cacheKeys.logs.summary(Number(searchSpaceId)), - }); - } catch (error) { - console.error("Error saving connector:", error); - toast.error("Failed to save connector changes"); - } finally { - setIsSaving(false); - } - }, - [ - editingConnector, - searchSpaceId, - isSaving, - startDate, - endDate, - indexConnector, - updateConnector, - periodicEnabled, - frequencyMinutes, - enableSummary, - getFrequencyLabel, - router, - connectorConfig, - connectorName, - setIsOpen, - ] + refreshConnectors(); + queryClient.invalidateQueries({ + queryKey: cacheKeys.logs.summary(Number(searchSpaceId)), + }); + } catch (error) { + console.error("Error saving connector:", error); + toast.error("Failed to save connector changes"); + } finally { + setIsSaving(false); + } + }, + [ + editingConnector, + searchSpaceId, + isSaving, + startDate, + endDate, + indexConnector, + updateConnector, + periodicEnabled, + frequencyMinutes, + enableSummary, + getFrequencyLabel, + router, + connectorConfig, + connectorName, + setIsOpen, + ] ); // Handle disconnecting connector @@ -1484,36 +1478,36 @@ export const useConnectorDialog = () => { : `${editingConnector.name} disconnected successfully` ); - // Update URL - for MCP from list view, go back to list; otherwise close modal - const url = new URL(window.location.href); - if (editingConnector.connector_type === "MCP_CONNECTOR" && cameFromMCPList) { - // Go back to MCP list view only if we came from there - setViewingMCPList(true); - url.searchParams.set("modal", "connectors"); - url.searchParams.set("view", "mcp-list"); - url.searchParams.delete("connectorId"); - } else { - // Close dialog for all other cases - setIsOpen(false); - url.searchParams.delete("modal"); - url.searchParams.delete("tab"); - url.searchParams.delete("view"); - url.searchParams.delete("connectorId"); - } - router.replace(url.pathname + url.search, { scroll: false }); + // Update URL - for MCP from list view, go back to list; otherwise close modal + const url = new URL(window.location.href); + if (editingConnector.connector_type === "MCP_CONNECTOR" && cameFromMCPList) { + // Go back to MCP list view only if we came from there + setViewingMCPList(true); + url.searchParams.set("modal", "connectors"); + url.searchParams.set("view", "mcp-list"); + url.searchParams.delete("connectorId"); + } else { + // Close dialog for all other cases + setIsOpen(false); + url.searchParams.delete("modal"); + url.searchParams.delete("tab"); + url.searchParams.delete("view"); + url.searchParams.delete("connectorId"); + } + router.replace(url.pathname + url.search, { scroll: false }); - refreshConnectors(); - queryClient.invalidateQueries({ - queryKey: cacheKeys.logs.summary(Number(searchSpaceId)), - }); - } catch (error) { - console.error("Error disconnecting connector:", error); - toast.error("Failed to disconnect connector"); - } finally { - setIsDisconnecting(false); - } - }, - [editingConnector, searchSpaceId, deleteConnector, router, cameFromMCPList, setIsOpen] + refreshConnectors(); + queryClient.invalidateQueries({ + queryKey: cacheKeys.logs.summary(Number(searchSpaceId)), + }); + } catch (error) { + console.error("Error disconnecting connector:", error); + toast.error("Failed to disconnect connector"); + } finally { + setIsDisconnecting(false); + } + }, + [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 b77edb6b7..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,21 +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 { useMediaQuery } from "@/hooks/use-media-query"; -import { - agentToolsAtom, - disabledToolsAtom, - enabledToolCountAtom, - hydrateDisabledToolsAtom, - toggleToolAtom, -} from "@/atoms/agent-tools/agent-tools.atoms"; import { cn } from "@/lib/utils"; /** Placeholder texts that cycle in new chats when input is empty */ @@ -623,13 +623,13 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false - e.preventDefault()} - > + e.preventDefault()} + >
Agent Tools @@ -648,7 +648,9 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false const isDisabled = disabledTools.includes(tool.name); const row = (
{/* Connected tools strip */} -
-
diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index d13da7e47..20a3b5b7d 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -217,20 +217,20 @@ export function Sidebar({
4 ? "pb-8" : ""}`} > - {sharedChats.slice(0, 20).map((chat) => ( - setOpenDropdownChatId(open ? chat.id : null)} - onClick={() => onChatSelect(chat)} - onRename={() => onChatRename?.(chat)} - onArchive={() => onChatArchive?.(chat)} - onDelete={() => onChatDelete?.(chat)} - /> - ))} + {sharedChats.slice(0, 20).map((chat) => ( + setOpenDropdownChatId(open ? chat.id : null)} + onClick={() => onChatSelect(chat)} + onRename={() => onChatRename?.(chat)} + onArchive={() => onChatArchive?.(chat)} + onDelete={() => onChatDelete?.(chat)} + /> + ))}
{/* Gradient fade indicator when more than 4 items */} {sharedChats.length > 4 && ( @@ -291,20 +291,20 @@ export function Sidebar({
4 ? "pb-8" : ""}`} > - {chats.slice(0, 20).map((chat) => ( - setOpenDropdownChatId(open ? chat.id : null)} - onClick={() => onChatSelect(chat)} - onRename={() => onChatRename?.(chat)} - onArchive={() => onChatArchive?.(chat)} - onDelete={() => onChatDelete?.(chat)} - /> - ))} + {chats.slice(0, 20).map((chat) => ( + setOpenDropdownChatId(open ? chat.id : null)} + onClick={() => onChatSelect(chat)} + onRename={() => onChatRename?.(chat)} + onArchive={() => onChatArchive?.(chat)} + onDelete={() => onChatDelete?.(chat)} + /> + ))}
{/* Gradient fade indicator when more than 4 items */} {chats.length > 4 && ( diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx index 88bad2a27..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, - UserCog, Sun, + UserCog, } from "lucide-react"; import Image from "next/image"; import { useTranslations } from "next-intl"; 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();