diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index bd8c4ed3..cb1b3086 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -713,6 +713,8 @@ "Optional. Leave empty to allow all tools the server exposes.": "Optional. Leave empty to allow all tools the server exposes.", "Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"_*\".": "Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"_*\".", "Test": "Test", + "Failed": "Failed", + "OK · {{count}}": "OK · {{count}}", "Available tools": "Available tools", "No tools available": "No tools available", "Created successfully": "Created successfully", diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index f8c59436..e46a0579 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -1169,5 +1169,8 @@ "Protocol": "Протокол", "How chat requests are sent and how reasoning is surfaced": "Как отправляются запросы чата и как показывается reasoning", "OpenAI-compatible (surfaces reasoning)": "OpenAI-совместимый (показывает reasoning)", - "OpenAI (official)": "OpenAI (официальный)" + "OpenAI (official)": "OpenAI (официальный)", + "Test": "Тест", + "Failed": "Ошибка", + "OK · {{count}}": "OK · {{count}}" } diff --git a/apps/client/src/features/workspace/components/settings/components/ai-mcp-server-row.test.tsx b/apps/client/src/features/workspace/components/settings/components/ai-mcp-server-row.test.tsx new file mode 100644 index 00000000..ecd5d2a6 --- /dev/null +++ b/apps/client/src/features/workspace/components/settings/components/ai-mcp-server-row.test.tsx @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { IAiMcpServer } from "@/features/workspace/services/ai-mcp-server-service.ts"; + +// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts. + +// Stub react-i18next so `t` returns the key with `{{count}}` interpolated. This +// keeps assertions on the row's OWN label logic, mirroring the t-mock pattern +// used by other component tests in the repo. +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, opts?: { count?: number }) => + opts && typeof opts.count === "number" + ? key.replace("{{count}}", String(opts.count)) + : key, + }), +})); + +// Mock only the network call. The REAL useTestAiMcpServerMutation runs on a real +// QueryClient so each row gets a genuinely independent mutation instance — this +// is exactly the isolation the feature relies on (#170). +const testAiMcpServer = vi.fn(); +vi.mock("@/features/workspace/services/ai-mcp-server-service.ts", () => ({ + testAiMcpServer: (id: string) => testAiMcpServer(id), +})); + +import AiMcpServerRow from "./ai-mcp-server-row.tsx"; + +const baseServer = (over?: Partial): IAiMcpServer => ({ + id: "srv-1", + name: "Search", + transport: "http", + url: "https://example.com/mcp", + enabled: true, + toolAllowlist: null, + hasHeaders: false, + instructions: null, + ...over, +}); + +function renderRow(server: IAiMcpServer, testid: string) { + const client = new QueryClient({ + defaultOptions: { mutations: { retry: false }, queries: { retry: false } }, + }); + return render( + + +
+ +
+
+
, + ); +} + +describe("AiMcpServerRow — inline Test button", () => { + beforeEach(() => { + testAiMcpServer.mockReset(); + }); + + it("starts in the idle state with a plain 'Test' label", () => { + renderRow(baseServer(), "row"); + const row = screen.getByTestId("row"); + expect(within(row).getByRole("button", { name: "Test" })).toBeDefined(); + }); + + it("shows a green 'OK · N' label with the tool count on success", async () => { + testAiMcpServer.mockResolvedValue({ ok: true, tools: ["a", "b", "c"] }); + renderRow(baseServer(), "row"); + const row = screen.getByTestId("row"); + + fireEvent.click(within(row).getByRole("button", { name: "Test" })); + + await waitFor(() => + expect(within(row).getByRole("button", { name: /OK · 3/ })).toBeDefined(), + ); + }); + + it("shows 'Failed' on a connection error", async () => { + testAiMcpServer.mockResolvedValue({ ok: false, error: "boom" }); + renderRow(baseServer(), "row"); + const row = screen.getByTestId("row"); + + fireEvent.click(within(row).getByRole("button", { name: "Test" })); + + await waitFor(() => + expect(within(row).getByRole("button", { name: "Failed" })).toBeDefined(), + ); + }); + + it("keeps each row's result isolated (testing one does not affect another)", async () => { + // Resolve based on id so the two rows get different outcomes. + testAiMcpServer.mockImplementation(async (id: string) => + id === "ok-1" + ? { ok: true, tools: ["x", "y"] } + : { ok: false, error: "down" }, + ); + + renderRow(baseServer({ id: "ok-1", name: "Good" }), "row-ok"); + renderRow(baseServer({ id: "fail-1", name: "Bad" }), "row-fail"); + + const okRow = screen.getByTestId("row-ok"); + fireEvent.click(within(okRow).getByRole("button", { name: "Test" })); + + await waitFor(() => + expect(within(okRow).getByRole("button", { name: /OK · 2/ })).toBeDefined(), + ); + + // The untouched row must still be idle — no shared/global pending state. + const failRow = screen.getByTestId("row-fail"); + expect(within(failRow).getByRole("button", { name: "Test" })).toBeDefined(); + }); +}); diff --git a/apps/client/src/features/workspace/components/settings/components/ai-mcp-server-row.tsx b/apps/client/src/features/workspace/components/settings/components/ai-mcp-server-row.tsx new file mode 100644 index 00000000..04c305da --- /dev/null +++ b/apps/client/src/features/workspace/components/settings/components/ai-mcp-server-row.tsx @@ -0,0 +1,146 @@ +import { useEffect } from "react"; +import { ActionIcon, Badge, Button, Group, Stack, Switch, Text, Tooltip } from "@mantine/core"; +import { + IconCheck, + IconPencil, + IconPlugConnected, + IconTrash, + IconX, +} from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { useTestAiMcpServerMutation } from "@/features/workspace/queries/ai-mcp-server-query.ts"; +import { IAiMcpServer } from "@/features/workspace/services/ai-mcp-server-service.ts"; + +interface AiMcpServerRowProps { + server: IAiMcpServer; + onEdit: (server: IAiMcpServer) => void; + onDelete: (server: IAiMcpServer) => void; + onToggleEnabled: (server: IAiMcpServer, enabled: boolean) => void; +} + +/** + * A single external MCP server row with an inline "Test" button. Each row owns + * its OWN test mutation instance so the loading/result state is isolated per + * row — a list-level mutation would make every row's spinner and colour jump on + * any single test (#170). + */ +export default function AiMcpServerRow({ + server, + onEdit, + onDelete, + onToggleEnabled, +}: AiMcpServerRowProps) { + const { t } = useTranslation(); + const testMutation = useTestAiMcpServerMutation(); + + // The result colour/label reflects the connection params at the time of the + // test. The row is keyed by id and never remounts, so a stale "OK"/"Failed" + // would otherwise stick after the URL/transport/auth changes. Reset on those. + useEffect(() => { + testMutation.reset(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [server.url, server.transport, server.hasHeaders]); + + const result = testMutation.data; + + // Derive the button's appearance from the test outcome. Colour is never the + // only signal — the label changes too (a11y / colour-blind friendly). + let label = t("Test"); + let color: string | undefined; + let variant = "default"; + let icon = ; + let tooltip: string | undefined; + + if (result?.ok) { + label = t("OK · {{count}}", { count: result.tools.length }); + color = "green"; + variant = "light"; + icon = ; + tooltip = + result.tools.length > 0 + ? result.tools.join(", ") + : t("No tools available"); + } else if (result && "error" in result) { + label = t("Failed"); + color = "red"; + variant = "light"; + icon = ; + // The error string is already sanitized server-side (no secrets). + tooltip = result.error; + } + + const testButton = ( + + ); + + return ( + + + + + {server.name} + + + {server.transport.toUpperCase()} + + + + {server.url} + + + + + {/* Show the tooltip (tools list / error) only once there is a result. */} + {tooltip ? ( + + {testButton} + + ) : ( + testButton + )} + + onToggleEnabled(server, event.currentTarget.checked) + } + /> + onEdit(server)} + > + + + onDelete(server)} + > + + + + + ); +} 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..617845ed 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,6 +1,5 @@ import { useState } from "react"; import { - ActionIcon, Badge, Box, Button, @@ -8,12 +7,11 @@ import { Modal, Paper, Stack, - Switch, Text, } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import { modals } from "@mantine/modals"; -import { IconPencil, IconPlus, IconTrash } from "@tabler/icons-react"; +import { IconPlus } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import useUserRole from "@/hooks/use-user-role.tsx"; import { @@ -23,6 +21,7 @@ import { } from "@/features/workspace/queries/ai-mcp-server-query.ts"; import { IAiMcpServer } from "@/features/workspace/services/ai-mcp-server-service.ts"; import AiMcpServerForm from "./ai-mcp-server-form.tsx"; +import AiMcpServerRow from "./ai-mcp-server-row.tsx"; /** * Admin section: list / add / edit / delete external MCP servers the agent may @@ -112,55 +111,16 @@ 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)} - > - - - - + // Keyed by id (never remounts) so each row keeps its own test state. + + updateMutation.mutate({ id: s.id, enabled }) + } + /> ))}