mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-26 23:51:14 +02:00
Merge remote-tracking branch 'upstream/dev' into fixes-playground-ui
# Conflicts: # surfsense_web/lib/apis/base-api.service.ts
This commit is contained in:
commit
06c7e27c72
87 changed files with 2882 additions and 1673 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />;
|
||||
};
|
||||
|
|
@ -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" />;
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 master–detail:
|
||||
* - 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -324,7 +324,6 @@ export function FolderTreeView({
|
|||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-12 text-muted-foreground">
|
||||
<Search className="h-10 w-10" />
|
||||
<p className="text-sm text-muted-foreground">No matching documents</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">Try a different search term</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { AlarmClock, AlertTriangle, Shapes, SquareTerminal } from "lucide-react";
|
||||
import { AlarmClock, AlertTriangle, Shapes, SquareTerminal, Unplug } from "lucide-react";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTheme } from "next-themes";
|
||||
|
|
@ -300,6 +300,7 @@ export function LayoutDataProvider({
|
|||
const isAutomationsActive = pathname?.includes("/automations") === true;
|
||||
const isArtifactsActive = pathname?.endsWith("/artifacts") === true;
|
||||
const isPlaygroundRoute = pathname?.includes("/playground") === true;
|
||||
const isConnectorsRoute = pathname?.includes("/connectors") === true;
|
||||
const navItems: NavItem[] = useMemo(
|
||||
() =>
|
||||
(
|
||||
|
|
@ -316,6 +317,12 @@ export function LayoutDataProvider({
|
|||
icon: Shapes,
|
||||
isActive: isArtifactsActive,
|
||||
},
|
||||
{
|
||||
title: "Connectors",
|
||||
url: `/dashboard/${workspaceId}/connectors`,
|
||||
icon: Unplug,
|
||||
isActive: isConnectorsRoute,
|
||||
},
|
||||
{
|
||||
title: "Playground",
|
||||
url: `/dashboard/${workspaceId}/playground`,
|
||||
|
|
@ -324,7 +331,7 @@ export function LayoutDataProvider({
|
|||
},
|
||||
] as (NavItem | null)[]
|
||||
).filter((item): item is NavItem => item !== null),
|
||||
[workspaceId, isAutomationsActive, isArtifactsActive, isPlaygroundRoute]
|
||||
[workspaceId, isAutomationsActive, isArtifactsActive, isConnectorsRoute, isPlaygroundRoute]
|
||||
);
|
||||
|
||||
// Handlers
|
||||
|
|
@ -631,6 +638,7 @@ export function LayoutDataProvider({
|
|||
const isAutomationsPage = pathname?.includes("/automations") === true;
|
||||
const isArtifactsPage = pathname?.endsWith("/artifacts") === true;
|
||||
const isPlaygroundPage = pathname?.includes("/playground") === true;
|
||||
const isConnectorsPage = pathname?.includes("/connectors") === true;
|
||||
const isAllChatsPage = pathname?.endsWith("/chats") === true;
|
||||
const handleChatsClick = useCallback(() => {
|
||||
router.push(`/dashboard/${workspaceId}/chats`);
|
||||
|
|
@ -650,6 +658,7 @@ export function LayoutDataProvider({
|
|||
isAutomationsPage ||
|
||||
isArtifactsPage ||
|
||||
isPlaygroundPage ||
|
||||
isConnectorsPage ||
|
||||
isAllChatsPage;
|
||||
|
||||
return (
|
||||
|
|
@ -702,6 +711,7 @@ export function LayoutDataProvider({
|
|||
isAutomationsPage ||
|
||||
isArtifactsPage ||
|
||||
isPlaygroundPage ||
|
||||
isConnectorsPage ||
|
||||
isAllChatsPage
|
||||
? "items-start justify-center px-6 py-8 md:px-10 md:pb-10 md:pt-16"
|
||||
: undefined
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ import {
|
|||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { trackWorkspaceCreated } from "@/lib/posthog/events";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { queryClient } from "@/lib/query-client/client";
|
||||
|
||||
|
|
@ -68,7 +67,8 @@ export function CreateWorkspaceDialog({ open, onOpenChange }: CreateWorkspaceDia
|
|||
description: values.description || "",
|
||||
});
|
||||
|
||||
trackWorkspaceCreated(result.id, values.name);
|
||||
// workspace_created is now emitted server-side (workspaces_routes.py)
|
||||
// so PAT/MCP-created workspaces are also counted.
|
||||
|
||||
// Seed the gate's query so it resolves without a loader flash, and
|
||||
// route straight to onboarding vs. new-chat on the first hop.
|
||||
|
|
|
|||
|
|
@ -487,13 +487,9 @@ function AllChatsContent({ workspaceId, className }: AllChatsContentProps) {
|
|||
</div>
|
||||
) : isSearchMode ? (
|
||||
<div className="text-center py-8">
|
||||
<Search className="mx-auto mb-2.5 h-10 w-10 text-muted-foreground" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("no_chats_found") || "No chats found"}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground/70">
|
||||
{t("try_different_search") || "Try a different search term"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
|
|
|
|||
|
|
@ -8,25 +8,18 @@ import {
|
|||
FolderPlus,
|
||||
FolderSync,
|
||||
ListFilter,
|
||||
Plus,
|
||||
Settings2,
|
||||
SlidersVertical,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { makeFolderMention, mentionedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
|
||||
import { importConnectorRequestAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
||||
import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms";
|
||||
import { agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms";
|
||||
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||
import { useConnectorStatus } from "@/components/assistant-ui/connector-popup/hooks/use-connector-status";
|
||||
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
||||
import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog";
|
||||
import type { DocumentNodeDoc } from "@/components/documents/DocumentNode";
|
||||
import { getDocumentTypeIcon } from "@/components/documents/DocumentTypeIcon";
|
||||
|
|
@ -34,7 +27,7 @@ import type { FolderDisplay } from "@/components/documents/FolderNode";
|
|||
import { FolderPickerDialog } from "@/components/documents/FolderPickerDialog";
|
||||
import { FolderTreeView } from "@/components/documents/FolderTreeView";
|
||||
import { VersionHistoryDialog } from "@/components/documents/version-history";
|
||||
import { useOptionalRuntimeConfig, useRuntimeConfig } from "@/components/providers/runtime-config";
|
||||
import { useRuntimeConfig } from "@/components/providers/runtime-config";
|
||||
import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems";
|
||||
import {
|
||||
DEFAULT_EXCLUDE_PATTERNS,
|
||||
|
|
@ -57,7 +50,6 @@ import {
|
|||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
|
|
@ -67,9 +59,6 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useAnonymousMode, useIsAnonymous } from "@/contexts/anonymous-mode";
|
||||
import { useLoginGate } from "@/contexts/login-gate";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { useElectronAPI, usePlatform } from "@/hooks/use-platform";
|
||||
|
|
@ -239,11 +228,9 @@ export function EmbeddedDocumentsMenu({
|
|||
}
|
||||
|
||||
/**
|
||||
* Import menu: local file upload plus the cloud-drive import connectors
|
||||
* (Google Drive / OneDrive / Dropbox). Drive/OneDrive/Dropbox set
|
||||
* `importConnectorRequestAtom`, which the connector dialog consumes to run
|
||||
* OAuth or open the existing account's config. In anonymous mode, `gate`
|
||||
* intercepts every item to trigger the login flow.
|
||||
* Desktop-only "Watch Local Folder" entry point. File uploads now live in the
|
||||
* chat composer and cloud drives in the connector catalog, so this renders
|
||||
* nothing on web. `gate` login-gates the action in anonymous mode.
|
||||
*/
|
||||
export function EmbeddedImportMenu({
|
||||
gate,
|
||||
|
|
@ -252,30 +239,14 @@ export function EmbeddedImportMenu({
|
|||
gate?: (feature: string) => void;
|
||||
onFolderWatched?: () => void;
|
||||
}) {
|
||||
const { openDialog } = useDocumentUploadDialog();
|
||||
const setImportRequest = useSetAtom(importConnectorRequestAtom);
|
||||
// Provider is absent on anonymous /free pages, where every item is login-gated
|
||||
// anyway — defaulting to hosted (Composio) there is cosmetic.
|
||||
const selfHosted = useOptionalRuntimeConfig()?.deploymentMode === "self-hosted";
|
||||
const { isConnectorEnabled, getConnectorStatusMessage } = useConnectorStatus();
|
||||
const { data: connectors } = useAtomValue(connectorsAtom);
|
||||
|
||||
// Watch Local Folder is a desktop-app feature (needs the Electron folder watcher).
|
||||
const { isDesktop } = usePlatform();
|
||||
const params = useParams();
|
||||
const workspaceId = getWorkspaceIdNumber(params) ?? 0;
|
||||
const [folderWatchOpen, setFolderWatchOpen] = useState(false);
|
||||
|
||||
// Native Google Drive connector self-hosted only; hosted deployments use Composio.
|
||||
const driveType = selfHosted
|
||||
? EnumConnectorName.GOOGLE_DRIVE_CONNECTOR
|
||||
: EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR;
|
||||
|
||||
const cloudItems = [
|
||||
{ type: driveType, label: "Google Drive" },
|
||||
{ type: EnumConnectorName.ONEDRIVE_CONNECTOR, label: "OneDrive" },
|
||||
{ type: EnumConnectorName.DROPBOX_CONNECTOR, label: "Dropbox" },
|
||||
];
|
||||
// Nothing to import on web anymore — hide the button rather than show an empty menu.
|
||||
if (!isDesktop) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
|
|
@ -285,99 +256,20 @@ export function EmbeddedImportMenu({
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
aria-label="Import documents"
|
||||
aria-label="Watch local folder"
|
||||
>
|
||||
<FilePlus className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem onSelect={() => (gate ? gate("upload files") : openDialog())}>
|
||||
<Upload className="h-4 w-4" />
|
||||
Upload Files
|
||||
<DropdownMenuItem
|
||||
onSelect={() => (gate ? gate("watch local folders") : setFolderWatchOpen(true))}
|
||||
>
|
||||
<FolderSync className="h-4 w-4" />
|
||||
Watch Local Folder
|
||||
</DropdownMenuItem>
|
||||
{isDesktop && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => (gate ? gate("watch local folders") : setFolderWatchOpen(true))}
|
||||
>
|
||||
<FolderSync className="h-4 w-4" />
|
||||
Watch Local Folder
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{cloudItems.map((item) => {
|
||||
const enabled = gate ? true : isConnectorEnabled(item.type);
|
||||
const statusMessage = enabled ? null : getConnectorStatusMessage(item.type);
|
||||
const icon = getConnectorIcon(item.type, "h-4 w-4");
|
||||
// gate = anonymous mode; treat every connector as unconnected so items
|
||||
// route through the login gate rather than reading workspace connectors.
|
||||
const accountCount = gate
|
||||
? 0
|
||||
: (connectors ?? []).filter(
|
||||
(c: SearchSourceConnector) => c.connector_type === item.type
|
||||
).length;
|
||||
|
||||
// Unavailable (e.g. maintenance): non-actionable item explaining why.
|
||||
if (!enabled) {
|
||||
return (
|
||||
<DropdownMenuItem key={item.type} disabled title={statusMessage ?? undefined}>
|
||||
{icon}
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
// Connected: manage the existing account(s) or add another.
|
||||
if (accountCount > 0) {
|
||||
return (
|
||||
<DropdownMenuSub key={item.type}>
|
||||
<DropdownMenuSubTrigger>
|
||||
{icon}
|
||||
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">{accountCount}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-48">
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
gate
|
||||
? gate("manage import connectors")
|
||||
: setImportRequest({ connectorType: item.type, mode: "auto" })
|
||||
}
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
{accountCount > 1 ? "Manage accounts" : "Manage"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
gate
|
||||
? gate("import from cloud storage")
|
||||
: setImportRequest({ connectorType: item.type, mode: "connect" })
|
||||
}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add another account
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
// Not connected: single click starts the first OAuth connect.
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.type}
|
||||
onSelect={() =>
|
||||
gate
|
||||
? gate("import from cloud storage")
|
||||
: setImportRequest({ connectorType: item.type, mode: "auto" })
|
||||
}
|
||||
>
|
||||
{icon}
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
{isDesktop && !gate && (
|
||||
{!gate && (
|
||||
<FolderWatchDialog
|
||||
open={folderWatchOpen}
|
||||
onOpenChange={setFolderWatchOpen}
|
||||
|
|
|
|||
|
|
@ -140,6 +140,10 @@ export function Sidebar({
|
|||
() => navItems.find((item) => item.url.endsWith("/artifacts")),
|
||||
[navItems]
|
||||
);
|
||||
const connectorsItem = useMemo(
|
||||
() => navItems.find((item) => item.url.endsWith("/connectors")),
|
||||
[navItems]
|
||||
);
|
||||
const playgroundItem = useMemo(
|
||||
() => navItems.find((item) => item.url.endsWith("/playground")),
|
||||
[navItems]
|
||||
|
|
@ -150,6 +154,7 @@ export function Sidebar({
|
|||
(item) =>
|
||||
!item.url.endsWith("/automations") &&
|
||||
!item.url.endsWith("/artifacts") &&
|
||||
!item.url.endsWith("/connectors") &&
|
||||
!item.url.endsWith("/playground")
|
||||
),
|
||||
[navItems]
|
||||
|
|
@ -254,6 +259,16 @@ export function Sidebar({
|
|||
tooltipContent={isCollapsed ? artifactsItem.title : undefined}
|
||||
/>
|
||||
)}
|
||||
{connectorsItem && (
|
||||
<SidebarButton
|
||||
icon={connectorsItem.icon}
|
||||
label={connectorsItem.title}
|
||||
onClick={() => onNavItemClick?.(connectorsItem)}
|
||||
isCollapsed={isCollapsed}
|
||||
isActive={connectorsItem.isActive}
|
||||
tooltipContent={isCollapsed ? connectorsItem.title : undefined}
|
||||
/>
|
||||
)}
|
||||
{playgroundItem && (
|
||||
<SidebarButton
|
||||
icon={playgroundItem.icon}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,16 @@ interface PublicChatSnapshotsManagerProps {
|
|||
workspaceId: number;
|
||||
}
|
||||
|
||||
const infoAlert = (
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertDescription>
|
||||
Public chats allow anyone with the URL to view a snapshot of a chat. They do not update when
|
||||
the original chat changes.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
export function PublicChatSnapshotsManager({
|
||||
workspaceId: _workspaceId,
|
||||
}: PublicChatSnapshotsManagerProps) {
|
||||
|
|
@ -81,14 +91,7 @@ export function PublicChatSnapshotsManager({
|
|||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-5">
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertDescription>
|
||||
<div className="flex min-h-[1.625em] items-center">
|
||||
<Skeleton className="h-4 w-60 bg-accent-foreground/15" />
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{infoAlert}
|
||||
|
||||
{/* Cards grid skeleton */}
|
||||
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2">
|
||||
|
|
@ -135,13 +138,7 @@ export function PublicChatSnapshotsManager({
|
|||
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-5">
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertDescription>
|
||||
Public chats allow anyone with the URL to view a snapshot of a chat. They do not update
|
||||
when the original chat changes.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{infoAlert}
|
||||
|
||||
<PublicChatSnapshotsList
|
||||
snapshots={snapshots}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||
import { Check, ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -15,11 +14,7 @@ import { Spinner } from "@/components/ui/spinner";
|
|||
import type { IncentiveTaskInfo } from "@/contracts/types/incentive-tasks.types";
|
||||
import { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service";
|
||||
import { stripeApiService } from "@/lib/apis/stripe-api.service";
|
||||
import {
|
||||
trackIncentivePageViewed,
|
||||
trackIncentiveTaskClicked,
|
||||
trackIncentiveTaskCompleted,
|
||||
} from "@/lib/posthog/events";
|
||||
import { trackIncentiveTaskClicked } from "@/lib/posthog/events";
|
||||
import { getWorkspaceIdParam } from "@/lib/route-params";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -35,9 +30,7 @@ export function EarnCreditsContent() {
|
|||
const queryClient = useQueryClient();
|
||||
const workspaceId = getWorkspaceIdParam(params) ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
trackIncentivePageViewed();
|
||||
}, []);
|
||||
// incentive_page_viewed removed — redundant with $pageview.
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["incentive-tasks"],
|
||||
|
|
@ -51,13 +44,11 @@ export function EarnCreditsContent() {
|
|||
|
||||
const completeMutation = useMutation({
|
||||
mutationFn: incentiveTasksApiService.completeTask,
|
||||
onSuccess: (response, taskType) => {
|
||||
onSuccess: (response) => {
|
||||
if (response.success) {
|
||||
toast.success(response.message);
|
||||
const task = data?.tasks.find((t) => t.task_type === taskType);
|
||||
if (task) {
|
||||
trackIncentiveTaskCompleted(taskType, task.credit_micros_reward);
|
||||
}
|
||||
// incentive_task_completed is now emitted server-side
|
||||
// (incentive_tasks_routes.complete_task) where credit is granted.
|
||||
queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] });
|
||||
queryClient.invalidateQueries({ queryKey: USER_QUERY_KEY });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,11 +29,7 @@ import { Switch } from "@/components/ui/switch";
|
|||
import type { ProcessingMode } from "@/contracts/types/document.types";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import {
|
||||
trackDocumentUploadFailure,
|
||||
trackDocumentUploadStarted,
|
||||
trackDocumentUploadSuccess,
|
||||
} from "@/lib/posthog/events";
|
||||
import { trackDocumentUploadStarted } from "@/lib/posthog/events";
|
||||
import {
|
||||
getAcceptedFileTypes,
|
||||
getSupportedExtensions,
|
||||
|
|
@ -380,13 +376,14 @@ export function DocumentUploadTab({
|
|||
setUploadProgress(Math.round((uploaded / total) * 100));
|
||||
}
|
||||
|
||||
trackDocumentUploadSuccess(Number(workspaceId), total);
|
||||
// Ingestion outcome is now emitted server-side
|
||||
// (document_processing_completed/_failed in document_tasks.py); the
|
||||
// upload POST succeeding only means the file was accepted, not processed.
|
||||
toast(t("upload_initiated"), { description: t("upload_initiated_desc") });
|
||||
setFolderUpload(null);
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Upload failed";
|
||||
trackDocumentUploadFailure(Number(workspaceId), message);
|
||||
toast(t("upload_error"), {
|
||||
description: `${t("upload_error_desc")}: ${message}`,
|
||||
});
|
||||
|
|
@ -421,7 +418,7 @@ export function DocumentUploadTab({
|
|||
onSuccess: () => {
|
||||
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
|
||||
setUploadProgress(100);
|
||||
trackDocumentUploadSuccess(Number(workspaceId), files.length);
|
||||
// Ingestion outcome now server-side (document_processing_*).
|
||||
toast(t("upload_initiated"), { description: t("upload_initiated_desc") });
|
||||
onSuccess?.();
|
||||
},
|
||||
|
|
@ -429,7 +426,6 @@ export function DocumentUploadTab({
|
|||
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
|
||||
setUploadProgress(0);
|
||||
const message = error instanceof Error ? error.message : "Upload failed";
|
||||
trackDocumentUploadFailure(Number(workspaceId), message);
|
||||
toast(t("upload_error"), {
|
||||
description: `${t("upload_error_desc")}: ${message}`,
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue