Merge branch 'main' into feature/689-dedupe-subtasks

This commit is contained in:
Puzhen Zhang 2025-11-19 22:33:57 +08:00 committed by GitHub
commit 292c0420ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 787 additions and 1094 deletions

View file

@ -1,499 +0,0 @@
import { Button } from "@/components/ui/button";
import { TooltipSimple } from "@/components/ui/tooltip";
import { CircleAlert } from "lucide-react";
import { proxyFetchGet, proxyFetchPost, proxyFetchPut, proxyFetchDelete, fetchDelete, fetchGet, fetchPost } from "@/api/http";
import React, { useState, useCallback, useEffect, useRef } from "react";
import ellipseIcon from "@/assets/mcp/Ellipse-25.svg";
import { capitalizeFirstLetter } from "@/lib";
import { MCPEnvDialog } from "@/pages/Setting/components/MCPEnvDialog";
import { useAuthStore } from "@/store/authStore";
import { OAuth } from "@/lib/oauth";
import { useTranslation } from "react-i18next";
interface IntegrationItem {
key: string;
name: string;
desc: string;
env_vars: string[];
toolkit?: string; // Add toolkit field
onInstall: () => void | Promise<void>;
}
interface IntegrationListProps {
items: IntegrationItem[];
addOption: (mcp: any, isLocal: boolean) => void;
onShowEnvConfig?: (mcp: any) => void;
installedKeys?: string[];
oauth?: OAuth | null;
}
export default function IntegrationList({
items,
addOption,
onShowEnvConfig,
installedKeys = [],
oauth,
}: IntegrationListProps) {
const [callBackUrl, setCallBackUrl] = useState<string | null>(null);
const [showEnvConfig, setShowEnvConfig] = useState(false);
const [activeMcp, setActiveMcp] = useState<any | null>(null);
const { email, checkAgentTool } = useAuthStore();
const { t } = useTranslation();
// local installed status
const [installed, setInstalled] = useState<{ [key: string]: boolean }>({});
// configs cache
const [configs, setConfigs] = useState<any[]>([]);
// 1. add useRef lock
const isLockedRef = useRef(false);
// add cache oauth event ref
const pendingOauthEventRef = useRef<{
provider: string;
code: string;
} | null>(null);
async function fetchInstalled(ignore: boolean = false) {
try {
const configsRes = await proxyFetchGet("/api/configs");
console.log("configsRes", configsRes);
if (!ignore) {
console.log("configsRes", Array.isArray(configsRes), configsRes);
setConfigs(Array.isArray(configsRes) ? configsRes : []);
}
} catch (e) {
console.log("fetchInstalled error", e);
if (!ignore) setConfigs([]);
}
}
// fetch configs when mounted
useEffect(() => {
let ignore = false;
fetchInstalled(ignore);
return () => {
ignore = true;
};
}, []);
// items or configs change, recalculate installed
useEffect(() => {
// For Google Calendar, check for allowed env keys
// For other integrations, check by config_group
const map: { [key: string]: boolean } = {};
items.forEach((item) => {
if (item.key === "Google Calendar") {
// Only mark installed when refresh token is present (auth completed)
const hasRefreshToken = configs.some(
(c: any) =>
c.config_group?.toLowerCase() === "google calendar" &&
c.config_name === "GOOGLE_REFRESH_TOKEN" &&
c.config_value && String(c.config_value).length > 0
);
map[item.key] = hasRefreshToken;
} else {
// For other integrations, use config_group presence
const hasConfig = configs.some(
(c: any) => c.config_group?.toLowerCase() === item.key.toLowerCase()
);
map[item.key] = hasConfig;
}
});
setInstalled(map);
}, [items, configs]);
// public save env/config logic
const saveEnvAndConfig = async (
provider: string,
envVarKey: string,
value: string
) => {
const configPayload = {
// Keep exact group name to satisfy backend whitelist
config_group: provider,
config_name: envVarKey,
config_value: value,
};
// Fetch latest configs to avoid stale state when deciding POST/PUT
let latestConfigs: any[] = Array.isArray(configs) ? configs : [];
try {
const fresh = await proxyFetchGet("/api/configs");
if (Array.isArray(fresh)) latestConfigs = fresh;
} catch {}
// Backend uniqueness is by config_name for a user
let existingConfig = latestConfigs.find((c: any) => c.config_name === envVarKey);
if (existingConfig) {
await proxyFetchPut(`/api/configs/${existingConfig.id}`, configPayload);
} else {
const res = await proxyFetchPost("/api/configs", configPayload);
if (res && res.detail && (res.detail as string).toLowerCase().includes("already exists")) {
try {
const again = await proxyFetchGet("/api/configs");
const found = Array.isArray(again) ? again.find((c: any) => c.config_name === envVarKey) : null;
if (found) {
await proxyFetchPut(`/api/configs/${found.id}`, configPayload);
}
} catch {}
}
}
if (window.electronAPI?.envWrite) {
await window.electronAPI.envWrite(email, { key: envVarKey, value });
}
};
// useCallback to ensure processOauth can get the latest items when items change
const processOauth = useCallback(
async (data: { provider: string; code: string }) => {
if (isLockedRef.current) return;
if (!items || items.length === 0) {
// items not ready, cache event, wait for items to have value
pendingOauthEventRef.current = data;
console.warn("items is empty, cache oauth event", data);
return;
}
const provider = data.provider.toLowerCase();
isLockedRef.current = true;
try {
const tokenResult = await proxyFetchPost(
`/api/oauth/${provider}/token`,
{ code: data.code }
);
setInstalled((prev) => ({
...prev,
[capitalizeFirstLetter(provider)]: true,
}));
const currentItem = items.find(
(item) => item.key.toLowerCase() === provider
);
if (provider === "slack") {
if (
tokenResult.access_token &&
currentItem &&
currentItem.env_vars &&
currentItem.env_vars.length > 0
) {
const envVarKey = currentItem.env_vars[0];
await saveEnvAndConfig(
provider,
envVarKey,
tokenResult.access_token
);
await fetchInstalled();
console.log(
"Slack authorization successful and configuration saved!"
);
} else {
console.log(
"Slack authorization successful, but access_token not found or env configuration not found"
);
}
}
} catch (e: any) {
console.log(`${data.provider} authorization failed: ${e.message || e}`);
} finally {
isLockedRef.current = false;
}
},
[items, oauth]
);
// listen to main process oauth authorization callback, automatically mark as installed and get token
useEffect(() => {
const handler = (_event: any, data: { provider: string; code: string }) => {
if (!data.provider || !data.code) return;
processOauth(data);
};
window.ipcRenderer?.on("oauth-authorized", handler);
return () => {
window.ipcRenderer?.off("oauth-authorized", handler);
};
}, [items, oauth, processOauth]);
// listen to oauth callback URL notification
useEffect(() => {
const handler = (_event: any, data: { url: string; provider: string }) => {
console.log('OAuth callback URL:', data);
if (data.url && data.provider) {
setCallBackUrl(data.url);
}
};
window.ipcRenderer?.on("oauth-callback-url", handler);
return () => {
window.ipcRenderer?.off("oauth-callback-url", handler);
};
}, []);
// listen to items change, if there is a cached oauth event and items is ready, automatically process
useEffect(() => {
if (items && items.length > 0 && pendingOauthEventRef.current) {
processOauth(pendingOauthEventRef.current);
pendingOauthEventRef.current = null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items, oauth]);
// install/uninstall
const handleInstall = useCallback(
async (item: IntegrationItem) => {
console.log(item);
if (item.key === "EXA Search") {
let mcp = {
name: "EXA Search",
key: "EXA Search",
install_command: {
env: {} as any,
},
id: 13,
};
item.env_vars.map((key) => {
mcp.install_command.env[key] = "";
});
onShowEnvConfig?.(mcp);
// setActiveMcp(mcp);
// setShowEnvConfig(true);
return;
}
if (item.key === "Google Calendar") {
// Always prompt env dialog first instead of jumping to authorization
let mcp = {
name: "Google Calendar",
key: "Google Calendar",
install_command: {
env: {} as any,
},
id: 14,
};
item.env_vars.map((key) => {
mcp.install_command.env[key] = "";
});
onShowEnvConfig?.(mcp);
return;
}
if (installed[item.key]) return;
await item.onInstall();
// refresh configs after install to update installed state indicator
await fetchInstalled();
},
[installed]
);
const onConnect = async (mcp: any) => {
console.log("[IntegrationList onConnect] Starting for", mcp.key);
// Refresh configs first to get latest state
await fetchInstalled();
// Save all environment variables
await Promise.all(
Object.keys(mcp.install_command.env).map(async (key) => {
return saveEnvAndConfig(mcp.key, key, mcp.install_command.env[key]);
})
);
// After saving env vars, handle Google Calendar authorization flow
if (mcp.key === "Google Calendar") {
console.log("[IntegrationList onConnect] Google Calendar detected, starting auth flow");
// Trigger install/authorization
const calendarItem = items.find(item => item.key === "Google Calendar");
try {
if (calendarItem && calendarItem.onInstall) {
await calendarItem.onInstall();
} else {
await fetchPost("/install/tool/google_calendar");
}
} catch (_) {}
console.log("[IntegrationList onConnect] Starting OAuth status polling");
// Keep the dialog open and poll OAuth status until completion
const start = Date.now();
const timeoutMs = 5 * 60 * 1000; // 5 minutes
while (Date.now() - start < timeoutMs) {
try {
const statusRes: any = await fetchGet("/oauth/status/google_calendar");
console.log("[IntegrationList onConnect] OAuth status:", statusRes?.status);
if (statusRes?.status === "success") {
console.log("[IntegrationList onConnect] Success! Closing dialog");
await fetchInstalled();
onClose();
return;
}
if (statusRes?.status === "failed" || statusRes?.status === "cancelled") {
console.log("[IntegrationList onConnect] Failed/cancelled, keeping dialog open");
// Stop waiting on failure/cancellation; keep dialog open for retry
return;
}
} catch (err) {
console.log("[IntegrationList onConnect] Polling error:", err);
// ignore transient polling errors
}
await new Promise((r) => setTimeout(r, 1500));
}
// Timeout reached; return to allow user to try again
console.log("[IntegrationList onConnect] Polling timeout");
return;
}
console.log("[IntegrationList onConnect] Non-Google Calendar, closing immediately");
await fetchInstalled();
addOption(mcp, true);
onClose();
};
const onClose = () => {
setShowEnvConfig(false);
setActiveMcp(null);
};
// uninstall logic
const handleUninstall = useCallback(
async (item: IntegrationItem) => {
// find all config_group matching config, delete one by one
checkAgentTool(item.key);
const groupKey = item.key.toLowerCase();
const toDelete = configs.filter(
(c: any) => c.config_group && c.config_group.toLowerCase() === groupKey
);
for (const config of toDelete) {
try {
await proxyFetchDelete(`/api/configs/${config.id}`);
// delete env
if (
item.env_vars &&
item.env_vars.length > 0 &&
window.electronAPI?.envRemove
) {
await window.electronAPI.envRemove(email, item.env_vars[0]);
}
} catch (e) {
// ignore error
}
}
// Clean up authentication tokens for Google Calendar and Notion
if (item.key === "Google Calendar") {
try {
await fetchDelete("/uninstall/tool/google_calendar");
console.log("Cleaned up Google Calendar authentication tokens");
} catch (e) {
console.log("Failed to clean up Google Calendar tokens:", e);
}
} else if (item.key === "Notion") {
try {
await fetchDelete("/uninstall/tool/notion");
console.log("Cleaned up Notion authentication tokens");
} catch (e) {
console.log("Failed to clean up Notion tokens:", e);
}
}
// delete after refresh configs
setConfigs((prev) =>
prev.filter((c: any) => c.config_group?.toLowerCase() !== groupKey)
);
},
[configs]
);
return (
<div className="space-y-3">
<MCPEnvDialog
showEnvConfig={showEnvConfig}
onClose={onClose}
onConnect={onConnect}
activeMcp={activeMcp}
></MCPEnvDialog>
{items.map((item) => {
const isInstalled = !!installed[item.key];
return (
<div
key={item.key}
className="cursor-pointer hover:bg-gray-100 px-3 py-2 flex justify-between"
onClick={() => {
if (
![
"X(Twitter)",
"WhatsApp",
"LinkedIn",
"Reddit",
"Github",
].includes(item.name)
) {
if (item.env_vars.length === 0 || isInstalled) {
// Ensure toolkit field is passed and normalized for known cases
const normalizedToolkit =
item.name === "Notion" ? "notion_mcp_toolkit" : item.toolkit;
addOption({ ...item, toolkit: normalizedToolkit }, true);
} else {
handleInstall(item);
}
}
}}
>
<div className="flex items-center gap-xs">
<img
src={ellipseIcon}
alt="icon"
className="w-3 h-3 mr-2"
style={{
filter: isInstalled
? "grayscale(0%) brightness(0) saturate(100%) invert(41%) sepia(99%) saturate(749%) hue-rotate(81deg) brightness(95%) contrast(92%)"
: "none",
}}
/>
<div className="text-base leading-snug font-bold text-text-action">
{item.name}
</div>
<div className="flex items-center">
<TooltipSimple content={item.desc}>
<CircleAlert className="w-4 h-4 text-icon-secondary" />
</TooltipSimple>
</div>
</div>
{item.env_vars.length !== 0 && (
<Button
disabled={[
"X(Twitter)",
"WhatsApp",
"LinkedIn",
"Reddit",
"Github",
].includes(item.name)}
variant={isInstalled ? "secondary" : "primary"}
size="sm"
onClick={(e) => {
e.stopPropagation();
return isInstalled
? handleUninstall(item)
: handleInstall(item);
}}
>
{[
"X(Twitter)",
"WhatsApp",
"LinkedIn",
"Reddit",
"Github",
].includes(item.name)
? t("layout.coming-soon")
: isInstalled
? t("layout.uninstall")
: t("layout.install")}
</Button>
)}
</div>
);
})}
</div>
);
}

View file

@ -13,7 +13,7 @@ import { Textarea } from "../ui/textarea";
import { Button } from "../ui/button";
import githubIcon from "@/assets/github.svg";
import { TooltipSimple } from "../ui/tooltip";
import IntegrationList from "./IntegrationList";
import IntegrationList from "@/components/IntegrationList";
import { getProxyBaseURL } from "@/lib";
import { capitalizeFirstLetter } from "@/lib";
import { useAuthStore } from "@/store/authStore";
@ -720,11 +720,13 @@ const ToolSelect = forwardRef<
{isOpen && (
<div className="absolute top-full left-0 right-0 z-50 mt-1 bg-dropdown-bg rounded-lg border border-solid border-input-border-default overflow-y-auto">
<div className="max-h-[192px] overflow-y-auto">
<IntegrationList
onShowEnvConfig={onShowEnvConfig}
addOption={addOption}
items={integrations}
/>
<IntegrationList
variant="select"
onShowEnvConfig={onShowEnvConfig}
addOption={addOption}
items={integrations}
translationNamespace="layout"
/>
{mcpList
.filter(
(opt) =>

View file

@ -0,0 +1,446 @@
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { TooltipSimple } from "@/components/ui/tooltip";
import { CircleAlert, Settings2 } from "lucide-react";
import { fetchGet, fetchPost } from "@/api/http";
import React, { useState, useCallback } from "react";
import ellipseIcon from "@/assets/mcp/Ellipse-25.svg";
import { MCPEnvDialog } from "@/pages/Setting/components/MCPEnvDialog";
import { OAuth } from "@/lib/oauth";
import { useTranslation } from "react-i18next";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import {
useIntegrationManagement,
type IntegrationItem,
} from "@/hooks/useIntegrationManagement";
type IntegrationListVariant = "select" | "manage";
interface IntegrationListProps {
items: IntegrationItem[];
variant?: IntegrationListVariant; // "select" for AddWorker, "manage" for Setting
// Select mode props (AddWorker)
addOption?: (mcp: any, isLocal: boolean) => void;
onShowEnvConfig?: (mcp: any) => void;
// Manage mode props (Setting)
showSelect?: boolean;
selectPlaceholder?: string;
selectContent?: React.ReactNode;
onSelectChange?: (value: string, item: IntegrationItem) => void;
showConfigButton?: boolean;
showInstallButton?: boolean;
onConfigClick?: (item: IntegrationItem) => void;
showStatusDot?: boolean;
// Common props
installedKeys?: string[];
oauth?: OAuth | null;
translationNamespace?: "layout" | "setting"; // For translation keys
}
export default function IntegrationList({
items,
variant = "manage", // Default to manage mode for backward compatibility
addOption,
onShowEnvConfig,
showSelect = false,
selectPlaceholder = "Select...",
selectContent,
onSelectChange,
showConfigButton = true,
showInstallButton = true,
onConfigClick,
showStatusDot = true,
installedKeys = [],
oauth,
translationNamespace = variant === "select" ? "layout" : "setting",
}: IntegrationListProps) {
const { t } = useTranslation();
const [showEnvConfig, setShowEnvConfig] = useState(false);
const [activeMcp, setActiveMcp] = useState<any | null>(null);
const isSelectMode = variant === "select";
// Use shared hook for integration management
const {
installed,
fetchInstalled,
saveEnvAndConfig,
handleUninstall,
createMcpFromItem,
} = useIntegrationManagement(items);
// Install handler - different logic for select vs manage mode
const handleInstall = useCallback(
async (item: IntegrationItem) => {
console.log(item);
const searchKey = isSelectMode ? "EXA Search" : "Search";
if (item.key === searchKey) {
const mcp = createMcpFromItem(item, 13);
if (isSelectMode) {
onShowEnvConfig?.(mcp);
} else {
setActiveMcp(mcp);
setShowEnvConfig(true);
}
return;
}
if (item.key === "Google Calendar") {
const mcp = createMcpFromItem(item, 14);
if (isSelectMode) {
onShowEnvConfig?.(mcp);
} else {
setActiveMcp(mcp);
setShowEnvConfig(true);
}
return;
}
if (installed[item.key]) return;
await item.onInstall();
// Only refresh in select mode
if (isSelectMode) {
await fetchInstalled();
}
},
[
installed,
createMcpFromItem,
isSelectMode,
onShowEnvConfig,
fetchInstalled,
]
);
// onConnect handler - different logic for select vs manage mode
const onConnect = async (mcp: any) => {
console.log("[IntegrationList onConnect] Starting for", mcp.key);
// Refresh configs first to get latest state
await fetchInstalled();
// Save all environment variables
await Promise.all(
Object.keys(mcp.install_command.env).map(async (key) => {
return saveEnvAndConfig(mcp.key, key, mcp.install_command.env[key]);
})
);
// After saving env vars, handle Google Calendar authorization flow
if (mcp.key === "Google Calendar") {
console.log(
"[IntegrationList onConnect] Google Calendar detected, starting auth flow"
);
// Trigger install/authorization
const calendarItem = items.find((item) => item.key === "Google Calendar");
try {
if (calendarItem && calendarItem.onInstall) {
await calendarItem.onInstall();
} else {
await fetchPost("/install/tool/google_calendar");
}
} catch (_) {}
// Select mode: poll OAuth status
if (isSelectMode) {
console.log(
"[IntegrationList onConnect] Starting OAuth status polling"
);
const start = Date.now();
const timeoutMs = 5 * 60 * 1000; // 5 minutes
while (Date.now() - start < timeoutMs) {
try {
const statusRes: any = await fetchGet(
"/oauth/status/google_calendar"
);
console.log(
"[IntegrationList onConnect] OAuth status:",
statusRes?.status
);
if (statusRes?.status === "success") {
console.log(
"[IntegrationList onConnect] Success! Closing dialog"
);
await fetchInstalled();
onClose();
return;
}
if (
statusRes?.status === "failed" ||
statusRes?.status === "cancelled"
) {
console.log(
"[IntegrationList onConnect] Failed/cancelled, keeping dialog open"
);
return;
}
} catch (err) {
console.log("[IntegrationList onConnect] Polling error:", err);
}
await new Promise((r) => setTimeout(r, 1500));
}
console.log("[IntegrationList onConnect] Polling timeout");
return;
}
}
// Select mode: add to tools and close
if (isSelectMode && addOption) {
console.log(
"[IntegrationList onConnect] Non-Google Calendar, closing immediately"
);
await fetchInstalled();
addOption(mcp, true);
onClose();
} else {
// Manage mode: just close
await fetchInstalled();
onClose();
}
};
const onClose = () => {
setShowEnvConfig(false);
setActiveMcp(null);
};
const handleOpenConfig = useCallback(
(item: IntegrationItem) => {
// if external handler provided by parent, use it
if (onConfigClick) {
onConfigClick(item);
return;
}
// default behavior: if item has env vars, open built-in MCP config dialog
if (item?.env_vars && item.env_vars.length > 0) {
const mcp = createMcpFromItem(item, -1);
setActiveMcp(mcp);
setShowEnvConfig(true);
}
},
[onConfigClick, createMcpFromItem]
);
const COMING_SOON_ITEMS = [
"X(Twitter)",
"WhatsApp",
"LinkedIn",
"Reddit",
"Github",
];
// Determine container and item styles based on variant
const containerClassName = isSelectMode
? "space-y-3"
: "flex flex-col w-full items-start justify-start gap-4";
const itemClassName = isSelectMode
? "cursor-pointer hover:bg-gray-100 px-3 py-2 flex justify-between"
: "w-full px-6 py-4 bg-surface-secondary rounded-2xl";
const titleClassName = isSelectMode
? "text-base leading-snug font-bold text-text-action"
: "text-label-lg font-bold text-text-heading";
return (
<div className={containerClassName}>
<MCPEnvDialog
showEnvConfig={showEnvConfig}
onClose={onClose}
onConnect={onConnect}
activeMcp={activeMcp}
></MCPEnvDialog>
{items.map((item) => {
const isInstalled = !!installed[item.key];
const isComingSoon = COMING_SOON_ITEMS.includes(item.name);
return (
<div key={item.key} className=" w-full">
<div
className={itemClassName}
onClick={
isSelectMode
? () => {
if (!isComingSoon) {
if (item.env_vars.length === 0 || isInstalled) {
// Ensure toolkit field is passed and normalized for known cases
const normalizedToolkit =
item.name === "Notion"
? "notion_mcp_toolkit"
: item.toolkit;
addOption?.(
{ ...item, toolkit: normalizedToolkit },
true
);
} else {
handleInstall(item);
}
}
}
: undefined
}
>
{isSelectMode ? (
<div className="flex items-center gap-xs">
{(isSelectMode || showStatusDot) && (
<img
src={ellipseIcon}
alt="icon"
className="w-3 h-3 mr-2"
style={{
filter: isInstalled
? "grayscale(0%) brightness(0) saturate(100%) invert(41%) sepia(99%) saturate(749%) hue-rotate(81deg) brightness(95%) contrast(92%)"
: "none",
}}
/>
)}
<div className={titleClassName}>{item.name}</div>
<div className="flex items-center">
<TooltipSimple content={item.desc}>
<CircleAlert className="w-4 h-4 text-icon-secondary" />
</TooltipSimple>
</div>
</div>
) : (
<div className="flex flex-row w-full items-center justify-between gap-xs">
<div className="flex flex-row items-center gap-xs">
{showStatusDot && (
<img
src={ellipseIcon}
alt="icon"
className="w-3 h-3 mr-2"
style={{
filter: isInstalled
? "grayscale(0%) brightness(0) saturate(100%) invert(41%) sepia(99%) saturate(749%) hue-rotate(81deg) brightness(95%) contrast(92%)"
: "none",
}}
/>
)}
<div className={titleClassName}>{item.name}</div>
<div className="flex items-center">
<Tooltip>
<TooltipTrigger asChild>
<CircleAlert className="w-4 h-4 text-icon-secondary" />
</TooltipTrigger>
<TooltipContent>
<div>{item.desc}</div>
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="flex flex-row items-center gap-md">
{showConfigButton && (
<Button
type="button"
variant="outline"
size="sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleOpenConfig(item);
}}
>
<Settings2 className="w-4 h-4" />
{t("setting.setting")}
</Button>
)}
{showInstallButton && (
<Button
type="button"
disabled={isComingSoon}
variant={
isComingSoon
? "ghost"
: isInstalled
? "outline"
: "primary"
}
size="sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
return isInstalled
? handleUninstall(item)
: handleInstall(item);
}}
>
{isComingSoon
? t(`${translationNamespace}.coming-soon`)
: isInstalled
? t(`${translationNamespace}.uninstall`)
: t(`${translationNamespace}.install`)}
</Button>
)}
</div>
</div>
)}
{isSelectMode && item.env_vars.length !== 0 && (
<Button
disabled={isComingSoon}
variant={isInstalled ? "secondary" : "primary"}
size="sm"
onClick={(e) => {
e.stopPropagation();
return isInstalled
? handleUninstall(item)
: handleInstall(item);
}}
>
{isComingSoon
? t(`${translationNamespace}.coming-soon`)
: isInstalled
? t(`${translationNamespace}.uninstall`)
: t(`${translationNamespace}.install`)}
</Button>
)}
</div>
{!isSelectMode && showSelect && (
<div className="flex flex-row w-full items-center gap-md mt-6 pt-6 border-b-0 border-x-0 border-solid border-border-secondary">
<div className="flex flex-row w-full items-center justify-between gap-md">
<div className="text-body-md text-text-body">
{" "}
Default {item.name}
</div>
<div className="flex-1 max-w-[300px]">
<Select onValueChange={(v) => onSelectChange?.(v, item)}>
<SelectTrigger size="default">
<SelectValue placeholder={selectPlaceholder} />
</SelectTrigger>
<SelectContent className="z-100">
{selectContent ?? (
<>
<SelectItem value="more">
More integrations
</SelectItem>
</>
)}
</SelectContent>
</Select>
</div>
</div>
</div>
)}
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,294 @@
import { useState, useCallback, useEffect, useRef } from "react";
import {
proxyFetchGet,
proxyFetchPost,
proxyFetchPut,
proxyFetchDelete,
fetchDelete,
} from "@/api/http";
import { capitalizeFirstLetter } from "@/lib";
import { useAuthStore } from "@/store/authStore";
export interface IntegrationItem {
key: string;
name: string;
desc: string | React.ReactNode;
env_vars: string[];
toolkit?: string;
onInstall: () => void | Promise<void>;
}
/**
* Hook for managing integration configurations, OAuth, and installation state
*/
export function useIntegrationManagement(items: IntegrationItem[]) {
const { email, checkAgentTool } = useAuthStore();
// Local installed status
const [installed, setInstalled] = useState<{ [key: string]: boolean }>({});
// Configs cache
const [configs, setConfigs] = useState<any[]>([]);
// Lock to prevent concurrent OAuth processing
const isLockedRef = useRef(false);
// Cache OAuth event when items are not ready
const pendingOauthEventRef = useRef<{
provider: string;
code: string;
} | null>(null);
const [callBackUrl, setCallBackUrl] = useState<string | null>(null);
// Fetch installed configs
const fetchInstalled = useCallback(async (ignore: boolean = false) => {
try {
const configsRes = await proxyFetchGet("/api/configs");
if (!ignore) {
setConfigs(Array.isArray(configsRes) ? configsRes : []);
}
} catch (e) {
if (!ignore) setConfigs([]);
}
}, []);
// Fetch configs when mounted
useEffect(() => {
let ignore = false;
fetchInstalled(ignore);
return () => {
ignore = true;
};
}, [fetchInstalled]);
// Recalculate installed status when items or configs change
useEffect(() => {
const map: { [key: string]: boolean } = {};
items.forEach((item) => {
if (item.key === "Google Calendar") {
// Only mark installed when refresh token is present (auth completed)
const hasRefreshToken = configs.some(
(c: any) =>
c.config_group?.toLowerCase() === "google calendar" &&
c.config_name === "GOOGLE_REFRESH_TOKEN" &&
c.config_value && String(c.config_value).length > 0
);
map[item.key] = hasRefreshToken;
} else {
// For other integrations, use config_group presence
const hasConfig = configs.some(
(c: any) => c.config_group?.toLowerCase() === item.key.toLowerCase()
);
map[item.key] = hasConfig;
}
});
setInstalled(map);
}, [items, configs]);
// Save environment variable and config
const saveEnvAndConfig = useCallback(async (
provider: string,
envVarKey: string,
value: string
) => {
const configPayload = {
// Keep exact group name to satisfy backend whitelist
config_group: provider,
config_name: envVarKey,
config_value: value,
};
// Fetch latest configs to avoid stale state when deciding POST/PUT
let latestConfigs: any[] = Array.isArray(configs) ? configs : [];
try {
const fresh = await proxyFetchGet("/api/configs");
if (Array.isArray(fresh)) latestConfigs = fresh;
} catch {}
// Backend uniqueness is by config_name for a user
let existingConfig = latestConfigs.find((c: any) => c.config_name === envVarKey);
if (existingConfig) {
await proxyFetchPut(`/api/configs/${existingConfig.id}`, configPayload);
} else {
const res = await proxyFetchPost("/api/configs", configPayload);
if (res && res.detail && (res.detail as string).toLowerCase().includes("already exists")) {
try {
const again = await proxyFetchGet("/api/configs");
const found = Array.isArray(again) ? again.find((c: any) => c.config_name === envVarKey) : null;
if (found) {
await proxyFetchPut(`/api/configs/${found.id}`, configPayload);
}
} catch {}
}
}
if (window.electronAPI?.envWrite) {
await window.electronAPI.envWrite(email, { key: envVarKey, value });
}
}, [configs, email]);
// Process OAuth callback
const processOauth = useCallback(
async (data: { provider: string; code: string }) => {
if (isLockedRef.current) return;
if (!items || items.length === 0) {
// Items not ready, cache event, wait for items to have value
pendingOauthEventRef.current = data;
console.warn("items is empty, cache oauth event", data);
return;
}
const provider = data.provider.toLowerCase();
isLockedRef.current = true;
try {
const tokenResult = await proxyFetchPost(
`/api/oauth/${provider}/token`,
{ code: data.code }
);
setInstalled((prev) => ({
...prev,
[capitalizeFirstLetter(provider)]: true,
}));
const currentItem = items.find(
(item) => item.key.toLowerCase() === provider
);
if (provider === "slack") {
if (
tokenResult.access_token &&
currentItem &&
currentItem.env_vars &&
currentItem.env_vars.length > 0
) {
const envVarKey = currentItem.env_vars[0];
await saveEnvAndConfig(
provider,
envVarKey,
tokenResult.access_token
);
await fetchInstalled();
console.log(
"Slack authorization successful and configuration saved!"
);
} else {
console.log(
"Slack authorization successful, but access_token not found or env configuration not found"
);
}
}
} catch (e: any) {
console.log(`${data.provider} authorization failed: ${e.message || e}`);
} finally {
isLockedRef.current = false;
}
},
[items, saveEnvAndConfig, fetchInstalled]
);
// Listen to main process OAuth authorization callback
useEffect(() => {
const handler = (_event: any, data: { provider: string; code: string }) => {
if (!data.provider || !data.code) return;
processOauth(data);
};
window.ipcRenderer?.on("oauth-authorized", handler);
return () => {
window.ipcRenderer?.off("oauth-authorized", handler);
};
}, [processOauth]);
// Listen to OAuth callback URL notification
useEffect(() => {
const handler = (_event: any, data: { url: string; provider: string }) => {
if (data.url && data.provider) {
setCallBackUrl(data.url);
}
};
window.ipcRenderer?.on("oauth-callback-url", handler);
return () => {
window.ipcRenderer?.off("oauth-callback-url", handler);
};
}, []);
// Process cached OAuth event when items are ready
useEffect(() => {
if (items && items.length > 0 && pendingOauthEventRef.current) {
processOauth(pendingOauthEventRef.current);
pendingOauthEventRef.current = null;
}
}, [items, processOauth]);
// Uninstall integration
const handleUninstall = useCallback(
async (item: IntegrationItem) => {
checkAgentTool(item.key);
const groupKey = item.key.toLowerCase();
const toDelete = configs.filter(
(c: any) => c.config_group && c.config_group.toLowerCase() === groupKey
);
for (const config of toDelete) {
try {
await proxyFetchDelete(`/api/configs/${config.id}`);
// Delete env
if (
item.env_vars &&
item.env_vars.length > 0 &&
window.electronAPI?.envRemove
) {
await window.electronAPI.envRemove(email, item.env_vars[0]);
}
} catch (e) {
// Ignore error
}
}
// Clean up authentication tokens for Google Calendar and Notion
if (item.key === "Google Calendar") {
try {
await fetchDelete("/uninstall/tool/google_calendar");
console.log("Cleaned up Google Calendar authentication tokens");
} catch (e) {
console.log("Failed to clean up Google Calendar tokens:", e);
}
} else if (item.key === "Notion") {
try {
await fetchDelete("/uninstall/tool/notion");
console.log("Cleaned up Notion authentication tokens");
} catch (e) {
console.log("Failed to clean up Notion tokens:", e);
}
}
// Update configs after deletion
setConfigs((prev) =>
prev.filter((c: any) => c.config_group?.toLowerCase() !== groupKey)
);
},
[configs, email, checkAgentTool]
);
// Helper to create MCP object from integration item
const createMcpFromItem = useCallback((item: IntegrationItem, id: number) => {
const mcp = {
name: item.name,
key: item.key,
install_command: {
env: {} as any,
},
id,
};
item.env_vars.forEach((key) => {
mcp.install_command.env[key] = "";
});
return mcp;
}, []);
return {
installed,
configs,
callBackUrl,
fetchInstalled,
saveEnvAndConfig,
handleUninstall,
createMcpFromItem,
};
}

View file

@ -18,7 +18,7 @@ import { Button } from "@/components/ui/button";
import { ChevronDown, ChevronUp, Plus, Store, ChevronLeft } from "lucide-react";
import SearchInput from "@/components/SearchInput";
import { useNavigate } from "react-router-dom";
import IntegrationList from "./components/IntegrationList";
import IntegrationList from "@/components/IntegrationList";
import { getProxyBaseURL } from "@/lib";
import { useAuthStore } from "@/store/authStore";
import { useTranslation } from "react-i18next";
@ -654,6 +654,7 @@ export default function SettingMCP() {
<>
<div className="flex-1 w-full">
<IntegrationList
variant="manage"
items={essentialIntegrations}
showConfigButton={true}
showInstallButton={false}
@ -703,6 +704,7 @@ export default function SettingMCP() {
{!collapsedMCP && (
<IntegrationList
key={refreshKey}
variant="manage"
items={integrations}
showConfigButton={false}
showInstallButton={true}
@ -759,43 +761,43 @@ export default function SettingMCP() {
)}
</>
)}
<MCPConfigDialog
open={!!showConfig}
form={configForm}
mcp={showConfig}
onChange={setConfigForm as any}
onSave={handleConfigSave}
onClose={handleConfigClose}
loading={saving}
errorMsg={errorMsg}
onSwitchStatus={handleConfigSwitch}
/>
<MCPAddDialog
open={showAdd}
addType={addType}
setAddType={setAddType}
localJson={localJson}
setLocalJson={setLocalJson}
remoteName={remoteName}
setRemoteName={setRemoteName}
remoteUrl={remoteUrl}
setRemoteUrl={setRemoteUrl}
installing={installing}
onClose={() => setShowAdd(false)}
onInstall={handleInstall}
/>
<MCPDeleteDialog
open={!!deleteTarget}
target={deleteTarget}
onCancel={() => setDeleteTarget(null)}
onConfirm={handleDelete}
loading={deleting}
/>
<SearchEngineConfigDialog
open={showSearchEngineConfig}
onClose={() => setShowSearchEngineConfig(false)}
/>
</div>
<MCPConfigDialog
open={!!showConfig}
form={configForm}
mcp={showConfig}
onChange={setConfigForm as any}
onSave={handleConfigSave}
onClose={handleConfigClose}
loading={saving}
errorMsg={errorMsg}
onSwitchStatus={handleConfigSwitch}
/>
<MCPAddDialog
open={showAdd}
addType={addType}
setAddType={setAddType}
localJson={localJson}
setLocalJson={setLocalJson}
remoteName={remoteName}
setRemoteName={setRemoteName}
remoteUrl={remoteUrl}
setRemoteUrl={setRemoteUrl}
installing={installing}
onClose={() => setShowAdd(false)}
onInstall={handleInstall}
/>
<MCPDeleteDialog
open={!!deleteTarget}
target={deleteTarget}
onCancel={() => setDeleteTarget(null)}
onConfirm={handleDelete}
loading={deleting}
/>
<SearchEngineConfigDialog
open={showSearchEngineConfig}
onClose={() => setShowSearchEngineConfig(false)}
/>
</>
)}
</div>

View file

@ -1,552 +0,0 @@
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { CircleAlert, Settings2, Check } from "lucide-react";
import {
proxyFetchGet,
proxyFetchPost,
proxyFetchPut,
proxyFetchDelete,
fetchDelete,
} from "@/api/http";
import React, { useState, useCallback, useEffect, useRef } from "react";
import ellipseIcon from "@/assets/mcp/Ellipse-25.svg";
import { capitalizeFirstLetter } from "@/lib";
import { MCPEnvDialog } from "./MCPEnvDialog";
import { useAuthStore } from "@/store/authStore";
import { OAuth } from "@/lib/oauth";
import { useTranslation } from "react-i18next";
import { DropdownMenu, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
SelectItemWithButton,
} from "@/components/ui/select";
import * as SelectPrimitive from "@radix-ui/react-select";
interface IntegrationItem {
key: string;
name: string;
desc: string | React.ReactNode;
env_vars: string[];
onInstall: () => void | Promise<void>;
}
interface IntegrationListProps {
items: IntegrationItem[];
installedKeys?: string[];
oauth?: OAuth;
// optional select beside each item for importing options
showSelect?: boolean; // default hidden
selectPlaceholder?: string; // placeholder text
selectContent?: React.ReactNode; // custom content for SelectContent (e.g., list of <SelectItem />)
onSelectChange?: (value: string, item: IntegrationItem) => void; // callback when select changes
// button group options
showConfigButton?: boolean; // whether to show the config button (default: true)
showInstallButton?: boolean; // whether to show the install/uninstall button (default: true)
onConfigClick?: (item: IntegrationItem) => void; // optional external handler to open a popup
// status dot icon (ellipse) visibility
showStatusDot?: boolean; // default: true
}
export default function IntegrationList({
items,
showSelect = false,
selectPlaceholder = "Select...",
selectContent,
onSelectChange,
showConfigButton = true,
showInstallButton = true,
onConfigClick,
showStatusDot = true,
}: IntegrationListProps) {
const { t } = useTranslation();
const [showEnvConfig, setShowEnvConfig] = useState(false);
const [activeMcp, setActiveMcp] = useState<any | null>(null);
const { email, checkAgentTool } = useAuthStore();
const [callBackUrl, setCallBackUrl] = useState<string | null>(null);
// local installed status
const [installed, setInstalled] = useState<{ [key: string]: boolean }>({});
// configs cache
const [configs, setConfigs] = useState<any[]>([]);
// 1. add useRef lock
const isLockedRef = useRef(false);
// 2. add ref to cache oauth event
const pendingOauthEventRef = useRef<{
provider: string;
code: string;
} | null>(null);
async function fetchInstalled(ignore: boolean = false) {
try {
const configsRes = await proxyFetchGet("/api/configs");
if (!ignore) {
setConfigs(Array.isArray(configsRes) ? configsRes : []);
}
} catch (e) {
if (!ignore) setConfigs([]);
}
}
// 3. fetch configs when mounted
useEffect(() => {
let ignore = false;
fetchInstalled();
return () => {
ignore = true;
};
}, []);
// items or configs change, recalculate installed
useEffect(() => {
// construct installed map
const map: { [key: string]: boolean } = {};
items.forEach((item) => {
if (item.key === "Google Calendar") {
// Only mark installed after refresh token exists
const hasRefreshToken = configs.some(
(c: any) =>
c.config_group?.toLowerCase() === "google calendar" &&
c.config_name === "GOOGLE_REFRESH_TOKEN" &&
c.config_value && String(c.config_value).length > 0
);
map[item.key] = hasRefreshToken;
} else {
// For other integrations, use presence of any config in the group
const hasConfig = configs.some(
(c: any) => c.config_group?.toLowerCase() === item.key.toLowerCase()
);
map[item.key] = hasConfig;
}
});
setInstalled(map);
}, [items, configs]);
// public save env/config logic
const saveEnvAndConfig = async (
provider: string,
envVarKey: string,
value: string
) => {
const configPayload = {
// Use exact group name, do not transform case to avoid whitelist mismatch
config_group: provider,
config_name: envVarKey,
config_value: value,
};
// Fetch latest configs to avoid stale state when deciding POST/PUT
let latestConfigs: any[] = Array.isArray(configs) ? configs : [];
try {
const fresh = await proxyFetchGet("/api/configs");
if (Array.isArray(fresh)) latestConfigs = fresh;
} catch {}
// Check if config already exists (by name, regardless of group - backend uniqueness is by name)
let existingConfig = latestConfigs.find((c: any) => c.config_name === envVarKey);
if (existingConfig) {
await proxyFetchPut(`/api/configs/${existingConfig.id}`, configPayload);
} else {
const res = await proxyFetchPost("/api/configs", configPayload);
// If backend says it already exists (race), switch to PUT
if (res && res.detail && (res.detail as string).toLowerCase().includes("already exists")) {
try {
const again = await proxyFetchGet("/api/configs");
const found = Array.isArray(again) ? again.find((c: any) => c.config_name === envVarKey) : null;
if (found) {
await proxyFetchPut(`/api/configs/${found.id}`, configPayload);
}
} catch {}
}
}
if (window.electronAPI?.envWrite) {
await window.electronAPI.envWrite(email, { key: envVarKey, value });
}
};
// wrap with useCallback, ensure processOauth can get the latest items and oauth when items change
const processOauth = useCallback(
async (data: { provider: string; code: string }) => {
if (isLockedRef.current) return;
console.log("items", items);
if (!items || items.length === 0) {
// items are not ready, cache event, wait for items to have value
pendingOauthEventRef.current = data;
console.warn("items are empty, cache oauth event", data);
return;
}
const provider = data.provider.toLowerCase();
isLockedRef.current = true;
try {
const tokenResult = await proxyFetchPost(
`/api/oauth/${provider}/token`,
{ code: data.code }
);
setInstalled((prev) => ({
...prev,
[capitalizeFirstLetter(provider)]: true,
}));
const currentItem = items.find(
(item) => item.key.toLowerCase() === provider
);
console.log("provider", provider);
console.log("items", items);
if (provider === "slack") {
if (
tokenResult.access_token &&
currentItem &&
currentItem.env_vars &&
currentItem.env_vars.length > 0
) {
const envVarKey = currentItem.env_vars[0];
await saveEnvAndConfig(
provider,
envVarKey,
tokenResult.access_token
);
fetchInstalled();
console.log(
"Slack authorization successful and configuration saved!"
);
} else {
console.log(
"Slack authorization successful, but access_token not found or env configuration not found"
);
}
} else {
// other provider authorization successful, can be extended
}
} catch (e: any) {
console.log(`${data.provider} authorization failed: ${e.message || e}`);
} finally {
isLockedRef.current = false;
}
},
[items, callBackUrl] // add oauth to dependencies
);
// listen to main process oauth authorization callback, automatically mark as installed and get token
useEffect(() => {
const handler = (_event: any, data: { provider: string; code: string }) => {
if (!data.provider || !data.code) return;
processOauth(data);
};
window.ipcRenderer?.on("oauth-authorized", handler);
return () => {
window.ipcRenderer?.off("oauth-authorized", handler);
};
}, [processOauth]);
// listen to oauth callback URL notification
useEffect(() => {
const handler = (_event: any, data: { url: string; provider: string }) => {
console.log("Received OAuth callback URL:", data);
if (data.url && data.provider) {
console.log(`${data.provider} OAuth callback URL: ${data.url}`);
setCallBackUrl(data.url);
// Add user prompt or other processing logic here
}
};
window.ipcRenderer?.on("oauth-callback-url", handler);
return () => {
window.ipcRenderer?.off("oauth-callback-url", handler);
};
}, []);
// as long as oauth changes and there is a cached event, process it
useEffect(() => {
if (pendingOauthEventRef.current) {
processOauth(pendingOauthEventRef.current);
pendingOauthEventRef.current = null;
}
}, [processOauth]);
// install/uninstall
const handleInstall = useCallback(
async (item: IntegrationItem) => {
console.log(item);
if (item.key === "Search") {
let mcp = {
name: "Search",
key: "Search",
install_command: {
env: {} as any,
},
id: 13,
};
item.env_vars.map((key) => {
mcp.install_command.env[key] = "";
});
setActiveMcp(mcp);
setShowEnvConfig(true);
return;
}
if (item.key === "Google Calendar") {
let mcp = {
name: "Google Calendar",
key: "Google Calendar",
install_command: {
env: {} as any,
},
id: 14,
};
item.env_vars.map((key) => {
mcp.install_command.env[key] = "";
});
setActiveMcp(mcp);
setShowEnvConfig(true);
return;
}
if (installed[item.key]) return;
await item.onInstall();
},
[installed]
);
const onConnect = async (mcp: any) => {
// Refresh configs first to get latest state
await fetchInstalled();
// Save all environment variables
await Promise.all(
Object.keys(mcp.install_command.env).map((key) => {
return saveEnvAndConfig(mcp.key, key, mcp.install_command.env[key]);
})
);
// After saving env vars, trigger installation/instantiation for Google Calendar
if (mcp.key === "Google Calendar") {
const calendarItem = items.find(item => item.key === "Google Calendar");
if (calendarItem && calendarItem.onInstall) {
await calendarItem.onInstall();
}
}
await fetchInstalled();
onClose();
};
const onClose = () => {
setShowEnvConfig(false);
setActiveMcp(null);
};
const handleOpenConfig = useCallback((item: IntegrationItem) => {
// if external handler provided by parent, use it
if (onConfigClick) {
onConfigClick(item);
return;
}
// default behavior: if item has env vars, open built-in MCP config dialog
if (item?.env_vars && item.env_vars.length > 0) {
const mcp = {
name: item.name,
key: item.key,
install_command: {
env: {} as any,
},
id: -1,
};
item.env_vars.forEach((key) => {
(mcp.install_command.env as any)[key] = "";
});
setActiveMcp(mcp);
setShowEnvConfig(true);
}
}, [onConfigClick]);
// uninstall logic
const handleUninstall = useCallback(
async (item: IntegrationItem) => {
checkAgentTool(item.key);
// find all configs that match config_group, delete one by one
const groupKey = item.key.toLowerCase();
const toDelete = configs.filter(
(c: any) => c.config_group && c.config_group.toLowerCase() === groupKey
);
console.log("toDelete", toDelete);
for (const config of toDelete) {
try {
await proxyFetchDelete(`/api/configs/${config.id}`);
console.log("envRemove", email, item.env_vars[0]);
// delete env
if (
item.env_vars &&
item.env_vars.length > 0 &&
window.electronAPI?.envRemove
) {
await window.electronAPI.envRemove(email, item.env_vars[0]);
}
} catch (e) {
console.log("envRemove error", e);
// ignore error
}
}
// Clean up authentication tokens for Google Calendar and Notion
if (item.key === "Google Calendar") {
try {
await fetchDelete("/uninstall/tool/google_calendar");
console.log("Cleaned up Google Calendar authentication tokens");
} catch (e) {
console.log("Failed to clean up Google Calendar tokens:", e);
}
} else if (item.key === "Notion") {
try {
await fetchDelete("/uninstall/tool/notion");
console.log("Cleaned up Notion authentication tokens");
} catch (e) {
console.log("Failed to clean up Notion tokens:", e);
}
}
// after deletion, refresh configs
setConfigs((prev) =>
prev.filter((c: any) => c.config_group?.toLowerCase() !== groupKey)
);
},
[configs]
);
return (
<div className="flex flex-col w-full items-start justify-start gap-4">
<MCPEnvDialog
showEnvConfig={showEnvConfig}
onClose={onClose}
onConnect={onConnect}
activeMcp={activeMcp}
></MCPEnvDialog>
{items.map((item) => {
const isInstalled = !!installed[item.key];
return (
<div
key={item.key}
className="w-full px-6 py-4 bg-surface-secondary rounded-2xl flex flex-col items-center justify-between"
>
<div className="flex flex-row w-full items-center gap-xs">
<div className="flex flex-row w-full items-center gap-xs">
{showStatusDot && (
<img
src={ellipseIcon}
alt="icon"
className="w-3 h-3 mr-2"
style={{
filter: isInstalled
? "grayscale(0%) brightness(0) saturate(100%) invert(41%) sepia(99%) saturate(749%) hue-rotate(81deg) brightness(95%) contrast(92%)"
: "none",
}}
/>
)}
<div className="text-label-lg font-bold text-text-heading">
{item.name}
</div>
<div className="flex items-center">
<Tooltip>
<TooltipTrigger asChild>
<CircleAlert className="w-4 h-4 text-icon-secondary" />
</TooltipTrigger>
<TooltipContent>
<div>{item.desc}</div>
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="flex flex-row items-center gap-md">
{showConfigButton && (
<Button
type="button"
variant="outline"
size="sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleOpenConfig(item);
}}
>
<Settings2 className="w-4 h-4" />
{t("setting.setting")}
</Button>
)}
{showInstallButton && (
<Button
type="button"
disabled={[
"X(Twitter)",
"WhatsApp",
"LinkedIn",
"Reddit",
"Github",
].includes(item.name)}
variant={[
"X(Twitter)",
"WhatsApp",
"LinkedIn",
"Reddit",
"Github",
].includes(item.name) ? "ghost" : (isInstalled ? "outline" : "primary")}
size="sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
return isInstalled ? handleUninstall(item) : handleInstall(item);
}}
>
{[
"X(Twitter)",
"WhatsApp",
"LinkedIn",
"Reddit",
"Github",
].includes(item.name)
? t("setting.coming-soon")
: isInstalled
? t("setting.uninstall")
: t("setting.install")}
</Button>
)}
</div>
</div>
{showSelect && (
<div className="flex flex-row w-full items-center gap-md mt-6 pt-6 border-b-0 border-x-0 border-solid border-border-secondary">
<div className="flex flex-row w-full items-center justify-between gap-md">
<div className="text-body-md text-text-body"> Default {item.name}</div>
<div className="flex-1 max-w-[300px]">
<Select onValueChange={(v) => onSelectChange?.(v, item)}>
<SelectTrigger size="default">
<SelectValue placeholder={selectPlaceholder} />
</SelectTrigger>
<SelectContent className="z-100">
{selectContent ?? (
<>
<SelectItem value="more">More integrations</SelectItem>
</>
)}
</SelectContent>
</Select>
</div>
</div>
</div>
)}
</div>
);
})}
</div>
);
}