diff --git a/surfsense_backend/app/capabilities/__init__.py b/surfsense_backend/app/capabilities/__init__.py index 9c97ef59a..dfdab630b 100644 --- a/surfsense_backend/app/capabilities/__init__.py +++ b/surfsense_backend/app/capabilities/__init__.py @@ -1,4 +1,4 @@ -"""Scraper capability registry — typed, stateless verbs. See plans/backend/04-capabilities.md.""" +"""Scraper capability registry — typed, stateless verbs.""" from __future__ import annotations diff --git a/surfsense_web/app/dashboard/[workspace_id]/client-layout.tsx b/surfsense_web/app/dashboard/[workspace_id]/client-layout.tsx index 54fc9d000..fb76af227 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/client-layout.tsx @@ -8,7 +8,6 @@ import { useEffect } from "react"; import { pendingUserImageDataUrlsAtom } from "@/atoms/chat/pending-user-images.atom"; import { llmSetupStatusAtomFamily } from "@/atoms/model-connections/model-connections-query.atoms"; import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms"; -import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup"; import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup"; import { LayoutDataProvider } from "@/components/layout"; import { OnboardingTour } from "@/components/onboarding-tour"; @@ -169,7 +168,6 @@ export function DashboardClientLayout({ initialPlaygroundSidebarCollapsed={initialPlaygroundSidebarCollapsed} > {children} - ); diff --git a/surfsense_web/app/dashboard/[workspace_id]/connectors/callback/route.ts b/surfsense_web/app/dashboard/[workspace_id]/connectors/callback/route.ts index 09a2c2f2c..857075e58 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/connectors/callback/route.ts +++ b/surfsense_web/app/dashboard/[workspace_id]/connectors/callback/route.ts @@ -19,7 +19,9 @@ export async function GET( const response = new NextResponse(null, { status: 302, headers: { - Location: `/dashboard/${workspace_id}/new-chat`, + // Land on the connectors panel so `useConnectorDialog` (mounted there) + // consumes the result cookie and continues the indexing/edit flow. + Location: `/dashboard/${workspace_id}/connectors`, }, }); response.cookies.set(OAUTH_RESULT_COOKIE, result, { diff --git a/surfsense_web/app/dashboard/[workspace_id]/connectors/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/connectors/page.tsx new file mode 100644 index 000000000..d43a54d3d --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/connectors/page.tsx @@ -0,0 +1,5 @@ +import { ConnectorsSection } from "@/components/assistant-ui/connector-popup/connectors-panel"; + +export default function ConnectorsPage() { + return ; +} diff --git a/surfsense_web/app/dashboard/error.tsx b/surfsense_web/app/dashboard/error.tsx index 28a22fe3c..948ff134b 100644 --- a/surfsense_web/app/dashboard/error.tsx +++ b/surfsense_web/app/dashboard/error.tsx @@ -43,21 +43,15 @@ export default function DashboardError({ - - Go to dashboard home - - - - Report Issue - + + ); diff --git a/surfsense_web/components/assistant-ui/composer-add-menu-drawer.tsx b/surfsense_web/components/assistant-ui/composer-add-menu-drawer.tsx new file mode 100644 index 000000000..86a923917 --- /dev/null +++ b/surfsense_web/components/assistant-ui/composer-add-menu-drawer.tsx @@ -0,0 +1,349 @@ +"use client"; + +import { + ChevronLeft, + ChevronRight, + LayoutGrid, + Settings2, + TriangleAlert, + Unplug, + Upload, + Wrench, +} from "lucide-react"; +import Image from "next/image"; +import { type ReactNode, useCallback, useRef, useState } from "react"; +import type { ConnectorRow } from "@/components/assistant-ui/connector-popup/hooks/use-connector-rows"; +import { Button } from "@/components/ui/button"; +import { + Drawer, + DrawerContent, + DrawerHandle, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer"; +import { Separator } from "@/components/ui/separator"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Spinner } from "@/components/ui/spinner"; +import { Switch } from "@/components/ui/switch"; +import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; +import { + CONNECTOR_TOOL_ICON_PATHS, + getToolDisplayName, + getToolIcon, +} from "@/contracts/enums/toolIcons"; +import { cn } from "@/lib/utils"; + +/** Minimal shape of a grouped agent-tool section (mirrors thread.tsx). */ +export interface ToolGroupView { + label: string; + tools: { name: string }[]; + connectorIcon?: string; +} + +interface ComposerAddMenuDrawerProps { + /** The `+` button; rendered as the drawer trigger. */ + trigger: ReactNode; + onUploadFiles: () => void; + /** Connected connectors: one row per type, with live indexing health (`useConnectorRows`). */ + connectorRows: ConnectorRow[]; + /** Open a connector's manage view (deep-links via importConnectorRequestAtom). */ + onSelectConnector: (row: ConnectorRow) => void; + /** Navigate to the full connectors catalog. */ + onBrowseConnectors: () => void; + regularToolGroups: ToolGroupView[]; + connectorToolGroups: ToolGroupView[]; + otherToolGroup?: ToolGroupView; + disabledToolsSet: Set; + onToggleTool: (name: string) => void; + onToggleToolGroup: (names: string[]) => void; + /** True while the tool list is still loading (shows a skeleton). */ + toolsLoading: boolean; +} + +/** + * Mobile "+" menu. A single vaul drawer that behaves like a flat list at the + * root and drills into submenus in place (each level replaces the previous, + * with a back button) rather than nesting overlays — the touch-friendly + * equivalent of the desktop dropdown submenus. Screen state is a small push/pop + * stack; closing the drawer resets it to the root. + */ +type Screen = + | { kind: "root" } + | { kind: "connectors" } + | { kind: "tools" } + | { kind: "toolGroup"; label: string }; + +const ROW = "flex w-full items-center gap-3 px-4 py-3 text-sm hover:bg-accent hover:text-accent-foreground transition-colors"; + +export function ComposerAddMenuDrawer({ + trigger, + onUploadFiles, + connectorRows, + onSelectConnector, + onBrowseConnectors, + regularToolGroups, + connectorToolGroups, + otherToolGroup, + disabledToolsSet, + onToggleTool, + onToggleToolGroup, + toolsLoading, +}: ComposerAddMenuDrawerProps) { + const [open, setOpen] = useState(false); + const [stack, setStack] = useState([{ kind: "root" }]); + // Slide direction: forward on push, back on pop — drives the enter animation. + const dirRef = useRef<"forward" | "back">("forward"); + const current = stack[stack.length - 1]; + + const push = useCallback((screen: Screen) => { + dirRef.current = "forward"; + setStack((prev) => [...prev, screen]); + }, []); + const pop = useCallback(() => { + dirRef.current = "back"; + setStack((prev) => (prev.length > 1 ? prev.slice(0, -1) : prev)); + }, []); + + const handleOpenChange = useCallback((next: boolean) => { + setOpen(next); + // Reset to root when closed so the next open starts fresh. + if (!next) { + dirRef.current = "forward"; + setStack([{ kind: "root" }]); + } + }, []); + + const close = useCallback(() => handleOpenChange(false), [handleOpenChange]); + + const title = + current.kind === "connectors" + ? "MCP Connectors" + : current.kind === "tools" + ? "Manage Tools" + : current.kind === "toolGroup" + ? current.label + : "Add"; + + const renderToolRow = (name: string) => { + const isDisabled = disabledToolsSet.has(name); + const ToolIcon = getToolIcon(name); + return ( +
+ + {getToolDisplayName(name)} + onToggleTool(name)} + className="shrink-0" + /> +
+ ); + }; + + const renderBody = () => { + if (current.kind === "root") { + return ( + <> + + + + + ); + } + + if (current.kind === "connectors") { + return ( + <> + {connectorRows.length === 0 ? ( +

+ No connectors yet. +

+ ) : ( + connectorRows.map((row) => ( + + )) + )} + + + + ); + } + + if (current.kind === "toolGroup") { + const group = connectorToolGroups.find((g) => g.label === current.label); + return <>{group?.tools.map((t) => renderToolRow(t.name))}; + } + + // current.kind === "tools" + if (toolsLoading) { + return ( +
+ + {["t1", "t2", "t3", "t4"].map((k) => ( +
+ + + +
+ ))} +
+ ); + } + + return ( + <> + {regularToolGroups.map((group) => ( +
+
+ {group.label} +
+ {group.tools.map((t) => renderToolRow(t.name))} +
+ ))} + {connectorToolGroups.length > 0 && ( +
+
+ Connector Actions +
+ {connectorToolGroups.map((group) => { + const iconInfo = CONNECTOR_TOOL_ICON_PATHS[group.connectorIcon ?? ""]; + const toolNames = group.tools.map((t) => t.name); + const allDisabled = toolNames.every((n) => disabledToolsSet.has(n)); + return ( +
+ + onToggleToolGroup(toolNames)} + className="shrink-0" + /> +
+ ); + })} +
+ )} + {otherToolGroup && ( +
+
+ {otherToolGroup.label} +
+ {otherToolGroup.tools.map((t) => renderToolRow(t.name))} +
+ )} + + ); + }; + + return ( + + {trigger} + + + + {stack.length > 1 ? ( + + ) : ( + + )} + {title} + + +
+
+ {renderBody()} +
+
+
+
+ ); +} diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx deleted file mode 100644 index a8bfc29bd..000000000 --- a/surfsense_web/components/assistant-ui/connector-popup.tsx +++ /dev/null @@ -1,388 +0,0 @@ -"use client"; - -import { useAtomValue } from "jotai"; -import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react"; -import { createPortal } from "react-dom"; -import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom"; -import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms"; -import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; -import { Tabs, TabsContent } from "@/components/ui/tabs"; -import type { SearchSourceConnector } from "@/contracts/types/connector.types"; -import { useConnectorsSync } from "@/hooks/use-connectors-sync"; -import { PICKER_CLOSE_EVENT, PICKER_OPEN_EVENT } from "@/hooks/use-google-picker"; -import { useZeroDocumentTypeCounts } from "@/hooks/use-zero-document-type-counts"; -import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header"; -import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view"; -import { ConnectorEditView } from "./connector-popup/connector-configs/views/connector-edit-view"; -import { IndexingConfigurationView } from "./connector-popup/connector-configs/views/indexing-configuration-view"; -import { - COMPOSIO_CONNECTORS, - OAUTH_CONNECTORS, -} from "./connector-popup/constants/connector-constants"; -import { useConnectorDialog } from "./connector-popup/hooks/use-connector-dialog"; -import { useIndexingConnectors } from "./connector-popup/hooks/use-indexing-connectors"; -import { ActiveConnectorsTab } from "./connector-popup/tabs/active-connectors-tab"; -import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab"; -import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view"; -import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view"; - -export interface ConnectorIndicatorHandle { - open: () => void; -} - -interface ConnectorIndicatorProps { - showTrigger?: boolean; -} - -export const ConnectorIndicator = forwardRef( - (_props, ref) => { - const workspaceId = useAtomValue(activeWorkspaceIdAtom); - - // Real-time document type counts via Zero (updates instantly as docs are indexed) - const documentTypeCounts = useZeroDocumentTypeCounts(workspaceId); - // Read status inbox items from shared atom (populated by LayoutDataProvider) - // instead of creating a duplicate useInbox("status") hook. - const statusInboxItems = useAtomValue(statusInboxItemsAtom); - const inboxItems = useMemo( - () => statusInboxItems.filter((item) => item.type === "connector_indexing"), - [statusInboxItems] - ); - - // Use the custom hook for dialog state management - const { - isOpen, - activeTab, - connectingId, - isScrolled, - searchQuery, - indexingConfig, - indexingConnector, - indexingConnectorConfig, - editingConnector, - connectingConnectorType, - isCreatingConnector, - startDate, - endDate, - isStartingIndexing, - isSaving, - isDisconnecting, - periodicEnabled, - frequencyMinutes, - enableVisionLlm, - allConnectors, - viewingAccountsType, - viewingMCPList, - isYouTubeView, - isFromOAuth, - setSearchQuery, - setStartDate, - setEndDate, - setPeriodicEnabled, - setFrequencyMinutes, - setEnableVisionLlm, - handleOpenChange, - handleTabChange, - handleScroll, - handleConnectOAuth, - handleConnectNonOAuth, - handleCreateWebcrawler, - handleCreateYouTubeCrawler, - handleSubmitConnectForm, - handleStartIndexing, - handleSkipIndexing, - handleStartEdit, - handleSaveConnector, - handleDisconnectConnector, - handleBackFromEdit, - handleBackFromConnect, - handleBackFromYouTube, - handleViewAccountsList, - handleBackFromAccountsList, - handleBackFromMCPList, - handleAddNewMCPFromList, - handleQuickIndexConnector, - connectorConfig, - setConnectorConfig, - setIndexingConnectorConfig, - setConnectorName, - } = useConnectorDialog(); - - const [pickerOpen, setPickerOpen] = useState(false); - useEffect(() => { - const onOpen = () => setPickerOpen(true); - const onClose = () => setPickerOpen(false); - window.addEventListener(PICKER_OPEN_EVENT, onOpen); - window.addEventListener(PICKER_CLOSE_EVENT, onClose); - return () => { - window.removeEventListener(PICKER_OPEN_EVENT, onOpen); - window.removeEventListener(PICKER_CLOSE_EVENT, onClose); - }; - }, []); - - const { - connectors: connectorsFromSync = [], - loading: connectorsLoading, - error: connectorsError, - refreshConnectors: refreshConnectorsSync, - } = useConnectorsSync(workspaceId); - - const useSyncData = connectorsFromSync.length > 0 || (connectorsLoading && !connectorsError); - const connectors = useSyncData ? connectorsFromSync : allConnectors || []; - - const refreshConnectors = async () => { - if (useSyncData) { - await refreshConnectorsSync(); - } - }; - - // Track indexing state locally - clears automatically when last_indexed_at changes via real-time sync - // Also clears when failed notifications are detected - const { indexingConnectorIds, startIndexing, stopIndexing } = useIndexingConnectors( - connectors as SearchSourceConnector[], - inboxItems - ); - - // Get document types that have documents in the workspace - const activeDocumentTypes = documentTypeCounts - ? Object.entries(documentTypeCounts).filter(([, count]) => count > 0) - : []; - - const hasConnectors = connectors.length > 0; - const hasSources = hasConnectors || activeDocumentTypes.length > 0; - const totalSourceCount = connectors.length + activeDocumentTypes.length; - - const activeConnectorsCount = connectors.length; - - // Check which connectors are already connected - // Real-time connector updates via Zero sync - const connectedTypes = new Set( - (connectors || []).map((c: SearchSourceConnector) => c.connector_type) - ); - - useImperativeHandle(ref, () => ({ - open: () => handleOpenChange(true), - })); - - if (!workspaceId) return null; - - return ( - - {isOpen && - createPortal( - - ); - } -); - -ConnectorIndicator.displayName = "ConnectorIndicator"; diff --git a/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx index d8e4b174c..dadfe32ab 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx @@ -23,7 +23,6 @@ interface ConnectorCardProps { accountCount?: number; connectorCount?: number; isIndexing?: boolean; - deprecated?: boolean; onConnect?: () => void; onManage?: () => void; } @@ -53,15 +52,11 @@ export const ConnectorCard: FC = ({ accountCount, connectorCount, isIndexing = false, - deprecated = false, onConnect, onManage, }) => { const isMCP = connectorType === EnumConnectorName.MCP_CONNECTOR; const isLive = !!connectorType && LIVE_CONNECTOR_TYPES.has(connectorType); - // Deprecated connectors can no longer be connected, but existing rows stay - // manageable (so users can disconnect them). - const isDeprecatedForConnect = deprecated && !isConnected; // Get connector status const { getConnectorStatus, isConnectorEnabled, getConnectorStatusMessage, shouldShowWarnings } = useConnectorStatus(); @@ -78,10 +73,6 @@ export const ConnectorCard: FC = ({ return null; } - if (isDeprecatedForConnect) { - return "Deprecated. No longer available to connect."; - } - return description; }; @@ -102,16 +93,7 @@ export const ConnectorCard: FC = ({ : "bg-slate-400/5 dark:bg-white/5 border-slate-400/5 dark:border-white/5" )} > - {isMCP ? ( -