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:
Michael Suchacz
2026-09-27 19:53:42 +02:00
committed by GitHub
parent 856e764fe4
commit dd9494d5df
8 changed files with 536 additions and 120 deletions
@@ -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>
);
};