mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
feat(site): group selected MCP servers into one composer pill (#29258)
When three or more MCP servers are active in the Agents chat composer, their pills collapse into one `N MCPs` pill with an overlapping icon stack. Clicking it opens a popover that lists the same named pills the toolbar shows for one or two servers, so each can be removed with its X. Switching a server on, `Auth`, and `Disconnect` stay in the `+` menu. Always-on servers show a lock instead of an X in the popover and the toolbar, the `+` menu marks them with the same lock, and OAuth-pending servers stay excluded from the count. The popover reuses `ToolBadge` for its rows and shares `BadgePopoverContent` with the `+N` overflow popover. Pills in both popovers truncate long names so the X stays in view, and every pill X (MCP, workspace, planning) honors the composer's disabled state. `MCPServerIcon` moved to `site/src/modules/mcpServers/` with `tile` and `circle` variants and renders a decorative `alt=""`, since every consumer shows the server name beside it; the overlapping icon stack moved into `MCPServerIconStack.tsx`. Known limitation, consistent with the existing pills: removing the third server collapses the group and unmounts the popover, so focus returns to the document. Restoring focus across all badges is left for a follow-up. ## Follow-ups - Move the badge family (`ToolBadge`, `MCPGroupBadge`, `BadgePopoverContent`, `BadgeDismissButton`) out of `AgentChatInput.tsx` into a sibling file as a pure-move PR. - Extract the `+` menu's per-server MCP row into a named component. The second `coder-frontend-review` pass asked for it (RP6); it stays out of this PR because refactoring that working single-caller row inside a feature PR is what SD7 rejects. ## Validation - Stories cover the two-pill, closed, open, long-name, disabled, five-server, and nested-overflow states. Vitest covers removing a server from the popover, switching one on from the `+` menu, the two-pill path, OAuth-pending exclusion, and the disabled composer. - On `ee4d9b52ecf`: `biome check`, `tsc`, the `AgentChatInput` unit tests (12) and Storybook project (71 stories), and the full pre-commit hook pass. The pre-push hook did not run locally; `ci`, `flake-go`, and `AI Documentation Check` pass on this head. - Live UAT passed on `282d3f0257e`: long names truncate with the X reachable at 320 to 414px, always-on pills show the lock, and 4 to 3 to 2 servers collapses back to named pills. Earlier rounds covered the grouping threshold, `mcp_server_ids` persistence, and nested overflow. Later commits were not re-exercised live. - Codex: nine rounds, clean from round 6 on, the latest on `48f82d33bbf`. It was not re-requested on `ee4d9b52ecf` because the six-pass review budget was already spent. @DanielleMaywood: four threads fixed in `d17ca4d6d27`, one question answered in-thread. - `coder-frontend-review` pass 1 on `d17ca4d6d27` produced `48f82d33bbf`. Pass 2 ran on request against `6cce1dc3053`, which merges `main` again and keeps both sides of an `AgentChatInput.test.tsx` conflict. It produced `ee4d9b52ecf`: named `MCPGroupBadgeProps` and `MCPServerIconStackProps`, an inlined single-use slice, and the `+N` chip on `-ml-1.5 text-2xs`. All eight roles end PASS or N/A. Declined: reusing `MCPServerIcon` in the single-server pill and `+` menu row (it frames a smaller glyph, so it would restyle existing surfaces) and replacing the stories' `buildMCPServer` factory (that file's convention). > Xum (Coder's coding agent) implemented this on behalf of @ibetitsmike.
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "cn";
|
||||
import { ServerIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
|
||||
|
||||
const iconVariants = cva(
|
||||
"flex shrink-0 items-center justify-center bg-surface-secondary",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
tile: "rounded border border-solid border-border",
|
||||
circle: "rounded-full",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "tile",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
type MCPServerIconProps = VariantProps<typeof iconVariants> & {
|
||||
iconUrl: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const MCPServerIcon: FC<MCPServerIconProps> = ({
|
||||
iconUrl,
|
||||
className,
|
||||
variant,
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn(iconVariants({ variant }), className)}>
|
||||
{iconUrl ? (
|
||||
<ExternalImage src={iconUrl} alt="" className="size-3/5" />
|
||||
) : (
|
||||
<ServerIcon className="size-3/5 text-content-secondary" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
import { MCPServerIcon } from "./MCPServerIcon";
|
||||
import { MCPServerIcon } from "#/modules/mcpServers/MCPServerIcon";
|
||||
|
||||
const MCPServerFormBackLink: FC<{ to: string }> = ({ to }) => {
|
||||
return (
|
||||
@@ -82,9 +82,7 @@ export const MCPServerFormHeader: FC<MCPServerFormHeaderProps> = ({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 pt-6 min-w-0">
|
||||
{isEditing && (
|
||||
<MCPServerIcon iconUrl={iconUrl} name={title} className="size-12" />
|
||||
)}
|
||||
{isEditing && <MCPServerIcon iconUrl={iconUrl} className="size-12" />}
|
||||
<SettingsHeaderTitle>
|
||||
<span
|
||||
className={cn(
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { cn } from "cn";
|
||||
import { ServerIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
|
||||
|
||||
export const MCPServerIcon: FC<{
|
||||
iconUrl: string;
|
||||
name: string;
|
||||
className?: string;
|
||||
}> = ({ iconUrl, name, className }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded bg-surface-secondary border border-solid border-border",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{iconUrl ? (
|
||||
<ExternalImage
|
||||
src={iconUrl}
|
||||
alt={`${name} icon`}
|
||||
className="size-3/5"
|
||||
/>
|
||||
) : (
|
||||
<ServerIcon className="size-3/5 text-content-secondary" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Badge } from "#/components/Badge/Badge";
|
||||
import { TableCell, TableRow } from "#/components/Table/Table";
|
||||
import { useClickableTableRow } from "#/hooks/useClickableTableRow";
|
||||
import { MCPServerIcon } from "./MCPServerIcon";
|
||||
import { MCPServerIcon } from "#/modules/mcpServers/MCPServerIcon";
|
||||
import { AUTH_TYPE_LABELS, AVAILABILITY_LABELS } from "./mcpServerFormLogic";
|
||||
|
||||
type MCPServerRowProps = {
|
||||
@@ -25,7 +25,6 @@ export const MCPServerRow: FC<MCPServerRowProps> = ({ server, onClick }) => {
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<MCPServerIcon
|
||||
iconUrl={server.icon_url}
|
||||
name={server.display_name}
|
||||
className={cn("size-10", !enabled && "opacity-50")}
|
||||
/>
|
||||
<span
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { MonitorDotIcon } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { expect, fn, spyOn, userEvent, waitFor, within } from "storybook/test";
|
||||
import {
|
||||
expect,
|
||||
fn,
|
||||
screen,
|
||||
spyOn,
|
||||
userEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from "storybook/test";
|
||||
import { API } from "#/api/api";
|
||||
import { preferenceSettingsKey } from "#/api/queries/users";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
@@ -24,6 +32,7 @@ import {
|
||||
import {
|
||||
AgentChatInput,
|
||||
type AgentContextUsage,
|
||||
type AttachedWorkspaceInfo,
|
||||
type UploadState,
|
||||
} from "./AgentChatInput";
|
||||
import type { ChatMessageInputRef } from "./ChatMessageInput/ChatMessageInput";
|
||||
@@ -638,6 +647,16 @@ const notionMCPConnected = buildMCPServer({
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const mockSlackMCPAlwaysOnNeedingAuth = buildMCPServer({
|
||||
id: "mcp-slack",
|
||||
display_name: "Slack",
|
||||
slug: "slack",
|
||||
availability: "force_on",
|
||||
auth_type: "oauth2",
|
||||
auth_connected: false,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const mcpDefaults = {
|
||||
chatOrganizationId: "org-1",
|
||||
onMCPSelectionChange: fn(),
|
||||
@@ -668,7 +687,7 @@ const startMCPOAuthFlow = async (canvasElement: HTMLElement) => {
|
||||
|
||||
// ── MCP stories ────────────────────────────────────────────────
|
||||
|
||||
/** Input with multiple MCP servers selected — shows icon stack in toolbar. */
|
||||
/** Three selected servers collapse into a single "3 MCPs" pill. */
|
||||
export const WithMCPServers: Story = {
|
||||
args: {
|
||||
...mcpDefaults,
|
||||
@@ -677,6 +696,72 @@ export const WithMCPServers: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const WithTwoMCPServers: Story = {
|
||||
args: {
|
||||
...mcpDefaults,
|
||||
mcpServers: [linearMCP, githubMCPConnected],
|
||||
selectedMCPServerIds: [linearMCP.id, githubMCPConnected.id],
|
||||
},
|
||||
};
|
||||
|
||||
/** The pill lists only its active servers; Notion stays in the plus menu. */
|
||||
export const MCPGroupPopoverOpen: Story = {
|
||||
args: {
|
||||
...WithMCPServers.args,
|
||||
mcpServers: [sentryMCP, linearMCP, githubMCPConnected, notionMCPConnected],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByRole("button", { name: "3 MCPs" }),
|
||||
);
|
||||
await screen.findByRole("dialog");
|
||||
},
|
||||
};
|
||||
|
||||
export const MCPGroupDisabled: Story = {
|
||||
args: {
|
||||
...WithMCPServers.args,
|
||||
isDisabled: true,
|
||||
},
|
||||
play: MCPGroupPopoverOpen.play,
|
||||
};
|
||||
|
||||
const mockLongNameMCP = buildMCPServer({
|
||||
id: "mcp-long-name",
|
||||
display_name: "Coder Internal Documentation Search Server",
|
||||
slug: "internal-docs",
|
||||
availability: "default_on",
|
||||
auth_type: "api_key",
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
/** A long server name truncates inside the popover instead of pushing its X out of view. */
|
||||
export const MCPGroupPopoverLongName: Story = {
|
||||
args: {
|
||||
...mcpDefaults,
|
||||
mcpServers: [sentryMCP, linearMCP, mockLongNameMCP],
|
||||
selectedMCPServerIds: [sentryMCP.id, linearMCP.id, mockLongNameMCP.id],
|
||||
},
|
||||
play: MCPGroupPopoverOpen.play,
|
||||
};
|
||||
|
||||
export const PlusMenuAlwaysOnNeedingAuth: Story = {
|
||||
args: {
|
||||
...WithMCPServers.args,
|
||||
mcpServers: [
|
||||
sentryMCP,
|
||||
linearMCP,
|
||||
githubMCPConnected,
|
||||
mockSlackMCPAlwaysOnNeedingAuth,
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByRole("button", { name: "More options" }),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** MCP server needing OAuth — shows Auth button instead of toggle. */
|
||||
export const WithMCPNeedingAuth: Story = {
|
||||
args: {
|
||||
@@ -1258,26 +1343,58 @@ const pagerdutyMCP = buildMCPServer({
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
// Wide badges that cannot fit force into +N overflow.
|
||||
const confluenceWideMCP = buildMCPServer({
|
||||
id: "mcp-confluence-wide",
|
||||
display_name: "Confluence Cloud Enterprise Wiki",
|
||||
slug: "confluence-wide",
|
||||
availability: "default_on",
|
||||
auth_type: "none",
|
||||
enabled: true,
|
||||
});
|
||||
const mockOverflowAttachedWorkspace = {
|
||||
id: MockWorkspace.id,
|
||||
name: "an-extremely-long-attached-workspace-name-that-cannot-fit-inline",
|
||||
route: "/@admin/attached",
|
||||
statusIcon: <MonitorDotIcon className="size-3" />,
|
||||
statusLabel: "Workspace running",
|
||||
} satisfies AttachedWorkspaceInfo;
|
||||
|
||||
const datadogWideMCP = buildMCPServer({
|
||||
id: "mcp-datadog-wide",
|
||||
display_name: "Datadog Infrastructure Monitoring",
|
||||
slug: "datadog-wide",
|
||||
availability: "default_on",
|
||||
auth_type: "none",
|
||||
enabled: true,
|
||||
});
|
||||
export const MCPGroupMoreThanThree: Story = {
|
||||
args: {
|
||||
...mcpDefaults,
|
||||
mcpServers: [
|
||||
sentryMCP,
|
||||
linearMCP,
|
||||
githubMCPConnected,
|
||||
notionMCPConnected,
|
||||
confluenceMCP,
|
||||
],
|
||||
selectedMCPServerIds: [
|
||||
sentryMCP.id,
|
||||
linearMCP.id,
|
||||
githubMCPConnected.id,
|
||||
notionMCPConnected.id,
|
||||
confluenceMCP.id,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** Many tools with a workspace at 414px — forces overflow and "+N" pill. */
|
||||
export const MCPGroupInOverflow: Story = {
|
||||
args: {
|
||||
...WithMCPServers.args,
|
||||
attachedWorkspace: mockOverflowAttachedWorkspace,
|
||||
onWorkspaceChange: fn(),
|
||||
},
|
||||
parameters: {
|
||||
viewport: { defaultViewport: "mobile2" },
|
||||
pixel: { matrix: { viewports: ["phone"] } },
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole("button", { name: /more item/ }),
|
||||
);
|
||||
const overflow = await screen.findByRole("dialog");
|
||||
await userEvent.click(
|
||||
within(overflow).getByRole("button", { name: "3 MCPs" }),
|
||||
);
|
||||
// The group popover is a second dialog, so wait for one of its pills.
|
||||
await screen.findByRole("button", { name: "Remove Linear" });
|
||||
},
|
||||
};
|
||||
|
||||
/** A long attached workspace keeps the grouped tools in the "+N" overflow. */
|
||||
export const OverflowBadges: Story = {
|
||||
args: {
|
||||
...mcpDefaults,
|
||||
@@ -1297,31 +1414,8 @@ export const OverflowBadges: Story = {
|
||||
datadogMCP.id,
|
||||
pagerdutyMCP.id,
|
||||
],
|
||||
workspaceOptions: [
|
||||
{
|
||||
id: "ws-1",
|
||||
name: "my-long-workspace-name",
|
||||
owner_name: "admin",
|
||||
organization_id: "org-1",
|
||||
},
|
||||
],
|
||||
selectedWorkspaceId: "ws-1",
|
||||
attachedWorkspace: mockOverflowAttachedWorkspace,
|
||||
onWorkspaceChange: fn(),
|
||||
attachedWorkspace: {
|
||||
id: "ws-1",
|
||||
name: "my-long-workspace-name",
|
||||
route: "/@admin/my-long-workspace-name",
|
||||
statusIcon: <MonitorDotIcon className="size-3" />,
|
||||
statusLabel: "Workspace running",
|
||||
},
|
||||
workspace: {
|
||||
...MockWorkspace,
|
||||
id: "ws-1",
|
||||
name: "my-long-workspace-name",
|
||||
owner_name: "admin",
|
||||
},
|
||||
workspaceAgent: MockWorkspaceAgent,
|
||||
chatId: "overflow-chat-id",
|
||||
},
|
||||
parameters: {
|
||||
viewport: { defaultViewport: "mobile2" },
|
||||
@@ -1550,8 +1644,9 @@ export const ModelExpandsWhileBadgesOverflow: Story = {
|
||||
displayName: "Claude Sonnet 4.5",
|
||||
},
|
||||
],
|
||||
mcpServers: [confluenceWideMCP, datadogWideMCP],
|
||||
selectedMCPServerIds: [confluenceWideMCP.id, datadogWideMCP.id],
|
||||
mcpServers: [confluenceMCP, datadogMCP],
|
||||
selectedMCPServerIds: [confluenceMCP.id, datadogMCP.id],
|
||||
attachedWorkspace: mockOverflowAttachedWorkspace,
|
||||
},
|
||||
parameters: {
|
||||
viewport: { defaultViewport: "mobile2" },
|
||||
|
||||
@@ -6,10 +6,12 @@ import {
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createRef, type ReactNode } from "react";
|
||||
import { type ComponentProps, createRef, type ReactNode } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { AppProviders } from "#/App";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { MockMCPServerConfig } from "#/testHelpers/chatEntities";
|
||||
import { createMockFile } from "#/testHelpers/files";
|
||||
import { mobileViewportMediaQuery } from "#/utils/mobile";
|
||||
import type * as speechRecognition from "../hooks/useSpeechRecognition";
|
||||
@@ -52,6 +54,60 @@ const modelOptions = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
const inputProps = {
|
||||
onSend: vi.fn(),
|
||||
isDisabled: false,
|
||||
isLoading: false,
|
||||
selectedModel: modelOptions[0].id,
|
||||
onModelChange: vi.fn(),
|
||||
modelOptions,
|
||||
modelSelectorPlaceholder: "Select model",
|
||||
hasModelOptions: true,
|
||||
canConfigureAgentSetup: false,
|
||||
} satisfies ComponentProps<typeof AgentChatInput>;
|
||||
|
||||
const mockSentryMCP: TypesGen.MCPServerConfig = {
|
||||
...MockMCPServerConfig,
|
||||
id: "mcp-sentry",
|
||||
display_name: "Sentry",
|
||||
availability: "force_on",
|
||||
auth_type: "oauth2",
|
||||
auth_connected: true,
|
||||
};
|
||||
|
||||
const mockLinearMCP: TypesGen.MCPServerConfig = {
|
||||
...MockMCPServerConfig,
|
||||
id: "mcp-linear",
|
||||
display_name: "Linear",
|
||||
availability: "default_on",
|
||||
auth_type: "api_key",
|
||||
};
|
||||
|
||||
const mockGitHubMCP: TypesGen.MCPServerConfig = {
|
||||
...MockMCPServerConfig,
|
||||
id: "mcp-github",
|
||||
display_name: "GitHub",
|
||||
availability: "default_on",
|
||||
auth_type: "oauth2",
|
||||
auth_connected: true,
|
||||
};
|
||||
|
||||
const mockGitHubMCPNeedingAuth: TypesGen.MCPServerConfig = {
|
||||
...mockGitHubMCP,
|
||||
auth_connected: false,
|
||||
};
|
||||
|
||||
const mockNotionMCP: TypesGen.MCPServerConfig = {
|
||||
...MockMCPServerConfig,
|
||||
id: "mcp-notion",
|
||||
display_name: "Notion",
|
||||
availability: "default_on",
|
||||
auth_type: "api_key",
|
||||
};
|
||||
|
||||
const mockMCPServers = [mockSentryMCP, mockLinearMCP, mockGitHubMCP];
|
||||
const mockSelectedMCPServerIds = mockMCPServers.map((server) => server.id);
|
||||
|
||||
const renderInput = (children: ReactNode) => {
|
||||
return render(<AppProviders>{children}</AppProviders>);
|
||||
};
|
||||
@@ -130,6 +186,115 @@ describe("AgentChatInput", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("removes a selected MCP server from the group", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onMCPSelectionChange = vi.fn();
|
||||
renderInput(
|
||||
<AgentChatInput
|
||||
{...inputProps}
|
||||
mcpServers={mockMCPServers}
|
||||
selectedMCPServerIds={mockSelectedMCPServerIds}
|
||||
onMCPSelectionChange={onMCPSelectionChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "3 MCPs" }));
|
||||
await user.click(
|
||||
within(screen.getByRole("dialog")).getByRole("button", {
|
||||
name: "Remove Linear",
|
||||
}),
|
||||
);
|
||||
expect(onMCPSelectionChange).toHaveBeenCalledWith([
|
||||
mockSentryMCP.id,
|
||||
mockGitHubMCP.id,
|
||||
]);
|
||||
});
|
||||
|
||||
it("enables an unselected MCP server from the plus menu while the group is collapsed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onMCPSelectionChange = vi.fn();
|
||||
renderInput(
|
||||
<AgentChatInput
|
||||
{...inputProps}
|
||||
mcpServers={[...mockMCPServers, mockNotionMCP]}
|
||||
selectedMCPServerIds={mockSelectedMCPServerIds}
|
||||
onMCPSelectionChange={onMCPSelectionChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "More options" }));
|
||||
await user.click(
|
||||
await screen.findByRole("switch", { name: "Enable Notion" }),
|
||||
);
|
||||
expect(onMCPSelectionChange).toHaveBeenCalledWith([
|
||||
mockSentryMCP.id,
|
||||
mockLinearMCP.id,
|
||||
mockGitHubMCP.id,
|
||||
mockNotionMCP.id,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps two active MCP servers as individual pills", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onMCPSelectionChange = vi.fn();
|
||||
renderInput(
|
||||
<AgentChatInput
|
||||
{...inputProps}
|
||||
mcpServers={[mockLinearMCP, mockGitHubMCP]}
|
||||
selectedMCPServerIds={[mockLinearMCP.id, mockGitHubMCP.id]}
|
||||
onMCPSelectionChange={onMCPSelectionChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Remove Linear" }));
|
||||
expect(onMCPSelectionChange).toHaveBeenCalledWith([mockGitHubMCP.id]);
|
||||
});
|
||||
|
||||
it("excludes selected MCP servers that still need OAuth from the group", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onMCPSelectionChange = vi.fn();
|
||||
renderInput(
|
||||
<AgentChatInput
|
||||
{...inputProps}
|
||||
mcpServers={[mockSentryMCP, mockLinearMCP, mockGitHubMCPNeedingAuth]}
|
||||
selectedMCPServerIds={[
|
||||
mockSentryMCP.id,
|
||||
mockLinearMCP.id,
|
||||
mockGitHubMCPNeedingAuth.id,
|
||||
]}
|
||||
onMCPSelectionChange={onMCPSelectionChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Remove Linear" }));
|
||||
expect(onMCPSelectionChange).toHaveBeenCalledWith([
|
||||
mockSentryMCP.id,
|
||||
mockGitHubMCPNeedingAuth.id,
|
||||
]);
|
||||
});
|
||||
|
||||
it("allows viewing a disabled MCP group without changing its selection", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onMCPSelectionChange = vi.fn();
|
||||
renderInput(
|
||||
<AgentChatInput
|
||||
{...inputProps}
|
||||
isDisabled
|
||||
mcpServers={mockMCPServers}
|
||||
selectedMCPServerIds={mockSelectedMCPServerIds}
|
||||
onMCPSelectionChange={onMCPSelectionChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "3 MCPs" }));
|
||||
await user.click(
|
||||
within(screen.getByRole("dialog")).getByRole("button", {
|
||||
name: "Remove Linear",
|
||||
}),
|
||||
);
|
||||
expect(onMCPSelectionChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("swaps Stop for the Queue button once a draft is entered while streaming", async () => {
|
||||
const user = userEvent.setup();
|
||||
const inputRef = createRef<ChatMessageInputRef>();
|
||||
|
||||
@@ -3,7 +3,9 @@ import {
|
||||
ArrowLeftIcon,
|
||||
ArrowUpIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
LockIcon,
|
||||
MicIcon,
|
||||
MonitorIcon,
|
||||
PaperclipIcon,
|
||||
@@ -45,6 +47,7 @@ import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
type PopoverContentProps,
|
||||
PopoverTrigger,
|
||||
} from "#/components/Popover/Popover";
|
||||
import { Separator } from "#/components/Separator/Separator";
|
||||
@@ -100,6 +103,7 @@ import type { SkillMetadata } from "./ChatMessageInput/SkillsTriggerMenu";
|
||||
import type { AgentContextUsage } from "./ContextUsageIndicator";
|
||||
import { ContextUsageIndicator } from "./ContextUsageIndicator";
|
||||
import { ImageLightbox } from "./ImageLightbox";
|
||||
import { MCPServerIconStack } from "./MCPServerIconStack";
|
||||
import { QueuedMessagesList } from "./QueuedMessagesList";
|
||||
import { TextPreviewDialog } from "./TextPreviewDialog";
|
||||
import { WorkspacePill } from "./WorkspacePill";
|
||||
@@ -251,12 +255,42 @@ export type AttachedWorkspaceInfo = {
|
||||
const pillSizingClasses =
|
||||
"grow shrink-0 basis-[calc(8ch_+_3.125rem)] max-w-max";
|
||||
|
||||
// Pills clamp to the popover width so a long name truncates instead
|
||||
// of pushing its X out of view.
|
||||
const BadgePopoverContent: FC<PopoverContentProps> = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
className={cn(
|
||||
"flex w-auto max-w-64 flex-wrap gap-1 p-2 *:max-w-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
type ToolBadgeData =
|
||||
| { kind: "workspace"; name: string }
|
||||
| ({ kind: "attached-workspace" } & AttachedWorkspaceInfo)
|
||||
| { kind: "mcp"; server: TypesGen.MCPServerConfig }
|
||||
| { kind: "mcp-group"; servers: readonly TypesGen.MCPServerConfig[] }
|
||||
| { kind: "planning" };
|
||||
|
||||
// Non-MCP badges can share a kind, so their keys are position-qualified.
|
||||
const badgeKey = (badge: ToolBadgeData, index: number) => {
|
||||
switch (badge.kind) {
|
||||
case "mcp":
|
||||
return badge.server.id;
|
||||
case "mcp-group":
|
||||
return badge.kind;
|
||||
default:
|
||||
return `${badge.kind}-${index}`;
|
||||
}
|
||||
};
|
||||
|
||||
// Small `X` button rendered inside pill-style badges (attached
|
||||
// workspace, MCP server, planning indicator) to dismiss or disable
|
||||
// the badge without opening the `+` menu. Callers pass the action
|
||||
@@ -270,7 +304,7 @@ const BadgeDismissButton: FC<{
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={isDisabled}
|
||||
className="group -mx-1 -my-1 inline-flex size-5 cursor-pointer items-center justify-center rounded-full border-0 bg-transparent p-0 text-content-secondary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="group -mx-1 -my-1 inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-full border-0 bg-transparent p-0 text-content-secondary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<span className="inline-flex size-3.5 items-center justify-center rounded-full transition-colors group-hover:bg-surface-tertiary group-hover:text-content-primary">
|
||||
@@ -279,6 +313,54 @@ const BadgeDismissButton: FC<{
|
||||
</button>
|
||||
);
|
||||
|
||||
type MCPGroupBadgeProps = {
|
||||
servers: readonly TypesGen.MCPServerConfig[];
|
||||
onRemoveMcp?: (serverId: string) => void;
|
||||
isDisabled?: boolean;
|
||||
className: string;
|
||||
};
|
||||
|
||||
const MCPGroupBadge: FC<MCPGroupBadgeProps> = ({
|
||||
servers,
|
||||
onRemoveMcp,
|
||||
isDisabled,
|
||||
className,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const label = `${servers.length} MCPs`;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
className,
|
||||
"cursor-pointer border-0 transition-colors hover:bg-surface-tertiary hover:text-content-primary",
|
||||
)}
|
||||
>
|
||||
<MCPServerIconStack servers={servers} />
|
||||
{label}
|
||||
<ChevronDownIcon
|
||||
className={cn("size-3 transition-transform", open && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<BadgePopoverContent>
|
||||
{servers.map((server) => (
|
||||
<ToolBadge
|
||||
key={server.id}
|
||||
badge={{ kind: "mcp", server }}
|
||||
onRemoveMcp={onRemoveMcp}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
))}
|
||||
</BadgePopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const ToolBadge: FC<{
|
||||
badge: ToolBadgeData;
|
||||
onRemoveWorkspace?: () => void;
|
||||
@@ -341,6 +423,7 @@ const ToolBadge: FC<{
|
||||
<BadgeDismissButton
|
||||
onClick={onRemoveWorkspace}
|
||||
ariaLabel={`Remove workspace ${badge.name}`}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
@@ -364,12 +447,24 @@ const ToolBadge: FC<{
|
||||
<BadgeDismissButton
|
||||
onClick={onRemoveWorkspace}
|
||||
ariaLabel={`Remove workspace ${badge.name}`}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (badge.kind === "mcp-group") {
|
||||
return (
|
||||
<MCPGroupBadge
|
||||
servers={badge.servers}
|
||||
onRemoveMcp={onRemoveMcp}
|
||||
isDisabled={isDisabled}
|
||||
className={badgeCls}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isForceOn = badge.server.availability === "force_on";
|
||||
return (
|
||||
<span className={badgeCls}>
|
||||
@@ -382,12 +477,20 @@ const ToolBadge: FC<{
|
||||
) : (
|
||||
<ServerIcon className="size-3" />
|
||||
)}
|
||||
{badge.server.display_name}
|
||||
{!isForceOn && onRemoveMcp && (
|
||||
<BadgeDismissButton
|
||||
onClick={() => onRemoveMcp(badge.server.id)}
|
||||
ariaLabel={`Remove ${badge.server.display_name}`}
|
||||
/>
|
||||
<span className="truncate">{badge.server.display_name}</span>
|
||||
{isForceOn ? (
|
||||
<>
|
||||
<LockIcon className="size-3 shrink-0" />
|
||||
<span className="sr-only">Always on</span>
|
||||
</>
|
||||
) : (
|
||||
onRemoveMcp && (
|
||||
<BadgeDismissButton
|
||||
onClick={() => onRemoveMcp(badge.server.id)}
|
||||
ariaLabel={`Remove ${badge.server.display_name}`}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
@@ -666,8 +769,12 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
|
||||
if (shouldShowSelectedWorkspaceBadge && selectedWorkspace) {
|
||||
allBadges.push({ kind: "workspace", name: selectedWorkspace.name });
|
||||
}
|
||||
for (const s of activeMcpServers) {
|
||||
allBadges.push({ kind: "mcp", server: s });
|
||||
if (activeMcpServers.length >= 3) {
|
||||
allBadges.push({ kind: "mcp-group", servers: activeMcpServers });
|
||||
} else {
|
||||
for (const server of activeMcpServers) {
|
||||
allBadges.push({ kind: "mcp", server });
|
||||
}
|
||||
}
|
||||
|
||||
const overflowCount = useOverflowCount(badgeContainerRef, allBadges.length);
|
||||
@@ -1533,21 +1640,32 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-content-secondary">
|
||||
{server.display_name}
|
||||
</span>
|
||||
{isForceOn && (
|
||||
<LockIcon className="size-3 shrink-0 text-content-secondary" />
|
||||
)}
|
||||
{needsAuth ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 shrink-0 px-2 text-[10px] leading-none"
|
||||
onClick={() => connectMCPServer(server.id)}
|
||||
disabled={
|
||||
isDisabled || mcpConnectingId !== null
|
||||
}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<Spinner loading className="h-2.5 w-2.5" />
|
||||
) : null}
|
||||
Auth
|
||||
</Button>
|
||||
<>
|
||||
{isForceOn && (
|
||||
<span className="sr-only">Always on</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 shrink-0 px-2 text-[10px] leading-none"
|
||||
onClick={() => connectMCPServer(server.id)}
|
||||
disabled={
|
||||
isDisabled || mcpConnectingId !== null
|
||||
}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<Spinner
|
||||
loading
|
||||
className="h-2.5 w-2.5"
|
||||
/>
|
||||
) : null}
|
||||
Auth
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{server.auth_type === "oauth2" && (
|
||||
@@ -1572,7 +1690,11 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
|
||||
handleMcpToggle(server.id, checked)
|
||||
}
|
||||
disabled={isDisabled || isForceOn}
|
||||
aria-label={`${isSelected ? "Disable" : "Enable"} ${server.display_name}`}
|
||||
aria-label={
|
||||
isForceOn
|
||||
? `${server.display_name} always on`
|
||||
: `${isSelected ? "Disable" : "Enable"} ${server.display_name}`
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -1655,7 +1777,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
|
||||
}
|
||||
return (
|
||||
<ToolBadge
|
||||
key={badge.kind === "mcp" ? badge.server.id : badge.kind}
|
||||
key={badgeKey(badge, i)}
|
||||
badge={badge}
|
||||
onRemoveWorkspace={removeWorkspaceHandler}
|
||||
onRemoveMcp={handleRemoveMcp}
|
||||
@@ -1684,11 +1806,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
|
||||
+{overflowCount}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
{/* Anchored above the +N pill; hugs the toolbar row. */}
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="flex w-auto max-w-64 flex-wrap gap-1 p-2"
|
||||
<BadgePopoverContent
|
||||
onInteractOutside={(event) => {
|
||||
// The workspace pill portals its menu outside
|
||||
// this popover; dismissing would unmount the
|
||||
@@ -1732,13 +1850,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
|
||||
}
|
||||
return (
|
||||
<ToolBadge
|
||||
// Non-MCP badges can share a kind, so keys
|
||||
// are position-qualified.
|
||||
key={
|
||||
badge.kind === "mcp"
|
||||
? badge.server.id
|
||||
: `${badge.kind}-overflow-${visibleCount + i}`
|
||||
}
|
||||
key={badgeKey(badge, visibleCount + i)}
|
||||
badge={badge}
|
||||
onRemoveWorkspace={removeWorkspaceHandler}
|
||||
onRemoveMcp={handleRemoveMcp}
|
||||
@@ -1750,7 +1862,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</PopoverContent>
|
||||
</BadgePopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { FC } from "react";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { MCPServerIcon } from "#/modules/mcpServers/MCPServerIcon";
|
||||
|
||||
const ICON_STACK_MAX = 3;
|
||||
|
||||
type MCPServerIconStackProps = {
|
||||
servers: readonly TypesGen.MCPServerConfig[];
|
||||
};
|
||||
|
||||
export const MCPServerIconStack: FC<MCPServerIconStackProps> = ({
|
||||
servers,
|
||||
}) => {
|
||||
return (
|
||||
<span className="inline-flex items-center">
|
||||
{servers.slice(0, ICON_STACK_MAX).map((server) => (
|
||||
<span
|
||||
key={server.id}
|
||||
className="inline-flex rounded-full ring-1 ring-surface-primary not-first:-ml-1.5"
|
||||
>
|
||||
<MCPServerIcon
|
||||
iconUrl={server.icon_url}
|
||||
variant="circle"
|
||||
className="size-4"
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
{servers.length > ICON_STACK_MAX && (
|
||||
<span className="-ml-1.5 inline-flex size-4 items-center justify-center rounded-full bg-surface-secondary text-2xs font-medium text-content-secondary ring-1 ring-surface-primary">
|
||||
+{servers.length - ICON_STACK_MAX}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user