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, }; }