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.
This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-03-11 15:09:10 -07:00
parent 7c3aedf811
commit d61e29e74b
7 changed files with 312 additions and 97 deletions

View file

@ -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)

View file

@ -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<boolean>;
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<Document | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [mobileActionDoc, setMobileActionDoc] = useState<Document | null>(null);
const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false);
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
const router = useRouter();
const desktopSentinelRef = useRef<HTMLDivElement>(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 (
<div className="bg-sidebar overflow-hidden select-none border-t border-border/50 flex-1 flex flex-col min-h-0">
{/* Desktop Table View */}
<div className="hidden md:flex md:flex-col flex-1 min-h-0">
<Table className="table-fixed w-full">
<TableHeader>
<TableRow className="hover:bg-transparent border-b border-border/50">
<TableHead className="w-10 pl-3 pr-0 text-center h-8">
<div className="flex items-center justify-center h-full">
<Checkbox
checked={allMentionedOnPage || (someMentionedOnPage && "indeterminate")}
onCheckedChange={(v) => toggleAll(!!v)}
aria-label={hasChatMode ? "Toggle all for chat" : "Select all"}
className="border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary"
/>
</div>
</TableHead>
<TableHead className="h-8 px-2">
<SortableHeader
sortKey="title"
currentSortKey={sortKey}
sortDesc={sortDesc}
onSort={onSortHeader}
icon={<FileText size={14} className="text-muted-foreground" />}
>
Document
</SortableHeader>
</TableHead>
<TableHead className="w-10 text-center h-8 px-0">
<span className="flex items-center justify-center">
<Network size={14} className="text-muted-foreground" />
</span>
</TableHead>
<TableHead className="w-12 text-center h-8 pl-0 pr-3">
<Table className="table-fixed w-full">
<TableHeader>
<TableRow className="hover:bg-transparent border-b border-border/50">
<TableHead className="w-10 pl-3 pr-0 text-center h-8">
<div className="flex items-center justify-center h-full">
<Checkbox
checked={allMentionedOnPage || (someMentionedOnPage && "indeterminate")}
onCheckedChange={(v) => toggleAll(!!v)}
aria-label={hasChatMode ? "Toggle all for chat" : "Select all"}
className="border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary"
/>
</div>
</TableHead>
<TableHead className="h-8 px-2">
<SortableHeader
sortKey="title"
currentSortKey={sortKey}
sortDesc={sortDesc}
onSort={onSortHeader}
icon={<FileText size={14} className="text-muted-foreground" />}
>
Document
</SortableHeader>
</TableHead>
<TableHead className="w-10 text-center h-8 px-0">
<span className="flex items-center justify-center">
<Network size={14} className="text-muted-foreground" />
</span>
</TableHead>
<TableHead className="w-12 text-center h-8 pl-0 pr-3">
{hasDeletableSelection ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setBulkDeleteConfirmOpen(true)}
className="inline-flex items-center justify-center h-6 w-6 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent>
Delete {deletableSelectedIds.length} selected
</TooltipContent>
</Tooltip>
) : (
<span className="text-xs font-medium text-muted-foreground">Status</span>
</TableHead>
</TableRow>
</TableHeader>
</Table>
)}
</TableHead>
</TableRow>
</TableHeader>
</Table>
{loading ? (
<div className="flex-1 overflow-auto">
<Table className="table-fixed w-full">
@ -605,50 +683,50 @@ export function DocumentsTableShell({
<div ref={desktopScrollRef} className="flex-1 overflow-auto">
<Table className="table-fixed w-full">
<TableBody>
{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 (
<RowContextMenu
key={doc.id}
doc={doc}
onPreview={handleViewDocument}
onDelete={setDeleteDoc}
searchSpaceId={searchSpaceId}
onEditNavigate={onEditNavigate}
{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 (
<RowContextMenu
key={doc.id}
doc={doc}
onPreview={handleViewDocument}
onDelete={setDeleteDoc}
searchSpaceId={searchSpaceId}
onEditNavigate={onEditNavigate}
>
<tr
className={`border-b border-border/50 transition-colors ${
isMentioned ? "bg-primary/5 hover:bg-primary/8" : "hover:bg-muted/30"
} ${canInteract && hasChatMode ? "cursor-pointer" : ""}`}
onClick={handleRowClick}
>
<tr
className={`border-b border-border/50 transition-colors ${
isMentioned ? "bg-primary/5 hover:bg-primary/8" : "hover:bg-muted/30"
} ${canInteract && hasChatMode ? "cursor-pointer" : ""}`}
onClick={handleRowClick}
>
<TableCell
className="w-10 pl-3 pr-0 py-1.5 text-center"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-center h-full">
<Checkbox
checked={isMentioned}
onCheckedChange={() => 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" : ""}`}
/>
<Checkbox
checked={isMentioned}
onCheckedChange={() => 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" : ""}`}
/>
</div>
</TableCell>
<TableCell className="px-2 py-1.5 max-w-0">
@ -742,6 +820,22 @@ export function DocumentsTableShell({
ref={mobileScrollRef}
className="md:hidden divide-y divide-border/50 flex-1 overflow-auto"
>
{hasDeletableSelection && (
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border/50 sticky top-0 z-10">
<span className="text-xs text-muted-foreground">
{deletableSelectedIds.length} deletable selected
</span>
<Button
variant="destructive"
size="sm"
className="h-7 px-2.5 text-xs"
onClick={() => setBulkDeleteConfirmOpen(true)}
>
<Trash2 size={12} className="mr-1" />
Delete
</Button>
</div>
)}
{sorted.map((doc) => {
const isMentioned = mentionedDocIds?.has(doc.id) ?? false;
const canInteract = isSelectable(doc);
@ -957,6 +1051,41 @@ export function DocumentsTableShell({
</div>
</DrawerContent>
</Drawer>
{/* Bulk Delete Confirmation Dialog */}
<AlertDialog
open={bulkDeleteConfirmOpen}
onOpenChange={(open) => !open && !isBulkDeleting && setBulkDeleteConfirmOpen(false)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Delete {deletableSelectedIds.length} document
{deletableSelectedIds.length !== 1 ? "s" : ""}?
</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone.{" "}
{deletableSelectedIds.length === 1
? "This document"
: `These ${deletableSelectedIds.length} documents`}{" "}
will be permanently deleted from your search space.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isBulkDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleBulkDelete();
}}
disabled={isBulkDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isBulkDeleting ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View file

@ -258,7 +258,10 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
</TooltipIconButton>
)}
<DialogContent 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">
<DialogContent
onFocusOutside={(e) => 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"
>
<DialogTitle className="sr-only">Manage Connectors</DialogTitle>
{/* YouTube Crawler View - shown when adding YouTube videos */}
{isYouTubeView && searchSpaceId ? (

View file

@ -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

View file

@ -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) => {

View file

@ -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}

View file

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