merge upstream/dev into feat/migrate-electric-to-zero

Resolve 8 conflicts:
- Accept upstream deletion of 3 composio_*_connector.py (unified Google connectors)
- Accept our deletion of ElectricProvider.tsx, use-connectors-electric.ts,
  use-messages-electric.ts (replaced by Zero equivalents)
- Keep both new deps in package.json (@rocicorp/zero + @slate-serializers/html)
- Regenerate pnpm-lock.yaml
This commit is contained in:
CREDO23 2026-03-24 17:40:34 +02:00
commit 5d8a62a4a6
207 changed files with 28023 additions and 12247 deletions

View file

@ -234,7 +234,7 @@ const AssistantActionBar: FC = () => {
hideWhenRunning
autohide="not-last"
autohideFloat="single-branch"
className="aui-assistant-action-bar-root -ml-1 col-start-3 row-start-2 flex gap-1 text-muted-foreground md:data-floating:absolute md:data-floating:rounded-md md:data-floating:border md:data-floating:bg-background md:data-floating:p-1 md:data-floating:shadow-sm [&>button]:opacity-100 md:[&>button]:opacity-[var(--aui-button-opacity,1)]"
className="aui-assistant-action-bar-root -ml-1 col-start-3 row-start-2 flex gap-1 text-muted-foreground md:data-floating:absolute md:data-floating:rounded-md md:data-floating:p-1 [&>button]:opacity-100 md:[&>button]:opacity-[var(--aui-button-opacity,1)]"
>
<ActionBarPrimitive.Copy asChild>
<TooltipIconButton tooltip="Copy">
@ -247,7 +247,7 @@ const AssistantActionBar: FC = () => {
</TooltipIconButton>
</ActionBarPrimitive.Copy>
<ActionBarPrimitive.ExportMarkdown asChild>
<TooltipIconButton tooltip="Export as Markdown">
<TooltipIconButton tooltip="Download">
<DownloadIcon />
</TooltipIconButton>
</ActionBarPrimitive.ExportMarkdown>

View file

@ -2,8 +2,8 @@
import { useAtomValue, useSetAtom } from "jotai";
import { AlertTriangle, Cable, Settings } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { type FC, forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react";
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
import {
@ -49,9 +49,8 @@ interface ConnectorIndicatorProps {
export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, ConnectorIndicatorProps>(
({ showTrigger = true }, ref) => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const searchParams = useSearchParams();
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
const { data: currentUser } = useAtomValue(currentUserAtom);
useAtomValue(currentUserAtom);
const { data: preferences = {}, isFetching: preferencesLoading } =
useAtomValue(llmPreferencesAtom);
const { data: globalConfigs = [], isFetching: globalConfigsLoading } =
@ -85,9 +84,6 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
[statusInboxItems]
);
// Check if YouTube view is active
const isYouTubeView = searchParams.get("view") === "youtube";
// Use the custom hook for dialog state management
const {
isOpen,
@ -112,6 +108,8 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
allConnectors,
viewingAccountsType,
viewingMCPList,
isYouTubeView,
isFromOAuth,
setSearchQuery,
setStartDate,
setEndDate,
@ -206,14 +204,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
if (!searchSpaceId) return null;
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open && pickerOpen) return;
handleOpenChange(open);
}}
modal={!pickerOpen}
>
<Dialog open={isOpen} modal={false} onOpenChange={handleOpenChange}>
{showTrigger && (
<TooltipIconButton
data-joyride="connector-icon"
@ -249,9 +240,27 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
</TooltipIconButton>
)}
{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()}
className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 dark:ring-0 bg-muted dark:bg-muted text-foreground focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5 select-none"
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 border border-border ring-0 dark:ring-0 bg-muted dark:bg-muted text-foreground [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5 select-none"
>
<DialogTitle className="sr-only">Manage Connectors</DialogTitle>
{/* YouTube Crawler View - shown when adding YouTube videos */}
@ -329,20 +338,27 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
}}
onDisconnect={() => handleDisconnectConnector(() => refreshConnectors())}
onBack={handleBackFromEdit}
onQuickIndex={
editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR"
? () => {
startIndexing(editingConnector.id);
handleQuickIndexConnector(
editingConnector.id,
editingConnector.connector_type,
stopIndexing,
startDate,
endDate
);
}
: undefined
}
onQuickIndex={(() => {
const cfg = connectorConfig || editingConnector.config;
const isDrive =
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR";
const hasDriveItems = isDrive
? ((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}
/>
@ -363,6 +379,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
frequencyMinutes={frequencyMinutes}
enableSummary={enableSummary}
isStartingIndexing={isStartingIndexing}
isFromOAuth={isFromOAuth}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPeriodicEnabledChange={setPeriodicEnabled}

View file

@ -1,24 +1,9 @@
{
"connectorStatuses": {
"GOOGLE_DRIVE_CONNECTOR": {
"enabled": true,
"status": "warning",
"statusMessage": "Our Google OAuth app is not verified. You may see a 'non-verified app' warning during sign-in."
},
"GOOGLE_GMAIL_CONNECTOR": {
"enabled": true,
"status": "warning",
"statusMessage": "Our Google OAuth app is not verified. You may see a 'non-verified app' warning during sign-in."
},
"GOOGLE_CALENDAR_CONNECTOR": {
"enabled": true,
"status": "warning",
"statusMessage": "Our Google OAuth app is not verified. You may see a 'non-verified app' warning during sign-in."
},
"YOUTUBE_CONNECTOR": {
"enabled": true,
"status": "warning",
"statusMessage": "Doesn't work on cloud version due to YouTube blocks. Will be fixed soon."
"statusMessage": "Sometimes may not work due to Youtube blocks."
},
"WEBCRAWLER_CONNECTOR": {
"enabled": true,

View file

@ -1,7 +1,6 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import * as RadioGroup from "@radix-ui/react-radio-group";
import { Info } from "lucide-react";
import type { FC } from "react";
import { useId, useRef, useState } from "react";
@ -26,6 +25,7 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
@ -282,10 +282,9 @@ export const ElasticsearchConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSub
render={({ field }) => (
<FormItem className="space-y-3">
<FormControl>
<RadioGroup.Root
<RadioGroup
onValueChange={(value) => {
field.onChange(value);
// Clear auth fields when method changes
if (value !== "basic") {
form.setValue("username", "");
form.setValue("password", "");
@ -295,38 +294,22 @@ export const ElasticsearchConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSub
}
}}
value={field.value}
className="flex flex-col space-y-2"
className="flex flex-col gap-2"
>
<div className="flex items-center space-x-2">
<RadioGroup.Item
value="api_key"
id={authApiKeyId}
className="aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
>
<RadioGroup.Indicator className="flex items-center justify-center">
<div className="h-2.5 w-2.5 rounded-full bg-current" />
</RadioGroup.Indicator>
</RadioGroup.Item>
<div className="flex items-center gap-2">
<RadioGroupItem value="api_key" id={authApiKeyId} />
<Label htmlFor={authApiKeyId} className="text-xs sm:text-sm">
API Key
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroup.Item
value="basic"
id={authBasicId}
className="aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
>
<RadioGroup.Indicator className="flex items-center justify-center">
<div className="h-2.5 w-2.5 rounded-full bg-current" />
</RadioGroup.Indicator>
</RadioGroup.Item>
<div className="flex items-center gap-2">
<RadioGroupItem value="basic" id={authBasicId} />
<Label htmlFor={authBasicId} className="text-xs sm:text-sm">
Username & Password
</Label>
</div>
</RadioGroup.Root>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>

View file

@ -12,7 +12,7 @@ import {
X,
} from "lucide-react";
import type { FC } from "react";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { ComposioDriveFolderTree } from "@/components/connectors/composio-drive-folder-tree";
import { Label } from "@/components/ui/label";
import {
@ -23,13 +23,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
interface ComposioDriveConfigProps {
connector: SearchSourceConnector;
onConfigChange?: (config: Record<string, unknown>) => void;
onNameChange?: (name: string) => void;
}
import type { ConnectorConfigProps } from "../index";
interface SelectedFolder {
id: string;
@ -56,14 +50,14 @@ function getFileIconFromName(fileName: string, className: string = "size-3.5 shr
lowerName.endsWith(".csv") ||
lowerName.includes("spreadsheet")
) {
return <FileSpreadsheet className={`${className} text-green-500`} />;
return <FileSpreadsheet className={`${className} text-muted-foreground`} />;
}
if (
lowerName.endsWith(".pptx") ||
lowerName.endsWith(".ppt") ||
lowerName.includes("presentation")
) {
return <Presentation className={`${className} text-orange-500`} />;
return <Presentation className={`${className} text-muted-foreground`} />;
}
if (
lowerName.endsWith(".docx") ||
@ -73,7 +67,7 @@ function getFileIconFromName(fileName: string, className: string = "size-3.5 shr
lowerName.includes("word") ||
lowerName.includes("text")
) {
return <FileText className={`${className} text-gray-500`} />;
return <FileText className={`${className} text-muted-foreground`} />;
}
if (
lowerName.endsWith(".png") ||
@ -83,15 +77,12 @@ function getFileIconFromName(fileName: string, className: string = "size-3.5 shr
lowerName.endsWith(".webp") ||
lowerName.endsWith(".svg")
) {
return <Image className={`${className} text-purple-500`} />;
return <Image className={`${className} text-muted-foreground`} />;
}
return <File className={`${className} text-gray-500`} />;
return <File className={`${className} text-muted-foreground`} />;
}
export const ComposioDriveConfig: FC<ComposioDriveConfigProps> = ({
connector,
onConfigChange,
}) => {
export const ComposioDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfigChange }) => {
const isIndexable = connector.config?.is_indexable as boolean;
const existingFolders =
@ -103,6 +94,13 @@ export const ComposioDriveConfig: FC<ComposioDriveConfigProps> = ({
const [selectedFolders, setSelectedFolders] = useState<SelectedFolder[]>(existingFolders);
const [selectedFiles, setSelectedFiles] = useState<SelectedFolder[]>(existingFiles);
const [indexingOptions, setIndexingOptions] = useState<IndexingOptions>(existingIndexingOptions);
const [authError, setAuthError] = useState(false);
const isAuthExpired = connector.config?.auth_expired === true || authError;
const handleAuthError = useCallback(() => {
setAuthError(true);
}, []);
const [isEditMode] = useState(() => existingFolders.length > 0 || existingFiles.length > 0);
const [isFolderTreeOpen, setIsFolderTreeOpen] = useState(!isEditMode);
@ -201,7 +199,7 @@ export const ComposioDriveConfig: FC<ComposioDriveConfigProps> = ({
className="text-xs sm:text-sm text-muted-foreground truncate flex items-center gap-1.5"
title={folder.name}
>
<FolderClosed className="size-3.5 shrink-0 text-gray-500" />
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate">{folder.name}</span>
<button
type="button"
@ -235,6 +233,13 @@ export const ComposioDriveConfig: FC<ComposioDriveConfigProps> = ({
</div>
)}
{isAuthExpired && (
<p className="text-xs text-amber-600 dark:text-amber-500">
Your Google Drive authentication has expired. Please re-authenticate using the button
below.
</p>
)}
{isEditMode ? (
<div className="space-y-2">
<button
@ -242,12 +247,12 @@ export const ComposioDriveConfig: FC<ComposioDriveConfigProps> = ({
onClick={() => setIsFolderTreeOpen(!isFolderTreeOpen)}
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground transition-colors w-fit"
>
Change Selection
{isFolderTreeOpen ? (
<ChevronDown className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
Change Selection
</button>
{isFolderTreeOpen && (
<ComposioDriveFolderTree
@ -256,6 +261,7 @@ export const ComposioDriveConfig: FC<ComposioDriveConfigProps> = ({
onSelectFolders={handleSelectFolders}
selectedFiles={selectedFiles}
onSelectFiles={handleSelectFiles}
onAuthError={handleAuthError}
/>
)}
</div>
@ -266,6 +272,7 @@ export const ComposioDriveConfig: FC<ComposioDriveConfigProps> = ({
onSelectFolders={handleSelectFolders}
selectedFiles={selectedFiles}
onSelectFiles={handleSelectFiles}
onAuthError={handleAuthError}
/>
)}
</div>

View file

@ -1,12 +1,12 @@
"use client";
import * as RadioGroup from "@radix-ui/react-radio-group";
import { KeyRound, Server } from "lucide-react";
import type { FC } from "react";
import { useEffect, useId, useState } from "react";
import { useEffect, useId, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import type { ConnectorConfigProps } from "../index";
export interface ElasticsearchConfigProps extends ConnectorConfigProps {
@ -56,8 +56,12 @@ export const ElasticsearchConfig: FC<ElasticsearchConfigProps> = ({
: ""
);
// Update values when connector changes
// Update values when the connector identity changes (e.g. switching to a different connector)
const connectorIdRef = useRef(connector.id);
useEffect(() => {
if (connectorIdRef.current === connector.id) return;
connectorIdRef.current = connector.id;
setName(connector.name || "");
setEndpointUrl((connector.config?.ELASTICSEARCH_URL as string) || "");
setAuthMethod(
@ -82,7 +86,7 @@ export const ElasticsearchConfig: FC<ElasticsearchConfigProps> = ({
? String(connector.config.ELASTICSEARCH_MAX_DOCUMENTS)
: ""
);
}, [connector.config, connector.name]);
}, [connector]);
const stringToArray = (str: string): string[] => {
const items = str
@ -192,9 +196,9 @@ export const ElasticsearchConfig: FC<ElasticsearchConfigProps> = ({
const handleMaxDocumentsChange = (value: string) => {
setMaxDocuments(value);
if (value && value.trim()) {
if (value?.trim()) {
const num = parseInt(value, 10);
if (!isNaN(num) && num > 0) {
if (!Number.isNaN(num) && num > 0) {
updateConfig({ ELASTICSEARCH_MAX_DOCUMENTS: num });
}
} else {
@ -255,41 +259,25 @@ export const ElasticsearchConfig: FC<ElasticsearchConfigProps> = ({
</div>
<div className="space-y-4">
<RadioGroup.Root
<RadioGroup
value={authMethod}
onValueChange={(value) => handleAuthMethodChange(value as "basic" | "api_key")}
className="flex flex-col space-y-2"
className="flex flex-col gap-2"
>
<div className="flex items-center space-x-2">
<RadioGroup.Item
value="api_key"
id={authApiKeyId}
className="aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
>
<RadioGroup.Indicator className="flex items-center justify-center">
<div className="h-2.5 w-2.5 rounded-full bg-current" />
</RadioGroup.Indicator>
</RadioGroup.Item>
<div className="flex items-center gap-2">
<RadioGroupItem value="api_key" id={authApiKeyId} />
<Label htmlFor={authApiKeyId} className="text-xs sm:text-sm">
API Key
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroup.Item
value="basic"
id={authBasicId}
className="aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
>
<RadioGroup.Indicator className="flex items-center justify-center">
<div className="h-2.5 w-2.5 rounded-full bg-current" />
</RadioGroup.Indicator>
</RadioGroup.Item>
<div className="flex items-center gap-2">
<RadioGroupItem value="basic" id={authBasicId} />
<Label htmlFor={authBasicId} className="text-xs sm:text-sm">
Username & Password
</Label>
</div>
</RadioGroup.Root>
</RadioGroup>
{authMethod === "basic" && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">

View file

@ -1,14 +1,11 @@
"use client";
import {
ChevronDown,
ChevronRight,
File,
FileSpreadsheet,
FileText,
FolderClosed,
Image,
Loader2,
Presentation,
X,
} from "lucide-react";
@ -23,6 +20,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { type PickerResult, useGooglePicker } from "@/hooks/use-google-picker";
import type { ConnectorConfigProps } from "../index";
@ -52,14 +50,14 @@ function getFileIconFromName(fileName: string, className: string = "size-3.5 shr
lowerName.endsWith(".csv") ||
lowerName.includes("spreadsheet")
) {
return <FileSpreadsheet className={`${className} text-green-500`} />;
return <FileSpreadsheet className={`${className} text-muted-foreground`} />;
}
if (
lowerName.endsWith(".pptx") ||
lowerName.endsWith(".ppt") ||
lowerName.includes("presentation")
) {
return <Presentation className={`${className} text-orange-500`} />;
return <Presentation className={`${className} text-muted-foreground`} />;
}
if (
lowerName.endsWith(".docx") ||
@ -69,7 +67,7 @@ function getFileIconFromName(fileName: string, className: string = "size-3.5 shr
lowerName.includes("word") ||
lowerName.includes("text")
) {
return <FileText className={`${className} text-gray-500`} />;
return <FileText className={`${className} text-muted-foreground`} />;
}
if (
lowerName.endsWith(".png") ||
@ -79,9 +77,9 @@ function getFileIconFromName(fileName: string, className: string = "size-3.5 shr
lowerName.endsWith(".webp") ||
lowerName.endsWith(".svg")
) {
return <Image className={`${className} text-purple-500`} />;
return <Image className={`${className} text-muted-foreground`} />;
}
return <File className={`${className} text-gray-500`} />;
return <File className={`${className} text-muted-foreground`} />;
}
export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfigChange }) => {
@ -141,6 +139,10 @@ export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfi
onPicked: handlePicked,
});
const isAuthExpired =
connector.config?.auth_expired === true ||
(!!pickerError && pickerError.toLowerCase().includes("authentication expired"));
const handleIndexingOptionChange = (key: keyof IndexingOptions, value: number | boolean) => {
const newOptions = { ...indexingOptions, [key]: value };
setIndexingOptions(newOptions);
@ -195,7 +197,7 @@ export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfi
className="text-xs sm:text-sm text-muted-foreground truncate flex items-center gap-1.5"
title={folder.name}
>
<FolderClosed className="size-3.5 shrink-0 text-gray-500" />
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate">{folder.name}</span>
<button
type="button"
@ -233,14 +235,21 @@ export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfi
type="button"
variant="outline"
onClick={openPicker}
disabled={pickerLoading}
disabled={pickerLoading || isAuthExpired}
className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-white/10 text-xs sm:text-sm h-8 sm:h-9"
>
{pickerLoading && <Loader2 className="size-3.5 mr-1.5 animate-spin" />}
{pickerLoading && <Spinner size="xs" className="mr-1.5" />}
{totalSelected > 0 ? "Change Selection" : "Select from Google Drive"}
</Button>
{pickerError && <p className="text-xs text-destructive">{pickerError}</p>}
{pickerError && !isAuthExpired && <p className="text-xs text-destructive">{pickerError}</p>}
{isAuthExpired && (
<p className="text-xs text-amber-600 dark:text-amber-500">
Your Google Drive authentication has expired. Please re-authenticate using the button
below.
</p>
)}
</div>
{/* Indexing Options */}

View file

@ -1,6 +1,5 @@
"use client";
import { FolderOpen } from "lucide-react";
import type { FC } from "react";
import { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
@ -127,7 +126,6 @@ export const ObsidianConfig: FC<ObsidianConfigProps> = ({
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<div className="space-y-1 sm:space-y-2">
<h3 className="font-medium text-sm sm:text-base flex items-center gap-2">
<FolderOpen className="h-4 w-4 text-purple-500" />
Vault Configuration
</h3>
</div>

View file

@ -1,11 +1,16 @@
"use client";
import { useAtomValue } from "jotai";
import { ArrowLeft, Info, RefreshCw, Trash2 } from "lucide-react";
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { Button } from "@/components/ui/button";
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 { authenticatedFetch } from "@/lib/auth-utils";
import { cn } from "@/lib/utils";
import { DateRangeSelector } from "../../components/date-range-selector";
import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
@ -13,6 +18,17 @@ import { SummaryConfig } from "../../components/summary-config";
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
import { getConnectorConfigComponent } from "../index";
const REAUTH_ENDPOINTS: Partial<Record<string, string>> = {
[EnumConnectorName.LINEAR_CONNECTOR]: "/api/v1/auth/linear/connector/reauth",
[EnumConnectorName.NOTION_CONNECTOR]: "/api/v1/auth/notion/connector/reauth",
[EnumConnectorName.GOOGLE_DRIVE_CONNECTOR]: "/api/v1/auth/google/drive/connector/reauth",
[EnumConnectorName.GOOGLE_GMAIL_CONNECTOR]: "/api/v1/auth/google/gmail/connector/reauth",
[EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR]: "/api/v1/auth/google/calendar/connector/reauth",
[EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
[EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
[EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
};
interface ConnectorEditViewProps {
connector: SearchSourceConnector;
startDate: Date | undefined;
@ -60,6 +76,41 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
onConfigChange,
onNameChange,
}) => {
const searchSpaceIdAtom = useAtomValue(activeSearchSpaceIdAtom);
const isAuthExpired = connector.config?.auth_expired === true;
const reauthEndpoint = REAUTH_ENDPOINTS[connector.connector_type];
const [reauthing, setReauthing] = useState(false);
const handleReauth = useCallback(async () => {
const spaceId = searchSpaceId ?? searchSpaceIdAtom;
if (!spaceId || !reauthEndpoint) return;
setReauthing(true);
try {
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
const url = new URL(`${backendUrl}${reauthEndpoint}`);
url.searchParams.set("connector_id", String(connector.id));
url.searchParams.set("space_id", String(spaceId));
url.searchParams.set("return_url", window.location.pathname);
const response = await authenticatedFetch(url.toString());
if (!response.ok) {
const data = await response.json().catch(() => ({}));
toast.error(data.detail ?? "Failed to initiate re-authentication.");
return;
}
const data = await response.json();
if (data.auth_url) {
window.location.href = data.auth_url;
} else if (data.success) {
toast.success(data.message ?? "Authentication refreshed successfully.");
window.location.reload();
}
} catch {
toast.error("Failed to initiate re-authentication.");
} finally {
setReauthing(false);
}
}, [searchSpaceId, searchSpaceIdAtom, reauthEndpoint, connector.id]);
// Get connector-specific config component
const ConnectorConfigComponent = useMemo(
() => getConnectorConfigComponent(connector.connector_type),
@ -169,30 +220,28 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
</p>
</div>
</div>
{/* Quick Index Button - only show for indexable connectors, but not for Google Drive (requires folder selection) */}
{connector.is_indexable &&
onQuickIndex &&
connector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" && (
<Button
variant="secondary"
size="sm"
onClick={handleQuickIndex}
disabled={isQuickIndexing || isIndexing || isSaving || isDisconnecting}
className="text-xs sm:text-sm bg-slate-400/10 dark:bg-white/10 hover:bg-slate-400/20 dark:hover:bg-white/20 border-slate-400/20 dark:border-white/20 w-full sm:w-auto"
>
{isQuickIndexing || isIndexing ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
Syncing
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Quick Index
</>
)}
</Button>
)}
{/* Quick Index Button - hidden when auth is expired */}
{connector.is_indexable && onQuickIndex && !isAuthExpired && (
<Button
variant="secondary"
size="sm"
onClick={handleQuickIndex}
disabled={isQuickIndexing || isIndexing || isSaving || isDisconnecting}
className="text-xs sm:text-sm bg-slate-400/10 dark:bg-white/10 hover:bg-slate-400/20 dark:hover:bg-white/20 border-slate-400/20 dark:border-white/20 w-full sm:w-auto"
>
{isQuickIndexing || isIndexing ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
Syncing
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Quick Index
</>
)}
</Button>
)}
</div>
</div>
@ -350,20 +399,31 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
Disconnect
</Button>
)}
<Button
onClick={onSave}
disabled={isSaving || isDisconnecting}
className="text-xs sm:text-sm flex-1 sm:flex-initial h-12 sm:h-auto py-3 sm:py-2"
>
{isSaving ? (
<>
<Spinner size="sm" className="mr-2" />
Saving
</>
) : (
"Save Changes"
)}
</Button>
{isAuthExpired && reauthEndpoint ? (
<Button
onClick={handleReauth}
disabled={reauthing || isDisconnecting}
className="text-xs sm:text-sm flex-1 sm:flex-initial h-12 sm:h-auto py-3 sm:py-2 bg-amber-600 hover:bg-amber-700 text-white"
>
<RefreshCw className={cn("size-3.5", reauthing && "animate-spin")} />
Re-authenticate
</Button>
) : (
<Button
onClick={onSave}
disabled={isSaving || isDisconnecting}
className="text-xs sm:text-sm flex-1 sm:flex-initial h-12 sm:h-auto py-3 sm:py-2"
>
{isSaving ? (
<>
<Spinner size="sm" className="mr-2" />
Saving
</>
) : (
"Save Changes"
)}
</Button>
)}
</div>
</div>
);

View file

@ -1,7 +1,6 @@
"use client";
import { ArrowLeft, Check, Info } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
@ -24,6 +23,7 @@ interface IndexingConfigurationViewProps {
frequencyMinutes: string;
enableSummary: boolean;
isStartingIndexing: boolean;
isFromOAuth?: boolean;
onStartDateChange: (date: Date | undefined) => void;
onEndDateChange: (date: Date | undefined) => void;
onPeriodicEnabledChange: (enabled: boolean) => void;
@ -43,6 +43,7 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
frequencyMinutes,
enableSummary,
isStartingIndexing,
isFromOAuth = false,
onStartDateChange,
onEndDateChange,
onPeriodicEnabledChange,
@ -52,9 +53,6 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
onStartIndexing,
onSkip,
}) => {
const searchParams = useSearchParams();
const isFromOAuth = searchParams.get("view") === "configure";
// Get connector-specific config component
const ConnectorConfigComponent = useMemo(
() => (connector ? getConnectorConfigComponent(connector.connector_type) : null),

View file

@ -2,27 +2,30 @@ import { EnumConnectorName } from "@/contracts/enums/connector";
// OAuth Connectors (Quick Connect)
export const OAUTH_CONNECTORS = [
// {
// id: "google-drive-connector",
// title: "Google Drive",
// description: "Search your Drive files",
// connectorType: EnumConnectorName.GOOGLE_DRIVE_CONNECTOR,
// authEndpoint: "/api/v1/auth/google/drive/connector/add/",
// },
// {
// id: "google-gmail-connector",
// title: "Gmail",
// description: "Search through your emails",
// connectorType: EnumConnectorName.GOOGLE_GMAIL_CONNECTOR,
// authEndpoint: "/api/v1/auth/google/gmail/connector/add/",
// },
// {
// id: "google-calendar-connector",
// title: "Google Calendar",
// description: "Search through your events",
// connectorType: EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR,
// authEndpoint: "/api/v1/auth/google/calendar/connector/add/",
// },
{
id: "google-drive-connector",
title: "Google Drive",
description: "Search your Drive files",
connectorType: EnumConnectorName.GOOGLE_DRIVE_CONNECTOR,
authEndpoint: "/api/v1/auth/google/drive/connector/add/",
selfHostedOnly: true,
},
{
id: "google-gmail-connector",
title: "Gmail",
description: "Search through your emails",
connectorType: EnumConnectorName.GOOGLE_GMAIL_CONNECTOR,
authEndpoint: "/api/v1/auth/google/gmail/connector/add/",
selfHostedOnly: true,
},
{
id: "google-calendar-connector",
title: "Google Calendar",
description: "Search through your events",
connectorType: EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR,
authEndpoint: "/api/v1/auth/google/calendar/connector/add/",
selfHostedOnly: true,
},
{
id: "airtable-connector",
title: "Airtable",

View file

@ -1,24 +1,6 @@
import { z } from "zod";
import { searchSourceConnectorTypeEnum } from "@/contracts/types/connector.types";
/**
* Schema for URL query parameters used by the connector popup
*/
export const connectorPopupQueryParamsSchema = z.object({
modal: z.enum(["connectors"]).optional(),
tab: z.enum(["all", "active"]).optional(),
view: z
.enum(["configure", "edit", "connect", "youtube", "accounts", "mcp-list", "composio"])
.optional(),
connector: z.string().optional(),
connectorId: z.string().optional(),
connectorType: z.string().optional(),
success: z.enum(["true", "false"]).optional(),
error: z.string().optional(),
});
export type ConnectorPopupQueryParams = z.infer<typeof connectorPopupQueryParamsSchema>;
/**
* Schema for OAuth API response (auth_url)
*/
@ -72,31 +54,10 @@ export const dateRangeSchema = z
export type DateRange = z.infer<typeof dateRangeSchema>;
/**
* Schema for connector ID validation (used in URL params)
* Schema for connector ID validation
*/
export const connectorIdSchema = z.string().min(1, "Connector ID is required");
/**
* Helper function to safely parse query params
*/
export function parseConnectorPopupQueryParams(
params: URLSearchParams | Record<string, string | null>
): ConnectorPopupQueryParams {
const obj: Record<string, string | undefined> = {};
if (params instanceof URLSearchParams) {
params.forEach((value, key) => {
obj[key] = value || undefined;
});
} else {
Object.entries(params).forEach(([key, value]) => {
obj[key] = value || undefined;
});
}
return connectorPopupQueryParamsSchema.parse(obj);
}
/**
* Helper function to safely parse OAuth response
*/

View file

@ -1,6 +1,5 @@
import { format } from "date-fns";
import { useAtom, useAtomValue } from "jotai";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
@ -37,14 +36,25 @@ import {
import {
dateRangeSchema,
frequencyMinutesSchema,
parseConnectorPopupQueryParams,
parseOAuthAuthResponse,
validateIndexingConfigState,
} from "../constants/connector-popup.schemas";
const OAUTH_RESULT_COOKIE = "connector_oauth_result";
function readOAuthResultCookie(): string | null {
const match = document.cookie
.split("; ")
.find((row) => row.startsWith(`${OAUTH_RESULT_COOKIE}=`));
return match ? decodeURIComponent(match.split("=").slice(1).join("=")) : null;
}
function clearOAuthResultCookie(): void {
// biome-ignore lint: only standard way to expire a cookie
document.cookie = `${OAUTH_RESULT_COOKIE}=; path=/; max-age=0`;
}
export const useConnectorDialog = () => {
const router = useRouter();
const searchParams = useSearchParams();
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const { data: allConnectors, refetch: refetchAllConnectors } = useAtomValue(connectorsAtom);
const { mutateAsync: indexConnector } = useAtomValue(indexConnectorMutationAtom);
@ -102,6 +112,9 @@ export const useConnectorDialog = () => {
// Track if we came from MCP list view when entering edit mode
const [cameFromMCPList, setCameFromMCPList] = useState(false);
// Track if we came from MCP list view when entering connect mode
const [connectCameFromMCPList, setConnectCameFromMCPList] = useState(false);
// Helper function to get frequency label
const getFrequencyLabel = useCallback((minutes: string): string => {
switch (minutes) {
@ -181,352 +194,139 @@ export const useConnectorDialog = () => {
[searchSpaceId, indexConnector, updateConnector, refetchAllConnectors]
);
// When the dialog is opened externally (via setConnectorDialogOpen atom from
// thread.tsx / DocumentsSidebar.tsx), the URL is not updated. Sync it here
// so that other handlers that read window.location.href see modal=connectors.
const activeTabRef = useRef(activeTab);
activeTabRef.current = activeTab;
useEffect(() => {
if (isOpen) {
const url = new URL(window.location.href);
const modalParam = url.searchParams.get("modal");
const tabParam = url.searchParams.get("tab");
if (modalParam !== "connectors" || (tabParam !== "all" && tabParam !== "active")) {
url.searchParams.set("modal", "connectors");
url.searchParams.set("tab", activeTabRef.current);
window.history.replaceState({ modal: true }, "", url.toString());
}
}
}, [isOpen]);
// YouTube view state
const [isYouTubeView, setIsYouTubeView] = useState(false);
// Synchronize state with URL query params
// Track whether the current indexing config came from an OAuth redirect
const [isFromOAuth, setIsFromOAuth] = useState(false);
// Consume OAuth result from cookie (set by /connectors/callback route handler)
useEffect(() => {
const raw = readOAuthResultCookie();
if (!raw || !searchSpaceId) return;
clearOAuthResultCookie();
let result: {
success: string | null;
error: string | null;
connector: string | null;
connectorId: string | null;
};
try {
const params = parseConnectorPopupQueryParams(searchParams);
result = JSON.parse(raw);
} catch {
return;
}
if (params.modal === "connectors") {
setIsOpen(true);
if (result.error) {
const oauthConnector = result.connector
? OAUTH_CONNECTORS.find((c) => c.id === result.connector)
: null;
const name = oauthConnector?.title || "connector";
if (params.tab === "active" || params.tab === "all") {
setActiveTab(params.tab);
}
// Clear indexing config if view is not "configure" anymore
if (params.view !== "configure" && indexingConfig) {
setIndexingConfig(null);
}
// Clear editing connector if view is not "edit" anymore
if (params.view !== "edit" && editingConnector) {
setEditingConnector(null);
setConnectorName(null);
}
// Clear connecting connector type if view is not "connect" anymore
if (params.view !== "connect" && connectingConnectorType) {
setConnectingConnectorType(null);
}
// Clear viewing accounts type if view is not "accounts" anymore
if (params.view !== "accounts" && viewingAccountsType) {
setViewingAccountsType(null);
}
// Clear MCP list view if view is not "mcp-list" anymore
if (params.view !== "mcp-list" && viewingMCPList) {
setViewingMCPList(false);
}
// Handle MCP list view
if (params.view === "mcp-list" && !viewingMCPList) {
setViewingMCPList(true);
}
// Handle connect view
if (params.view === "connect" && params.connectorType && !connectingConnectorType) {
setConnectingConnectorType(params.connectorType);
}
// Handle accounts view
if (params.view === "accounts" && params.connectorType) {
// Update state if not set, or if connectorType has changed
const needsUpdate =
!viewingAccountsType || viewingAccountsType.connectorType !== params.connectorType;
if (needsUpdate) {
// Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
const oauthConnector =
OAUTH_CONNECTORS.find((c) => c.connectorType === params.connectorType) ||
COMPOSIO_CONNECTORS.find((c) => c.connectorType === params.connectorType);
if (oauthConnector) {
setViewingAccountsType({
connectorType: oauthConnector.connectorType,
connectorTitle: oauthConnector.title,
});
}
}
}
// Handle YouTube view
if (params.view === "youtube") {
// YouTube view is active - no additional state needed
}
// Handle configure view (for page refresh support)
if (params.view === "configure" && params.connector && !indexingConfig && allConnectors) {
// Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
const oauthConnector =
OAUTH_CONNECTORS.find((c) => c.id === params.connector) ||
COMPOSIO_CONNECTORS.find((c) => c.id === params.connector);
if (oauthConnector) {
let existingConnector: SearchSourceConnector | undefined;
if (params.connectorId) {
const connectorId = parseInt(params.connectorId, 10);
existingConnector = allConnectors.find(
(c: SearchSourceConnector) => c.id === connectorId
);
} else {
existingConnector = allConnectors.find(
(c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType
);
}
if (existingConnector) {
const connectorValidation = searchSourceConnector.safeParse(existingConnector);
if (connectorValidation.success) {
const config = validateIndexingConfigState({
connectorType: oauthConnector.connectorType,
connectorId: existingConnector.id,
connectorTitle: oauthConnector.title,
});
setIndexingConfig(config);
setIndexingConnector(existingConnector);
setIndexingConnectorConfig(existingConnector.config);
}
}
}
}
// Handle edit view
if (params.view === "edit" && params.connectorId && allConnectors && !editingConnector) {
const connectorId = parseInt(params.connectorId, 10);
const connector = allConnectors.find((c: SearchSourceConnector) => c.id === connectorId);
if (connector) {
const connectorValidation = searchSourceConnector.safeParse(connector);
if (connectorValidation.success) {
setEditingConnector(connector);
setConnectorConfig(connector.config);
setConnectorName(connector.name);
// Load existing periodic sync settings (disabled for non-indexable connectors)
setPeriodicEnabled(
!connector.is_indexable ? false : connector.periodic_indexing_enabled
);
setFrequencyMinutes(connector.indexing_frequency_minutes?.toString() || "1440");
setEnableSummary(connector.enable_summary ?? false);
// Reset dates - user can set new ones for re-indexing
setStartDate(undefined);
setEndDate(undefined);
}
}
}
if (result.error === "duplicate_account") {
toast.error(`This ${name} account is already connected`, {
description: "Please use a different account or manage the existing connection.",
});
} else {
// Do NOT call setIsOpen(false) here. Closing the dialog is handled
// explicitly by handleOpenChange and the individual action handlers.
// Relying on URL params to close the dialog caused a race condition
// where Next.js router updates from tab switches briefly produced
// stale searchParams without the "modal" key, closing the popup.
// Still clean up sub-view state when the modal param is gone
// (e.g. after browser back navigation or explicit handler URL cleanup).
if (indexingConfig) {
setIndexingConfig(null);
setIndexingConnector(null);
setIndexingConnectorConfig(null);
setStartDate(undefined);
setEndDate(undefined);
setPeriodicEnabled(false);
setFrequencyMinutes("1440");
setEnableSummary(false);
setIsScrolled(false);
setSearchQuery("");
}
if (editingConnector) {
setEditingConnector(null);
setConnectorName(null);
setConnectorConfig(null);
setStartDate(undefined);
setEndDate(undefined);
setPeriodicEnabled(false);
setFrequencyMinutes("1440");
setEnableSummary(false);
setIsScrolled(false);
setSearchQuery("");
}
if (connectingConnectorType) {
setConnectingConnectorType(null);
}
if (viewingAccountsType) {
setViewingAccountsType(null);
}
}
} catch (error) {
// Invalid query params - log but don't crash
console.warn("Invalid connector popup query params:", error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
searchParams,
allConnectors,
editingConnector,
indexingConfig,
connectingConnectorType,
viewingAccountsType,
viewingMCPList,
setIsOpen,
]);
// Detect OAuth success / Failure and transition to config view
useEffect(() => {
try {
const params = parseConnectorPopupQueryParams(searchParams);
// Handle OAuth errors (e.g., duplicate account)
if (params.error && params.modal === "connectors") {
const oauthConnector = params.connector
? OAUTH_CONNECTORS.find((c) => c.id === params.connector)
: null;
const connectorName = oauthConnector?.title || "connector";
if (params.error === "duplicate_account") {
toast.error(`This ${connectorName} account is already connected`, {
description: "Please use a different account or manage the existing connection.",
});
} else {
toast.error(`Failed to connect ${connectorName}`, {
description: params.error.replace(/_/g, " "),
});
}
// Clean up error params from URL
const url = new URL(window.location.href);
url.searchParams.delete("error");
url.searchParams.delete("connector");
window.history.replaceState({}, "", url.toString());
// Open the popup to show the connectors
setIsOpen(true);
return;
}
if (params.success === "true" && searchSpaceId && params.modal === "connectors") {
// For auto-index connectors: close modal and show loading toast before refetch
const earlyConnector = params.connector
? OAUTH_CONNECTORS.find((c) => c.id === params.connector) ||
COMPOSIO_CONNECTORS.find((c) => c.id === params.connector)
: null;
if (earlyConnector && AUTO_INDEX_CONNECTOR_TYPES.has(earlyConnector.connectorType)) {
toast.loading(`Setting up ${earlyConnector.title}...`, { id: "auto-index" });
setIsOpen(false);
const url = new URL(window.location.href);
url.searchParams.delete("success");
url.searchParams.delete("connector");
url.searchParams.delete("connectorId");
url.searchParams.delete("view");
url.searchParams.delete("modal");
url.searchParams.delete("tab");
router.replace(url.pathname + url.search, { scroll: false });
}
refetchAllConnectors().then(async (result) => {
if (!result.data) {
toast.dismiss("auto-index");
return;
}
let newConnector: SearchSourceConnector | undefined;
let oauthConnector:
| (typeof OAUTH_CONNECTORS)[number]
| (typeof COMPOSIO_CONNECTORS)[number]
| undefined;
// First, try to find connector by connectorId if provided
if (params.connectorId) {
const connectorId = parseInt(params.connectorId, 10);
newConnector = result.data.find((c: SearchSourceConnector) => c.id === connectorId);
// If we found the connector, find the matching OAuth/Composio connector by type
if (newConnector) {
const connectorType = newConnector.connector_type;
oauthConnector =
OAUTH_CONNECTORS.find((c) => c.connectorType === connectorType) ||
COMPOSIO_CONNECTORS.find((c) => c.connectorType === connectorType);
}
}
// If we don't have a connector yet, try to find by connector param
if (!newConnector && params.connector) {
oauthConnector =
OAUTH_CONNECTORS.find((c) => c.id === params.connector) ||
COMPOSIO_CONNECTORS.find((c) => c.id === params.connector);
if (oauthConnector) {
const oauthConnectorType = oauthConnector.connectorType;
newConnector = result.data.find(
(c: SearchSourceConnector) => c.connector_type === oauthConnectorType
);
}
}
if (newConnector && oauthConnector) {
const connectorValidation = searchSourceConnector.safeParse(newConnector);
if (connectorValidation.success) {
trackConnectorConnected(
Number(searchSpaceId),
oauthConnector.connectorType,
newConnector.id
);
if (
newConnector.is_indexable &&
AUTO_INDEX_CONNECTOR_TYPES.has(oauthConnector.connectorType)
) {
await handleAutoIndex(
newConnector,
oauthConnector.title,
oauthConnector.connectorType
);
} else {
toast.dismiss("auto-index");
const config = validateIndexingConfigState({
connectorType: oauthConnector.connectorType,
connectorId: newConnector.id,
connectorTitle: oauthConnector.title,
});
setIndexingConfig(config);
setIndexingConnector(newConnector);
setIndexingConnectorConfig(newConnector.config);
setIsOpen(true);
const url = new URL(window.location.href);
url.searchParams.delete("success");
url.searchParams.set("connectorId", newConnector.id.toString());
url.searchParams.set("view", "configure");
window.history.replaceState({}, "", url.toString());
}
} else {
console.warn("Invalid connector data after OAuth:", connectorValidation.error);
toast.dismiss("auto-index");
toast.error("Failed to validate connector data");
}
} else {
toast.dismiss("auto-index");
}
toast.error(`Failed to connect ${name}`, {
description: result.error.replace(/_/g, " "),
});
}
} catch (error) {
// Invalid query params - log but don't crash
console.warn("Invalid connector popup query params in OAuth success handler:", error);
setIsOpen(true);
return;
}
}, [searchParams, searchSpaceId, refetchAllConnectors, setIsOpen, handleAutoIndex, router]);
if (result.success === "true") {
const earlyConnector = result.connector
? OAUTH_CONNECTORS.find((c) => c.id === result.connector) ||
COMPOSIO_CONNECTORS.find((c) => c.id === result.connector)
: null;
if (earlyConnector && AUTO_INDEX_CONNECTOR_TYPES.has(earlyConnector.connectorType)) {
toast.loading(`Setting up ${earlyConnector.title}...`, { id: "auto-index" });
setIsOpen(false);
}
refetchAllConnectors().then(async (fetchResult) => {
if (!fetchResult.data) {
toast.dismiss("auto-index");
return;
}
let newConnector: SearchSourceConnector | undefined;
let oauthConnector:
| (typeof OAUTH_CONNECTORS)[number]
| (typeof COMPOSIO_CONNECTORS)[number]
| undefined;
if (result.connectorId) {
const connectorId = parseInt(result.connectorId, 10);
newConnector = fetchResult.data.find((c: SearchSourceConnector) => c.id === connectorId);
if (newConnector) {
const connectorType = newConnector.connector_type;
oauthConnector =
OAUTH_CONNECTORS.find((c) => c.connectorType === connectorType) ||
COMPOSIO_CONNECTORS.find((c) => c.connectorType === connectorType);
}
}
if (!newConnector && result.connector) {
oauthConnector =
OAUTH_CONNECTORS.find((c) => c.id === result.connector) ||
COMPOSIO_CONNECTORS.find((c) => c.id === result.connector);
if (oauthConnector) {
const oauthType = oauthConnector.connectorType;
newConnector = fetchResult.data.find(
(c: SearchSourceConnector) => c.connector_type === oauthType
);
}
}
if (newConnector && oauthConnector) {
const connectorValidation = searchSourceConnector.safeParse(newConnector);
if (connectorValidation.success) {
trackConnectorConnected(
Number(searchSpaceId),
oauthConnector.connectorType,
newConnector.id
);
if (
newConnector.is_indexable &&
AUTO_INDEX_CONNECTOR_TYPES.has(oauthConnector.connectorType)
) {
await handleAutoIndex(
newConnector,
oauthConnector.title,
oauthConnector.connectorType
);
} else {
toast.dismiss("auto-index");
const config = validateIndexingConfigState({
connectorType: oauthConnector.connectorType,
connectorId: newConnector.id,
connectorTitle: oauthConnector.title,
});
setIndexingConfig(config);
setIndexingConnector(newConnector);
setIndexingConnectorConfig(newConnector.config);
setIsFromOAuth(true);
setIsOpen(true);
}
} else {
console.warn("Invalid connector data after OAuth:", connectorValidation.error);
toast.dismiss("auto-index");
toast.error("Failed to validate connector data");
}
} else {
toast.dismiss("auto-index");
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchSpaceId, handleAutoIndex, refetchAllConnectors, setIsOpen]);
// Handle OAuth connection
const handleConnectOAuth = useCallback(
@ -572,12 +372,7 @@ export const useConnectorDialog = () => {
// Handle creating YouTube crawler (not a connector, shows view in popup)
const handleCreateYouTubeCrawler = useCallback(() => {
if (!searchSpaceId) return;
// Update URL to show YouTube view
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("view", "youtube");
window.history.pushState({ modal: true }, "", url.toString());
setIsYouTubeView(true);
}, [searchSpaceId]);
// Handle creating webcrawler connector
@ -629,10 +424,6 @@ export const useConnectorDialog = () => {
setIndexingConnector(connector);
setIndexingConnectorConfig(connector.config || {});
setIsOpen(true);
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("view", "configure");
window.history.pushState({ modal: true }, "", url.toString());
}
}
}
@ -648,16 +439,7 @@ export const useConnectorDialog = () => {
const handleConnectNonOAuth = useCallback(
(connectorType: string) => {
if (!searchSpaceId) return;
// Set connecting state
setConnectingConnectorType(connectorType);
// Update URL to show connect view
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("view", "connect");
url.searchParams.set("connectorType", connectorType);
window.history.pushState({ modal: true }, "", url.toString());
},
[searchSpaceId]
);
@ -810,26 +592,16 @@ export const useConnectorDialog = () => {
: `${connectorTitle} connected and syncing started!`;
toast.success(successMessage);
// Close dialog and clean up URL
setIsOpen(false);
const url = new URL(window.location.href);
url.searchParams.delete("modal");
url.searchParams.delete("tab");
url.searchParams.delete("view");
url.searchParams.delete("connectorType");
router.replace(url.pathname + url.search, { scroll: false });
// Clear indexing config state since we're not showing the view
setIndexingConfig(null);
setIndexingConnector(null);
setIndexingConnectorConfig(null);
// Invalidate queries to refresh data
queryClient.invalidateQueries({
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
});
// Refresh connectors list
await refetchAllConnectors();
} else {
// Non-indexable connector
@ -856,15 +628,6 @@ export const useConnectorDialog = () => {
description: "Configure the webhook URL in your Circleback settings.",
});
// Transition to edit view
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("view", "edit");
url.searchParams.set("connectorId", connector.id.toString());
url.searchParams.delete("connectorType");
router.replace(url.pathname + url.search, { scroll: false });
// Refresh connectors list
await refetchAllConnectors();
} else {
// Other non-indexable connectors - just show success message and close
@ -874,19 +637,10 @@ export const useConnectorDialog = () => {
: `${connectorTitle} connected successfully!`;
toast.success(successMessage);
// Refresh connectors list before closing modal
await refetchAllConnectors();
// Close dialog and clean up URL
setIsOpen(false);
const url = new URL(window.location.href);
url.searchParams.delete("modal");
url.searchParams.delete("tab");
url.searchParams.delete("view");
url.searchParams.delete("connectorType");
router.replace(url.pathname + url.search, { scroll: false });
// Clear indexing config state
setIndexingConfig(null);
setIndexingConnector(null);
setIndexingConnectorConfig(null);
@ -911,96 +665,64 @@ export const useConnectorDialog = () => {
refetchAllConnectors,
updateConnector,
indexConnector,
router,
setIsOpen,
]
);
// Handle going back from connect view
const handleBackFromConnect = useCallback(() => {
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
// If we're connecting an MCP and came from list view, go back to list
if (connectingConnectorType === "MCP_CONNECTOR" && viewingMCPList) {
url.searchParams.set("view", "mcp-list");
} else {
url.searchParams.set("tab", "all");
url.searchParams.delete("view");
if (connectCameFromMCPList) {
setViewingMCPList(true);
setConnectCameFromMCPList(false);
}
url.searchParams.delete("connectorType");
router.replace(url.pathname + url.search, { scroll: false });
}, [router, connectingConnectorType, viewingMCPList]);
setConnectingConnectorType(null);
}, [connectCameFromMCPList]);
// Handle going back from YouTube view
const handleBackFromYouTube = useCallback(() => {
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("tab", "all");
url.searchParams.delete("view");
router.replace(url.pathname + url.search, { scroll: false });
}, [router]);
setIsYouTubeView(false);
}, []);
// Handle viewing accounts list for OAuth connector type
const handleViewAccountsList = useCallback(
(connectorType: string, _connectorTitle?: string) => {
if (!searchSpaceId) return;
// Update URL to show accounts view, preserving current tab
// The useEffect will handle setting viewingAccountsType based on URL params
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("view", "accounts");
url.searchParams.set("connectorType", connectorType);
// Keep the current tab in URL so we can go back to it
router.replace(url.pathname + url.search, { scroll: false });
const oauthConnector =
OAUTH_CONNECTORS.find((c) => c.connectorType === connectorType) ||
COMPOSIO_CONNECTORS.find((c) => c.connectorType === connectorType);
if (oauthConnector) {
setViewingAccountsType({
connectorType: oauthConnector.connectorType,
connectorTitle: oauthConnector.title,
});
}
},
[searchSpaceId, router]
[searchSpaceId]
);
// Handle going back from accounts list view
const handleBackFromAccountsList = useCallback(() => {
setViewingAccountsType(null);
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
// Keep the current tab (don't change it) - just remove view-specific params
url.searchParams.delete("view");
url.searchParams.delete("connectorType");
router.replace(url.pathname + url.search, { scroll: false });
}, [router]);
}, []);
// Handle viewing MCP list
const handleViewMCPList = useCallback(() => {
if (!searchSpaceId) return;
setViewingMCPList(true);
// Update URL to show MCP list view
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("view", "mcp-list");
router.replace(url.pathname + url.search, { scroll: false });
}, [searchSpaceId, router]);
}, [searchSpaceId]);
// Handle going back from MCP list view
const handleBackFromMCPList = useCallback(() => {
setViewingMCPList(false);
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.delete("view");
router.replace(url.pathname + url.search, { scroll: false });
}, [router]);
}, []);
// Handle adding new MCP from list view
const handleAddNewMCPFromList = useCallback(() => {
setConnectCameFromMCPList(true);
setViewingMCPList(false);
setConnectingConnectorType("MCP_CONNECTOR");
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("view", "connect");
url.searchParams.set("connectorType", "MCP_CONNECTOR");
router.replace(url.pathname + url.search, { scroll: false });
}, [router]);
}, []);
// Handle starting indexing
const handleStartIndexing = useCallback(
@ -1143,15 +865,8 @@ export const useConnectorDialog = () => {
toast.success(`${indexingConfig.connectorTitle} indexing started`);
// Close dialog and clean up URL
setIsOpen(false);
const url = new URL(window.location.href);
url.searchParams.delete("modal");
url.searchParams.delete("tab");
url.searchParams.delete("success");
url.searchParams.delete("connector");
url.searchParams.delete("view");
router.replace(url.pathname + url.search, { scroll: false });
setIsFromOAuth(false);
refreshConnectors();
queryClient.invalidateQueries({
@ -1174,7 +889,6 @@ export const useConnectorDialog = () => {
periodicEnabled,
frequencyMinutes,
enableSummary,
router,
indexingConnectorConfig,
setIsOpen,
]
@ -1182,16 +896,9 @@ export const useConnectorDialog = () => {
// Handle skipping indexing
const handleSkipIndexing = useCallback(() => {
// Close dialog and clean up URL
setIsOpen(false);
const url = new URL(window.location.href);
url.searchParams.delete("modal");
url.searchParams.delete("tab");
url.searchParams.delete("success");
url.searchParams.delete("connector");
url.searchParams.delete("view");
router.replace(url.pathname + url.search, { scroll: false });
}, [router, setIsOpen]);
setIsFromOAuth(false);
}, [setIsOpen]);
// Handle starting edit mode
const handleStartEdit = useCallback(
@ -1213,20 +920,21 @@ export const useConnectorDialog = () => {
return;
}
// Track if we came from accounts list view
// If viewingAccountsType matches this connector type, preserve it
// Track if we came from accounts list view so handleBackFromEdit can restore it
if (viewingAccountsType && viewingAccountsType.connectorType === connector.connector_type) {
setCameFromAccountsList(viewingAccountsType);
} else {
setCameFromAccountsList(null);
}
setViewingAccountsType(null);
// Track if we came from MCP list view
// Track if we came from MCP list view so handleBackFromEdit can restore it
if (viewingMCPList && connector.connector_type === "MCP_CONNECTOR") {
setCameFromMCPList(true);
} else {
setCameFromMCPList(false);
}
setViewingMCPList(false);
// Track index with date range opened event
if (connector.is_indexable) {
@ -1239,20 +947,11 @@ export const useConnectorDialog = () => {
setEditingConnector(connector);
setConnectorName(connector.name);
// Load existing periodic sync settings (disabled for non-indexable connectors)
setPeriodicEnabled(!connector.is_indexable ? false : connector.periodic_indexing_enabled);
setFrequencyMinutes(connector.indexing_frequency_minutes?.toString() || "1440");
setEnableSummary(connector.enable_summary ?? false);
// Reset dates - user can set new ones for re-indexing
setStartDate(undefined);
setEndDate(undefined);
// Update URL
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("view", "edit");
url.searchParams.set("connectorId", connector.id.toString());
window.history.pushState({ modal: true }, "", url.toString());
},
[searchSpaceId, viewingAccountsType, viewingMCPList, handleViewMCPList, activeTab]
);
@ -1433,14 +1132,7 @@ export const useConnectorDialog = () => {
: indexingDescription,
});
// Close dialog and clean up URL
setIsOpen(false);
const url = new URL(window.location.href);
url.searchParams.delete("modal");
url.searchParams.delete("tab");
url.searchParams.delete("view");
url.searchParams.delete("connectorId");
router.replace(url.pathname + url.search, { scroll: false });
refreshConnectors();
queryClient.invalidateQueries({
@ -1465,7 +1157,6 @@ export const useConnectorDialog = () => {
frequencyMinutes,
enableSummary,
getFrequencyLabel,
router,
connectorConfig,
connectorName,
setIsOpen,
@ -1496,23 +1187,17 @@ export const useConnectorDialog = () => {
: `${editingConnector.name} disconnected successfully`
);
// Update URL - for MCP from list view, go back to list; otherwise close modal
const url = new URL(window.location.href);
if (editingConnector.connector_type === "MCP_CONNECTOR" && cameFromMCPList) {
// Go back to MCP list view only if we came from there
setViewingMCPList(true);
url.searchParams.set("modal", "connectors");
url.searchParams.set("view", "mcp-list");
url.searchParams.delete("connectorId");
setEditingConnector(null);
setConnectorName(null);
setConnectorConfig(null);
} else {
// Close dialog for all other cases
setEditingConnector(null);
setConnectorName(null);
setConnectorConfig(null);
setIsOpen(false);
url.searchParams.delete("modal");
url.searchParams.delete("tab");
url.searchParams.delete("view");
url.searchParams.delete("connectorId");
}
router.replace(url.pathname + url.search, { scroll: false });
refreshConnectors();
queryClient.invalidateQueries({
@ -1525,7 +1210,7 @@ export const useConnectorDialog = () => {
setIsDisconnecting(false);
}
},
[editingConnector, searchSpaceId, deleteConnector, router, cameFromMCPList, setIsOpen]
[editingConnector, searchSpaceId, deleteConnector, cameFromMCPList, setIsOpen]
);
// Handle quick index (index with selected date range, or backend defaults if none selected)
@ -1584,66 +1269,35 @@ export const useConnectorDialog = () => {
// Handle going back from edit view
const handleBackFromEdit = useCallback(() => {
// If editing an MCP connector and came from MCP list, go back to MCP list view
if (editingConnector?.connector_type === "MCP_CONNECTOR" && cameFromMCPList) {
setViewingMCPList(true);
setCameFromMCPList(false);
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("view", "mcp-list");
url.searchParams.delete("connectorId");
router.replace(url.pathname + url.search, { scroll: false });
setEditingConnector(null);
setConnectorName(null);
setConnectorConfig(null);
return;
}
// If we came from accounts list view, go back there
if (cameFromAccountsList && editingConnector) {
// Restore accounts list view
setViewingAccountsType(cameFromAccountsList);
setCameFromAccountsList(null);
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("view", "accounts");
url.searchParams.set("connectorType", cameFromAccountsList.connectorType);
url.searchParams.delete("connectorId");
router.replace(url.pathname + url.search, { scroll: false });
} else {
// Otherwise, go back to main connector popup (preserve the tab the user was on)
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("tab", activeTab); // Use current tab instead of always "all"
url.searchParams.delete("view");
url.searchParams.delete("connectorId");
router.replace(url.pathname + url.search, { scroll: false });
}
setEditingConnector(null);
setConnectorName(null);
setConnectorConfig(null);
}, [router, cameFromAccountsList, editingConnector, cameFromMCPList, activeTab]);
}, [cameFromAccountsList, editingConnector, cameFromMCPList]);
// Handle dialog open/close
const handleOpenChange = useCallback(
(open: boolean) => {
setIsOpen(open);
if (open) {
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("tab", activeTab);
window.history.pushState({ modal: true }, "", url.toString());
} else {
const url = new URL(window.location.href);
url.searchParams.delete("modal");
url.searchParams.delete("tab");
url.searchParams.delete("success");
url.searchParams.delete("connector");
url.searchParams.delete("view");
window.history.pushState({ modal: false }, "", url.toString());
if (!open) {
setIsScrolled(false);
setSearchQuery("");
setIsYouTubeView(false);
setIsFromOAuth(false);
if (!isStartingIndexing && !isSaving && !isDisconnecting && !isCreatingConnector) {
setIndexingConfig(null);
setIndexingConnector(null);
@ -1653,7 +1307,10 @@ export const useConnectorDialog = () => {
setConnectorConfig(null);
setConnectingConnectorType(null);
setViewingAccountsType(null);
setViewingMCPList(false);
setCameFromAccountsList(null);
setCameFromMCPList(false);
setConnectCameFromMCPList(false);
setStartDate(undefined);
setEndDate(undefined);
setPeriodicEnabled(false);
@ -1662,14 +1319,9 @@ export const useConnectorDialog = () => {
}
}
},
[activeTab, isStartingIndexing, isDisconnecting, isSaving, isCreatingConnector, setIsOpen]
[isStartingIndexing, isDisconnecting, isSaving, isCreatingConnector, setIsOpen]
);
// Handle tab change — only update React state.
// Avoid window.history.replaceState here: Next.js intercepts it, triggers a
// searchParams update/transition, and the resulting concurrent re-render can
// cause Radix Dialog's DismissableLayer to detect a transient focus-outside
// event, which fires onOpenChange(false) and closes the dialog.
const handleTabChange = useCallback((value: string) => {
setActiveTab(value);
}, []);
@ -1704,6 +1356,8 @@ export const useConnectorDialog = () => {
allConnectors,
viewingAccountsType,
viewingMCPList,
isYouTubeView,
isFromOAuth,
// Setters
setSearchQuery,

View file

@ -12,19 +12,16 @@ export type { IndexingConfigState } from "./constants/connector-constants";
// Constants and types
export { CRAWLERS, OAUTH_CONNECTORS, OTHER_CONNECTORS } from "./constants/connector-constants";
export type {
ConnectorPopupQueryParams,
DateRange,
FrequencyMinutes,
OAuthAuthResponse,
} from "./constants/connector-popup.schemas";
// Schemas and validation
export {
connectorPopupQueryParamsSchema,
dateRangeSchema,
frequencyMinutesSchema,
indexingConfigStateSchema,
oauthAuthResponseSchema,
parseConnectorPopupQueryParams,
parseOAuthAuthResponse,
validateIndexingConfigState,
} from "./constants/connector-popup.schemas";

View file

@ -31,10 +31,10 @@ export const CONNECTOR_TO_DOCUMENT_TYPE: Record<string, string> = {
// Special mappings (connector type differs from document type)
GOOGLE_DRIVE_CONNECTOR: "GOOGLE_DRIVE_FILE",
WEBCRAWLER_CONNECTOR: "CRAWLED_URL",
// Composio connectors map to their own document types
COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
COMPOSIO_GMAIL_CONNECTOR: "COMPOSIO_GMAIL_CONNECTOR",
COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR",
// Composio connectors map to unified Google document types
COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "GOOGLE_DRIVE_FILE",
COMPOSIO_GMAIL_CONNECTOR: "GOOGLE_GMAIL_CONNECTOR",
COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: "GOOGLE_CALENDAR_CONNECTOR",
};
/**

View file

@ -33,6 +33,8 @@ import { z } from "zod";
import type { MCPServerConfig, MCPToolDefinition } from "@/contracts/types/mcp.types";
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
const IS_DEV = process.env.NODE_ENV === "development";
/**
* Zod schema for MCP server configuration
* Supports both stdio (local process) and HTTP (remote server) transports
@ -102,11 +104,11 @@ export const parseMCPConfig = (configJson: string): MCPConfigValidationResult =>
// Check cache first
const cached = configCache.get(configJson);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
console.log("[MCP Validator] ✅ Using cached config");
if (IS_DEV) console.log("[MCP Validator] ✅ Using cached config");
return { config: cached.config, error: null };
}
console.log("[MCP Validator] 🔍 Parsing new config...");
if (IS_DEV) console.log("[MCP Validator] 🔍 Parsing new config...");
// Clean up expired cache entries periodically
if (configCache.size > 100) {
@ -176,7 +178,7 @@ export const parseMCPConfig = (configJson: string): MCPConfigValidationResult =>
timestamp: Date.now(),
});
console.log("[MCP Validator] ✅ Config parsed successfully:", config);
if (IS_DEV) console.log("[MCP Validator] ✅ Config parsed successfully:", config);
return {
config,

View file

@ -1,17 +1,34 @@
"use client";
import { ArrowLeft, Plus, Server } from "lucide-react";
import type { FC } from "react";
import { useAtomValue } from "jotai";
import { ArrowLeft, Plus, RefreshCw, Server } from "lucide-react";
import { type FC, useCallback, useState } from "react";
import { toast } from "sonner";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { Button } from "@/components/ui/button";
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 { authenticatedFetch } from "@/lib/auth-utils";
import { formatRelativeDate } from "@/lib/format-date";
import { cn } from "@/lib/utils";
import { useConnectorStatus } from "../hooks/use-connector-status";
import { getConnectorDisplayName } from "../tabs/all-connectors-tab";
const REAUTH_ENDPOINTS: Partial<Record<string, string>> = {
[EnumConnectorName.LINEAR_CONNECTOR]: "/api/v1/auth/linear/connector/reauth",
[EnumConnectorName.NOTION_CONNECTOR]: "/api/v1/auth/notion/connector/reauth",
[EnumConnectorName.GOOGLE_DRIVE_CONNECTOR]: "/api/v1/auth/google/drive/connector/reauth",
[EnumConnectorName.GOOGLE_GMAIL_CONNECTOR]: "/api/v1/auth/google/gmail/connector/reauth",
[EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR]: "/api/v1/auth/google/calendar/connector/reauth",
[EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
[EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
[EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
[EnumConnectorName.JIRA_CONNECTOR]: "/api/v1/auth/jira/connector/reauth",
[EnumConnectorName.CONFLUENCE_CONNECTOR]: "/api/v1/auth/confluence/connector/reauth",
};
interface ConnectorAccountsListViewProps {
connectorType: string;
connectorTitle: string;
@ -43,12 +60,49 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
isConnecting = false,
addButtonText,
}) => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const [reauthingId, setReauthingId] = useState<number | null>(null);
// Get connector status
const { isConnectorEnabled, getConnectorStatusMessage } = useConnectorStatus();
const isEnabled = isConnectorEnabled(connectorType);
const statusMessage = getConnectorStatusMessage(connectorType);
const reauthEndpoint = REAUTH_ENDPOINTS[connectorType];
const handleReauth = useCallback(
async (connectorId: number) => {
if (!searchSpaceId || !reauthEndpoint) return;
setReauthingId(connectorId);
try {
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
const url = new URL(`${backendUrl}${reauthEndpoint}`);
url.searchParams.set("connector_id", String(connectorId));
url.searchParams.set("space_id", String(searchSpaceId));
url.searchParams.set("return_url", window.location.pathname);
const response = await authenticatedFetch(url.toString());
if (!response.ok) {
const data = await response.json().catch(() => ({}));
toast.error(data.detail ?? "Failed to initiate re-authentication.");
return;
}
const data = await response.json();
if (data.auth_url) {
window.location.href = data.auth_url;
} else if (data.success) {
toast.success(data.message ?? "Authentication refreshed successfully.");
window.location.reload();
}
} catch {
toast.error("Failed to initiate re-authentication.");
} finally {
setReauthingId(null);
}
},
[searchSpaceId, reauthEndpoint]
);
// Filter connectors to only show those of this type
const typeConnectors = connectors.filter((c) => c.connector_type === connectorType);
@ -149,6 +203,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{typeConnectors.map((connector) => {
const isIndexing = indexingConnectorIds.has(connector.id);
const isAuthExpired = !!reauthEndpoint && connector.config?.auth_expired === true;
return (
<div
@ -189,14 +244,28 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
</p>
)}
</div>
<Button
variant="secondary"
size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80 shrink-0"
onClick={() => onManage(connector)}
>
Manage
</Button>
{isAuthExpired ? (
<Button
size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-amber-600 hover:bg-amber-700 text-white border-0 shadow-xs shrink-0"
onClick={() => handleReauth(connector.id)}
disabled={reauthingId === connector.id}
>
<RefreshCw
className={cn("size-3.5", reauthingId === connector.id && "animate-spin")}
/>
Re-authenticate
</Button>
) : (
<Button
variant="secondary"
size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80 shrink-0"
onClick={() => onManage(connector)}
>
Manage
</Button>
)}
</div>
);
})}

View file

@ -30,10 +30,11 @@ export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?:
[isThreadRunning]
);
// Calculate summary info
const completedSteps = steps.filter((s) => getEffectiveStatus(s) === "completed").length;
const inProgressStep = steps.find((s) => getEffectiveStatus(s) === "in_progress");
const allCompleted = completedSteps === steps.length && steps.length > 0 && !isThreadRunning;
const allCompleted =
steps.length > 0 &&
!isThreadRunning &&
steps.every((s) => getEffectiveStatus(s) === "completed");
const isProcessing = isThreadRunning && !allCompleted;
// Auto-collapse when all tasks are completed
@ -45,18 +46,17 @@ export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?:
if (steps.length === 0) return null;
// Generate header text
const getHeaderText = () => {
if (allCompleted) {
return `Reviewed ${completedSteps} ${completedSteps === 1 ? "step" : "steps"}`;
return "Reviewed";
}
if (inProgressStep) {
return inProgressStep.title;
}
if (isProcessing) {
return `Processing ${completedSteps}/${steps.length} steps`;
return "Processing";
}
return `Reviewed ${completedSteps} ${completedSteps === 1 ? "step" : "steps"}`;
return "Reviewed";
};
return (
@ -129,11 +129,7 @@ export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?:
effectiveStatus === "pending" && "text-muted-foreground/60"
)}
>
{effectiveStatus === "in_progress" ? (
<TextShimmerLoader text={step.title} size="sm" />
) : (
step.title
)}
{step.title}
</div>
{/* Step items (sub-content) */}

View file

@ -26,9 +26,11 @@ import {
SquareIcon,
Unplug,
Upload,
Wrench,
X,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import Image from "next/image";
import { useParams } from "next/navigation";
import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
@ -88,7 +90,11 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { getToolIcon } from "@/contracts/enums/toolIcons";
import {
CONNECTOR_ICON_TO_TYPES,
CONNECTOR_TOOL_ICON_PATHS,
getToolIcon,
} from "@/contracts/enums/toolIcons";
import type { Document } from "@/contracts/types/document.types";
import { useBatchCommentsPreload } from "@/hooks/use-comments";
import { useCommentsSync } from "@/hooks/use-comments-sync";
@ -97,12 +103,12 @@ import { cn } from "@/lib/utils";
/** Placeholder texts that cycle in new chats when input is empty */
const CYCLING_PLACEHOLDERS = [
"Ask SurfSense anything or @mention docs.",
"Generate a podcast from my vacation ideas in Notion.",
"Sum up last week's meeting notes from Drive in a bulleted list.",
"Give me a brief overview of the most urgent tickets in Jira and Linear.",
"Ask SurfSense anything or @mention docs",
"Generate a podcast from my vacation ideas in Notion",
"Sum up last week's meeting notes from Drive in a bulleted list",
"Give me a brief overview of the most urgent tickets in Jira and Linear",
"Briefly, what are today's top ten important emails and calendar events?",
"Check if this week's Slack messages reference any GitHub issues.",
"Check if this week's Slack messages reference any GitHub issues",
];
interface ThreadProps {
@ -256,7 +262,7 @@ const BANNER_CONNECTORS = [
const BANNER_DISMISSED_KEY = "surfsense-connect-tools-banner-dismissed";
const ConnectToolsBanner: FC = () => {
const ConnectToolsBanner: FC<{ isThreadEmpty: boolean }> = ({ isThreadEmpty }) => {
const { data: connectors } = useAtomValue(connectorsAtom);
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
const [dismissed, setDismissed] = useState(() => {
@ -266,7 +272,7 @@ const ConnectToolsBanner: FC = () => {
const hasConnectors = (connectors?.length ?? 0) > 0;
if (dismissed || hasConnectors) return null;
if (dismissed || hasConnectors || !isThreadEmpty) return null;
const handleDismiss = (e: React.MouseEvent) => {
e.stopPropagation();
@ -560,7 +566,7 @@ const Composer: FC = () => {
)}
<ComposerAction isBlockedByOtherUser={isBlockedByOtherUser} />
<ConnectorIndicator showTrigger={false} />
<ConnectToolsBanner />
<ConnectToolsBanner isThreadEmpty={isThreadEmpty} />
</div>
</ComposerPrimitive.Root>
);
@ -598,29 +604,49 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
const { data: agentTools } = useAtomValue(agentToolsAtom);
const disabledTools = useAtomValue(disabledToolsAtom);
const toggleTool = useSetAtom(toggleToolAtom);
const setDisabledTools = useSetAtom(disabledToolsAtom);
const hydrateDisabled = useSetAtom(hydrateDisabledToolsAtom);
const { data: connectors } = useAtomValue(connectorsAtom);
const connectedTypes = useMemo(
() => new Set<string>((connectors ?? []).map((c) => c.connector_type)),
[connectors]
);
const toggleToolGroup = useCallback(
(toolNames: string[]) => {
const allDisabled = toolNames.every((name) => disabledTools.includes(name));
if (allDisabled) {
setDisabledTools((prev) => prev.filter((t) => !toolNames.includes(t)));
} else {
setDisabledTools((prev) => [...new Set([...prev, ...toolNames])]);
}
},
[disabledTools, setDisabledTools]
);
const hasWebSearchTool = agentTools?.some((t) => t.name === "web_search") ?? false;
const isWebSearchEnabled = hasWebSearchTool && !disabledTools.includes("web_search");
const filteredTools = useMemo(
() => agentTools?.filter((t) => t.name !== "web_search"),
[agentTools]
);
const filteredEnabledCount = useMemo(() => {
if (!filteredTools) return 0;
return (
filteredTools.length -
disabledTools.filter((d) => filteredTools.some((t) => t.name === d)).length
);
}, [filteredTools, disabledTools]);
const groupedTools = useMemo(() => {
if (!filteredTools) return [];
const toolsByName = new Map(filteredTools.map((t) => [t.name, t]));
const result: { label: string; tools: typeof filteredTools }[] = [];
const result: { label: string; tools: typeof filteredTools; connectorIcon?: string }[] = [];
const placed = new Set<string>();
for (const group of TOOL_GROUPS) {
if (group.connectorIcon) {
const requiredTypes = CONNECTOR_ICON_TO_TYPES[group.connectorIcon];
const isConnected = requiredTypes?.some((t) => connectedTypes.has(t));
if (!isConnected) {
for (const name of group.tools) placed.add(name);
continue;
}
}
const matched = group.tools.flatMap((name) => {
const tool = toolsByName.get(name);
if (!tool) return [];
@ -628,7 +654,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
return [tool];
});
if (matched.length > 0) {
result.push({ label: group.label, tools: matched });
result.push({ label: group.label, tools: matched, connectorIcon: group.connectorIcon });
}
}
@ -638,7 +664,25 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
}
return result;
}, [filteredTools]);
}, [filteredTools, connectedTypes]);
const { visibleTotal, visibleEnabled } = useMemo(() => {
let total = 0;
let enabled = 0;
for (const group of groupedTools) {
if (group.connectorIcon) {
total += 1;
const allDisabled = group.tools.every((t) => disabledTools.includes(t.name));
if (!allDisabled) enabled += 1;
} else {
for (const tool of group.tools) {
total += 1;
if (!disabledTools.includes(tool.name)) enabled += 1;
}
}
}
return { visibleTotal: total, visibleEnabled: enabled };
}, [groupedTools, disabledTools]);
useEffect(() => {
hydrateDisabled();
@ -691,37 +735,81 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
<div className="flex items-center justify-between px-4 py-2">
<DrawerTitle className="text-sm font-medium">Agent Tools</DrawerTitle>
<span className="text-xs text-muted-foreground">
{filteredEnabledCount}/{filteredTools?.length ?? 0} enabled
{visibleEnabled}/{visibleTotal} enabled
</span>
</div>
<div className="overflow-y-auto pb-6" onScroll={handleToolsScroll}>
{groupedTools.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}
{groupedTools
.filter((g) => !g.connectorIcon)
.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 = disabledTools.includes(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-muted-foreground/10 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>
{group.tools.map((tool) => {
const isDisabled = disabledTools.includes(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-muted-foreground/10 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>
);
})}
))}
{groupedTools.some((g) => g.connectorIcon) && (
<div>
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground/80 font-medium select-none">
Connector Actions
</div>
{groupedTools
.filter((g) => g.connectorIcon)
.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) => disabledTools.includes(n));
return (
<div
key={group.label}
className="flex w-full items-center gap-3 px-4 py-2 hover:bg-muted-foreground/10 transition-colors"
>
{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="flex-1 min-w-0 text-sm font-medium truncate">
{group.label}
</span>
<Switch
checked={!allDisabled}
onCheckedChange={() => toggleToolGroup(toolNames)}
className="shrink-0"
/>
</div>
);
})}
</div>
))}
)}
{!filteredTools?.length && (
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
Loading tools...
@ -766,7 +854,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
<div className="flex items-center justify-between px-2.5 py-2 sm:px-3 sm:py-2.5 border-b">
<span className="text-xs sm:text-sm font-medium">Agent Tools</span>
<span className="text-[10px] sm:text-xs text-muted-foreground">
{filteredEnabledCount}/{filteredTools?.length ?? 0} enabled
{visibleEnabled}/{visibleTotal} enabled
</span>
</div>
<div
@ -777,38 +865,89 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
WebkitMaskImage: `linear-gradient(to bottom, ${toolsScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${toolsScrollPos === "bottom" ? "black" : "transparent"})`,
}}
>
{groupedTools.map((group) => (
<div key={group.label}>
<div className="px-2.5 sm:px-3 pt-2 pb-0.5 text-[10px] sm:text-xs text-muted-foreground/80 font-normal select-none">
{group.label}
{groupedTools
.filter((g) => !g.connectorIcon)
.map((group) => (
<div key={group.label}>
<div className="px-2.5 sm:px-3 pt-2 pb-0.5 text-[10px] sm:text-xs text-muted-foreground/80 font-normal select-none">
{group.label}
</div>
{group.tools.map((tool) => {
const isDisabled = disabledTools.includes(tool.name);
const ToolIcon = getToolIcon(tool.name);
const row = (
<div className="flex w-full items-center gap-2 sm:gap-3 px-2.5 sm:px-3 py-1 sm:py-1.5 hover:bg-muted-foreground/10 transition-colors">
<ToolIcon className="size-3.5 sm:size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 min-w-0 text-xs sm:text-sm font-medium truncate">
{formatToolName(tool.name)}
</span>
<Switch
checked={!isDisabled}
onCheckedChange={() => toggleTool(tool.name)}
className="shrink-0 scale-[0.6] sm:scale-75"
/>
</div>
);
return (
<Tooltip key={tool.name}>
<TooltipTrigger asChild>{row}</TooltipTrigger>
<TooltipContent side="right" className="max-w-64 text-xs">
{tool.description}
</TooltipContent>
</Tooltip>
);
})}
</div>
{group.tools.map((tool) => {
const isDisabled = disabledTools.includes(tool.name);
const ToolIcon = getToolIcon(tool.name);
const row = (
<div className="flex w-full items-center gap-2 sm:gap-3 px-2.5 sm:px-3 py-1 sm:py-1.5 hover:bg-muted-foreground/10 transition-colors">
<ToolIcon className="size-3.5 sm:size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 min-w-0 text-xs sm:text-sm font-medium truncate">
{formatToolName(tool.name)}
</span>
<Switch
checked={!isDisabled}
onCheckedChange={() => toggleTool(tool.name)}
className="shrink-0 scale-[0.6] sm:scale-75"
/>
</div>
);
return (
<Tooltip key={tool.name}>
<TooltipTrigger asChild>{row}</TooltipTrigger>
<TooltipContent side="right" className="max-w-64 text-xs">
{tool.description}
</TooltipContent>
</Tooltip>
);
})}
))}
{groupedTools.some((g) => g.connectorIcon) && (
<div>
<div className="px-2.5 sm:px-3 pt-2 pb-0.5 text-[10px] sm:text-xs text-muted-foreground/80 font-normal select-none">
Connector Actions
</div>
{groupedTools
.filter((g) => g.connectorIcon)
.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) => disabledTools.includes(n));
const groupDef = TOOL_GROUPS.find((g) => g.label === group.label);
const row = (
<div className="flex w-full items-center gap-2 sm:gap-3 px-2.5 sm:px-3 py-1 sm:py-1.5 hover:bg-muted-foreground/10 transition-colors">
{iconInfo ? (
<Image
src={iconInfo.src}
alt={iconInfo.alt}
width={16}
height={16}
className="size-3.5 sm:size-4 shrink-0 select-none pointer-events-none"
draggable={false}
/>
) : (
<Wrench className="size-3.5 sm:size-4 shrink-0 text-muted-foreground" />
)}
<span className="flex-1 min-w-0 text-xs sm:text-sm font-medium truncate">
{group.label}
</span>
<Switch
checked={!allDisabled}
onCheckedChange={() => toggleToolGroup(toolNames)}
className="shrink-0 scale-[0.6] sm:scale-75"
/>
</div>
);
return (
<Tooltip key={group.label}>
<TooltipTrigger asChild>{row}</TooltipTrigger>
<TooltipContent side="right" className="max-w-72 text-xs">
{groupDef?.tooltip ??
group.tools.map((t) => t.description).join(" · ")}
</TooltipContent>
</Tooltip>
);
})}
</div>
))}
)}
{!filteredTools?.length && (
<div className="px-3 py-4 text-center text-xs text-muted-foreground">
Loading tools...
@ -931,7 +1070,14 @@ function formatToolName(name: string): string {
.join(" ");
}
const TOOL_GROUPS: { label: string; tools: string[] }[] = [
interface ToolGroup {
label: string;
tools: string[];
connectorIcon?: string;
tooltip?: string;
}
const TOOL_GROUPS: ToolGroup[] = [
{
label: "Research",
tools: ["search_knowledge_base", "search_surfsense_docs", "scrape_webpage", "link_preview"],
@ -950,6 +1096,48 @@ const TOOL_GROUPS: { label: string; tools: string[] }[] = [
label: "Memory",
tools: ["save_memory", "recall_memory"],
},
{
label: "Gmail",
tools: ["create_gmail_draft", "update_gmail_draft", "send_gmail_email", "trash_gmail_email"],
connectorIcon: "gmail",
tooltip: "Create drafts, update drafts, send emails, and trash emails in Gmail.",
},
{
label: "Google Calendar",
tools: ["create_calendar_event", "update_calendar_event", "delete_calendar_event"],
connectorIcon: "google_calendar",
tooltip: "Create, update, and delete events in Google Calendar.",
},
{
label: "Google Drive",
tools: ["create_google_drive_file", "delete_google_drive_file"],
connectorIcon: "google_drive",
tooltip: "Create and delete files in Google Drive.",
},
{
label: "Notion",
tools: ["create_notion_page", "update_notion_page", "delete_notion_page"],
connectorIcon: "notion",
tooltip: "Create, update, and delete pages in Notion.",
},
{
label: "Linear",
tools: ["create_linear_issue", "update_linear_issue", "delete_linear_issue"],
connectorIcon: "linear",
tooltip: "Create, update, and delete issues in Linear.",
},
{
label: "Jira",
tools: ["create_jira_issue", "update_jira_issue", "delete_jira_issue"],
connectorIcon: "jira",
tooltip: "Create, update, and delete issues in Jira.",
},
{
label: "Confluence",
tools: ["create_confluence_page", "update_confluence_page", "delete_confluence_page"],
connectorIcon: "confluence",
tooltip: "Create, update, and delete pages in Confluence.",
},
];
const MessageError: FC = () => {