Merge remote-tracking branch 'upstream/dev' into fixes-playground-ui

# Conflicts:
#	surfsense_web/lib/apis/base-api.service.ts
This commit is contained in:
CREDO23 2026-07-24 08:29:39 +02:00
commit 06c7e27c72
87 changed files with 2882 additions and 1673 deletions

View file

@ -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<string>;
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<Screen[]>([{ 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 (
<div key={name} className={ROW}>
<ToolIcon className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate font-medium">{getToolDisplayName(name)}</span>
<Switch
checked={!isDisabled}
onCheckedChange={() => onToggleTool(name)}
className="shrink-0"
/>
</div>
);
};
const renderBody = () => {
if (current.kind === "root") {
return (
<>
<button
type="button"
className={ROW}
onClick={() => {
onUploadFiles();
close();
}}
>
<Upload className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 text-left">Upload Files</span>
</button>
<button
type="button"
className={ROW}
onClick={() => push({ kind: "connectors" })}
>
<Unplug className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 text-left">MCP Connectors</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
</button>
<button type="button" className={ROW} onClick={() => push({ kind: "tools" })}>
<Settings2 className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 text-left">Manage Tools</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
</button>
</>
);
}
if (current.kind === "connectors") {
return (
<>
{connectorRows.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-muted-foreground">
No connectors yet.
</p>
) : (
connectorRows.map((row) => (
<button
type="button"
key={row.type}
className={ROW}
onClick={() => {
onSelectConnector(row);
close();
}}
>
{getConnectorIcon(row.type, "size-4 shrink-0 text-muted-foreground")}
<span className="min-w-0 flex-1 truncate text-left">{row.title}</span>
{row.health === "syncing" ? (
<Spinner size="xs" className="shrink-0" />
) : row.health === "failed" ? (
<TriangleAlert
className="size-4 shrink-0 text-destructive"
aria-label={row.errorMessage ?? "Indexing failed"}
/>
) : row.accountCount > 1 ? (
<span className="shrink-0 text-xs text-muted-foreground">{row.accountCount}</span>
) : null}
</button>
))
)}
<Separator className="my-1" />
<button
type="button"
className={ROW}
onClick={() => {
onBrowseConnectors();
close();
}}
>
<LayoutGrid className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 text-left">Manage connectors</span>
</button>
</>
);
}
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 (
<div className="px-4 pt-3 pb-2">
<Skeleton className="h-3 w-16 mb-2" />
{["t1", "t2", "t3", "t4"].map((k) => (
<div key={k} className="flex items-center gap-3 py-2">
<Skeleton className="size-4 rounded shrink-0" />
<Skeleton className="h-3.5 flex-1" />
<Skeleton className="h-5 w-9 rounded-full shrink-0" />
</div>
))}
</div>
);
}
return (
<>
{regularToolGroups.map((group) => (
<div key={group.label}>
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground font-semibold select-none">
{group.label}
</div>
{group.tools.map((t) => renderToolRow(t.name))}
</div>
))}
{connectorToolGroups.length > 0 && (
<div>
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground font-semibold select-none">
Connector Actions
</div>
{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 (
<div key={group.label} className={ROW}>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-3"
onClick={() => push({ kind: "toolGroup", label: group.label })}
>
{iconInfo ? (
<Image
src={iconInfo.src}
alt={iconInfo.alt}
width={18}
height={18}
className="size-[18px] shrink-0 select-none pointer-events-none"
draggable={false}
/>
) : (
<Wrench className="size-4 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 flex-1 truncate text-left font-medium">
{group.label}
</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
</button>
<Switch
checked={!allDisabled}
onCheckedChange={() => onToggleToolGroup(toolNames)}
className="shrink-0"
/>
</div>
);
})}
</div>
)}
{otherToolGroup && (
<div>
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground font-semibold select-none">
{otherToolGroup.label}
</div>
{otherToolGroup.tools.map((t) => renderToolRow(t.name))}
</div>
)}
</>
);
};
return (
<Drawer open={open} onOpenChange={handleOpenChange} shouldScaleBackground={false}>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="h-[85vh] max-h-[85vh] z-80" overlayClassName="z-80">
<DrawerHandle />
<DrawerHeader className="flex flex-row items-center gap-1 px-2 pb-2 pt-1">
{stack.length > 1 ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-9 shrink-0"
onClick={pop}
aria-label="Back"
>
<ChevronLeft className="size-5" />
</Button>
) : (
<span className="size-9 shrink-0" aria-hidden />
)}
<DrawerTitle className="flex-1 text-center text-base font-semibold">{title}</DrawerTitle>
<span className="size-9 shrink-0" aria-hidden />
</DrawerHeader>
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin pb-6">
<div
key={current.kind === "toolGroup" ? `toolGroup:${current.label}` : current.kind}
className={cn(
"animate-in fade-in-0 duration-200",
dirRef.current === "forward" ? "slide-in-from-right-4" : "slide-in-from-left-4"
)}
>
{renderBody()}
</div>
</div>
</DrawerContent>
</Drawer>
);
}

View file

