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 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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(); From 5e28125090aa54e769ca67c6bce7692f0dbaf5fb Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Wed, 11 Mar 2026 02:47:46 -0700 Subject: [PATCH 07/10] feat: Enhance PostHog integration with referral tracking and error handling - Added referral code tracking in PostHog initialization to capture initial and last seen referral codes. - Wrapped PostHog capture calls in a safeCapture function to prevent app breakage due to ad-blockers. - Introduced PostHogReferral component in PostHogProvider for improved referral tracking. --- .../components/providers/PostHogProvider.tsx | 4 +- .../components/providers/PostHogReferral.tsx | 34 +++++ surfsense_web/instrumentation-client.ts | 62 +++++--- surfsense_web/lib/posthog/events.ts | 136 ++++++++++-------- 4 files changed, 156 insertions(+), 80 deletions(-) create mode 100644 surfsense_web/components/providers/PostHogReferral.tsx diff --git a/surfsense_web/components/providers/PostHogProvider.tsx b/surfsense_web/components/providers/PostHogProvider.tsx index 1216730f3..906d552c2 100644 --- a/surfsense_web/components/providers/PostHogProvider.tsx +++ b/surfsense_web/components/providers/PostHogProvider.tsx @@ -5,17 +5,17 @@ import posthog from "posthog-js"; import type { ReactNode } from "react"; import "../../instrumentation-client"; import { PostHogIdentify } from "./PostHogIdentify"; +import { PostHogReferral } from "./PostHogReferral"; interface PostHogProviderProps { children: ReactNode; } export function PostHogProvider({ children }: PostHogProviderProps) { - // posthog-js is initialized by importing instrumentation-client.ts above - // We wrap the app with the PostHogProvider for hook access return ( + {children} ); diff --git a/surfsense_web/components/providers/PostHogReferral.tsx b/surfsense_web/components/providers/PostHogReferral.tsx new file mode 100644 index 000000000..8db27ac95 --- /dev/null +++ b/surfsense_web/components/providers/PostHogReferral.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { useEffect } from "react"; +import { trackReferralLanding } from "@/lib/posthog/events"; + +const REF_STORAGE_KEY = "surfsense_ref_code"; + +/** + * Captures the ?ref= URL parameter on first landing and fires a + * PostHog event so marketing campaigns can be attributed. + * + * The ref code is persisted to sessionStorage so it survives client-side + * navigations that strip query params (e.g. login redirect), but a fresh + * event is fired for each new browser session with a ref param. + */ +export function PostHogReferral() { + useEffect(() => { + if (typeof window === "undefined") return; + + const params = new URLSearchParams(window.location.search); + const ref = params.get("ref"); + + if (ref) { + try { + sessionStorage.setItem(REF_STORAGE_KEY, ref); + } catch { + // Private browsing may block sessionStorage + } + trackReferralLanding(ref, window.location.href); + } + }, []); + + return null; +} diff --git a/surfsense_web/instrumentation-client.ts b/surfsense_web/instrumentation-client.ts index c6c6320b0..2c5e0a2c2 100644 --- a/surfsense_web/instrumentation-client.ts +++ b/surfsense_web/instrumentation-client.ts @@ -1,29 +1,47 @@ import posthog from "posthog-js"; function initPostHog() { - if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) return; + try { + if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) return; - posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, { - api_host: "/ingest", - ui_host: "https://us.posthog.com", - defaults: "2025-11-30", - capture_pageview: "history_change", - capture_pageleave: true, - before_send: (event) => { - if (event.properties) { - event.properties.$set = { - ...event.properties.$set, - last_seen_at: new Date().toISOString(), - }; - } - return event; - }, - loaded: (ph) => { - if (typeof window !== "undefined") { - window.posthog = ph; - } - }, - }); + posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, { + api_host: "/ingest", + ui_host: "https://us.posthog.com", + defaults: "2025-11-30", + capture_pageview: "history_change", + capture_pageleave: true, + before_send: (event) => { + if (event.properties) { + const params = new URLSearchParams(window.location.search); + const ref = params.get("ref"); + if (ref) { + event.properties.ref_code = ref; + event.properties.$set = { + ...event.properties.$set, + initial_ref_code: ref, + }; + event.properties.$set_once = { + ...event.properties.$set_once, + first_ref_code: ref, + }; + } + + event.properties.$set = { + ...event.properties.$set, + last_seen_at: new Date().toISOString(), + }; + } + return event; + }, + loaded: (ph) => { + if (typeof window !== "undefined") { + window.posthog = ph; + } + }, + }); + } catch { + // PostHog init failed (likely ad-blocker) – app must continue to work + } } if (typeof window !== "undefined") { diff --git a/surfsense_web/lib/posthog/events.ts b/surfsense_web/lib/posthog/events.ts index aa1d1e8c5..53aaa71b9 100644 --- a/surfsense_web/lib/posthog/events.ts +++ b/surfsense_web/lib/posthog/events.ts @@ -3,7 +3,10 @@ import posthog from "posthog-js"; /** * PostHog Analytics Event Definitions * - * This file defines all custom analytics events tracked in SurfSense. + * All capture/identify/reset calls are wrapped in try-catch so that + * ad-blockers that interfere with posthog-js can never break app + * functionality (e.g. the chat flow). + * * Events follow a consistent naming convention: category_action * * Categories: @@ -14,47 +17,47 @@ import posthog from "posthog-js"; * - connector: External connector events * - contact: Contact form events * - settings: Settings changes + * - marketing: Marketing/referral tracking */ +function safeCapture(event: string, properties?: Record) { + try { + posthog.capture(event, properties); + } catch { + // Silently ignore – analytics should never break the app + } +} + // ============================================ // AUTH EVENTS // ============================================ export function trackLoginAttempt(method: "local" | "google") { - posthog.capture("auth_login_attempt", { - method, - }); + safeCapture("auth_login_attempt", { method }); } export function trackLoginSuccess(method: "local" | "google") { - posthog.capture("auth_login_success", { - method, - }); + safeCapture("auth_login_success", { method }); } export function trackLoginFailure(method: "local" | "google", error?: string) { - posthog.capture("auth_login_failure", { - method, - error, - }); + safeCapture("auth_login_failure", { method, error }); } export function trackRegistrationAttempt() { - posthog.capture("auth_registration_attempt"); + safeCapture("auth_registration_attempt"); } export function trackRegistrationSuccess() { - posthog.capture("auth_registration_success"); + safeCapture("auth_registration_success"); } export function trackRegistrationFailure(error?: string) { - posthog.capture("auth_registration_failure", { - error, - }); + safeCapture("auth_registration_failure", { error }); } export function trackLogout() { - posthog.capture("auth_logout"); + safeCapture("auth_logout"); } // ============================================ @@ -62,20 +65,20 @@ export function trackLogout() { // ============================================ export function trackSearchSpaceCreated(searchSpaceId: number, name: string) { - posthog.capture("search_space_created", { + safeCapture("search_space_created", { search_space_id: searchSpaceId, name, }); } export function trackSearchSpaceDeleted(searchSpaceId: number) { - posthog.capture("search_space_deleted", { + safeCapture("search_space_deleted", { search_space_id: searchSpaceId, }); } export function trackSearchSpaceViewed(searchSpaceId: number) { - posthog.capture("search_space_viewed", { + safeCapture("search_space_viewed", { search_space_id: searchSpaceId, }); } @@ -85,7 +88,7 @@ export function trackSearchSpaceViewed(searchSpaceId: number) { // ============================================ export function trackChatCreated(searchSpaceId: number, chatId: number) { - posthog.capture("chat_created", { + safeCapture("chat_created", { search_space_id: searchSpaceId, chat_id: chatId, }); @@ -100,7 +103,7 @@ export function trackChatMessageSent( messageLength?: number; } ) { - posthog.capture("chat_message_sent", { + safeCapture("chat_message_sent", { search_space_id: searchSpaceId, chat_id: chatId, has_attachments: options?.hasAttachments ?? false, @@ -110,14 +113,14 @@ export function trackChatMessageSent( } export function trackChatResponseReceived(searchSpaceId: number, chatId: number) { - posthog.capture("chat_response_received", { + safeCapture("chat_response_received", { search_space_id: searchSpaceId, chat_id: chatId, }); } export function trackChatError(searchSpaceId: number, chatId: number, error?: string) { - posthog.capture("chat_error", { + safeCapture("chat_error", { search_space_id: searchSpaceId, chat_id: chatId, error, @@ -133,7 +136,7 @@ export function trackDocumentUploadStarted( fileCount: number, totalSizeBytes: number ) { - posthog.capture("document_upload_started", { + safeCapture("document_upload_started", { search_space_id: searchSpaceId, file_count: fileCount, total_size_bytes: totalSizeBytes, @@ -141,35 +144,35 @@ export function trackDocumentUploadStarted( } export function trackDocumentUploadSuccess(searchSpaceId: number, fileCount: number) { - posthog.capture("document_upload_success", { + safeCapture("document_upload_success", { search_space_id: searchSpaceId, file_count: fileCount, }); } export function trackDocumentUploadFailure(searchSpaceId: number, error?: string) { - posthog.capture("document_upload_failure", { + safeCapture("document_upload_failure", { search_space_id: searchSpaceId, error, }); } export function trackDocumentDeleted(searchSpaceId: number, documentId: number) { - posthog.capture("document_deleted", { + safeCapture("document_deleted", { search_space_id: searchSpaceId, document_id: documentId, }); } export function trackDocumentBulkDeleted(searchSpaceId: number, count: number) { - posthog.capture("document_bulk_deleted", { + safeCapture("document_bulk_deleted", { search_space_id: searchSpaceId, count, }); } export function trackYouTubeImport(searchSpaceId: number, url: string) { - posthog.capture("youtube_import_started", { + safeCapture("youtube_import_started", { search_space_id: searchSpaceId, url, }); @@ -180,7 +183,7 @@ export function trackYouTubeImport(searchSpaceId: number, url: string) { // ============================================ export function trackConnectorSetupStarted(searchSpaceId: number, connectorType: string) { - posthog.capture("connector_setup_started", { + safeCapture("connector_setup_started", { search_space_id: searchSpaceId, connector_type: connectorType, }); @@ -191,7 +194,7 @@ export function trackConnectorSetupSuccess( connectorType: string, connectorId: number ) { - posthog.capture("connector_setup_success", { + safeCapture("connector_setup_success", { search_space_id: searchSpaceId, connector_type: connectorType, connector_id: connectorId, @@ -203,7 +206,7 @@ export function trackConnectorSetupFailure( connectorType: string, error?: string ) { - posthog.capture("connector_setup_failure", { + safeCapture("connector_setup_failure", { search_space_id: searchSpaceId, connector_type: connectorType, error, @@ -215,7 +218,7 @@ export function trackConnectorDeleted( connectorType: string, connectorId: number ) { - posthog.capture("connector_deleted", { + safeCapture("connector_deleted", { search_space_id: searchSpaceId, connector_type: connectorType, connector_id: connectorId, @@ -227,7 +230,7 @@ export function trackConnectorSynced( connectorType: string, connectorId: number ) { - posthog.capture("connector_synced", { + safeCapture("connector_synced", { search_space_id: searchSpaceId, connector_type: connectorType, connector_id: connectorId, @@ -239,14 +242,14 @@ export function trackConnectorSynced( // ============================================ export function trackSettingsViewed(searchSpaceId: number, section: string) { - posthog.capture("settings_viewed", { + safeCapture("settings_viewed", { search_space_id: searchSpaceId, section, }); } export function trackSettingsUpdated(searchSpaceId: number, section: string, setting: string) { - posthog.capture("settings_updated", { + safeCapture("settings_updated", { search_space_id: searchSpaceId, section, setting, @@ -258,14 +261,14 @@ export function trackSettingsUpdated(searchSpaceId: number, section: string, set // ============================================ export function trackPodcastGenerated(searchSpaceId: number, chatId: number) { - posthog.capture("podcast_generated", { + safeCapture("podcast_generated", { search_space_id: searchSpaceId, chat_id: chatId, }); } export function trackSourcesTabViewed(searchSpaceId: number, tab: string) { - posthog.capture("sources_tab_viewed", { + safeCapture("sources_tab_viewed", { search_space_id: searchSpaceId, tab, }); @@ -283,7 +286,7 @@ export function trackSearchSpaceInviteSent( hasMaxUses?: boolean; } ) { - posthog.capture("search_space_invite_sent", { + safeCapture("search_space_invite_sent", { search_space_id: searchSpaceId, role_name: options?.roleName, has_expiry: options?.hasExpiry ?? false, @@ -296,7 +299,7 @@ export function trackSearchSpaceInviteAccepted( searchSpaceName: string, roleName?: string | null ) { - posthog.capture("search_space_invite_accepted", { + safeCapture("search_space_invite_accepted", { search_space_id: searchSpaceId, search_space_name: searchSpaceName, role_name: roleName, @@ -304,7 +307,7 @@ export function trackSearchSpaceInviteAccepted( } export function trackSearchSpaceInviteDeclined(searchSpaceName?: string) { - posthog.capture("search_space_invite_declined", { + safeCapture("search_space_invite_declined", { search_space_name: searchSpaceName, }); } @@ -314,7 +317,7 @@ export function trackSearchSpaceUserAdded( searchSpaceName: string, roleName?: string | null ) { - posthog.capture("search_space_user_added", { + safeCapture("search_space_user_added", { search_space_id: searchSpaceId, search_space_name: searchSpaceName, role_name: roleName, @@ -326,7 +329,7 @@ export function trackSearchSpaceUsersViewed( userCount: number, ownerCount: number ) { - posthog.capture("search_space_users_viewed", { + safeCapture("search_space_users_viewed", { search_space_id: searchSpaceId, user_count: userCount, owner_count: ownerCount, @@ -342,7 +345,7 @@ export function trackConnectorConnected( connectorType: string, connectorId?: number ) { - posthog.capture("connector_connected", { + safeCapture("connector_connected", { search_space_id: searchSpaceId, connector_type: connectorType, connector_id: connectorId, @@ -358,7 +361,7 @@ export function trackIndexWithDateRangeOpened( connectorType: string, connectorId: number ) { - posthog.capture("index_with_date_range_opened", { + safeCapture("index_with_date_range_opened", { search_space_id: searchSpaceId, connector_type: connectorType, connector_id: connectorId, @@ -374,7 +377,7 @@ export function trackIndexWithDateRangeStarted( hasEndDate?: boolean; } ) { - posthog.capture("index_with_date_range_started", { + safeCapture("index_with_date_range_started", { search_space_id: searchSpaceId, connector_type: connectorType, connector_id: connectorId, @@ -388,7 +391,7 @@ export function trackQuickIndexClicked( connectorType: string, connectorId: number ) { - posthog.capture("quick_index_clicked", { + safeCapture("quick_index_clicked", { search_space_id: searchSpaceId, connector_type: connectorType, connector_id: connectorId, @@ -400,7 +403,7 @@ export function trackConfigurePeriodicIndexingOpened( connectorType: string, connectorId: number ) { - posthog.capture("configure_periodic_indexing_opened", { + safeCapture("configure_periodic_indexing_opened", { search_space_id: searchSpaceId, connector_type: connectorType, connector_id: connectorId, @@ -413,7 +416,7 @@ export function trackPeriodicIndexingStarted( connectorId: number, frequencyMinutes: number ) { - posthog.capture("periodic_indexing_started", { + safeCapture("periodic_indexing_started", { search_space_id: searchSpaceId, connector_type: connectorType, connector_id: connectorId, @@ -426,24 +429,37 @@ export function trackPeriodicIndexingStarted( // ============================================ export function trackIncentivePageViewed() { - posthog.capture("incentive_page_viewed"); + safeCapture("incentive_page_viewed"); } export function trackIncentiveTaskCompleted(taskType: string, pagesRewarded: number) { - posthog.capture("incentive_task_completed", { + safeCapture("incentive_task_completed", { task_type: taskType, pages_rewarded: pagesRewarded, }); } export function trackIncentiveTaskClicked(taskType: string) { - posthog.capture("incentive_task_clicked", { + safeCapture("incentive_task_clicked", { task_type: taskType, }); } export function trackIncentiveContactOpened() { - posthog.capture("incentive_contact_opened"); + safeCapture("incentive_contact_opened"); +} + +// ============================================ +// MARKETING / REFERRAL EVENTS +// ============================================ + +export function trackReferralLanding(refCode: string, landingUrl: string) { + safeCapture("marketing_referral_landing", { + ref_code: refCode, + landing_url: landingUrl, + $set_once: { first_ref_code: refCode }, + $set: { latest_ref_code: refCode }, + }); } // ============================================ @@ -455,12 +471,20 @@ export function trackIncentiveContactOpened() { * Call this after successful authentication */ export function identifyUser(userId: string, properties?: Record) { - posthog.identify(userId, properties); + try { + posthog.identify(userId, properties); + } catch { + // Silently ignore – ad-blockers may break posthog + } } /** * Reset user identity (call on logout) */ export function resetUser() { - posthog.reset(); + try { + posthog.reset(); + } catch { + // Silently ignore – ad-blockers may break posthog + } } From 7c3aedf811698fed54ebab9c9ae68495f46e0ca3 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Wed, 11 Mar 2026 02:49:00 -0700 Subject: [PATCH 08/10] fix: Improve safety of PostHog event handling by using optional chaining for properties --- surfsense_web/instrumentation-client.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/surfsense_web/instrumentation-client.ts b/surfsense_web/instrumentation-client.ts index 2c5e0a2c2..20d370c4e 100644 --- a/surfsense_web/instrumentation-client.ts +++ b/surfsense_web/instrumentation-client.ts @@ -10,8 +10,8 @@ function initPostHog() { defaults: "2025-11-30", capture_pageview: "history_change", capture_pageleave: true, - before_send: (event) => { - if (event.properties) { + before_send: (event) => { + if (event?.properties) { const params = new URLSearchParams(window.location.search); const ref = params.get("ref"); if (ref) { From d61e29e74b32eaa9467b8749c52391063fa76001 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Wed, 11 Mar 2026 15:09:10 -0700 Subject: [PATCH 09/10] feat: fixed connectors dialog navigation, Implement bulk document deletion and improve search space filtering - Added bulk delete functionality for documents in DocumentsTableShell and DocumentsSidebar. - Enhanced search space retrieval to exclude spaces marked for deletion in read_search_spaces. - Updated connector dialog to synchronize URL parameters when opened externally. - Improved layout behavior to handle search space deletion and redirection more effectively. --- .../app/routes/search_spaces_routes.py | 7 +- .../components/DocumentsTableShell.tsx | 273 +++++++++++++----- .../assistant-ui/connector-popup.tsx | 5 +- .../hooks/use-connector-dialog.ts | 27 +- .../layout/providers/LayoutDataProvider.tsx | 62 ++-- .../layout/ui/sidebar/DocumentsSidebar.tsx | 28 +- surfsense_web/hooks/use-documents.ts | 7 + 7 files changed, 312 insertions(+), 97 deletions(-) diff --git a/surfsense_backend/app/routes/search_spaces_routes.py b/surfsense_backend/app/routes/search_spaces_routes.py index 95c947d3c..7f6638e2c 100644 --- a/surfsense_backend/app/routes/search_spaces_routes.py +++ b/surfsense_backend/app/routes/search_spaces_routes.py @@ -125,11 +125,14 @@ async def read_search_spaces( If False (default), return all search spaces the user has access to. """ try: + # Exclude spaces that are pending background deletion + not_deleting = ~SearchSpace.name.startswith("[DELETING] ") + if owned_only: # Return only search spaces where user is the original creator (user_id) result = await session.execute( select(SearchSpace) - .filter(SearchSpace.user_id == user.id) + .filter(SearchSpace.user_id == user.id, not_deleting) .order_by(SearchSpace.id.asc()) .offset(skip) .limit(limit) @@ -139,7 +142,7 @@ async def read_search_spaces( result = await session.execute( select(SearchSpace) .join(SearchSpaceMembership) - .filter(SearchSpaceMembership.user_id == user.id) + .filter(SearchSpaceMembership.user_id == user.id, not_deleting) .order_by(SearchSpace.id.asc()) .offset(skip) .limit(limit) diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx index cddb3e79a..464bac471 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx @@ -320,6 +320,7 @@ export function DocumentsTableShell({ sortDesc, onSortChange, deleteDocument, + bulkDeleteDocuments, searchSpaceId, hasMore = false, loadingMore = false, @@ -336,6 +337,7 @@ export function DocumentsTableShell({ sortDesc: boolean; onSortChange: (key: SortKey) => void; deleteDocument: (id: number) => Promise; + bulkDeleteDocuments?: (ids: number[]) => Promise<{ success: number; failed: number }>; searchSpaceId: string; hasMore?: boolean; loadingMore?: boolean; @@ -370,6 +372,8 @@ export function DocumentsTableShell({ const [deleteDoc, setDeleteDoc] = useState(null); const [isDeleting, setIsDeleting] = useState(false); const [mobileActionDoc, setMobileActionDoc] = useState(null); + const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false); + const [isBulkDeleting, setIsBulkDeleting] = useState(false); const router = useRouter(); const desktopSentinelRef = useRef(null); @@ -496,45 +500,119 @@ export function DocumentsTableShell({ const onSortHeader = (key: SortKey) => onSortChange(key); + const deletableSelectedIds = React.useMemo(() => { + if (!mentionedDocIds || mentionedDocIds.size === 0) return []; + return sorted + .filter((doc) => { + if (!mentionedDocIds.has(doc.id)) return false; + const state = doc.status?.state; + return ( + state !== "pending" && + state !== "processing" && + !NON_DELETABLE_DOCUMENT_TYPES.includes( + doc.document_type as (typeof NON_DELETABLE_DOCUMENT_TYPES)[number] + ) + ); + }) + .map((doc) => doc.id); + }, [sorted, mentionedDocIds]); + + const hasDeletableSelection = deletableSelectedIds.length > 0; + + const handleBulkDelete = useCallback(async () => { + if (deletableSelectedIds.length === 0) return; + setIsBulkDeleting(true); + try { + if (bulkDeleteDocuments) { + const { success, failed } = await bulkDeleteDocuments(deletableSelectedIds); + if (success > 0) { + toast.success(`Deleted ${success} document${success !== 1 ? "s" : ""}`); + } + if (failed > 0) { + toast.error(`Failed to delete ${failed} document${failed !== 1 ? "s" : ""}`); + } + } else { + const results = await Promise.allSettled( + deletableSelectedIds.map((id) => deleteDocument(id)) + ); + const successCount = results.filter( + (r) => r.status === "fulfilled" && r.value === true + ).length; + const failCount = deletableSelectedIds.length - successCount; + if (successCount > 0) { + toast.success( + `Deleted ${successCount} document${successCount !== 1 ? "s" : ""}` + ); + } + if (failCount > 0) { + toast.error( + `Failed to delete ${failCount} document${failCount !== 1 ? "s" : ""}` + ); + } + } + } catch { + toast.error("Failed to delete documents"); + } + setIsBulkDeleting(false); + setBulkDeleteConfirmOpen(false); + }, [deletableSelectedIds, bulkDeleteDocuments, deleteDocument]); + return (
{/* Desktop Table View */}
- - - - -
- toggleAll(!!v)} - aria-label={hasChatMode ? "Toggle all for chat" : "Select all"} - className="border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary" - /> -
-
- - } - > - Document - - - - - - - - +
+ + + +
+ toggleAll(!!v)} + aria-label={hasChatMode ? "Toggle all for chat" : "Select all"} + className="border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary" + /> +
+
+ + } + > + Document + + + + + + + + + {hasDeletableSelection ? ( + + + + + + Delete {deletableSelectedIds.length} selected + + + ) : ( Status - -
-
-
+ )} + + + + {loading ? (
@@ -605,50 +683,50 @@ export function DocumentsTableShell({
- {sorted.map((doc) => { - const isMentioned = mentionedDocIds?.has(doc.id) ?? false; - const canInteract = isSelectable(doc); - const handleRowToggle = () => { - if (canInteract && onToggleChatMention) { - onToggleChatMention(doc, isMentioned); - } - }; - const handleRowClick = (e: React.MouseEvent) => { - if (e.ctrlKey || e.metaKey) { - e.preventDefault(); - e.stopPropagation(); - handleViewMetadata(doc); - return; - } - handleRowToggle(); - }; - return ( - { + const isMentioned = mentionedDocIds?.has(doc.id) ?? false; + const canInteract = isSelectable(doc); + const handleRowToggle = () => { + if (canInteract && onToggleChatMention) { + onToggleChatMention(doc, isMentioned); + } + }; + const handleRowClick = (e: React.MouseEvent) => { + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + e.stopPropagation(); + handleViewMetadata(doc); + return; + } + handleRowToggle(); + }; + return ( + + - e.stopPropagation()} >
- handleRowToggle()} - disabled={!canInteract} - aria-label={isMentioned ? "Remove from chat" : "Add to chat"} - className={`border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary ${!canInteract ? "opacity-40 cursor-not-allowed" : ""}`} - /> + handleRowToggle()} + disabled={!canInteract} + aria-label={isMentioned ? "Remove from chat" : "Add to chat"} + className={`border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary ${!canInteract ? "opacity-40 cursor-not-allowed" : ""}`} + />
@@ -742,6 +820,22 @@ export function DocumentsTableShell({ ref={mobileScrollRef} className="md:hidden divide-y divide-border/50 flex-1 overflow-auto" > + {hasDeletableSelection && ( +
+ + {deletableSelectedIds.length} deletable selected + + +
+ )} {sorted.map((doc) => { const isMentioned = mentionedDocIds?.has(doc.id) ?? false; const canInteract = isSelectable(doc); @@ -957,6 +1051,41 @@ export function DocumentsTableShell({ + + {/* Bulk Delete Confirmation Dialog */} + !open && !isBulkDeleting && setBulkDeleteConfirmOpen(false)} + > + + + + Delete {deletableSelectedIds.length} document + {deletableSelectedIds.length !== 1 ? "s" : ""}? + + + This action cannot be undone.{" "} + {deletableSelectedIds.length === 1 + ? "This document" + : `These ${deletableSelectedIds.length} documents`}{" "} + will be permanently deleted from your search space. + + + + Cancel + { + e.preventDefault(); + handleBulkDelete(); + }} + disabled={isBulkDeleting} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {isBulkDeleting ? "Deleting..." : "Delete"} + + + + ); } diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx index cf1235c65..11f48a80b 100644 --- a/surfsense_web/components/assistant-ui/connector-popup.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup.tsx @@ -258,7 +258,10 @@ export const ConnectorIndicator = forwardRef )} - + e.preventDefault()} + className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 dark:ring-0 bg-muted dark:bg-muted text-foreground focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5 select-none" + > Manage Connectors {/* YouTube Crawler View - shown when adding YouTube videos */} {isYouTubeView && searchSpaceId ? ( 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 7d2b3682b..14183ec75 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 @@ -181,6 +181,24 @@ export const useConnectorDialog = () => { [searchSpaceId, indexConnector, updateConnector, refetchAllConnectors] ); + // When the dialog is opened externally (via setConnectorDialogOpen atom from + // thread.tsx / DocumentsSidebar.tsx), the URL is not updated. Sync it here + // so that other handlers that read window.location.href see modal=connectors. + const activeTabRef = useRef(activeTab); + activeTabRef.current = activeTab; + useEffect(() => { + if (isOpen) { + const url = new URL(window.location.href); + const modalParam = url.searchParams.get("modal"); + const tabParam = url.searchParams.get("tab"); + if (modalParam !== "connectors" || (tabParam !== "all" && tabParam !== "active")) { + url.searchParams.set("modal", "connectors"); + url.searchParams.set("tab", activeTabRef.current); + window.history.replaceState({ modal: true }, "", url.toString()); + } + } + }, [isOpen]); + // Synchronize state with URL query params useEffect(() => { try { @@ -1647,12 +1665,13 @@ export const useConnectorDialog = () => { [activeTab, isStartingIndexing, isDisconnecting, isSaving, isCreatingConnector, setIsOpen] ); - // Handle tab change + // Handle tab change — only update React state. + // Avoid window.history.replaceState here: Next.js intercepts it, triggers a + // searchParams update/transition, and the resulting concurrent re-render can + // cause Radix Dialog's DismissableLayer to detect a transient focus-outside + // event, which fires onOpenChange(false) and closes the dialog. const handleTabChange = useCallback((value: string) => { setActiveTab(value); - const url = new URL(window.location.href); - url.searchParams.set("tab", value); - window.history.replaceState({ modal: true }, "", url.toString()); }, []); // Handle scroll diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 7d7145962..69452a53b 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -83,7 +83,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid // Atoms const { data: user } = useAtomValue(currentUserAtom); - const { data: searchSpacesData, refetch: refetchSearchSpaces } = useAtomValue(searchSpacesAtom); + const { data: searchSpacesData, refetch: refetchSearchSpaces, isSuccess: searchSpacesLoaded } = useAtomValue(searchSpacesAtom); const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom); const currentThreadState = useAtomValue(currentThreadAtom); const resetCurrentThread = useSetAtom(resetCurrentThreadAtom); @@ -276,6 +276,17 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid return searchSpaces.find((s) => s.id === Number(searchSpaceId)) ?? null; }, [searchSpaceId, searchSpaces]); + // Safety redirect: if the current search space is no longer in the user's list + // (e.g. deleted by background task, membership revoked), redirect to a valid space. + useEffect(() => { + if (!searchSpacesLoaded || !searchSpaceId || isDeletingSearchSpace || isLeavingSearchSpace) return; + if (searchSpaces.length > 0 && !activeSearchSpace) { + router.replace(`/dashboard/${searchSpaces[0].id}/new-chat`); + } else if (searchSpaces.length === 0 && searchSpacesLoaded) { + router.replace("/dashboard"); + } + }, [searchSpacesLoaded, searchSpaceId, searchSpaces, activeSearchSpace, isDeletingSearchSpace, isLeavingSearchSpace, router]); + // Transform and split chats into private and shared based on visibility const { myChats, sharedChats } = useMemo(() => { if (!threadsData?.threads) return { myChats: [], sharedChats: [] }; @@ -384,17 +395,27 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid setIsDeletingSearchSpace(true); try { await deleteSearchSpace({ id: searchSpaceToDelete.id }); - refetchSearchSpaces(); - if (Number(searchSpaceId) === searchSpaceToDelete.id && searchSpaces.length > 1) { - const remaining = searchSpaces.filter((s) => s.id !== searchSpaceToDelete.id); - if (remaining.length > 0) { - router.push(`/dashboard/${remaining[0].id}/new-chat`); + + const isCurrentSpace = Number(searchSpaceId) === searchSpaceToDelete.id; + + // Await refetch so we have the freshest list (backend now hides [DELETING] spaces) + const result = await refetchSearchSpaces(); + const updatedSpaces = (result.data ?? []).filter( + (s) => s.id !== searchSpaceToDelete.id + ); + + if (isCurrentSpace) { + if (updatedSpaces.length > 0) { + router.push(`/dashboard/${updatedSpaces[0].id}/new-chat`); + } else { + router.push("/dashboard"); } - } else if (searchSpaces.length === 1) { - router.push("/dashboard"); } } catch (error) { console.error("Error deleting search space:", error); + toast.error( + t.has("delete_space_error") ? t("delete_space_error") : "Failed to delete search space" + ); } finally { setIsDeletingSearchSpace(false); setShowDeleteSearchSpaceDialog(false); @@ -405,8 +426,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid deleteSearchSpace, refetchSearchSpaces, searchSpaceId, - searchSpaces, router, + t, ]); const confirmLeaveSearchSpace = useCallback(async () => { @@ -414,23 +435,30 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid setIsLeavingSearchSpace(true); try { await searchSpacesApiService.leaveSearchSpace(searchSpaceToLeave.id); - refetchSearchSpaces(); - if (Number(searchSpaceId) === searchSpaceToLeave.id && searchSpaces.length > 1) { - const remaining = searchSpaces.filter((s) => s.id !== searchSpaceToLeave.id); - if (remaining.length > 0) { - router.push(`/dashboard/${remaining[0].id}/new-chat`); + + const isCurrentSpace = Number(searchSpaceId) === searchSpaceToLeave.id; + + const result = await refetchSearchSpaces(); + const updatedSpaces = (result.data ?? []).filter( + (s) => s.id !== searchSpaceToLeave.id + ); + + if (isCurrentSpace) { + if (updatedSpaces.length > 0) { + router.push(`/dashboard/${updatedSpaces[0].id}/new-chat`); + } else { + router.push("/dashboard"); } - } else if (searchSpaces.length === 1) { - router.push("/dashboard"); } } catch (error) { console.error("Error leaving search space:", error); + toast.error(t.has("leave_error") ? t("leave_error") : "Failed to leave search space"); } finally { setIsLeavingSearchSpace(false); setShowLeaveSearchSpaceDialog(false); setSearchSpaceToLeave(null); } - }, [searchSpaceToLeave, refetchSearchSpaces, searchSpaceId, searchSpaces, router]); + }, [searchSpaceToLeave, refetchSearchSpaces, searchSpaceId, router, t]); const handleNavItemClick = useCallback( (item: NavItem) => { diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index aaaae61f3..72b92fc55 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -102,6 +102,7 @@ export function DocumentsSidebar({ loadingMore: realtimeLoadingMore, hasMore: realtimeHasMore, loadMore: realtimeLoadMore, + removeItems: realtimeRemoveItems, error: realtimeError, } = useDocuments(searchSpaceId, activeTypes, sortKey, sortDesc ? "desc" : "asc"); @@ -137,6 +138,7 @@ export function DocumentsSidebar({ await deleteDocumentMutation({ id }); toast.success(t("delete_success") || "Document deleted"); setSidebarDocs((prev) => prev.filter((d) => d.id !== id)); + realtimeRemoveItems([id]); if (isSearchMode) { searchRemoveItems([id]); } @@ -146,7 +148,30 @@ export function DocumentsSidebar({ return false; } }, - [deleteDocumentMutation, isSearchMode, t, searchRemoveItems, setSidebarDocs] + [deleteDocumentMutation, isSearchMode, t, searchRemoveItems, realtimeRemoveItems, setSidebarDocs] + ); + + const handleBulkDeleteDocuments = useCallback( + async (ids: number[]): Promise<{ success: number; failed: number }> => { + const successIds: number[] = []; + const results = await Promise.allSettled( + ids.map(async (id) => { + await deleteDocumentMutation({ id }); + successIds.push(id); + }) + ); + if (successIds.length > 0) { + setSidebarDocs((prev) => prev.filter((d) => !successIds.includes(d.id))); + realtimeRemoveItems(successIds); + if (isSearchMode) { + searchRemoveItems(successIds); + } + } + const success = results.filter((r) => r.status === "fulfilled").length; + const failed = results.filter((r) => r.status === "rejected").length; + return { success, failed }; + }, + [deleteDocumentMutation, isSearchMode, searchRemoveItems, realtimeRemoveItems, setSidebarDocs] ); const sortKeyRef = useRef(sortKey); @@ -319,6 +344,7 @@ export function DocumentsSidebar({ sortDesc={sortDesc} onSortChange={handleSortChange} deleteDocument={handleDeleteDocument} + bulkDeleteDocuments={handleBulkDeleteDocuments} searchSpaceId={String(searchSpaceId)} hasMore={hasMore} loadingMore={loadingMore} diff --git a/surfsense_web/hooks/use-documents.ts b/surfsense_web/hooks/use-documents.ts index 3d6ee9be4..5fee85d01 100644 --- a/surfsense_web/hooks/use-documents.ts +++ b/surfsense_web/hooks/use-documents.ts @@ -490,6 +490,12 @@ export function useDocuments( apiToDisplayDoc, ]); + const removeItems = useCallback((ids: number[]) => { + const idSet = new Set(ids); + setDocuments((prev) => prev.filter((item) => !idSet.has(item.id))); + setTotal((prev) => Math.max(0, prev - ids.length)); + }, []); + return { documents, typeCounts, @@ -498,6 +504,7 @@ export function useDocuments( loadingMore, hasMore, loadMore, + removeItems, error, }; } From d960a065b1ce35c06caadcb9b41f9f63c37b9abb Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Wed, 11 Mar 2026 15:10:53 -0700 Subject: [PATCH 10/10] chore: linting --- .../components/DocumentsTableShell.tsx | 186 +++++++++--------- .../layout/providers/LayoutDataProvider.tsx | 36 ++-- .../layout/ui/sidebar/DocumentsSidebar.tsx | 9 +- surfsense_web/instrumentation-client.ts | 4 +- 4 files changed, 119 insertions(+), 116 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx index 464bac471..6ab96b920 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx @@ -540,14 +540,10 @@ export function DocumentsTableShell({ ).length; const failCount = deletableSelectedIds.length - successCount; if (successCount > 0) { - toast.success( - `Deleted ${successCount} document${successCount !== 1 ? "s" : ""}` - ); + toast.success(`Deleted ${successCount} document${successCount !== 1 ? "s" : ""}`); } if (failCount > 0) { - toast.error( - `Failed to delete ${failCount} document${failCount !== 1 ? "s" : ""}` - ); + toast.error(`Failed to delete ${failCount} document${failCount !== 1 ? "s" : ""}`); } } } catch { @@ -561,58 +557,56 @@ export function DocumentsTableShell({
{/* Desktop Table View */}
-
- - - -
- toggleAll(!!v)} - aria-label={hasChatMode ? "Toggle all for chat" : "Select all"} - className="border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary" - /> -
-
- - } - > - Document - - - - - - - - - {hasDeletableSelection ? ( - - - - - - Delete {deletableSelectedIds.length} selected - - - ) : ( - Status - )} - -
-
-
+ + + + +
+ toggleAll(!!v)} + aria-label={hasChatMode ? "Toggle all for chat" : "Select all"} + className="border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary" + /> +
+
+ + } + > + Document + + + + + + + + + {hasDeletableSelection ? ( + + + + + Delete {deletableSelectedIds.length} selected + + ) : ( + Status + )} + +
+
+
{loading ? (
@@ -683,50 +677,50 @@ export function DocumentsTableShell({
- {sorted.map((doc) => { - const isMentioned = mentionedDocIds?.has(doc.id) ?? false; - const canInteract = isSelectable(doc); - const handleRowToggle = () => { - if (canInteract && onToggleChatMention) { - onToggleChatMention(doc, isMentioned); - } - }; - const handleRowClick = (e: React.MouseEvent) => { - if (e.ctrlKey || e.metaKey) { - e.preventDefault(); - e.stopPropagation(); - handleViewMetadata(doc); - return; - } - handleRowToggle(); - }; - return ( - - { + const isMentioned = mentionedDocIds?.has(doc.id) ?? false; + const canInteract = isSelectable(doc); + const handleRowToggle = () => { + if (canInteract && onToggleChatMention) { + onToggleChatMention(doc, isMentioned); + } + }; + const handleRowClick = (e: React.MouseEvent) => { + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + e.stopPropagation(); + handleViewMetadata(doc); + return; + } + handleRowToggle(); + }; + return ( + + e.stopPropagation()} >
- handleRowToggle()} - disabled={!canInteract} - aria-label={isMentioned ? "Remove from chat" : "Add to chat"} - className={`border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary ${!canInteract ? "opacity-40 cursor-not-allowed" : ""}`} - /> + handleRowToggle()} + disabled={!canInteract} + aria-label={isMentioned ? "Remove from chat" : "Add to chat"} + className={`border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary ${!canInteract ? "opacity-40 cursor-not-allowed" : ""}`} + />
diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 69452a53b..4e167b2e4 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -83,7 +83,11 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid // Atoms const { data: user } = useAtomValue(currentUserAtom); - const { data: searchSpacesData, refetch: refetchSearchSpaces, isSuccess: searchSpacesLoaded } = useAtomValue(searchSpacesAtom); + const { + data: searchSpacesData, + refetch: refetchSearchSpaces, + isSuccess: searchSpacesLoaded, + } = useAtomValue(searchSpacesAtom); const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom); const currentThreadState = useAtomValue(currentThreadAtom); const resetCurrentThread = useSetAtom(resetCurrentThreadAtom); @@ -279,13 +283,22 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid // Safety redirect: if the current search space is no longer in the user's list // (e.g. deleted by background task, membership revoked), redirect to a valid space. useEffect(() => { - if (!searchSpacesLoaded || !searchSpaceId || isDeletingSearchSpace || isLeavingSearchSpace) return; + if (!searchSpacesLoaded || !searchSpaceId || isDeletingSearchSpace || isLeavingSearchSpace) + return; if (searchSpaces.length > 0 && !activeSearchSpace) { router.replace(`/dashboard/${searchSpaces[0].id}/new-chat`); } else if (searchSpaces.length === 0 && searchSpacesLoaded) { router.replace("/dashboard"); } - }, [searchSpacesLoaded, searchSpaceId, searchSpaces, activeSearchSpace, isDeletingSearchSpace, isLeavingSearchSpace, router]); + }, [ + searchSpacesLoaded, + searchSpaceId, + searchSpaces, + activeSearchSpace, + isDeletingSearchSpace, + isLeavingSearchSpace, + router, + ]); // Transform and split chats into private and shared based on visibility const { myChats, sharedChats } = useMemo(() => { @@ -400,9 +413,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid // Await refetch so we have the freshest list (backend now hides [DELETING] spaces) const result = await refetchSearchSpaces(); - const updatedSpaces = (result.data ?? []).filter( - (s) => s.id !== searchSpaceToDelete.id - ); + const updatedSpaces = (result.data ?? []).filter((s) => s.id !== searchSpaceToDelete.id); if (isCurrentSpace) { if (updatedSpaces.length > 0) { @@ -421,14 +432,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid setShowDeleteSearchSpaceDialog(false); setSearchSpaceToDelete(null); } - }, [ - searchSpaceToDelete, - deleteSearchSpace, - refetchSearchSpaces, - searchSpaceId, - router, - t, - ]); + }, [searchSpaceToDelete, deleteSearchSpace, refetchSearchSpaces, searchSpaceId, router, t]); const confirmLeaveSearchSpace = useCallback(async () => { if (!searchSpaceToLeave) return; @@ -439,9 +443,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid const isCurrentSpace = Number(searchSpaceId) === searchSpaceToLeave.id; const result = await refetchSearchSpaces(); - const updatedSpaces = (result.data ?? []).filter( - (s) => s.id !== searchSpaceToLeave.id - ); + const updatedSpaces = (result.data ?? []).filter((s) => s.id !== searchSpaceToLeave.id); if (isCurrentSpace) { if (updatedSpaces.length > 0) { diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index 72b92fc55..05cfb890a 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -148,7 +148,14 @@ export function DocumentsSidebar({ return false; } }, - [deleteDocumentMutation, isSearchMode, t, searchRemoveItems, realtimeRemoveItems, setSidebarDocs] + [ + deleteDocumentMutation, + isSearchMode, + t, + searchRemoveItems, + realtimeRemoveItems, + setSidebarDocs, + ] ); const handleBulkDeleteDocuments = useCallback( diff --git a/surfsense_web/instrumentation-client.ts b/surfsense_web/instrumentation-client.ts index 20d370c4e..31a4e620e 100644 --- a/surfsense_web/instrumentation-client.ts +++ b/surfsense_web/instrumentation-client.ts @@ -10,8 +10,8 @@ function initPostHog() { defaults: "2025-11-30", capture_pageview: "history_change", capture_pageleave: true, - before_send: (event) => { - if (event?.properties) { + before_send: (event) => { + if (event?.properties) { const params = new URLSearchParams(window.location.search); const ref = params.get("ref"); if (ref) {