feat(ai-chat): inline Test button per external MCP server row (#170)
Add a per-row Test button to the external MCP servers list that shows the
connection result inline (no toasts). Extract the row into AiMcpServerRow so
each row owns its own useTestAiMcpServerMutation instance — independent loading
and result, no cross-row flicker.
States: idle (Test), pending (loading), success (green, "OK · N" with the tool
count), failure (red, "Failed"); a tooltip shows the tool list or the error.
The result resets when url/transport/headers change (the row is keyed by id, so
it does not remount). Backend, service and mutation are unchanged.
- ai-mcp-servers.tsx: AiMcpServerRow + Test button + reset effect + tooltip.
- i18n: add Failed / "OK · {{n}}" (en, ru) and ru Test / tool-list keys.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -715,6 +715,8 @@
|
|||||||
"Test": "Test",
|
"Test": "Test",
|
||||||
"Available tools": "Available tools",
|
"Available tools": "Available tools",
|
||||||
"No tools available": "No tools available",
|
"No tools available": "No tools available",
|
||||||
|
"Failed": "Failed",
|
||||||
|
"OK · {{n}}": "OK · {{n}}",
|
||||||
"Created successfully": "Created successfully",
|
"Created successfully": "Created successfully",
|
||||||
"Deleted successfully": "Deleted successfully",
|
"Deleted successfully": "Deleted successfully",
|
||||||
"Clear": "Clear",
|
"Clear": "Clear",
|
||||||
|
|||||||
@@ -711,6 +711,11 @@
|
|||||||
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
|
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
|
||||||
"Failed to delete chat": "Не удалось удалить чат",
|
"Failed to delete chat": "Не удалось удалить чат",
|
||||||
"Failed to rename chat": "Не удалось переименовать чат",
|
"Failed to rename chat": "Не удалось переименовать чат",
|
||||||
|
"Failed": "Ошибка",
|
||||||
|
"OK · {{n}}": "OK · {{n}}",
|
||||||
|
"Test": "Тест",
|
||||||
|
"No tools available": "Инструменты недоступны",
|
||||||
|
"Available tools": "Доступные инструменты",
|
||||||
"Minimize": "Свернуть",
|
"Minimize": "Свернуть",
|
||||||
"No chats yet.": "Чатов пока нет.",
|
"No chats yet.": "Чатов пока нет.",
|
||||||
"Send": "Отправить",
|
"Send": "Отправить",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Badge,
|
Badge,
|
||||||
@@ -10,15 +10,24 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Switch,
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { modals } from "@mantine/modals";
|
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 { useTranslation } from "react-i18next";
|
||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
import {
|
import {
|
||||||
useAiMcpServersQuery,
|
useAiMcpServersQuery,
|
||||||
useDeleteAiMcpServerMutation,
|
useDeleteAiMcpServerMutation,
|
||||||
|
useTestAiMcpServerMutation,
|
||||||
useUpdateAiMcpServerMutation,
|
useUpdateAiMcpServerMutation,
|
||||||
} from "@/features/workspace/queries/ai-mcp-server-query.ts";
|
} from "@/features/workspace/queries/ai-mcp-server-query.ts";
|
||||||
import { IAiMcpServer } from "@/features/workspace/services/ai-mcp-server-service.ts";
|
import { IAiMcpServer } from "@/features/workspace/services/ai-mcp-server-service.ts";
|
||||||
@@ -112,7 +121,98 @@ export default function AiMcpServers() {
|
|||||||
|
|
||||||
<Stack gap="xs" mt="sm">
|
<Stack gap="xs" mt="sm">
|
||||||
{servers?.map((server) => (
|
{servers?.map((server) => (
|
||||||
<Group key={server.id} justify="space-between" wrap="nowrap">
|
<AiMcpServerRow
|
||||||
|
key={server.id}
|
||||||
|
server={server}
|
||||||
|
onEdit={openEdit}
|
||||||
|
onDelete={confirmDelete}
|
||||||
|
onToggleEnabled={(enabled) =>
|
||||||
|
updateMutation.mutate({ id: server.id, enabled })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={close}
|
||||||
|
title={editing ? t("Edit server") : t("Add server")}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{/* Remount the form per target so its internal state re-hydrates. */}
|
||||||
|
<AiMcpServerForm
|
||||||
|
key={editing?.id ?? "new"}
|
||||||
|
server={editing}
|
||||||
|
onClose={close}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = <IconPlugConnected size={16} />;
|
||||||
|
let buttonLabel = t("Test");
|
||||||
|
if (result?.ok) {
|
||||||
|
buttonColor = "green";
|
||||||
|
buttonVariant = "light";
|
||||||
|
buttonIcon = <IconCheck size={16} />;
|
||||||
|
buttonLabel = t("OK · {{n}}", { n: result.tools.length });
|
||||||
|
} else if (result && result.ok === false) {
|
||||||
|
buttonColor = "red";
|
||||||
|
buttonVariant = "light";
|
||||||
|
buttonIcon = <IconX size={16} />;
|
||||||
|
buttonLabel = t("Failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Text fw={500} truncate>
|
<Text fw={500} truncate>
|
||||||
@@ -133,21 +233,36 @@ export default function AiMcpServers() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Group gap="xs" wrap="nowrap">
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
{/* Always clickable: testing a disabled server before enabling it is useful. */}
|
||||||
|
<Tooltip
|
||||||
|
label={tooltipLabel}
|
||||||
|
disabled={!result}
|
||||||
|
multiline
|
||||||
|
maw={320}
|
||||||
|
withinPortal
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
miw={88}
|
||||||
|
color={buttonColor}
|
||||||
|
variant={buttonVariant}
|
||||||
|
leftSection={testMutation.isPending ? undefined : buttonIcon}
|
||||||
|
loading={testMutation.isPending}
|
||||||
|
onClick={() => testMutation.mutate(server.id)}
|
||||||
|
>
|
||||||
|
{buttonLabel}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
<Switch
|
<Switch
|
||||||
size="sm"
|
size="sm"
|
||||||
checked={server.enabled}
|
checked={server.enabled}
|
||||||
aria-label={t("Enabled")}
|
aria-label={t("Enabled")}
|
||||||
onChange={(event) =>
|
onChange={(event) => onToggleEnabled(event.currentTarget.checked)}
|
||||||
updateMutation.mutate({
|
|
||||||
id: server.id,
|
|
||||||
enabled: event.currentTarget.checked,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
aria-label={t("Edit")}
|
aria-label={t("Edit")}
|
||||||
onClick={() => openEdit(server)}
|
onClick={() => onEdit(server)}
|
||||||
>
|
>
|
||||||
<IconPencil size={16} />
|
<IconPencil size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -155,28 +270,11 @@ export default function AiMcpServers() {
|
|||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="red"
|
color="red"
|
||||||
aria-label={t("Delete")}
|
aria-label={t("Delete")}
|
||||||
onClick={() => confirmDelete(server)}
|
onClick={() => onDelete(server)}
|
||||||
>
|
>
|
||||||
<IconTrash size={16} />
|
<IconTrash size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={close}
|
|
||||||
title={editing ? t("Edit server") : t("Add server")}
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
{/* Remount the form per target so its internal state re-hydrates. */}
|
|
||||||
<AiMcpServerForm
|
|
||||||
key={editing?.id ?? "new"}
|
|
||||||
server={editing}
|
|
||||||
onClose={close}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
</Paper>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user