@ -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<ConnectorIndicatorHandle, ConnectorIndicatorProps>(
(_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<string>(
(connectors || []).map((c: SearchSourceConnector) => c.connector_type)
);
useImperativeHandle(ref, () => ({
open: () => handleOpenChange(true),
}));
if (!workspaceId) return null;
return (
<Dialog open={isOpen} modal={false} onOpenChange={handleOpenChange}>
{isOpen &&
createPortal(
<div
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
aria-hidden="true"
onClick={() => {
if (!pickerOpen) handleOpenChange(false);
}}
/>,
document.body
)}
<DialogContent
onFocusOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => {
if (pickerOpen) e.preventDefault();
}}
onPointerDownOutside={(e) => {
if (pickerOpen) 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 ring-0 dark:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 [&>button]:hover:opacity-100 [&>button]:hover:bg-accent [&>button]:hover:text-accent-foreground [&>button>svg]:size-5 select-none"
>
<DialogTitle className="sr-only">MCP Connectors</DialogTitle>
{/* YouTube Crawler View - shown when adding YouTube videos */}
{isYouTubeView && workspaceId ? (
<YouTubeCrawlerView workspaceId={workspaceId} onBack={handleBackFromYouTube} />
) : viewingMCPList ? (
<ConnectorAccountsListView
connectorType="MCP_CONNECTOR"
connectorTitle="MCP Connectors"
connectors={(allConnectors || []) as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
onBack={handleBackFromMCPList}
onManage={handleStartEdit}
onAddAccount={handleAddNewMCPFromList}
addButtonText="Add New MCP Server"
/>
) : viewingAccountsType ? (
<ConnectorAccountsListView
connectorType={viewingAccountsType.connectorType}
connectorTitle={viewingAccountsType.connectorTitle}
connectors={(connectors || []) as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
onBack={handleBackFromAccountsList}
onManage={handleStartEdit}
onAddAccount={() => {
// Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
const oauthConnector =
OAUTH_CONNECTORS.find(
(c) => c.connectorType === viewingAccountsType.connectorType
) ||
COMPOSIO_CONNECTORS.find(
(c) => c.connectorType === viewingAccountsType.connectorType
);
if (oauthConnector) {
handleConnectOAuth(oauthConnector);
}
}}
isConnecting={connectingId !== null}
/>
) : connectingConnectorType ? (
<ConnectorConnectView
connectorType={connectingConnectorType}
onSubmit={(formData) => handleSubmitConnectForm(formData, startIndexing)}
onBack={handleBackFromConnect}
isSubmitting={isCreatingConnector}
/>
) : editingConnector ? (
<ConnectorEditView
connector={{
...editingConnector,
config: connectorConfig || editingConnector.config,
name: editingConnector.name,
// Sync last_indexed_at with live data from real-time sync
last_indexed_at:
(connectors as SearchSourceConnector[]).find((c) => c.id === editingConnector.id)
?.last_indexed_at ?? editingConnector.last_indexed_at,
}}
startDate={startDate}
endDate={endDate}
periodicEnabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
enableVisionLlm={enableVisionLlm}
isSaving={isSaving}
isDisconnecting={isDisconnecting}
isIndexing={indexingConnectorIds.has(editingConnector.id)}
workspaceId={workspaceId?.toString()}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPeriodicEnabledChange={setPeriodicEnabled}
onFrequencyChange={setFrequencyMinutes}
onEnableVisionLlmChange={setEnableVisionLlm}
onSave={() => {
startIndexing(editingConnector.id);
handleSaveConnector(() => refreshConnectors());
}}
onDisconnect={() => handleDisconnectConnector(() => refreshConnectors())}
onBack={handleBackFromEdit}
onQuickIndex={(() => {
const cfg = connectorConfig || editingConnector.config;
const isDriveOrOneDrive =
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
editingConnector.connector_type === "ONEDRIVE_CONNECTOR" ||
editingConnector.connector_type === "DROPBOX_CONNECTOR";
const hasDriveItems = isDriveOrOneDrive
? ((cfg?.selected_folders as unknown[]) ?? []).length > 0 ||
((cfg?.selected_files as unknown[]) ?? []).length > 0
: true;
if (!hasDriveItems) return undefined;
return () => {
startIndexing(editingConnector.id);
handleQuickIndexConnector(
editingConnector.id,
editingConnector.connector_type,
stopIndexing,
startDate,
endDate
);
};
})()}
onConfigChange={setConnectorConfig}
onNameChange={setConnectorName}
/>
) : indexingConfig ? (
<IndexingConfigurationView
config={indexingConfig}
connector={
indexingConnector
? {
...indexingConnector,
config: indexingConnectorConfig || indexingConnector.config,
}
: undefined
}
startDate={startDate}
endDate={endDate}
periodicEnabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
enableVisionLlm={enableVisionLlm}
isStartingIndexing={isStartingIndexing}
isFromOAuth={isFromOAuth}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPeriodicEnabledChange={setPeriodicEnabled}
onFrequencyChange={setFrequencyMinutes}
onEnableVisionLlmChange={setEnableVisionLlm}
onConfigChange={setIndexingConnectorConfig}
onStartIndexing={() => {
if (indexingConfig.connectorId) {
startIndexing(indexingConfig.connectorId);
}
handleStartIndexing(() => refreshConnectors());
}}
onSkip={handleSkipIndexing}
/>
) : (
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="flex-1 flex flex-col min-h-0"
>
{/* Header */}
<ConnectorDialogHeader
activeTab={activeTab}
totalSourceCount={activeConnectorsCount}
searchQuery={searchQuery}
onTabChange={handleTabChange}
onSearchChange={setSearchQuery}
isScrolled={isScrolled}
/>
{/* Content */}
<div className="flex-1 min-h-0 relative overflow-hidden">
<div className="h-full overflow-y-auto" onScroll={handleScroll}>
<div className="px-4 sm:px-12 py-4 sm:py-8 pb-12 sm:pb-16">
<TabsContent value="all" className="m-0">
<AllConnectorsTab
searchQuery={searchQuery}
workspaceId={workspaceId}
connectedTypes={connectedTypes}
connectingId={connectingId}
allConnectors={connectors}
documentTypeCounts={documentTypeCounts}
indexingConnectorIds={indexingConnectorIds}
onConnectOAuth={handleConnectOAuth}
onConnectNonOAuth={handleConnectNonOAuth}
onCreateWebcrawler={handleCreateWebcrawler}
onCreateYouTubeCrawler={handleCreateYouTubeCrawler}
onManage={handleStartEdit}
onViewAccountsList={handleViewAccountsList}
/>
</TabsContent>
<ActiveConnectorsTab
searchQuery={searchQuery}
hasSources={hasSources}
totalSourceCount={totalSourceCount}
activeDocumentTypes={activeDocumentTypes}
connectors={connectors as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
onTabChange={handleTabChange}
onManage={handleStartEdit}
onViewAccountsList={handleViewAccountsList}
/>
</div>
</div>
{/* Bottom fade shadow */}
<div className="absolute bottom-0 left-0 right-0 h-7 bg-linear-to-t from-popover via-popover/80 to-transparent pointer-events-none z-10" />
</div>
</Tabs>
)}
</DialogContent>
</Dialog>
);
}
);
ConnectorIndicator.displayName = "ConnectorIndicator";

View file

@ -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<ConnectorCardProps> = ({
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<ConnectorCardProps> = ({
return null;
}
if (isDeprecatedForConnect) {
return "Deprecated. No longer available to connect.";
}
return description;
};
@ -102,16 +93,7 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
: "bg-slate-400/5 dark:bg-white/5 border-slate-400/5 dark:border-white/5"
)}
>
{isMCP ? (
<span
aria-hidden="true"
className="size-6 bg-current"
style={{
mask: "url('/connectors/modelcontextprotocol.svg') center / contain no-repeat",
WebkitMask: "url('/connectors/modelcontextprotocol.svg') center / contain no-repeat",
}}
/>
) : connectorType ? (
{connectorType ? (
getConnectorIcon(connectorType, "size-6")
) : id === "youtube-crawler" ? (
<IconBrandYoutube className="size-6" />
@ -169,20 +151,18 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
!isConnected && "shadow-xs"
)}
onClick={isConnected ? onManage : onConnect}
disabled={isConnecting || !isEnabled || isDeprecatedForConnect}
disabled={isConnecting || !isEnabled}
>
<span className={isConnecting ? "opacity-0" : ""}>
{isDeprecatedForConnect
? "Deprecated"
: !isEnabled
? "Unavailable"
: isConnected
? "Manage"
: id === "youtube-crawler"
? "Add"
: connectorType
? "Connect"
: "Add"}
{!isEnabled
? "Unavailable"
: isConnected
? "Manage"
: id === "youtube-crawler"
? "Add"
: connectorType
? "Connect"
: "Add"}
</span>
{isConnecting && <Spinner size="xs" className="absolute" />}
</Button>

View file

@ -139,7 +139,7 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
<Server className="h-4 w-4 shrink-0" />
<AlertDescription className="text-[10px] sm:text-xs">
Connect to an MCP (Model Context Protocol) server. Each MCP server is added as a separate
Connect to a Model Context Protocol server. Each MCP server is added as a separate
connector.
</AlertDescription>
</Alert>
@ -275,8 +275,8 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
<div className="mt-3 pt-3 border-t border-green-500/20">
<p className="font-semibold mb-2">Available tools:</p>
<ul className="list-disc list-inside text-xs space-y-0.5">
{testResult.tools.map((tool, i) => (
<li key={i}>{tool.name}</li>
{testResult.tools.map((tool) => (
<li key={tool.name}>{tool.name}</li>
))}
</ul>
</div>

View file

@ -1,14 +0,0 @@
"use client";
import type { FC } from "react";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
interface ComposioCalendarConfigProps {
connector: SearchSourceConnector;
onConfigChange?: (config: Record<string, unknown>) => void;
onNameChange?: (name: string) => void;
}
export const ComposioCalendarConfig: FC<ComposioCalendarConfigProps> = () => {
return <div className="space-y-6" />;
};

View file

@ -1,14 +0,0 @@
"use client";
import type { FC } from "react";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
interface ComposioGmailConfigProps {
connector: SearchSourceConnector;
onConfigChange?: (config: Record<string, unknown>) => void;
onNameChange?: (name: string) => void;
}
export const ComposioGmailConfig: FC<ComposioGmailConfigProps> = () => {
return <div className="space-y-6" />;
};

View file

@ -0,0 +1,48 @@
"use client";
import { CheckCircle2 } from "lucide-react";
import type { FC } from "react";
import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
import {
COMPOSIO_CONNECTORS,
CRAWLERS,
OAUTH_CONNECTORS,
OTHER_CONNECTORS,
} from "../../constants/connector-constants";
import type { ConnectorConfigProps } from "../index";
// Catalog descriptions keyed by connector type, reused as the capability line so
// the copy stays in sync with the catalog cards.
const DESCRIPTION_BY_TYPE = new Map<string, string>(
[...OAUTH_CONNECTORS, ...COMPOSIO_CONNECTORS, ...OTHER_CONNECTORS, ...CRAWLERS].map((c) => [
c.connectorType,
c.description,
])
);
/**
* Fallback manage view for live connectors that are neither MCP-backed nor have
* a dedicated config component (native/Composio Gmail & Calendar). There is
* nothing to configure, so we just confirm the connection and echo what the
* agent can do no Trusted Tools (that feature is MCP-only, see the backend
* `_ensure_mcp_connector_for_user`).
*/
export const LiveConnectorConnectedCard: FC<ConnectorConfigProps> = ({ connector }) => {
const capability =
DESCRIPTION_BY_TYPE.get(connector.connector_type) ??
`connect to ${getConnectorTypeDisplay(connector.connector_type)}`;
return (
<div className="space-y-6">
<div className="rounded-xl border border-border bg-emerald-500/5 p-4 flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-500/10 shrink-0 mt-0.5">
<CheckCircle2 className="size-4 text-emerald-500" />
</div>
<div className="text-xs sm:text-sm">
<p className="font-medium text-xs sm:text-sm">Connected</p>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">{capability}.</p>
</div>
</div>
</div>
);
};

View file

@ -55,12 +55,8 @@ const configMap: Record<string, () => Promise<{ default: FC<ConnectorConfigProps
import("./components/obsidian-config").then((m) => ({ default: m.ObsidianConfig })),
COMPOSIO_GOOGLE_DRIVE_CONNECTOR: () =>
import("./components/composio-drive-config").then((m) => ({ default: m.ComposioDriveConfig })),
COMPOSIO_GMAIL_CONNECTOR: () =>
import("./components/composio-gmail-config").then((m) => ({ default: m.ComposioGmailConfig })),
COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: () =>
import("./components/composio-calendar-config").then((m) => ({
default: m.ComposioCalendarConfig,
})),
// Composio Gmail/Calendar have nothing to configure; the edit view falls back
// to LiveConnectorConnectedCard for live connectors without a config here.
};
const componentCache = new Map<string, ConnectorConfigComponent>();

View file

@ -89,7 +89,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
return (
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* Header */}
<div className="flex-shrink-0 px-6 sm:px-12 pt-8 sm:pt-10">
<div className="flex-shrink-0">
<Button
variant="ghost"
type="button"
@ -123,7 +123,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
{/* Form Content - Scrollable */}
<div
ref={formContainerRef}
className="connector-connect-form-root flex-1 min-h-0 overflow-y-auto px-6 sm:px-12"
className="connector-connect-form-root flex-1 min-h-0 overflow-y-auto"
>
<ConnectFormComponent
onSubmit={onSubmit}
@ -134,15 +134,15 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
</div>
{/* Fixed Footer - Action buttons */}
<div className="flex-shrink-0 flex items-center justify-between px-6 sm:px-12 py-6 bg-popover">
<Button
<div className="flex-shrink-0 flex items-center justify-end py-6 bg-transparent">
{/* <Button
variant="ghost"
onClick={onBack}
disabled={isSubmitting}
className="text-xs sm:text-sm"
>
Cancel
</Button>
</Button> */}
<Button
type="button"
onClick={handleFormSubmit}

View file

@ -20,6 +20,7 @@ import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
import { VisionLLMConfig } from "../../components/vision-llm-config";
import { LIVE_CONNECTOR_TYPES } from "../../constants/connector-constants";
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
import { LiveConnectorConnectedCard } from "../components/live-connector-connected-card";
import { MCPServiceConfig } from "../components/mcp-service-config";
import { getConnectorConfigComponent } from "../index";
@ -124,8 +125,13 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
// Get connector-specific config component (MCP-backed connectors use a generic view)
const ConnectorConfigComponent = useMemo(() => {
if (isMCPBacked) return MCPServiceConfig;
return getConnectorConfigComponent(connector.connector_type);
}, [connector.connector_type, isMCPBacked]);
// Live connectors without a dedicated config (native/Composio Gmail &
// Calendar) fall back to a simple "Connected" card instead of a blank body.
return (
getConnectorConfigComponent(connector.connector_type) ??
(isLive ? LiveConnectorConnectedCard : null)
);
}, [connector.connector_type, isMCPBacked, isLive]);
const [isScrolled, setIsScrolled] = useState(false);
const [hasMoreContent, setHasMoreContent] = useState(false);
const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false);
@ -201,7 +207,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
{/* Fixed Header */}
<div
className={cn(
"flex-shrink-0 px-6 sm:px-12 pt-8 sm:pt-10 transition-shadow duration-200 relative z-10",
"flex-shrink-0 transition-shadow duration-200 relative z-10",
isScrolled && "shadow-sm"
)}
>
@ -262,7 +268,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
<div className="flex-1 min-h-0 relative overflow-hidden">
<div
ref={scrollContainerRef}
className="h-full overflow-y-auto px-6 sm:px-12"
className="h-full overflow-y-auto"
onScroll={handleScroll}
>
<div className="space-y-6 pb-6 pt-2">
@ -362,7 +368,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
</div>
{/* Fixed Footer - Action buttons */}
<div className="flex-shrink-0 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 sm:gap-0 px-6 sm:px-12 py-6 sm:py-6 bg-popover">
<div className="flex-shrink-0 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 sm:gap-0 py-6 sm:py-6 bg-transparent">
{showDisconnectConfirm ? (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 flex-1 sm:flex-initial">
<span className="text-xs sm:text-sm text-muted-foreground sm:whitespace-nowrap">

View file

@ -120,7 +120,7 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
{/* Fixed Header */}
<div
className={cn(
"shrink-0 px-6 sm:px-12 pt-8 sm:pt-10 transition-shadow duration-200 relative z-10",
"shrink-0 transition-shadow duration-200 relative z-10",
isScrolled && "shadow-sm"
)}
>
@ -166,7 +166,7 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
<div className="flex-1 min-h-0 relative overflow-hidden">
<div
ref={scrollContainerRef}
className="h-full overflow-y-auto px-6 sm:px-12"
className="h-full overflow-y-auto"
onScroll={handleScroll}
>
<div className="space-y-6 pb-6 pt-2">
@ -240,7 +240,7 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
</div>
{/* Fixed Footer - Action buttons */}
<div className="flex-shrink-0 flex items-center justify-end px-6 sm:px-12 py-6 bg-popover">
<div className="flex-shrink-0 flex items-center justify-end py-6 bg-transparent">
{isLive ? (
<Button onClick={onSkip} className="text-xs sm:text-sm">
Done

View file

@ -0,0 +1,483 @@
"use client";
import { useAtomValue } from "jotai";
import { ChevronRight, LayoutGrid, Search, TriangleAlert, X } from "lucide-react";
import { type ReactNode, useMemo, useState } from "react";
import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerContent,
DrawerHandle,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { useConnectorsSync } from "@/hooks/use-connectors-sync";
import { useZeroDocumentTypeCounts } from "@/hooks/use-zero-document-type-counts";
import { cn } from "@/lib/utils";
import { ConnectorConnectView } from "./connector-configs/views/connector-connect-view";
import { ConnectorEditView } from "./connector-configs/views/connector-edit-view";
import { IndexingConfigurationView } from "./connector-configs/views/indexing-configuration-view";
import {
COMPOSIO_CONNECTORS,
getConnectorTitle,
OAUTH_CONNECTORS,
} from "./constants/connector-constants";
import { type ConnectorRow, useConnectorRows } from "./hooks/use-connector-rows";
import { useConnectorDialog } from "./hooks/use-connector-dialog";
import { AllConnectorsTab } from "./tabs/all-connectors-tab";
import { ConnectorAccountsListView } from "./views/connector-accounts-list-view";
import { YouTubeCrawlerView } from "./views/youtube-crawler-view";
/**
* Connector management surface (single `/connectors` route) rendered inside the
* workspace panel. Stateful masterdetail:
* - sub-rail: Overview + a flat "Your connectors" list (only what's connected,
* each with a live status glyph) + an "Add MCP server" action;
* - detail pane: the flat catalog (search + cards) OR one of the existing flow
* views (connect / edit / indexing / accounts / YouTube), reused verbatim
* from the former dialog.
*
* Unconnected connectors are one click from the catalog cards, so they never
* clutter the rail. The hook's internal `isOpen`/`connectorDialogOpenAtom` is
* inert on a route and ignored.
*/
export function ConnectorsSection() {
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
const documentTypeCounts = useZeroDocumentTypeCounts(workspaceId);
const [drawerOpen, setDrawerOpen] = useState(false);
const {
connectingId,
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,
handleConnectOAuth,
handleConnectNonOAuth,
handleCreateWebcrawler,
handleCreateYouTubeCrawler,
handleSubmitConnectForm,
handleStartIndexing,
handleSkipIndexing,
handleStartEdit,
handleSaveConnector,
handleDisconnectConnector,
handleBackFromEdit,
handleBackFromConnect,
handleBackFromYouTube,
handleViewAccountsList,
handleBackFromAccountsList,
handleViewMCPList,
handleBackFromMCPList,
handleAddNewMCPFromList,
handleQuickIndexConnector,
connectorConfig,
setConnectorConfig,
setIndexingConnectorConfig,
setConnectorName,
} = useConnectorDialog();
const {
connectors: connectorsFromSync = [],
loading: connectorsLoading,
error: connectorsError,
refreshConnectors: refreshConnectorsSync,
} = useConnectorsSync(workspaceId);
const useSyncData = connectorsFromSync.length > 0 || (connectorsLoading && !connectorsError);
const connectors = (useSyncData ? connectorsFromSync : allConnectors || []) as SearchSourceConnector[];
const refreshConnectors = async () => {
if (useSyncData) {
await refreshConnectorsSync();
}
};
// "Your connectors" rows with live indexing health, plus the shared indexing
// controls the flow views reuse (single source of truth via useConnectorRows).
const {
rows: connectedRows,
indexingConnectorIds,
startIndexing,
stopIndexing,
} = useConnectorRows(connectors);
const connectedTypes = useMemo(
() => new Set<string>(connectors.map((c) => c.connector_type)),
[connectors]
);
if (!workspaceId) return null;
const activeConnectorType = editingConnector
? editingConnector.connector_type
: connectingConnectorType ||
viewingAccountsType?.connectorType ||
(viewingMCPList ? EnumConnectorName.MCP_CONNECTOR : null);
const flowView = ((): ReactNode => {
if (isYouTubeView) {
return <YouTubeCrawlerView workspaceId={workspaceId} onBack={handleBackFromYouTube} />;
}
if (viewingMCPList) {
return (
<ConnectorAccountsListView
connectorType="MCP_CONNECTOR"
connectorTitle="MCP Connectors"
connectors={(allConnectors || []) as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
onBack={handleBackFromMCPList}
onManage={handleStartEdit}
onAddAccount={handleAddNewMCPFromList}
addButtonText="Add New MCP Server"
/>
);
}
if (viewingAccountsType) {
return (
<ConnectorAccountsListView
connectorType={viewingAccountsType.connectorType}
connectorTitle={viewingAccountsType.connectorTitle}
connectors={connectors}
indexingConnectorIds={indexingConnectorIds}
onBack={handleBackFromAccountsList}
onManage={handleStartEdit}
onAddAccount={() => {
const oauthConnector =
OAUTH_CONNECTORS.find(
(c) => c.connectorType === viewingAccountsType.connectorType
) ||
COMPOSIO_CONNECTORS.find(
(c) => c.connectorType === viewingAccountsType.connectorType
);
if (oauthConnector) {
handleConnectOAuth(oauthConnector);
}
}}
isConnecting={connectingId !== null}
/>
);
}
if (connectingConnectorType) {
return (
<ConnectorConnectView
connectorType={connectingConnectorType}
onSubmit={(formData) => handleSubmitConnectForm(formData, startIndexing)}
onBack={handleBackFromConnect}
isSubmitting={isCreatingConnector}
/>
);
}
if (editingConnector) {
return (
<ConnectorEditView
connector={{
...editingConnector,
config: connectorConfig || editingConnector.config,
name: editingConnector.name,
last_indexed_at:
connectors.find((c) => c.id === editingConnector.id)?.last_indexed_at ??
editingConnector.last_indexed_at,
}}
startDate={startDate}
endDate={endDate}
periodicEnabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
enableVisionLlm={enableVisionLlm}
isSaving={isSaving}
isDisconnecting={isDisconnecting}
isIndexing={indexingConnectorIds.has(editingConnector.id)}
workspaceId={workspaceId?.toString()}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPeriodicEnabledChange={setPeriodicEnabled}
onFrequencyChange={setFrequencyMinutes}
onEnableVisionLlmChange={setEnableVisionLlm}
onSave={() => {
startIndexing(editingConnector.id);
handleSaveConnector(() => refreshConnectors());
}}
onDisconnect={() => handleDisconnectConnector(() => refreshConnectors())}
onBack={handleBackFromEdit}
onQuickIndex={(() => {
const cfg = connectorConfig || editingConnector.config;
const isDriveOrOneDrive =
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
editingConnector.connector_type === "ONEDRIVE_CONNECTOR" ||
editingConnector.connector_type === "DROPBOX_CONNECTOR";
const hasDriveItems = isDriveOrOneDrive
? ((cfg?.selected_folders as unknown[]) ?? []).length > 0 ||
((cfg?.selected_files as unknown[]) ?? []).length > 0
: true;
if (!hasDriveItems) return undefined;
return () => {
startIndexing(editingConnector.id);
handleQuickIndexConnector(
editingConnector.id,
editingConnector.connector_type,
stopIndexing,
startDate,
endDate
);
};
})()}
onConfigChange={setConnectorConfig}
onNameChange={setConnectorName}
/>
);
}
if (indexingConfig) {
return (
<IndexingConfigurationView
config={indexingConfig}
connector={
indexingConnector
? {
...indexingConnector,
config: indexingConnectorConfig || indexingConnector.config,
}
: undefined
}
startDate={startDate}
endDate={endDate}
periodicEnabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
enableVisionLlm={enableVisionLlm}
isStartingIndexing={isStartingIndexing}
isFromOAuth={isFromOAuth}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPeriodicEnabledChange={setPeriodicEnabled}
onFrequencyChange={setFrequencyMinutes}
onEnableVisionLlmChange={setEnableVisionLlm}
onConfigChange={setIndexingConnectorConfig}
onStartIndexing={() => {
if (indexingConfig.connectorId) {
startIndexing(indexingConfig.connectorId);
}
handleStartIndexing(() => refreshConnectors());
}}
onSkip={handleSkipIndexing}
/>
);
}
return null;
})();
const isFlowActive = flowView !== null;
const detailTitle =
isFlowActive && activeConnectorType ? getConnectorTitle(activeConnectorType) : "Overview";
const hasConnected = connectedRows.length > 0;
// Rail navigation: clear any open flow first (handleOpenChange(false) is the
// hook's full reset), then apply the target management action.
const resetFlow = () => handleOpenChange(false);
const goOverview = () => {
setDrawerOpen(false);
resetFlow();
};
const manageRow = (row: ConnectorRow) => {
setDrawerOpen(false);
resetFlow();
if (row.type === EnumConnectorName.MCP_CONNECTOR) {
handleViewMCPList();
return;
}
if (row.accountCount > 1) {
handleViewAccountsList(row.type, row.title);
return;
}
handleStartEdit(row.connectors[0]);
};
const addMcpServer = () => {
setDrawerOpen(false);
resetFlow();
handleConnectNonOAuth?.(EnumConnectorName.MCP_CONNECTOR);
};
const navBtnClass = (active: boolean) =>
cn(
"inline-flex w-full items-center justify-start gap-3 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150 focus:outline-none",
active
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
);
const railLabel = (text: string) => (
<p className="px-3 pb-1 pt-1 text-xs font-semibold text-muted-foreground">{text}</p>
);
const renderNav = () => (
<>
<button type="button" onClick={goOverview} className={navBtnClass(!isFlowActive)}>
<LayoutGrid className="h-4 w-4" />
<span className="min-w-0 truncate">Overview</span>
</button>
{hasConnected && (
<>
<Separator className="my-3 bg-border" />
{railLabel("Your connectors")}
<div className="flex flex-col gap-0.5">
{connectedRows.map((row) => {
const isActive = isFlowActive && activeConnectorType === row.type;
return (
<button
key={row.type}
type="button"
onClick={() => manageRow(row)}
className={cn(
"inline-flex w-full items-center justify-start gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors duration-150 focus:outline-none",
isActive
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<span className="flex size-4 shrink-0 items-center justify-center">
{getConnectorIcon(row.type, "size-4")}
</span>
<span className="min-w-0 truncate">{row.title}</span>
{row.health === "syncing" ? (
<Spinner size="xs" className="ml-auto shrink-0" />
) : row.health === "failed" ? (
<TriangleAlert
className="ml-auto h-3.5 w-3.5 shrink-0 text-destructive"
aria-label={row.errorMessage ?? "Indexing failed"}
/>
) : row.accountCount > 1 ? (
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-muted-foreground">
{row.accountCount}
</span>
) : null}
</button>
);
})}
</div>
</>
)}
<Separator className="my-3 bg-border" />
<button type="button" onClick={addMcpServer} className={navBtnClass(false)}>
{getConnectorIcon(EnumConnectorName.MCP_CONNECTOR, "h-4 w-4")}
<span className="min-w-0 truncate">Add MCP server</span>
</button>
</>
);
const catalog = (
<div className="flex flex-col gap-8">
<div className="w-full">
<div className="relative">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 h-4.5 w-4.5 text-muted-foreground" />
<Input
type="text"
autoComplete="off"
placeholder="Search connectors"
className="h-10 border-0 bg-muted pl-10 pr-9 text-base shadow-none"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<Button
variant="ghost"
size="icon"
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-2 top-1/2 h-7 w-7 -translate-y-1/2 rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
aria-label="Clear search"
>
<X className="h-4.5 w-4.5" />
</Button>
)}
</div>
</div>
<AllConnectorsTab
searchQuery={searchQuery}
workspaceId={workspaceId}
connectedTypes={connectedTypes}
connectingId={connectingId}
allConnectors={connectors}
documentTypeCounts={documentTypeCounts}
indexingConnectorIds={indexingConnectorIds}
onConnectOAuth={handleConnectOAuth}
onConnectNonOAuth={handleConnectNonOAuth}
onCreateWebcrawler={handleCreateWebcrawler}
onCreateYouTubeCrawler={handleCreateYouTubeCrawler}
onManage={handleStartEdit}
onViewAccountsList={handleViewAccountsList}
/>
</div>
);
return (
<section className="flex h-full min-h-[min(680px,calc(100vh-5rem))] w-full select-none flex-col gap-6 md:flex-row">
<div className="md:w-[220px] md:shrink-0">
<h1 className="mb-4 px-1 text-xl font-semibold tracking-tight text-foreground md:text-2xl">
Connectors
</h1>
<nav className="hidden flex-col gap-0.5 md:flex">{renderNav()}</nav>
<Drawer open={drawerOpen} onOpenChange={setDrawerOpen} shouldScaleBackground={false}>
<DrawerTrigger asChild>
<Button
type="button"
variant="outline"
className="flex h-10 w-full justify-between bg-transparent px-3 hover:bg-accent md:hidden"
>
<span className="truncate">{detailTitle}</span>
<ChevronRight className="h-4 w-4 rotate-90 text-muted-foreground" />
</Button>
</DrawerTrigger>
<DrawerContent className="h-[88vh] overflow-hidden rounded-t-2xl border bg-popover text-popover-foreground">
<DrawerHandle className="mt-3 h-1.5 w-10" />
<DrawerTitle className="sr-only">Connectors navigation</DrawerTitle>
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-4">
{renderNav()}
</nav>
</DrawerContent>
</Drawer>
</div>
<div className="min-w-0 flex-1">
<div className="hidden md:block">
<h2 className="text-lg font-semibold">{detailTitle}</h2>
<Separator className="mt-4 bg-border" />
</div>
<div className="min-w-0 pt-4 md:max-w-5xl">{isFlowActive ? flowView : catalog}</div>
</div>
</section>
);
}

View file

@ -1,42 +1,38 @@
import { EnumConnectorName } from "@/contracts/enums/connector";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
/**
* Connectors retired during the MCP migration (no viable official MCP server).
* The catalog card is shown disabled with a "Deprecated" badge so existing
* users understand why; the backend `/add` routes also refuse with HTTP 410.
* Reinstate by removing the type here and in the backend
* `DEPRECATED_CONNECTOR_TYPES` if demand returns.
*
* UI behavior: a deprecated connector is hidden from the catalog entirely unless
* the user already connected it before deprecation in that case it still shows
* as a normal card (inline, no "Deprecated" section) so they can manage/disconnect
* it. New connections are refused by the backend `/add` routes with HTTP 410.
* Reinstate by removing the type here and in the backend `DEPRECATED_CONNECTOR_TYPES`
* if demand returns.
*
* Full deprecated list (for devs):
* - DISCORD_CONNECTOR, TEAMS_CONNECTOR, LUMA_CONNECTOR
* - Search APIs (superseded by the Google-only web-search consolidation; the
* google_search subagent handles public web search, and Tavily/Linkup can
* still be added via the generic Custom MCP connector with API-key headers):
* TAVILY_API, SEARXNG_API, LINKUP_API, BAIDU_SEARCH_API
* - Legacy content crawlers/search (superseded by the file Import menu and
* hosted MCP tooling): YOUTUBE_CONNECTOR, WEBCRAWLER_CONNECTOR, ELASTICSEARCH_CONNECTOR
*/
export const DEPRECATED_CONNECTOR_TYPES = new Set<string>([
EnumConnectorName.DISCORD_CONNECTOR,
EnumConnectorName.TEAMS_CONNECTOR,
EnumConnectorName.LUMA_CONNECTOR,
// Search APIs retired by the Google-only web-search consolidation. Public
// web search now runs through the google_search subagent; Tavily/Linkup can
// still be added via the generic Custom MCP connector (API-key headers).
EnumConnectorName.TAVILY_API,
EnumConnectorName.SEARXNG_API,
EnumConnectorName.LINKUP_API,
EnumConnectorName.BAIDU_SEARCH_API,
// Legacy content crawlers/search retired in favor of the file Import menu and
// hosted MCP tooling. Existing rows stay manageable; new connections refused.
EnumConnectorName.YOUTUBE_CONNECTOR,
EnumConnectorName.WEBCRAWLER_CONNECTOR,
EnumConnectorName.ELASTICSEARCH_CONNECTOR,
]);
/**
* File-import connectors surfaced through the Documents sidebar "Import" menu
* instead of the external-MCP connector catalog. They index remote files into
* the knowledge base, so they belong with uploads, not with live MCP tools.
*/
export const IMPORT_CONNECTOR_TYPES = new Set<string>([
EnumConnectorName.GOOGLE_DRIVE_CONNECTOR,
EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
EnumConnectorName.ONEDRIVE_CONNECTOR,
EnumConnectorName.DROPBOX_CONNECTOR,
]);
/**
* Connectors that operate in real time (no background indexing).
* Used to adjust UI: hide sync controls, show "Connected" instead of doc counts.
@ -283,36 +279,35 @@ export function getConnectorTitle(connectorType: string): string {
);
}
/** One row per connected connector type (multiple accounts collapsed into one). */
export interface ConnectorTypeRow {
type: string;
title: string;
connectors: SearchSourceConnector[];
accountCount: number;
}
/**
* Primary way a user interacts with a connector.
* Drives the two top-level groupings in the connector catalog UI.
* Collapse a flat connector list into one row per `connector_type`, titled for
* display (MCP collapses to "MCP Servers") and sorted alphabetically. Shared by
* the connectors panel's "Your connectors" rail and the composer add-menu so the
* two lists never drift; callers layer their own extras (e.g. health) on top.
*/
export type ConnectorCategory = "knowledge_base" | "tools_live";
export const CONNECTOR_CATEGORY_LABELS: Record<ConnectorCategory, string> = {
knowledge_base: "Knowledge Base",
tools_live: "Tools & Live Sources",
};
const KNOWLEDGE_BASE_CONNECTOR_TYPES = new Set<string>([
EnumConnectorName.GOOGLE_DRIVE_CONNECTOR,
EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
EnumConnectorName.ONEDRIVE_CONNECTOR,
EnumConnectorName.DROPBOX_CONNECTOR,
EnumConnectorName.NOTION_CONNECTOR,
EnumConnectorName.CONFLUENCE_CONNECTOR,
EnumConnectorName.YOUTUBE_CONNECTOR,
EnumConnectorName.WEBCRAWLER_CONNECTOR,
EnumConnectorName.BOOKSTACK_CONNECTOR,
EnumConnectorName.GITHUB_CONNECTOR,
EnumConnectorName.ELASTICSEARCH_CONNECTOR,
EnumConnectorName.CIRCLEBACK_CONNECTOR,
EnumConnectorName.OBSIDIAN_CONNECTOR,
]);
/** Unmapped connectors surface under Tools & Live Sources. */
export function getConnectorCategory(connectorType: string): ConnectorCategory {
return KNOWLEDGE_BASE_CONNECTOR_TYPES.has(connectorType) ? "knowledge_base" : "tools_live";
export function groupConnectorsByType(connectors: SearchSourceConnector[]): ConnectorTypeRow[] {
const byType = new Map<string, SearchSourceConnector[]>();
for (const c of connectors) {
const arr = byType.get(c.connector_type) ?? [];
arr.push(c);
byType.set(c.connector_type, arr);
}
return [...byType.entries()]
.map(([type, list]) => ({
type,
title: type === EnumConnectorName.MCP_CONNECTOR ? "MCP Servers" : getConnectorTitle(type),
connectors: list,
accountCount: list.length,
}))
.sort((a, b) => a.title.localeCompare(b.title));
}
// Composio Toolkits (available integrations via Composio)

View file

@ -21,8 +21,6 @@ import { OAUTH_RESULT_COOKIE, parseOAuthCallbackResult } from "@/contracts/types
import { authenticatedFetch } from "@/lib/auth-fetch";
import { buildBackendUrl } from "@/lib/env-config";
import {
trackConnectorConnected,
trackConnectorDeleted,
trackConnectorSetupFailure,
trackConnectorSetupStarted,
trackIndexWithDateRangeOpened,
@ -296,11 +294,9 @@ export const useConnectorDialog = () => {
if (newConnector && oauthConnector) {
const connectorValidation = searchSourceConnector.safeParse(newConnector);
if (connectorValidation.success) {
trackConnectorConnected(
Number(workspaceId),
oauthConnector.connectorType,
newConnector.id
);
// connector_connected is now emitted server-side (after_insert
// listener in search_source_connectors_routes.py) — covers the
// OAuth callback redirect the frontend often never observes.
const isLiveConnector = LIVE_CONNECTOR_TYPES.has(oauthConnector.connectorType);
@ -436,12 +432,7 @@ export const useConnectorDialog = () => {
if (connector) {
const connectorValidation = searchSourceConnector.safeParse(connector);
if (connectorValidation.success) {
// Track webcrawler connector connected
trackConnectorConnected(
Number(workspaceId),
EnumConnectorName.WEBCRAWLER_CONNECTOR,
connector.id
);
// connector_connected is now emitted server-side (after_insert).
const config = validateIndexingConfigState({
connectorType: EnumConnectorName.WEBCRAWLER_CONNECTOR,
@ -540,8 +531,7 @@ export const useConnectorDialog = () => {
// Store connectingConnectorType before clearing it
const currentConnectorType = connectingConnectorType;
// Track connector connected event for non-OAuth connectors
trackConnectorConnected(Number(workspaceId), currentConnectorType, connector.id);
// connector_connected is now emitted server-side (after_insert).
// Find connector title from constants
const connectorInfo = OTHER_CONNECTORS.find(
@ -1229,12 +1219,8 @@ export const useConnectorDialog = () => {
id: editingConnector.id,
});
// Track connector deleted event
trackConnectorDeleted(
Number(workspaceId),
editingConnector.connector_type,
editingConnector.id
);
// connector_deleted is now emitted server-side
// (search_source_connectors_routes.delete_search_source_connector).
toast.success(
editingConnector.connector_type === "MCP_CONNECTOR"

View file

@ -0,0 +1,78 @@
"use client";
import { useAtomValue } from "jotai";
import { useMemo } from "react";
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { connectorIndexingMetadata } from "@/contracts/types/inbox.types";
import { type ConnectorTypeRow, groupConnectorsByType } from "../constants/connector-constants";
import { useIndexingConnectors } from "./use-indexing-connectors";
/** Health of a connected connector type, derived from indexing + inbox state. */
export type ConnectorHealth = "syncing" | "failed" | "ok";
/** A grouped connector row enriched with live indexing health. */
export interface ConnectorRow extends ConnectorTypeRow {
health: ConnectorHealth;
errorMessage?: string;
}
/**
* Single source of truth for the "Your connectors" list: groups the connectors
* by type and derives each row's health (syncing > failed > ok) from the status
* inbox and optimistic indexing state. Shared by the connectors panel rail and
* the composer add-menu so both render identical status glyphs.
*
* Also returns the underlying indexing controls so the panel can reuse them for
* its flow views instead of instantiating `useIndexingConnectors` twice.
*/
export function useConnectorRows(connectors: SearchSourceConnector[]) {
const statusInboxItems = useAtomValue(statusInboxItemsAtom);
const inboxItems = useMemo(
() => statusInboxItems.filter((item) => item.type === "connector_indexing"),
[statusInboxItems]
);
const { indexingConnectorIds, startIndexing, stopIndexing } = useIndexingConnectors(
connectors,
inboxItems
);
// Latest indexing status per connector id, parsed from the status inbox.
const statusByConnectorId = useMemo(() => {
const map = new Map<number, { status?: string; error?: string; at: string }>();
for (const item of inboxItems) {
const parsed = connectorIndexingMetadata.safeParse(item.metadata);
if (!parsed.success) continue;
const at = item.updated_at ?? item.created_at;
const prev = map.get(parsed.data.connector_id);
if (!prev || at > prev.at) {
map.set(parsed.data.connector_id, {
status: parsed.data.status,
error: parsed.data.error_message ?? undefined,
at,
});
}
}
return map;
}, [inboxItems]);
const rows = useMemo<ConnectorRow[]>(() => {
return groupConnectorsByType(connectors).map((row) => {
const ids = row.connectors.map((c) => c.id);
const syncing = ids.some(
(id) => indexingConnectorIds.has(id) || statusByConnectorId.get(id)?.status === "in_progress"
);
const failedEntry = syncing
? undefined
: ids.map((id) => statusByConnectorId.get(id)).find((s) => s?.status === "failed");
return {
...row,
health: syncing ? "syncing" : failedEntry ? "failed" : "ok",
errorMessage: failedEntry?.error,
} satisfies ConnectorRow;
});
}, [connectors, indexingConnectorIds, statusByConnectorId]);
return { rows, indexingConnectorIds, startIndexing, stopIndexing, inboxItems };
}

View file

@ -1,6 +1,3 @@
// Main component export
export { ConnectorIndicator } from "../connector-popup";
// Sub-components (if needed for external use)
export { ConnectorCard } from "./components/connector-card";
export { ConnectorDialogHeader } from "./components/connector-dialog-header";

View file

@ -11,7 +11,6 @@ import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels";
import { cn } from "@/lib/utils";
import {
COMPOSIO_CONNECTORS,
IMPORT_CONNECTOR_TYPES,
LIVE_CONNECTOR_TYPES,
OAUTH_CONNECTORS,
} from "../constants/connector-constants";
@ -40,11 +39,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
onManage,
onViewAccountsList,
}) => {
// Import connectors (Drive/OneDrive/Dropbox) are managed via the Documents
// sidebar "Import" menu, so they are excluded from the MCP connector list.
const connectors = allActiveConnectors.filter(
(c) => !IMPORT_CONNECTOR_TYPES.has(c.connector_type)
);
const connectors = allActiveConnectors;
// Convert activeDocumentTypes array to Record for utility function
const documentTypeCounts = activeDocumentTypes.reduce(
(acc, [docType, count]) => {
@ -152,7 +147,6 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
<div className="flex flex-col items-center justify-center py-20 text-center">
<Search className="size-8 text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">No connectors found</p>
<p className="text-xs text-muted-foreground/60 mt-1">Try a different search term</p>
</div>
) : hasSources ? (
<div className="space-y-6">

View file

@ -1,6 +1,5 @@
"use client";
import { Search } from "lucide-react";
import type { FC } from "react";
import { useIsSelfHosted } from "@/components/providers/runtime-config";
import { EnumConnectorName } from "@/contracts/enums/connector";
@ -9,12 +8,8 @@ import { usePlatform } from "@/hooks/use-platform";
import { ConnectorCard } from "../components/connector-card";
import {
COMPOSIO_CONNECTORS,
CONNECTOR_CATEGORY_LABELS,
type ConnectorCategory,
CRAWLERS,
DEPRECATED_CONNECTOR_TYPES,
getConnectorCategory,
IMPORT_CONNECTOR_TYPES,
OAUTH_CONNECTORS,
OTHER_CONNECTORS,
} from "../constants/connector-constants";
@ -83,63 +78,39 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
const passesDeploymentFilter = (c: DeploymentFilterableConnector) =>
(!c.selfHostedOnly || selfHosted) && (!c.desktopOnly || isDesktop);
// Import connectors (Drive/OneDrive/Dropbox) are managed via the Documents
// sidebar "Import" menu, so they never appear in the MCP connector catalog.
const notImport = (c: { connectorType?: string }) =>
!c.connectorType || !IMPORT_CONNECTOR_TYPES.has(c.connectorType);
// Filter connectors based on search, deployment mode, and import exclusion
// Filter connectors based on search and deployment mode
const filteredOAuth = OAUTH_CONNECTORS.filter(
(c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c) && notImport(c)
(c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c)
);
const filteredCrawlers = CRAWLERS.filter(
(c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c) && notImport(c)
(c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c)
);
const filteredOther = OTHER_CONNECTORS.filter(
(c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c) && notImport(c)
(c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c)
);
// Filter Composio connectors
const filteredComposio = COMPOSIO_CONNECTORS.filter(
(c) =>
(c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.description.toLowerCase().includes(searchQuery.toLowerCase())) &&
notImport(c)
c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.description.toLowerCase().includes(searchQuery.toLowerCase())
);
const isDeprecated = (c: { connectorType?: string }) =>
!!c.connectorType && DEPRECATED_CONNECTOR_TYPES.has(c.connectorType);
const inCategory =
(category: ConnectorCategory) =>
<T extends { connectorType?: string }>(connector: T): boolean =>
!isDeprecated(connector) &&
!!connector.connectorType &&
getConnectorCategory(connector.connectorType) === category;
const knowledgeBase = {
oauth: filteredOAuth.filter(inCategory("knowledge_base")),
composio: filteredComposio.filter(inCategory("knowledge_base")),
other: filteredOther.filter(inCategory("knowledge_base")),
crawlers: filteredCrawlers.filter(inCategory("knowledge_base")),
};
const toolsLive = {
oauth: filteredOAuth.filter(inCategory("tools_live")),
composio: filteredComposio.filter(inCategory("tools_live")),
other: filteredOther.filter(inCategory("tools_live")),
crawlers: filteredCrawlers.filter(inCategory("tools_live")),
};
// Deprecated cards render in a single section at the very bottom. Discord and
// Teams come from OAUTH_CONNECTORS; Luma, Tavily, Linkup, Baidu, Elasticsearch
// from OTHER_CONNECTORS (SearXNG has no catalog card); YouTube and Web Pages
// from CRAWLERS.
const deprecated = {
oauth: filteredOAuth.filter(isDeprecated),
other: filteredOther.filter(isDeprecated),
crawlers: filteredCrawlers.filter(isDeprecated),
// One flat grid, no separate "Deprecated" section. A deprecated connector is
// shown only if the user already connected it (before deprecation), so it
// stays manageable/disconnectable; never-connected deprecated connectors are
// hidden entirely. See DEPRECATED_CONNECTOR_TYPES for the full list.
const isVisible = <T extends { connectorType?: string }>(c: T) =>
!c.connectorType ||
!DEPRECATED_CONNECTOR_TYPES.has(c.connectorType) ||
connectedTypes.has(c.connectorType);
const available = {
oauth: filteredOAuth.filter(isVisible),
composio: filteredComposio.filter(isVisible),
other: filteredOther.filter(isVisible),
crawlers: filteredCrawlers.filter(isVisible),
};
const renderOAuthCard = (connector: OAuthConnector | ComposioConnector) => {
@ -169,7 +140,6 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
documentCount={documentCount}
accountCount={accountCount}
isIndexing={isIndexing}
deprecated={DEPRECATED_CONNECTOR_TYPES.has(connector.connectorType)}
onConnect={() => onConnectOAuth(connector)}
onManage={
isConnected && onViewAccountsList
@ -218,7 +188,6 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
documentCount={documentCount}
connectorCount={mcpConnectorCount}
isIndexing={isIndexing}
deprecated={DEPRECATED_CONNECTOR_TYPES.has(connector.connectorType)}
onConnect={handleConnect}
onManage={actualConnector && onManage ? () => onManage(actualConnector) : undefined}
/>
@ -267,86 +236,34 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
isConnecting={isConnecting}
documentCount={documentCount}
isIndexing={isIndexing}
deprecated={
!!crawler.connectorType && DEPRECATED_CONNECTOR_TYPES.has(crawler.connectorType)
}
onConnect={handleConnect}
onManage={actualConnector && onManage ? () => onManage(actualConnector) : undefined}
/>
);
};
const hasKnowledgeBase =
knowledgeBase.oauth.length > 0 ||
knowledgeBase.composio.length > 0 ||
knowledgeBase.other.length > 0 ||
knowledgeBase.crawlers.length > 0;
const hasToolsLive =
toolsLive.oauth.length > 0 ||
toolsLive.composio.length > 0 ||
toolsLive.other.length > 0 ||
toolsLive.crawlers.length > 0;
const hasDeprecated =
deprecated.oauth.length > 0 || deprecated.other.length > 0 || deprecated.crawlers.length > 0;
const hasAnyResults = hasKnowledgeBase || hasToolsLive || hasDeprecated;
const hasAnyResults =
available.oauth.length > 0 ||
available.composio.length > 0 ||
available.other.length > 0 ||
available.crawlers.length > 0;
if (!hasAnyResults && searchQuery) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Search className="size-8 text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">No connectors found</p>
<p className="text-xs text-muted-foreground/60 mt-1">Try a different search term</p>
</div>
);
}
return (
<div className="space-y-8">
{hasKnowledgeBase && (
<section>
<div className="flex items-center gap-2 mb-4">
<h3 className="text-sm font-semibold text-muted-foreground">
{CONNECTOR_CATEGORY_LABELS.knowledge_base}
</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{knowledgeBase.oauth.map(renderOAuthCard)}
{knowledgeBase.composio.map(renderOAuthCard)}
{knowledgeBase.crawlers.map(renderCrawlerCard)}
{knowledgeBase.other.map(renderOtherCard)}
</div>
</section>
)}
{hasToolsLive && (
<section>
<div className="flex items-center gap-2 mb-4">
<h3 className="text-sm font-semibold text-muted-foreground">
{CONNECTOR_CATEGORY_LABELS.tools_live}
</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{toolsLive.oauth.map(renderOAuthCard)}
{toolsLive.composio.map(renderOAuthCard)}
{toolsLive.crawlers.map(renderCrawlerCard)}
{toolsLive.other.map(renderOtherCard)}
</div>
</section>
)}
{hasDeprecated && (
<section>
<div className="flex items-center gap-2 mb-4">
<h3 className="text-sm font-semibold text-muted-foreground">Deprecated</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{deprecated.oauth.map(renderOAuthCard)}
{deprecated.crawlers.map(renderCrawlerCard)}
{deprecated.other.map(renderOtherCard)}
</div>
</section>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{available.oauth.map(renderOAuthCard)}
{available.composio.map(renderOAuthCard)}
{available.crawlers.map(renderCrawlerCard)}
{available.other.map(renderOtherCard)}
</div>
</div>
);
};

View file

@ -111,7 +111,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="px-6 sm:px-12 pt-8 sm:pt-10 pb-1 sm:pb-4 bg-popover">
<div className="pb-1 sm:pb-4 bg-transparent">
{/* Back button */}
<Button
type="button"
@ -165,7 +165,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 sm:px-12 pt-0 sm:pt-6 pb-6 sm:pb-8">
<div className="flex-1 overflow-y-auto pt-0 sm:pt-6 pb-6 sm:pb-8">
{/* Connected Accounts Grid */}
{typeConnectors.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">

View file

@ -216,7 +216,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ workspaceId, o
return (
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* Header */}
<div className="shrink-0 px-6 sm:px-12 pt-8 sm:pt-10">
<div className="shrink-0">
<Button
variant="ghost"
type="button"
@ -239,7 +239,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ workspaceId, o
</div>
{/* Form Content - Scrollable */}
<div className="flex-1 min-h-0 overflow-y-auto px-6 sm:px-12">
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="space-y-4 pb-6">
<div className="space-y-2">
<Label htmlFor="video-input" className="text-sm sm:text-base">
@ -325,7 +325,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ workspaceId, o
</div>
{/* Fixed Footer - Action buttons */}
<div className="shrink-0 flex items-center justify-between px-6 sm:px-12 py-6 bg-popover">
<div className="shrink-0 flex items-center justify-between py-6 bg-transparent">
<Button
variant="ghost"
onClick={onBack}

View file

@ -12,17 +12,17 @@ import {
ArrowUpIcon,
Camera,
ChevronDown,
ChevronRight,
Clipboard,
LayoutGrid,
Plus,
Settings2,
SquareIcon,
TriangleAlert,
Unplug,
Upload,
Wrench,
X,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import Image from "next/image";
import { useParams, useRouter } from "next/navigation";
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -44,7 +44,7 @@ import {
clearPremiumAlertForThreadAtom,
premiumAlertByThreadAtom,
} from "@/atoms/chat/premium-alert.atom";
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
import { importConnectorRequestAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { membersAtom } from "@/atoms/members/members-query.atoms";
import { llmSetupStatusAtomFamily } from "@/atoms/model-connections/model-connections-query.atoms";
@ -52,6 +52,11 @@ import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status";
import { ChatViewport } from "@/components/assistant-ui/chat-viewport";
import { ComposerAddMenuDrawer } from "@/components/assistant-ui/composer-add-menu-drawer";
import {
type ConnectorRow,
useConnectorRows,
} from "@/components/assistant-ui/connector-popup/hooks/use-connector-rows";
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
import {
InlineMentionEditor,
@ -68,19 +73,12 @@ import { ComposerSuggestionPopoverContent } from "@/components/new-chat/composer
import { PromptPicker, type PromptPickerRef } from "@/components/new-chat/prompt-picker";
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
Drawer,
DrawerContent,
DrawerHandle,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
@ -88,6 +86,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Popover, PopoverAnchor } from "@/components/ui/popover";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
@ -97,6 +96,7 @@ import {
getToolDisplayName,
getToolIcon,
} from "@/contracts/enums/toolIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { useBatchCommentsPreload } from "@/hooks/use-comments";
import { useCommentsSync } from "@/hooks/use-comments-sync";
import { useMediaQuery } from "@/hooks/use-media-query";
@ -173,7 +173,7 @@ const ThreadContent: FC<ThreadProps> = ({
footer={
<>
<PremiumQuotaPinnedAlert />
<Composer hasActiveThread={hasActiveThread} isLoadingMessages={isLoadingMessages} />
<Composer isLoadingMessages={isLoadingMessages} />
</>
}
>
@ -313,99 +313,6 @@ const ThreadWelcome: FC = () => {
);
};
const BANNER_CONNECTORS = [
{ type: "GOOGLE_DRIVE_CONNECTOR", label: "Google Drive" },
{ type: "GOOGLE_GMAIL_CONNECTOR", label: "Gmail" },
{ type: "NOTION_CONNECTOR", label: "Notion" },
{ type: "YOUTUBE_CONNECTOR", label: "YouTube" },
{ type: "SLACK_CONNECTOR", label: "Slack" },
] as const;
const BANNER_DISMISSED_KEY = "surfsense-connect-tools-banner-dismissed";
const ConnectToolsBanner: FC<{
isThreadEmpty: boolean;
onVisibleChange?: (visible: boolean) => void;
}> = ({ isThreadEmpty, onVisibleChange }) => {
const { data: connectors } = useAtomValue(connectorsAtom);
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
const [dismissed, setDismissed] = useState(() => {
if (typeof window === "undefined") return false;
return localStorage.getItem(BANNER_DISMISSED_KEY) === "true";
});
const [dismissRequested, setDismissRequested] = useState(false);
const hasConnectors = (connectors?.length ?? 0) > 0;
const isVisible = !dismissed && !hasConnectors && isThreadEmpty;
const shouldShowTray = isVisible && !dismissRequested;
useEffect(() => {
onVisibleChange?.(isVisible);
}, [isVisible, onVisibleChange]);
const handleDismiss = (e: React.MouseEvent) => {
e.stopPropagation();
setDismissRequested(true);
};
return (
<AnimatePresence
initial={false}
onExitComplete={() => {
if (!dismissRequested) return;
setDismissed(true);
localStorage.setItem(BANNER_DISMISSED_KEY, "true");
}}
>
{shouldShowTray ? (
<motion.div
key="connect-tools-tray"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -14 }}
transition={{ duration: 0.18, ease: "easeOut" }}
className="relative z-0 -mt-5 flex min-w-0 items-center gap-2 rounded-b-3xl border border-input bg-muted/40 px-4 pt-7 pb-3 shadow-sm shadow-black/5 dark:shadow-black/10"
>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 min-w-0 cursor-pointer justify-start gap-2 rounded-md px-0 text-[13px] font-normal text-muted-foreground select-none hover:bg-transparent hover:text-foreground"
onClick={() => setConnectorDialogOpen(true)}
>
<Unplug className="size-4 shrink-0" />
<span className="truncate">Connect your tools</span>
</Button>
<div className="min-w-0 flex-1" />
<AvatarGroup className="shrink-0">
{BANNER_CONNECTORS.map(({ type }, i) => (
<Avatar
key={type}
className="size-5"
style={{ zIndex: BANNER_CONNECTORS.length - i }}
>
<AvatarFallback className="bg-accent text-[10px]">
{getConnectorIcon(type, "size-3")}
</AvatarFallback>
</Avatar>
))}
</AvatarGroup>
<Button
type="button"
onClick={handleDismiss}
variant="ghost"
size="icon"
className="size-7 shrink-0 cursor-pointer rounded-md text-muted-foreground hover:bg-transparent hover:text-foreground"
aria-label="Dismiss"
>
<X className="size-3.5" />
</Button>
</motion.div>
) : null}
</AnimatePresence>
);
};
const PendingScreenImageStrip: FC = () => {
const [urls, setUrls] = useAtom(pendingUserImageDataUrlsAtom);
if (urls.length === 0) return null;
@ -485,8 +392,7 @@ const ClipboardChip: FC<{ text: string; onDismiss: () => void }> = ({ text, onDi
};
/**
* Inline "this workspace can't chat yet" tray, mirrored on top of the composer
* (same visual treatment as the "Connect your tools" tray below it).
* Inline "this workspace can't chat yet" tray, mirrored on top of the composer.
*
* Replaces the old full-screen onboarding redirect: a workspace with no usable
* chat model (fresh install, or an admin deleted the last model) renders the
@ -531,14 +437,10 @@ const ChatUnavailableNotice: FC<{ workspaceId: number; canConfigure: boolean }>
};
interface ComposerProps {
hasActiveThread?: boolean;
isLoadingMessages?: boolean;
}
const Composer: FC<ComposerProps> = ({
hasActiveThread = false,
isLoadingMessages = false,
}) => {
const Composer: FC<ComposerProps> = ({ isLoadingMessages = false }) => {
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
const setSubmittedMentions = useSetAtom(submittedMentionsAtom);
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
@ -575,7 +477,6 @@ const Composer: FC<ComposerProps> = ({
const isThreadEmpty = useAuiState(({ thread }) => thread.isEmpty);
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
const [connectToolsTrayVisible, setConnectToolsTrayVisible] = useState(false);
const { data: chatSetupStatus } = useAtomValue(llmSetupStatusAtomFamily(workspaceId ?? 0));
const isChatUnavailable = !!chatSetupStatus && chatSetupStatus.status !== "ready";
@ -1037,7 +938,6 @@ const Composer: FC<ComposerProps> = ({
<div
className={cn(
"aui-composer-attachment-dropzone relative z-10 flex w-full flex-col overflow-hidden rounded-3xl border border-input/20 bg-muted pt-2 shadow-sm shadow-black/5 outline-none transition-[border-color,box-shadow] hover:border-input/60 focus-within:border-input/60 dark:shadow-black/10",
connectToolsTrayVisible && "rounded-b-3xl shadow-none dark:shadow-none",
isChatUnavailable && "shadow-none dark:shadow-none"
)}
>
@ -1071,10 +971,6 @@ const Composer: FC<ComposerProps> = ({
onChatModelSelected={handleChatModelSelected}
/>
</div>
<ConnectToolsBanner
isThreadEmpty={!hasActiveThread && isThreadEmpty}
onVisibleChange={setConnectToolsTrayVisible}
/>
{!isLoadingMessages && isThreadEmpty && isComposerInputEmpty ? (
<div className="absolute top-full left-0 right-0 z-20">
<ChatExamplePrompts onSelect={handleExampleSelect} />
@ -1152,12 +1048,13 @@ const ComposerAction: FC<ComposerActionProps> = ({
onChatModelSelected,
}) => {
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
const router = useRouter();
const openConnectors = useCallback(
() => router.push(`/dashboard/${workspaceId}/connectors`),
[router, workspaceId]
);
const [toolsPopoverOpen, setToolsPopoverOpen] = useState(false);
const [openConnectorSubmenu, setOpenConnectorSubmenu] = useState<string | null>(null);
const [expandedConnectorGroups, setExpandedConnectorGroups] = useState<Set<string>>(
() => new Set()
);
const isDesktop = useMediaQuery("(min-width: 640px)");
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
const pendingScreenImages = useAtomValue(pendingUserImageDataUrlsAtom);
@ -1192,6 +1089,20 @@ const ComposerAction: FC<ComposerActionProps> = ({
() => new Set<string>((connectors ?? []).map((c) => c.connector_type)),
[connectors]
);
// "Your connectors" rows for the add-menu (desktop submenu + mobile drawer):
// same hook the connectors panel rail uses, so grouping + health glyphs match.
const { rows: connectorRows } = useConnectorRows((connectors ?? []) as SearchSourceConnector[]);
const setImportRequest = useSetAtom(importConnectorRequestAtom);
const openConnectorManage = useCallback(
(row: ConnectorRow) => {
// Deep-link into the connector's manage view: the panel's
// useConnectorDialog consumes this atom and routes by account count
// (none -> connect, one -> edit, many -> accounts list).
setImportRequest({ connectorType: row.type, mode: "auto" });
openConnectors();
},
[setImportRequest, openConnectors]
);
const toggleToolGroup = useCallback(
(toolNames: string[]) => {
@ -1204,18 +1115,6 @@ const ComposerAction: FC<ComposerActionProps> = ({
},
[disabledToolsSet, setDisabledTools]
);
const setConnectorGroupExpanded = useCallback((label: string, expanded: boolean) => {
setExpandedConnectorGroups((prev) => {
const next = new Set(prev);
if (expanded) {
next.add(label);
} else {
next.delete(label);
}
return next;
});
}, []);
const filteredTools = agentTools;
const groupedTools = useMemo(() => {
if (!filteredTools) return [];
@ -1284,206 +1183,30 @@ const ComposerAction: FC<ComposerActionProps> = ({
<div className="aui-composer-action-wrapper relative mx-3 mb-3 flex items-center justify-between">
<div className="flex items-center gap-1">
{!isDesktop ? (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 rounded-full p-0 font-semibold text-xs text-muted-foreground transition-colors dark:border-muted-foreground/15 hover:bg-foreground/10 hover:text-foreground"
aria-label="Upload files, manage tools and more"
data-joyride="connector-icon"
>
<Plus className="size-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="start" sideOffset={8}>
<DropdownMenuItem onSelect={() => openUploadDialog()}>
<Upload className="size-4" />
Upload Files
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setConnectorDialogOpen(true)}>
<Unplug className="size-4" />
MCP Connectors
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setToolsPopoverOpen(true)}>
<Settings2 className="size-4" />
Manage Tools
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Drawer
open={toolsPopoverOpen}
onOpenChange={setToolsPopoverOpen}
shouldScaleBackground={false}
>
<DrawerContent className="h-[85vh] max-h-[85vh] z-80" overlayClassName="z-80">
<DrawerHandle />
<DrawerHeader className="px-4 pb-3 pt-2">
<DrawerTitle className="flex items-center justify-center gap-2 text-base font-semibold">
Manage Tools
</DrawerTitle>
</DrawerHeader>
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin pb-6">
{regularToolGroups.map((group) => (
<div key={group.label}>
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground/80 font-medium select-none">
{group.label}
</div>
{group.tools.map((tool) => {
const isDisabled = disabledToolsSet.has(tool.name);
const ToolIcon = getToolIcon(tool.name);
return (
<div
key={tool.name}
className="flex w-full items-center gap-3 px-4 py-2 hover:bg-accent hover:text-accent-foreground transition-colors"
>
<ToolIcon className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 min-w-0 text-sm font-medium truncate">
{formatToolName(tool.name)}
</span>
<Switch
checked={!isDisabled}
onCheckedChange={() => toggleTool(tool.name)}
className="shrink-0"
/>
</div>
);
})}
</div>
))}
{connectorToolGroups.length > 0 && (
<div>
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground/80 font-medium select-none">
Connector Actions
</div>
{connectorToolGroups.map((group) => {
const iconKey = group.connectorIcon ?? "";
const iconInfo = CONNECTOR_TOOL_ICON_PATHS[iconKey];
const toolNames = group.tools.map((t) => t.name);
const allDisabled = toolNames.every((n) => disabledToolsSet.has(n));
const isExpanded = expandedConnectorGroups.has(group.label);
return (
<Collapsible
key={group.label}
open={isExpanded}
onOpenChange={(open) => setConnectorGroupExpanded(group.label, open)}
>
<div className="flex w-full items-center gap-3 px-4 py-2 hover:bg-accent hover:text-accent-foreground transition-colors">
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
className="h-auto min-w-0 flex-1 justify-start gap-3 p-0 text-left hover:bg-transparent hover:text-inherit"
>
{iconInfo ? (
<Image
src={iconInfo.src}
alt={iconInfo.alt}
width={18}
height={18}
className="size-[18px] shrink-0 select-none pointer-events-none"
draggable={false}
/>
) : (
<Wrench className="size-4 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{group.label}
</span>
{isExpanded ? (
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
)}
</Button>
</CollapsibleTrigger>
<Switch
checked={!allDisabled}
onCheckedChange={() => toggleToolGroup(toolNames)}
className="shrink-0"
/>
</div>
<CollapsibleContent className="pb-1">
{group.tools.map((tool) => {
const isDisabled = disabledToolsSet.has(tool.name);
return (
<div
key={tool.name}
className={cn(
"ml-8 flex items-center gap-3 px-4 py-1.5 rounded-md transition-colors",
"hover:bg-accent hover:text-accent-foreground",
!isDisabled && "text-primary"
)}
>
<span className="min-w-0 flex-1 truncate text-sm">
{formatToolName(tool.name)}
</span>
<Switch
checked={!isDisabled}
onCheckedChange={() => toggleTool(tool.name)}
className="shrink-0"
/>
</div>
);
})}
</CollapsibleContent>
</Collapsible>
);
})}
</div>
)}
{otherToolGroup && (
<div>
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground/80 font-medium select-none">
{otherToolGroup.label}
</div>
{otherToolGroup.tools.map((tool) => {
const isDisabled = disabledToolsSet.has(tool.name);
const ToolIcon = getToolIcon(tool.name);
return (
<div
key={tool.name}
className="flex w-full items-center gap-3 px-4 py-2 hover:bg-accent hover:text-accent-foreground transition-colors"
>
<ToolIcon className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 min-w-0 text-sm font-medium truncate">
{formatToolName(tool.name)}
</span>
<Switch
checked={!isDisabled}
onCheckedChange={() => toggleTool(tool.name)}
className="shrink-0"
/>
</div>
);
})}
</div>
)}
{!filteredTools?.length && (
<div className="px-4 pt-3 pb-2">
<Skeleton className="h-3 w-16 mb-2" />
{["t1", "t2", "t3", "t4"].map((k) => (
<div key={k} className="flex items-center gap-3 py-2">
<Skeleton className="size-4 rounded shrink-0" />
<Skeleton className="h-3.5 flex-1" />
<Skeleton className="h-5 w-9 rounded-full shrink-0" />
</div>
))}
<Skeleton className="h-3 w-24 mt-3 mb-2" />
{["c1", "c2", "c3"].map((k) => (
<div key={k} className="flex items-center gap-3 py-2">
<Skeleton className="size-4 rounded shrink-0" />
<Skeleton className="h-3.5 flex-1" />
<Skeleton className="h-5 w-9 rounded-full shrink-0" />
</div>
))}
</div>
)}
</div>
</DrawerContent>
</Drawer>
</>
<ComposerAddMenuDrawer
trigger={
<Button
variant="ghost"
size="icon"
className="h-9 w-9 rounded-full p-0 font-semibold text-xs text-muted-foreground transition-colors dark:border-muted-foreground/15 hover:bg-foreground/10 hover:text-foreground"
aria-label="Upload files, manage tools and more"
data-joyride="connector-icon"
>
<Plus className="size-5" />
</Button>
}
onUploadFiles={() => openUploadDialog()}
connectorRows={connectorRows}
onSelectConnector={openConnectorManage}
onBrowseConnectors={openConnectors}
regularToolGroups={regularToolGroups}
connectorToolGroups={connectorToolGroups}
otherToolGroup={otherToolGroup}
disabledToolsSet={disabledToolsSet}
onToggleTool={toggleTool}
onToggleToolGroup={toggleToolGroup}
toolsLoading={!filteredTools?.length}
/>
) : (
<DropdownMenu
onOpenChange={(open) => {
@ -1522,10 +1245,53 @@ const ComposerAction: FC<ComposerActionProps> = ({
<Camera className="h-4 w-4" />
Take a screenshot
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setConnectorDialogOpen(true)}>
<Unplug className="h-4 w-4" />
MCP Connectors
</DropdownMenuItem>
{connectorRows.length > 0 ? (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Unplug className="h-4 w-4" />
MCP Connectors
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent
collisionPadding={8}
className="w-60 max-h-72 gap-1 overflow-y-auto overscroll-none"
>
{connectorRows.map((row) => (
<DropdownMenuItem
key={row.type}
onSelect={() => openConnectorManage(row)}
className="gap-2"
>
{getConnectorIcon(row.type, "h-4 w-4")}
<span className="min-w-0 flex-1 truncate">{row.title}</span>
{row.health === "syncing" ? (
<Spinner size="xs" className="shrink-0" />
) : row.health === "failed" ? (
<TriangleAlert
className="h-3.5 w-3.5 shrink-0 text-destructive"
aria-label={row.errorMessage ?? "Indexing failed"}
/>
) : row.accountCount > 1 ? (
<span className="shrink-0 text-xs text-muted-foreground">
{row.accountCount}
</span>
) : null}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={openConnectors} className="gap-2">
<LayoutGrid className="h-4 w-4" />
Manage connectors
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
) : (
<DropdownMenuItem onSelect={openConnectors}>
<Unplug className="h-4 w-4" />
MCP Connectors
</DropdownMenuItem>
)}
<DropdownMenuSub
open={toolsPopoverOpen}
onOpenChange={(open) => {
@ -1546,7 +1312,7 @@ const ComposerAction: FC<ComposerActionProps> = ({
>
{regularToolGroups.map((group) => (
<div key={group.label}>
<div className="px-2 pt-1.5 pb-0.5 text-[10px] text-muted-foreground/80 font-normal select-none">
<div className="px-2 pt-1.5 pb-0.5 text-xs text-muted-foreground font-semibold select-none">
{group.label}
</div>
{group.tools.map((tool) => {
@ -1581,7 +1347,7 @@ const ComposerAction: FC<ComposerActionProps> = ({
))}
{connectorToolGroups.length > 0 && (
<div>
<div className="px-2 pt-1.5 pb-0.5 text-[10px] text-muted-foreground/80 font-normal select-none">
<div className="px-2 pt-1.5 pb-0.5 text-xs text-muted-foreground font-semibold select-none">
Connector Actions
</div>
{connectorToolGroups.map((group) => {
@ -1667,7 +1433,7 @@ const ComposerAction: FC<ComposerActionProps> = ({
)}
{otherToolGroup && (
<div>
<div className="px-2 pt-1.5 pb-0.5 text-[10px] text-muted-foreground/80 font-normal select-none">
<div className="px-2 pt-1.5 pb-0.5 text-xs text-muted-foreground font-semibold select-none">
{otherToolGroup.label}
</div>
{otherToolGroup.tools.map((tool) => {