diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index bd8c4ed3..971cbc11 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -715,6 +715,8 @@ "Test": "Test", "Available tools": "Available tools", "No tools available": "No tools available", + "Failed": "Failed", + "OK · {{n}}": "OK · {{n}}", "Created successfully": "Created successfully", "Deleted successfully": "Deleted successfully", "Clear": "Clear", diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index f8c59436..349dc227 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -711,6 +711,11 @@ "Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}", "Failed to delete chat": "Не удалось удалить чат", "Failed to rename chat": "Не удалось переименовать чат", + "Failed": "Ошибка", + "OK · {{n}}": "OK · {{n}}", + "Test": "Тест", + "No tools available": "Инструменты недоступны", + "Available tools": "Доступные инструменты", "Minimize": "Свернуть", "No chats yet.": "Чатов пока нет.", "Send": "Отправить", diff --git a/apps/client/src/features/workspace/components/settings/components/ai-mcp-servers.tsx b/apps/client/src/features/workspace/components/settings/components/ai-mcp-servers.tsx index 15db8c22..5dabd174 100644 --- a/apps/client/src/features/workspace/components/settings/components/ai-mcp-servers.tsx +++ b/apps/client/src/features/workspace/components/settings/components/ai-mcp-servers.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { ActionIcon, Badge, @@ -10,15 +10,24 @@ import { Stack, Switch, Text, + Tooltip, } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import { modals } from "@mantine/modals"; -import { IconPencil, IconPlus, IconTrash } from "@tabler/icons-react"; +import { + IconCheck, + IconPencil, + IconPlugConnected, + IconPlus, + IconTrash, + IconX, +} from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import useUserRole from "@/hooks/use-user-role.tsx"; import { useAiMcpServersQuery, useDeleteAiMcpServerMutation, + useTestAiMcpServerMutation, useUpdateAiMcpServerMutation, } from "@/features/workspace/queries/ai-mcp-server-query.ts"; import { IAiMcpServer } from "@/features/workspace/services/ai-mcp-server-service.ts"; @@ -112,55 +121,15 @@ export default function AiMcpServers() { {servers?.map((server) => ( - - - - - {server.name} - - - {server.transport.toUpperCase()} - - - - {server.url} - - - - - - updateMutation.mutate({ - id: server.id, - enabled: event.currentTarget.checked, - }) - } - /> - openEdit(server)} - > - - - confirmDelete(server)} - > - - - - + + updateMutation.mutate({ id: server.id, enabled }) + } + /> ))} @@ -180,3 +149,132 @@ export default function AiMcpServers() { ); } + +interface AiMcpServerRowProps { + server: IAiMcpServer; + onEdit: (server: IAiMcpServer) => void; + onDelete: (server: IAiMcpServer) => void; + onToggleEnabled: (enabled: boolean) => void; +} + +/** + * A single external MCP server row: name/badge/url on the left and the + * Test / Switch / Edit / Delete controls on the right. Each row owns its own + * `useTestAiMcpServerMutation()` so the inline Test result and loading state are + * independent per row (a shared mutation would make `isPending` global and make + * every row flicker). + */ +function AiMcpServerRow({ + server, + onEdit, + onDelete, + onToggleEnabled, +}: AiMcpServerRowProps) { + const { t } = useTranslation(); + const testMutation = useTestAiMcpServerMutation(); + const result = testMutation.data; + + // The row is keyed by `server.id`, so editing the connection-relevant fields + // (url/transport/headers) does NOT remount it — an old success/failure result + // would otherwise stick. Clear the result when those fields change. + useEffect(() => { + testMutation.reset(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [server.url, server.transport, server.hasHeaders]); + + // Tooltip text describes the cause/details; disabled while there is no result. + let tooltipLabel = ""; + if (result?.ok) { + tooltipLabel = + result.tools.length > 0 + ? result.tools.join(", ") + : t("No tools available"); + } else if (result && result.ok === false) { + tooltipLabel = result.error; + } + + // Pick the button presentation from the current test state. Color is never the + // only signal — the label changes too (a11y / colorblind-friendly). + let buttonColor: string | undefined; + let buttonVariant = "default"; + let buttonIcon = ; + let buttonLabel = t("Test"); + if (result?.ok) { + buttonColor = "green"; + buttonVariant = "light"; + buttonIcon = ; + buttonLabel = t("OK · {{n}}", { n: result.tools.length }); + } else if (result && result.ok === false) { + buttonColor = "red"; + buttonVariant = "light"; + buttonIcon = ; + buttonLabel = t("Failed"); + } + + return ( + + + + + {server.name} + + + {server.transport.toUpperCase()} + + + + {server.url} + + + + + {/* Always clickable: testing a disabled server before enabling it is useful. */} + + + + onToggleEnabled(event.currentTarget.checked)} + /> + onEdit(server)} + > + + + onDelete(server)} + > + + + + + ); +}