mirror of
https://github.com/coder/coder.git
synced 2026-09-24 06:47:27 +08:00
feat(site): add MCP server picker to agent chat UI (#23470)
## Summary
Adds a user-facing MCP server configuration panel to the chat input
toolbar. Users can toggle which MCP servers provide tools for their chat
sessions, and authenticate with OAuth2 servers via popup windows.
## Changes
### New Components
- **`MCPServerPicker`** (`MCPServerPicker.tsx`): Popover-based picker
that appears in the chat input toolbar next to the model selector. Shows
all enabled MCP servers with toggles.
- **`MCPServerPicker.stories.tsx`**: 13 Storybook stories covering all
states.
### Availability Policies
Respects the admin-configured availability for each server:
- **`force_on`**: Always active, toggle disabled, lock icon shown. User
cannot disable.
- **`default_on`**: Pre-selected by default, user can opt out via
toggle.
- **`default_off`**: Not selected by default, user must opt in via
toggle.
### OAuth2 Authentication
For servers with `auth_type: "oauth2"`:
- Shows auth status (connected/not connected)
- "Connect to authenticate" link opens a popup window to
`/api/experimental/mcp/servers/{id}/oauth2/connect`
- Listens for `postMessage` with `{type: "mcp-oauth2-complete"}` from
the callback page
- Same UX pattern as external auth on the Create Workspace screen
### Integration Points
- `AgentChatInput`: MCP picker appears in the toolbar after the model
selector
- `AgentDetail`: Manages MCP selection state, initializes from
`chat.mcp_server_ids` or defaults
- `AgentDetailView` / `AgentDetailContent`: Props plumbed through to
input
- `AgentCreatePage` / `AgentCreateForm`: MCP selection for new chats
- `mcp_server_ids` now sent with `CreateChatMessageRequest` and
`CreateChatRequest`
### Helper
- `getDefaultMCPSelection()`: Computes default selection from
availability policies (`force_on` + `default_on`)
## Storybook Stories
| Story | Description |
|-------|-------------|
| NoServers | No servers - picker hidden |
| AllDisabled | All disabled servers - picker hidden |
| SingleForceOn | Force-on server with locked toggle |
| SingleDefaultOnNoAuth | Default-on with no auth required |
| SingleDefaultOff | Optional server not selected |
| OAuthNeedsAuth | OAuth2 server needing authentication |
| OAuthConnected | OAuth2 server already connected |
| MixedServers | Multiple servers with mixed availability/auth |
| AllConnected | All OAuth2 servers authenticated |
| Disabled | Picker in disabled state |
| WithDisabledServer | Disabled servers filtered out |
| AllOptedOut | All toggled off except force_on |
| OptionalOAuthNeedsAuth | Optional OAuth2 needing auth |
This commit is contained in:
@@ -697,7 +697,7 @@ export const deleteChatUsageLimitGroupOverride = (
|
||||
|
||||
// ── MCP Server Configs ───────────────────────────────────────
|
||||
|
||||
const mcpServerConfigsKey = ["mcp-server-configs"] as const;
|
||||
export const mcpServerConfigsKey = ["mcp-server-configs"] as const;
|
||||
|
||||
export const mcpServerConfigs = () => ({
|
||||
queryKey: mcpServerConfigsKey,
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { chatModelConfigs, chatModels, createChat } from "api/queries/chats";
|
||||
import {
|
||||
chatModelConfigs,
|
||||
chatModels,
|
||||
createChat,
|
||||
mcpServerConfigs,
|
||||
} from "api/queries/chats";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import type { FC } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
@@ -24,6 +29,7 @@ const AgentCreatePage: FC = () => {
|
||||
|
||||
const chatModelsQuery = useQuery(chatModels());
|
||||
const chatModelConfigsQuery = useQuery(chatModelConfigs());
|
||||
const mcpServersQuery = useQuery(mcpServerConfigs());
|
||||
const createMutation = useMutation(createChat(queryClient));
|
||||
|
||||
const catalogModelOptions = getModelOptionsFromCatalog(
|
||||
@@ -39,6 +45,7 @@ const AgentCreatePage: FC = () => {
|
||||
fileIDs,
|
||||
workspaceId,
|
||||
model,
|
||||
mcpServerIds,
|
||||
}: CreateChatOptions) => {
|
||||
const modelConfigID =
|
||||
(model && modelConfigIDByModelID.get(model)) || nilUUID;
|
||||
@@ -55,6 +62,8 @@ const AgentCreatePage: FC = () => {
|
||||
content,
|
||||
workspace_id: workspaceId,
|
||||
model_config_id: modelConfigID,
|
||||
mcp_server_ids:
|
||||
mcpServerIds && mcpServerIds.length > 0 ? mcpServerIds : undefined,
|
||||
});
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -84,6 +93,8 @@ const AgentCreatePage: FC = () => {
|
||||
isModelCatalogLoading={chatModelsQuery.isLoading}
|
||||
isModelConfigsLoading={chatModelConfigsQuery.isLoading}
|
||||
modelCatalogError={chatModelsQuery.error}
|
||||
mcpServers={mcpServersQuery.data ?? []}
|
||||
onMCPAuthComplete={() => void mcpServersQuery.refetch()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
chatMessagesKey,
|
||||
chatModelsKey,
|
||||
chatsKey,
|
||||
mcpServerConfigsKey,
|
||||
} from "api/queries/chats";
|
||||
import { workspaceByIdKey } from "api/queries/workspaces";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
@@ -176,6 +177,7 @@ const buildQueries = (
|
||||
data: mockWorkspace,
|
||||
},
|
||||
{ key: chatModelsKey, data: mockModelCatalog },
|
||||
{ key: mcpServerConfigsKey, data: [] },
|
||||
];
|
||||
};
|
||||
|
||||
@@ -214,6 +216,7 @@ const meta: Meta<typeof AgentDetailLayout> = {
|
||||
beforeEach: () => {
|
||||
localStorage.removeItem(RIGHT_PANEL_OPEN_KEY);
|
||||
spyOn(API, "getApiKey").mockRejectedValue(new Error("missing API key"));
|
||||
spyOn(API.experimental, "getMCPServerConfigs").mockResolvedValue([]);
|
||||
return () => localStorage.removeItem(RIGHT_PANEL_OPEN_KEY);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
deleteChatQueuedMessage,
|
||||
editChatMessage,
|
||||
interruptChat,
|
||||
mcpServerConfigs,
|
||||
promoteChatQueuedMessage,
|
||||
userCompactionThresholds,
|
||||
} from "api/queries/chats";
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
AgentDetailNotFoundView,
|
||||
AgentDetailView,
|
||||
} from "./components/AgentDetailView";
|
||||
import { getDefaultMCPSelection } from "./components/MCPServerPicker";
|
||||
import { useGitWatcher } from "./hooks/useGitWatcher";
|
||||
import {
|
||||
buildModelConfigIDByModelID,
|
||||
@@ -315,8 +317,23 @@ const AgentDetail: FC = () => {
|
||||
const chatModelConfigsQuery = useQuery(chatModelConfigs());
|
||||
const userThresholdsQuery = useQuery(userCompactionThresholds());
|
||||
const desktopEnabledQuery = useQuery(chatDesktopEnabled());
|
||||
const mcpServersQuery = useQuery(mcpServerConfigs());
|
||||
const desktopEnabled = desktopEnabledQuery.data?.enable_desktop ?? false;
|
||||
|
||||
// MCP server selection state.
|
||||
const mcpServers = mcpServersQuery.data ?? [];
|
||||
const [selectedMCPServerIds, setSelectedMCPServerIds] = useState<
|
||||
string[] | null
|
||||
>(null);
|
||||
|
||||
const handleMCPSelectionChange = (ids: string[]) => {
|
||||
setSelectedMCPServerIds(ids);
|
||||
};
|
||||
|
||||
const handleMCPAuthComplete = (_serverId: string) => {
|
||||
void mcpServersQuery.refetch();
|
||||
};
|
||||
|
||||
const modelOptions = getModelOptionsFromCatalog(
|
||||
chatModelsQuery.data,
|
||||
chatModelConfigsQuery.data,
|
||||
@@ -405,6 +422,21 @@ const AgentDetail: FC = () => {
|
||||
};
|
||||
|
||||
const chatRecord = chatQuery.data;
|
||||
|
||||
// Initialize MCP selection from chat record or defaults.
|
||||
const effectiveMCPServerIds = (() => {
|
||||
if (selectedMCPServerIds !== null) {
|
||||
return selectedMCPServerIds;
|
||||
}
|
||||
// If the chat has MCP server IDs recorded (even empty, meaning
|
||||
// the user deliberately opted out), use those.
|
||||
if (chatRecord?.mcp_server_ids) {
|
||||
return chatRecord.mcp_server_ids;
|
||||
}
|
||||
// Otherwise, compute defaults from server availability.
|
||||
return getDefaultMCPSelection(mcpServers);
|
||||
})();
|
||||
|
||||
// Flatten paginated messages into chronological order.
|
||||
// Pages arrive newest-first per page, and pages[0] is the
|
||||
// most recent page.
|
||||
@@ -640,6 +672,10 @@ const AgentDetail: FC = () => {
|
||||
const request: TypesGen.CreateChatMessageRequest = {
|
||||
content,
|
||||
model_config_id: selectedModelConfigID,
|
||||
mcp_server_ids:
|
||||
effectiveMCPServerIds.length > 0
|
||||
? [...effectiveMCPServerIds]
|
||||
: undefined,
|
||||
};
|
||||
clearChatErrorReason(agentId);
|
||||
clearStreamError();
|
||||
@@ -909,6 +945,10 @@ const AgentDetail: FC = () => {
|
||||
isFetchingMoreMessages={chatMessagesQuery.isFetchingNextPage}
|
||||
onFetchMoreMessages={chatMessagesQuery.fetchNextPage}
|
||||
desktopChatId={desktopEnabled ? agentId : undefined}
|
||||
mcpServers={mcpServers}
|
||||
selectedMCPServerIds={effectiveMCPServerIds}
|
||||
onMCPSelectionChange={handleMCPSelectionChange}
|
||||
onMCPAuthComplete={handleMCPAuthComplete}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import type { ChatMessageInputRef } from "components/ChatMessageInput/ChatMessageInput";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
|
||||
@@ -343,3 +344,115 @@ export const AttachmentsOnly: Story = {
|
||||
};
|
||||
})(),
|
||||
};
|
||||
|
||||
// ── MCP server fixtures ────────────────────────────────────────
|
||||
|
||||
const now = "2026-03-19T12:00:00.000Z";
|
||||
|
||||
const makeMCPServer = (
|
||||
overrides: Partial<TypesGen.MCPServerConfig> &
|
||||
Pick<TypesGen.MCPServerConfig, "id" | "display_name" | "slug">,
|
||||
): TypesGen.MCPServerConfig => ({
|
||||
id: overrides.id,
|
||||
display_name: overrides.display_name,
|
||||
slug: overrides.slug,
|
||||
description: overrides.description ?? "",
|
||||
icon_url: overrides.icon_url ?? "",
|
||||
transport: overrides.transport ?? "streamable_http",
|
||||
url: overrides.url ?? "https://mcp.example.com/sse",
|
||||
auth_type: overrides.auth_type ?? "none",
|
||||
oauth2_client_id: overrides.oauth2_client_id,
|
||||
has_oauth2_secret: overrides.has_oauth2_secret ?? false,
|
||||
oauth2_auth_url: overrides.oauth2_auth_url,
|
||||
oauth2_token_url: overrides.oauth2_token_url,
|
||||
oauth2_scopes: overrides.oauth2_scopes,
|
||||
api_key_header: overrides.api_key_header,
|
||||
has_api_key: overrides.has_api_key ?? false,
|
||||
has_custom_headers: overrides.has_custom_headers ?? false,
|
||||
tool_allow_list: overrides.tool_allow_list ?? [],
|
||||
tool_deny_list: overrides.tool_deny_list ?? [],
|
||||
availability: overrides.availability ?? "default_on",
|
||||
enabled: overrides.enabled ?? true,
|
||||
created_at: overrides.created_at ?? now,
|
||||
updated_at: overrides.updated_at ?? now,
|
||||
auth_connected: overrides.auth_connected ?? false,
|
||||
});
|
||||
|
||||
const sentryMCP = makeMCPServer({
|
||||
id: "mcp-sentry",
|
||||
display_name: "Sentry",
|
||||
slug: "sentry",
|
||||
icon_url: "/icon/widgets.svg",
|
||||
availability: "force_on",
|
||||
auth_type: "oauth2",
|
||||
auth_connected: true,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const linearMCP = makeMCPServer({
|
||||
id: "mcp-linear",
|
||||
display_name: "Linear",
|
||||
slug: "linear",
|
||||
availability: "default_on",
|
||||
auth_type: "api_key",
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const githubMCP = makeMCPServer({
|
||||
id: "mcp-github",
|
||||
display_name: "GitHub",
|
||||
slug: "github",
|
||||
icon_url: "/icon/github.svg",
|
||||
availability: "default_on",
|
||||
auth_type: "oauth2",
|
||||
auth_connected: false,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const githubMCPConnected = { ...githubMCP, auth_connected: true };
|
||||
|
||||
const mcpDefaults = {
|
||||
onMCPSelectionChange: fn(),
|
||||
onMCPAuthComplete: fn(),
|
||||
};
|
||||
|
||||
// ── MCP stories ────────────────────────────────────────────────
|
||||
|
||||
/** Input with multiple MCP servers selected — shows icon stack in toolbar. */
|
||||
export const WithMCPServers: Story = {
|
||||
args: {
|
||||
...mcpDefaults,
|
||||
mcpServers: [sentryMCP, linearMCP, githubMCPConnected],
|
||||
selectedMCPServerIds: [sentryMCP.id, linearMCP.id, githubMCPConnected.id],
|
||||
},
|
||||
};
|
||||
|
||||
/** MCP server needing OAuth — shows Auth button instead of toggle. */
|
||||
export const WithMCPNeedingAuth: Story = {
|
||||
args: {
|
||||
...mcpDefaults,
|
||||
mcpServers: [sentryMCP, githubMCP],
|
||||
selectedMCPServerIds: [sentryMCP.id, githubMCP.id],
|
||||
},
|
||||
};
|
||||
|
||||
/** No MCP servers active — shows only "MCP" label with chevron. */
|
||||
export const WithMCPNoneActive: Story = {
|
||||
args: {
|
||||
...mcpDefaults,
|
||||
mcpServers: [
|
||||
{
|
||||
...sentryMCP,
|
||||
availability: "default_off",
|
||||
auth_connected: false,
|
||||
},
|
||||
{
|
||||
...linearMCP,
|
||||
availability: "default_off",
|
||||
auth_type: "oauth2",
|
||||
auth_connected: false,
|
||||
},
|
||||
],
|
||||
selectedMCPServerIds: [],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import type { ChatMessagePart, ChatQueuedMessage } from "api/typesGenerated";
|
||||
import {
|
||||
ModelSelector,
|
||||
@@ -38,6 +39,7 @@ import { cn } from "utils/cn";
|
||||
import { isMobileViewport } from "utils/mobile";
|
||||
import { formatProviderLabel } from "../utils/modelOptions";
|
||||
import { ImageLightbox } from "./ImageLightbox";
|
||||
import { MCPServerPicker } from "./MCPServerPicker";
|
||||
import { QueuedMessagesList } from "./QueuedMessagesList";
|
||||
|
||||
export type { ChatMessageInputRef } from "components/ChatMessageInput/ChatMessageInput";
|
||||
@@ -112,6 +114,11 @@ interface AgentChatInputProps {
|
||||
onRemoveAttachment?: (index: number) => void;
|
||||
uploadStates?: Map<File, UploadState>;
|
||||
previewUrls?: Map<File, string>;
|
||||
// MCP Server picker.
|
||||
mcpServers?: readonly TypesGen.MCPServerConfig[];
|
||||
selectedMCPServerIds?: readonly string[];
|
||||
onMCPSelectionChange?: (ids: string[]) => void;
|
||||
onMCPAuthComplete?: (serverId: string) => void;
|
||||
}
|
||||
const hasFiniteTokenValue = (value: number | undefined): value is number =>
|
||||
typeof value === "number" && Number.isFinite(value) && value >= 0;
|
||||
@@ -357,6 +364,10 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
|
||||
onRemoveAttachment,
|
||||
uploadStates,
|
||||
previewUrls,
|
||||
mcpServers,
|
||||
selectedMCPServerIds,
|
||||
onMCPSelectionChange,
|
||||
onMCPAuthComplete,
|
||||
}) => {
|
||||
const internalRef = useRef<ChatMessageInputRef>(null);
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
@@ -637,7 +648,6 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
|
||||
|
||||
<div className="flex items-center justify-between gap-2 px-2.5 pb-1.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{" "}
|
||||
<ModelSelector
|
||||
value={selectedModel}
|
||||
onValueChange={onModelChange}
|
||||
@@ -648,6 +658,18 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
|
||||
dropdownSide="top"
|
||||
dropdownAlign="center"
|
||||
/>
|
||||
{mcpServers &&
|
||||
mcpServers.length > 0 &&
|
||||
onMCPSelectionChange &&
|
||||
onMCPAuthComplete && (
|
||||
<MCPServerPicker
|
||||
servers={mcpServers}
|
||||
selectedServerIds={selectedMCPServerIds ?? []}
|
||||
onSelectionChange={onMCPSelectionChange}
|
||||
onAuthComplete={onMCPAuthComplete}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
)}
|
||||
{leftActions}
|
||||
{inputStatusText && (
|
||||
<span className="hidden text-xs text-content-secondary sm:inline">
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
isUsageLimitData,
|
||||
} from "../utils/usageLimitMessage";
|
||||
import { AgentChatInput } from "./AgentChatInput";
|
||||
import { getDefaultMCPSelection } from "./MCPServerPicker";
|
||||
|
||||
/** @internal Exported for testing. */
|
||||
export const emptyInputStorageKey = "agents.empty-input";
|
||||
@@ -50,6 +51,7 @@ export type CreateChatOptions = {
|
||||
fileIDs?: string[];
|
||||
workspaceId?: string;
|
||||
model?: string;
|
||||
mcpServerIds?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -116,6 +118,8 @@ interface AgentCreateFormProps {
|
||||
modelConfigs: readonly TypesGen.ChatModelConfig[];
|
||||
isModelConfigsLoading: boolean;
|
||||
modelCatalogError: unknown;
|
||||
mcpServers?: readonly TypesGen.MCPServerConfig[];
|
||||
onMCPAuthComplete?: (serverId: string) => void;
|
||||
}
|
||||
|
||||
export const AgentCreateForm: FC<AgentCreateFormProps> = ({
|
||||
@@ -128,6 +132,8 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
|
||||
isModelCatalogLoading,
|
||||
isModelConfigsLoading,
|
||||
modelCatalogError,
|
||||
mcpServers,
|
||||
onMCPAuthComplete,
|
||||
}) => {
|
||||
const { organizations } = useDashboard();
|
||||
const { initialInputValue, handleContentChange, submitDraft, resetDraft } =
|
||||
@@ -242,11 +248,17 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
|
||||
// the shared input component re-rendering on every change.
|
||||
const selectedWorkspaceIdRef = useRef(selectedWorkspaceId);
|
||||
const selectedModelRef = useRef(selectedModel);
|
||||
const [userMCPServerIds, setUserMCPServerIds] = useState<string[] | null>(
|
||||
null,
|
||||
);
|
||||
const effectiveMCPServerIds =
|
||||
userMCPServerIds ?? getDefaultMCPSelection(mcpServers ?? []);
|
||||
const selectedMCPServerIdsRef = useRef(effectiveMCPServerIds);
|
||||
useEffect(() => {
|
||||
selectedWorkspaceIdRef.current = selectedWorkspaceId;
|
||||
selectedModelRef.current = selectedModel;
|
||||
selectedMCPServerIdsRef.current = effectiveMCPServerIds;
|
||||
});
|
||||
|
||||
const handleWorkspaceChange = (value: string) => {
|
||||
if (value === autoCreateWorkspaceValue) {
|
||||
setSelectedWorkspaceId(null);
|
||||
@@ -273,6 +285,10 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
|
||||
fileIDs,
|
||||
workspaceId: selectedWorkspaceIdRef.current ?? undefined,
|
||||
model: selectedModelRef.current || undefined,
|
||||
mcpServerIds:
|
||||
selectedMCPServerIdsRef.current.length > 0
|
||||
? [...selectedMCPServerIdsRef.current]
|
||||
: undefined,
|
||||
}).catch(() => {
|
||||
// Re-enable draft persistence so the user can edit
|
||||
// and retry after a failed send attempt.
|
||||
@@ -368,6 +384,10 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
|
||||
onRemoveAttachment={handleRemoveAttachment}
|
||||
uploadStates={uploadStates}
|
||||
previewUrls={previewUrls}
|
||||
mcpServers={mcpServers}
|
||||
selectedMCPServerIds={effectiveMCPServerIds}
|
||||
onMCPSelectionChange={setUserMCPServerIds}
|
||||
onMCPAuthComplete={onMCPAuthComplete}
|
||||
leftActions={
|
||||
<Popover
|
||||
open={workspacePopoverOpen}
|
||||
|
||||
@@ -209,6 +209,10 @@ interface AgentDetailInputProps {
|
||||
// File parts from the message being edited, converted to
|
||||
// File objects and pre-populated into attachments.
|
||||
editingFileBlocks?: readonly TypesGen.ChatMessagePart[];
|
||||
mcpServers?: readonly TypesGen.MCPServerConfig[];
|
||||
selectedMCPServerIds?: readonly string[];
|
||||
onMCPSelectionChange?: (ids: string[]) => void;
|
||||
onMCPAuthComplete?: (serverId: string) => void;
|
||||
}
|
||||
|
||||
export const AgentDetailInput: FC<AgentDetailInputProps> = ({
|
||||
@@ -237,6 +241,10 @@ export const AgentDetailInput: FC<AgentDetailInputProps> = ({
|
||||
isEditingHistoryMessage,
|
||||
onCancelHistoryEdit,
|
||||
editingFileBlocks,
|
||||
mcpServers,
|
||||
selectedMCPServerIds,
|
||||
onMCPSelectionChange,
|
||||
onMCPAuthComplete,
|
||||
}) => {
|
||||
const messagesByID = useChatSelector(store, selectMessagesByID);
|
||||
const orderedMessageIDs = useChatSelector(store, selectOrderedMessageIDs);
|
||||
@@ -374,6 +382,10 @@ export const AgentDetailInput: FC<AgentDetailInputProps> = ({
|
||||
modelSelectorPlaceholder={modelSelectorPlaceholder}
|
||||
inputStatusText={inputStatusText}
|
||||
modelCatalogStatusMessage={modelCatalogStatusMessage}
|
||||
mcpServers={mcpServers}
|
||||
selectedMCPServerIds={selectedMCPServerIds}
|
||||
onMCPSelectionChange={onMCPSelectionChange}
|
||||
onMCPAuthComplete={onMCPAuthComplete}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -135,6 +135,10 @@ const meta: Meta<typeof AgentDetailView> = {
|
||||
handleUnarchiveAgentAction: fn(),
|
||||
handleArchiveAndDeleteWorkspaceAction: fn(),
|
||||
scrollContainerRef: { current: null },
|
||||
mcpServers: [],
|
||||
selectedMCPServerIds: [],
|
||||
onMCPSelectionChange: fn(),
|
||||
onMCPAuthComplete: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -126,6 +126,12 @@ interface AgentDetailViewProps {
|
||||
|
||||
urlTransform?: UrlTransform;
|
||||
|
||||
// MCP server state.
|
||||
mcpServers: readonly TypesGen.MCPServerConfig[];
|
||||
selectedMCPServerIds: readonly string[];
|
||||
onMCPSelectionChange: (ids: string[]) => void;
|
||||
onMCPAuthComplete: (serverId: string) => void;
|
||||
|
||||
// Desktop chat ID (optional).
|
||||
desktopChatId?: string;
|
||||
}
|
||||
@@ -177,6 +183,10 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
|
||||
isFetchingMoreMessages,
|
||||
onFetchMoreMessages,
|
||||
urlTransform,
|
||||
mcpServers,
|
||||
selectedMCPServerIds,
|
||||
onMCPSelectionChange,
|
||||
onMCPAuthComplete,
|
||||
desktopChatId,
|
||||
}) => {
|
||||
const [isRightPanelExpanded, setIsRightPanelExpanded] = useState(false);
|
||||
@@ -302,6 +312,10 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
|
||||
isEditingHistoryMessage={editing.editingMessageId !== null}
|
||||
onCancelHistoryEdit={editing.handleCancelHistoryEdit}
|
||||
editingFileBlocks={editing.editingFileBlocks}
|
||||
mcpServers={mcpServers}
|
||||
selectedMCPServerIds={selectedMCPServerIds}
|
||||
onMCPSelectionChange={onMCPSelectionChange}
|
||||
onMCPAuthComplete={onMCPAuthComplete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import { fn } from "storybook/test";
|
||||
import { getDefaultMCPSelection, MCPServerPicker } from "./MCPServerPicker";
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
const now = "2026-03-19T12:00:00.000Z";
|
||||
|
||||
const createServerConfig = (
|
||||
overrides: Partial<TypesGen.MCPServerConfig> &
|
||||
Pick<TypesGen.MCPServerConfig, "id" | "display_name" | "slug">,
|
||||
): TypesGen.MCPServerConfig => ({
|
||||
id: overrides.id,
|
||||
display_name: overrides.display_name,
|
||||
slug: overrides.slug,
|
||||
description: overrides.description ?? "",
|
||||
icon_url: overrides.icon_url ?? "",
|
||||
transport: overrides.transport ?? "streamable_http",
|
||||
url: overrides.url ?? "https://mcp.example.com/sse",
|
||||
auth_type: overrides.auth_type ?? "none",
|
||||
oauth2_client_id: overrides.oauth2_client_id,
|
||||
has_oauth2_secret: overrides.has_oauth2_secret ?? false,
|
||||
oauth2_auth_url: overrides.oauth2_auth_url,
|
||||
oauth2_token_url: overrides.oauth2_token_url,
|
||||
oauth2_scopes: overrides.oauth2_scopes,
|
||||
api_key_header: overrides.api_key_header,
|
||||
has_api_key: overrides.has_api_key ?? false,
|
||||
has_custom_headers: overrides.has_custom_headers ?? false,
|
||||
tool_allow_list: overrides.tool_allow_list ?? [],
|
||||
tool_deny_list: overrides.tool_deny_list ?? [],
|
||||
availability: overrides.availability ?? "default_on",
|
||||
enabled: overrides.enabled ?? true,
|
||||
created_at: overrides.created_at ?? now,
|
||||
updated_at: overrides.updated_at ?? now,
|
||||
auth_connected: overrides.auth_connected ?? false,
|
||||
});
|
||||
|
||||
// ── Fixtures ───────────────────────────────────────────────────
|
||||
|
||||
const sentryServer = createServerConfig({
|
||||
id: "mcp-sentry",
|
||||
display_name: "Sentry",
|
||||
slug: "sentry",
|
||||
description: "Error tracking and monitoring",
|
||||
icon_url: "/icon/widgets.svg",
|
||||
url: "https://mcp.sentry.io/sse",
|
||||
transport: "sse",
|
||||
auth_type: "oauth2",
|
||||
has_oauth2_secret: true,
|
||||
availability: "force_on",
|
||||
enabled: true,
|
||||
auth_connected: true,
|
||||
});
|
||||
|
||||
const linearServer = createServerConfig({
|
||||
id: "mcp-linear",
|
||||
display_name: "Linear",
|
||||
slug: "linear",
|
||||
description: "Project management and issue tracking",
|
||||
url: "https://mcp.linear.app/v1",
|
||||
transport: "streamable_http",
|
||||
auth_type: "api_key",
|
||||
has_api_key: true,
|
||||
availability: "default_on",
|
||||
enabled: true,
|
||||
auth_connected: false,
|
||||
});
|
||||
|
||||
const githubServer = createServerConfig({
|
||||
id: "mcp-github",
|
||||
display_name: "GitHub",
|
||||
slug: "github",
|
||||
description: "Code hosting and collaboration",
|
||||
icon_url: "/icon/github.svg",
|
||||
url: "https://api.githubcopilot.com/mcp/",
|
||||
transport: "streamable_http",
|
||||
auth_type: "oauth2",
|
||||
has_oauth2_secret: true,
|
||||
availability: "default_on",
|
||||
enabled: true,
|
||||
auth_connected: false,
|
||||
});
|
||||
|
||||
const githubServerConnected = {
|
||||
...githubServer,
|
||||
auth_connected: true,
|
||||
};
|
||||
|
||||
const slackServer = createServerConfig({
|
||||
id: "mcp-slack",
|
||||
display_name: "Slack",
|
||||
slug: "slack",
|
||||
description: "Team messaging and notifications",
|
||||
url: "https://mcp.slack.com/v1",
|
||||
transport: "streamable_http",
|
||||
auth_type: "oauth2",
|
||||
has_oauth2_secret: true,
|
||||
availability: "default_off",
|
||||
enabled: true,
|
||||
auth_connected: false,
|
||||
});
|
||||
|
||||
const datadogServer = createServerConfig({
|
||||
id: "mcp-datadog",
|
||||
display_name: "Datadog",
|
||||
slug: "datadog",
|
||||
description: "Infrastructure monitoring and APM",
|
||||
url: "https://mcp.datadog.com/v1",
|
||||
transport: "streamable_http",
|
||||
auth_type: "none",
|
||||
availability: "default_off",
|
||||
enabled: true,
|
||||
auth_connected: false,
|
||||
});
|
||||
|
||||
const disabledServer = createServerConfig({
|
||||
id: "mcp-disabled",
|
||||
display_name: "Disabled Server",
|
||||
slug: "disabled",
|
||||
url: "https://mcp.disabled.com/v1",
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const allServers = [
|
||||
sentryServer,
|
||||
linearServer,
|
||||
githubServer,
|
||||
slackServer,
|
||||
datadogServer,
|
||||
];
|
||||
|
||||
// ── Meta ───────────────────────────────────────────────────────
|
||||
|
||||
const meta: Meta<typeof MCPServerPicker> = {
|
||||
title: "pages/AgentsPage/MCPServerPicker",
|
||||
component: MCPServerPicker,
|
||||
args: {
|
||||
onSelectionChange: fn(),
|
||||
onAuthComplete: fn(),
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="p-10">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof MCPServerPicker>;
|
||||
|
||||
// ── Stories ────────────────────────────────────────────────────
|
||||
|
||||
/** No servers available — picker should not render. */
|
||||
export const NoServers: Story = {
|
||||
args: {
|
||||
servers: [],
|
||||
selectedServerIds: [],
|
||||
},
|
||||
};
|
||||
|
||||
/** All disabled servers — picker should not render. */
|
||||
export const AllDisabled: Story = {
|
||||
args: {
|
||||
servers: [disabledServer],
|
||||
selectedServerIds: [],
|
||||
},
|
||||
};
|
||||
|
||||
/** Single force-on server. Toggle should be disabled. */
|
||||
export const SingleForceOn: Story = {
|
||||
args: {
|
||||
servers: [sentryServer],
|
||||
selectedServerIds: [sentryServer.id],
|
||||
},
|
||||
};
|
||||
|
||||
/** Single default-on server with no auth required. */
|
||||
export const SingleDefaultOnNoAuth: Story = {
|
||||
args: {
|
||||
servers: [linearServer],
|
||||
selectedServerIds: [linearServer.id],
|
||||
},
|
||||
};
|
||||
|
||||
/** Single default-off optional server. */
|
||||
export const SingleDefaultOff: Story = {
|
||||
args: {
|
||||
servers: [datadogServer],
|
||||
selectedServerIds: [],
|
||||
},
|
||||
};
|
||||
|
||||
/** OAuth2 server needing authentication — shows Auth button. */
|
||||
export const OAuthNeedsAuth: Story = {
|
||||
args: {
|
||||
servers: [githubServer],
|
||||
selectedServerIds: [githubServer.id],
|
||||
},
|
||||
};
|
||||
|
||||
/** OAuth2 server already authenticated — shows check icon. */
|
||||
export const OAuthConnected: Story = {
|
||||
args: {
|
||||
servers: [githubServerConnected],
|
||||
selectedServerIds: [githubServerConnected.id],
|
||||
},
|
||||
};
|
||||
|
||||
/** Multiple servers with mixed availability and auth states. */
|
||||
export const MixedServers: Story = {
|
||||
args: {
|
||||
servers: allServers,
|
||||
selectedServerIds: getDefaultMCPSelection(allServers),
|
||||
},
|
||||
};
|
||||
|
||||
/** All servers with connected OAuth2 (GitHub connected). */
|
||||
export const AllConnected: Story = {
|
||||
args: {
|
||||
servers: [sentryServer, linearServer, githubServerConnected, datadogServer],
|
||||
selectedServerIds: getDefaultMCPSelection([
|
||||
sentryServer,
|
||||
linearServer,
|
||||
githubServerConnected,
|
||||
datadogServer,
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
/** Disabled state — all toggles disabled. */
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
servers: allServers,
|
||||
selectedServerIds: getDefaultMCPSelection(allServers),
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
/** Servers including a disabled one — disabled should be filtered out. */
|
||||
export const WithDisabledServer: Story = {
|
||||
args: {
|
||||
servers: [...allServers, disabledServer],
|
||||
selectedServerIds: getDefaultMCPSelection(allServers),
|
||||
},
|
||||
};
|
||||
|
||||
/** All servers opted out — only force_on remains active. */
|
||||
export const AllOptedOut: Story = {
|
||||
args: {
|
||||
servers: allServers,
|
||||
selectedServerIds: [sentryServer.id],
|
||||
},
|
||||
};
|
||||
|
||||
/** Default-off server with OAuth needing auth (opted in, Auth button shows). */
|
||||
export const OptionalOAuthNeedsAuth: Story = {
|
||||
args: {
|
||||
servers: [slackServer],
|
||||
selectedServerIds: [slackServer.id],
|
||||
},
|
||||
};
|
||||
|
||||
/** Trigger shows overlapping icon stack when multiple servers are active. */
|
||||
export const MultipleActiveIcons: Story = {
|
||||
args: {
|
||||
servers: [sentryServer, linearServer, githubServerConnected, datadogServer],
|
||||
selectedServerIds: [
|
||||
sentryServer.id,
|
||||
linearServer.id,
|
||||
githubServerConnected.id,
|
||||
datadogServer.id,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** More than 3 active servers shows +N overflow badge. */
|
||||
export const IconStackOverflow: Story = {
|
||||
args: {
|
||||
servers: allServers,
|
||||
selectedServerIds: allServers.map((s) => s.id),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,312 @@
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { ExternalImage } from "components/ExternalImage/ExternalImage";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "components/Popover/Popover";
|
||||
import { Spinner } from "components/Spinner/Spinner";
|
||||
import { Switch } from "components/Switch/Switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "components/Tooltip/Tooltip";
|
||||
import { ChevronDownIcon, LockIcon, ServerIcon } from "lucide-react";
|
||||
import { type FC, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────
|
||||
|
||||
interface MCPServerPickerProps {
|
||||
/** All MCP server configs from the API. Will be filtered to enabled only. */
|
||||
servers: readonly TypesGen.MCPServerConfig[];
|
||||
/** Currently selected server IDs. */
|
||||
selectedServerIds: readonly string[];
|
||||
/** Called when the user toggles a server. */
|
||||
onSelectionChange: (ids: string[]) => void;
|
||||
/** Called when an OAuth2 auth flow completes (server should be refetched). */
|
||||
onAuthComplete: (serverId: string) => void;
|
||||
/** Whether the picker is disabled (e.g. during submission). */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
const availabilityLabel = (a: string) => {
|
||||
switch (a) {
|
||||
case "force_on":
|
||||
return "Always on";
|
||||
case "default_on":
|
||||
return "On by default";
|
||||
case "default_off":
|
||||
return "Optional";
|
||||
default:
|
||||
return a;
|
||||
}
|
||||
};
|
||||
|
||||
const MCPIcon: FC<{ iconUrl: string; name: string; className?: string }> = ({
|
||||
iconUrl,
|
||||
name,
|
||||
className,
|
||||
}) => {
|
||||
const icon = iconUrl ? (
|
||||
<ExternalImage src={iconUrl} alt={`${name} icon`} className="h-3/5 w-3/5" />
|
||||
) : (
|
||||
<ServerIcon className="h-3/5 w-3/5 text-content-secondary" />
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-full bg-surface-secondary",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute the default selection based on server availability policies.
|
||||
* force_on and default_on servers are selected by default.
|
||||
*/
|
||||
export const getDefaultMCPSelection = (
|
||||
servers: readonly TypesGen.MCPServerConfig[],
|
||||
): string[] => {
|
||||
return servers
|
||||
.filter(
|
||||
(s) =>
|
||||
s.enabled &&
|
||||
(s.availability === "force_on" || s.availability === "default_on"),
|
||||
)
|
||||
.map((s) => s.id);
|
||||
};
|
||||
|
||||
// ── Overlapping icon stack for the trigger ─────────────────────
|
||||
|
||||
const ICON_STACK_MAX = 3;
|
||||
|
||||
const TriggerIconStack: FC<{
|
||||
servers: readonly TypesGen.MCPServerConfig[];
|
||||
}> = ({ servers }) => {
|
||||
const visible = servers.slice(0, ICON_STACK_MAX);
|
||||
return (
|
||||
<span className="inline-flex items-center">
|
||||
{visible.map((s, i) => (
|
||||
<span
|
||||
key={s.id}
|
||||
className={cn(
|
||||
"inline-flex rounded-full ring-1 ring-surface-primary",
|
||||
i > 0 && "-ml-1.5",
|
||||
)}
|
||||
>
|
||||
<MCPIcon
|
||||
iconUrl={s.icon_url}
|
||||
name={s.display_name}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
{servers.length > ICON_STACK_MAX && (
|
||||
<span className="-ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full bg-surface-secondary text-[9px] font-medium text-content-secondary ring-1 ring-surface-primary">
|
||||
+{servers.length - ICON_STACK_MAX}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────
|
||||
|
||||
export const MCPServerPicker: FC<MCPServerPickerProps> = ({
|
||||
servers,
|
||||
selectedServerIds,
|
||||
onSelectionChange,
|
||||
onAuthComplete,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [connectingServerId, setConnectingServerId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const popupRef = useRef<Window | null>(null);
|
||||
|
||||
// Filter to enabled servers only.
|
||||
const enabledServers = servers.filter((s) => s.enabled);
|
||||
|
||||
// Servers shown in the trigger icon stack: selected and
|
||||
// fully ready (no outstanding auth required).
|
||||
const activeServers = enabledServers.filter(
|
||||
(s) =>
|
||||
(s.availability === "force_on" || selectedServerIds.includes(s.id)) &&
|
||||
!(s.auth_type === "oauth2" && !s.auth_connected),
|
||||
);
|
||||
|
||||
// Listen for OAuth2 completion postMessage from popup.
|
||||
useEffect(() => {
|
||||
const handler = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
if (
|
||||
event.data?.type === "mcp-oauth2-complete" &&
|
||||
typeof event.data.serverID === "string"
|
||||
) {
|
||||
setConnectingServerId(null);
|
||||
onAuthComplete(event.data.serverID);
|
||||
popupRef.current = null;
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", handler);
|
||||
return () => window.removeEventListener("message", handler);
|
||||
}, [onAuthComplete]);
|
||||
|
||||
// Poll for popup close and clean up on unmount.
|
||||
useEffect(() => {
|
||||
if (!connectingServerId || !popupRef.current) return;
|
||||
const interval = setInterval(() => {
|
||||
if (popupRef.current?.closed) {
|
||||
setConnectingServerId(null);
|
||||
popupRef.current = null;
|
||||
}
|
||||
}, 500);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
// Close the popup if the component unmounts while
|
||||
// an auth flow is still in progress.
|
||||
if (popupRef.current && !popupRef.current.closed) {
|
||||
popupRef.current.close();
|
||||
popupRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [connectingServerId]);
|
||||
|
||||
const handleToggle = (serverId: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
onSelectionChange([...selectedServerIds, serverId]);
|
||||
} else {
|
||||
onSelectionChange(selectedServerIds.filter((id) => id !== serverId));
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = (server: TypesGen.MCPServerConfig) => {
|
||||
setConnectingServerId(server.id);
|
||||
const connectUrl = `/api/experimental/mcp/servers/${encodeURIComponent(server.id)}/oauth2/connect`;
|
||||
popupRef.current = window.open(
|
||||
connectUrl,
|
||||
"_blank",
|
||||
"width=900,height=600",
|
||||
);
|
||||
};
|
||||
|
||||
if (enabledServers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label="MCP Servers"
|
||||
className="group flex h-8 cursor-pointer items-center gap-1.5 border-none bg-transparent px-1 text-xs text-content-secondary shadow-none transition-colors hover:text-content-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span className="hidden sm:inline">MCP</span>
|
||||
{activeServers.length > 0 && (
|
||||
<TriggerIconStack servers={activeServers} />
|
||||
)}
|
||||
<ChevronDownIcon className="h-3.5 w-3.5 text-content-secondary transition-colors group-hover:text-content-primary" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-52 p-0">
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="max-h-64 overflow-y-auto py-1 [scrollbar-width:thin]">
|
||||
{enabledServers.map((server) => {
|
||||
const isForceOn = server.availability === "force_on";
|
||||
const isSelected =
|
||||
isForceOn || selectedServerIds.includes(server.id);
|
||||
const needsAuth =
|
||||
server.auth_type === "oauth2" && !server.auth_connected;
|
||||
const isConnecting = connectingServerId === server.id;
|
||||
|
||||
return (
|
||||
<Tooltip key={server.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-2 px-2.5 py-1.5">
|
||||
<MCPIcon
|
||||
iconUrl={server.icon_url}
|
||||
name={server.display_name}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-content-primary">
|
||||
{server.display_name}
|
||||
</span>
|
||||
{isForceOn && (
|
||||
<LockIcon className="h-3 w-3 shrink-0 text-content-secondary" />
|
||||
)}
|
||||
{needsAuth ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 w-fit min-w-0 shrink-0 gap-0 px-2 text-[10px] leading-none border-border/50"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleConnect(server);
|
||||
}}
|
||||
disabled={disabled || connectingServerId !== null}
|
||||
aria-label={`Authenticate with ${server.display_name}`}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<Spinner loading className="h-2.5 w-2.5" />
|
||||
) : null}
|
||||
Auth
|
||||
</Button>
|
||||
) : (
|
||||
<Switch
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) =>
|
||||
handleToggle(server.id, checked)
|
||||
}
|
||||
disabled={disabled || isForceOn}
|
||||
aria-label={`${isSelected ? "Disable" : "Enable"} ${server.display_name}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
className="max-w-[220px] px-2.5 py-1.5"
|
||||
>
|
||||
<span className="block font-semibold leading-tight text-content-primary">
|
||||
{server.display_name}
|
||||
</span>
|
||||
{server.description && (
|
||||
<span className="block leading-tight text-content-secondary">
|
||||
{server.description}
|
||||
</span>
|
||||
)}
|
||||
<span className="mt-1 block text-content-secondary leading-tight">
|
||||
{availabilityLabel(server.availability)}
|
||||
</span>
|
||||
{server.auth_type !== "none" && (
|
||||
<span className="block text-content-secondary leading-tight">
|
||||
{server.auth_connected
|
||||
? "Authenticated"
|
||||
: "Not authenticated"}
|
||||
</span>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user