fix(site): add plus menu to chat input toolbar (#23489)

This commit is contained in:
Danielle Maywood
2026-03-25 12:13:27 +00:00
committed by GitHub
parent 6b105994c8
commit a25f9293a1
5 changed files with 372 additions and 168 deletions
@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type * as TypesGen from "api/typesGenerated";
import { useEffect, useRef } from "react";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import type * as TypesGen from "#/api/typesGenerated";
import type { ChatMessageInputRef } from "#/components/ChatMessageInput/ChatMessageInput";
import { AgentChatInput, type UploadState } from "./AgentChatInput";
@@ -563,3 +563,18 @@ export const WithMCPNoneActive: Story = {
selectedMCPServerIds: [],
},
};
/** Plus menu open showing attach, MCP servers, and workspace placeholder. */
export const PlusMenuOpen: Story = {
args: {
...mcpDefaults,
mcpServers: [sentryMCP, linearMCP, githubMCPConnected],
selectedMCPServerIds: [sentryMCP.id, linearMCP.id, githubMCPConnected.id],
onAttach: fn(),
onRemoveAttachment: fn(),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "More options" }));
},
};
@@ -1,28 +1,29 @@
import type * as TypesGen from "api/typesGenerated";
import type { ChatMessagePart, ChatQueuedMessage } from "api/typesGenerated";
import { useSpeechRecognition } from "hooks/useSpeechRecognition";
import {
AlertTriangleIcon,
ArrowUpIcon,
Check,
CheckIcon,
ChevronRightIcon,
ClipboardPasteIcon,
ImageIcon,
MicIcon,
MonitorIcon,
PencilIcon,
PlusIcon,
ServerIcon,
Square,
XIcon,
} from "lucide-react";
import type React from "react";
import {
type FC,
type ReactNode,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { cn } from "utils/cn";
import { isMobileViewport } from "utils/mobile";
import type * as TypesGen from "#/api/typesGenerated";
import type { ChatMessagePart, ChatQueuedMessage } from "#/api/typesGenerated";
import {
ModelSelector,
type ModelSelectorOption,
@@ -32,19 +33,37 @@ import {
ChatMessageInput,
type ChatMessageInputRef,
} from "#/components/ChatMessageInput/ChatMessageInput";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "#/components/Command/Command";
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "#/components/Popover/Popover";
import { Separator } from "#/components/Separator/Separator";
import { Spinner } from "#/components/Spinner/Spinner";
import { Switch } from "#/components/Switch/Switch";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { useSpeechRecognition } from "#/hooks/useSpeechRecognition";
import { cn } from "#/utils/cn";
import { isMobileViewport } from "#/utils/mobile";
import {
fetchTextAttachmentContent,
formatTextAttachmentPreview,
} from "../utils/fetchTextAttachment";
import { formatProviderLabel } from "../utils/modelOptions";
import { ImageLightbox } from "./ImageLightbox";
import { MCPServerPicker } from "./MCPServerPicker";
import { QueuedMessagesList } from "./QueuedMessagesList";
import { TextPreviewDialog } from "./TextPreviewDialog";
@@ -92,9 +111,15 @@ interface AgentChatInputProps {
isStreaming?: boolean;
onInterrupt?: () => void;
isInterruptPending?: boolean;
// Extra controls rendered in the left action area (e.g. workspace
// selector on the create page).
leftActions?: ReactNode;
// Workspace picker.
workspaceOptions?: ReadonlyArray<{
id: string;
name: string;
owner_name: string;
}>;
selectedWorkspaceId?: string | null;
onWorkspaceChange?: (id: string | null) => void;
isWorkspaceLoading?: boolean;
// Queued user messages rendered above the textarea.
queuedMessages?: readonly ChatQueuedMessage[];
onDeleteQueuedMessage?: (id: number) => Promise<void> | void;
@@ -448,7 +473,10 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
isStreaming = false,
onInterrupt,
isInterruptPending = false,
leftActions,
workspaceOptions,
selectedWorkspaceId,
onWorkspaceChange,
isWorkspaceLoading,
queuedMessages = [],
onDeleteQueuedMessage,
onPromoteQueuedMessage,
@@ -476,6 +504,10 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
const [previewTextFileName, setPreviewTextFileName] = useState<string | null>(
null,
);
const [plusMenuOpen, setPlusMenuOpen] = useState(false);
const [workspacePickerOpen, setWorkspacePickerOpen] = useState(false);
const [mcpConnectingId, setMcpConnectingId] = useState<string | null>(null);
const mcpPopupRef = useRef<Window | null>(null);
const [hasFileReferences, setHasFileReferences] = useState(false);
@@ -499,6 +531,73 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
// so both point to the same ChatMessageInputRef instance.
useImperativeHandle(inputRef, () => internalRef.current!, []);
// 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"
) {
setMcpConnectingId(null);
onMCPAuthComplete?.(event.data.serverID);
mcpPopupRef.current = null;
}
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, [onMCPAuthComplete]);
// Poll for popup close and clean up on unmount.
useEffect(() => {
if (!mcpConnectingId || !mcpPopupRef.current) return;
const interval = setInterval(() => {
if (mcpPopupRef.current?.closed) {
setMcpConnectingId(null);
mcpPopupRef.current = null;
}
}, 500);
return () => {
clearInterval(interval);
if (mcpPopupRef.current && !mcpPopupRef.current.closed) {
mcpPopupRef.current.close();
mcpPopupRef.current = null;
}
};
}, [mcpConnectingId]);
const handleMcpToggle = (serverId: string, checked: boolean) => {
if (!onMCPSelectionChange || !selectedMCPServerIds) return;
if (checked) {
onMCPSelectionChange([...selectedMCPServerIds, serverId]);
} else {
onMCPSelectionChange(
selectedMCPServerIds.filter((id) => id !== serverId),
);
}
};
const handleMcpConnect = (server: TypesGen.MCPServerConfig) => {
setMcpConnectingId(server.id);
const connectUrl = `/api/experimental/mcp/servers/${encodeURIComponent(server.id)}/oauth2/connect`;
mcpPopupRef.current = window.open(
connectUrl,
"_blank",
"width=900,height=600",
);
};
const selectedWorkspace = workspaceOptions?.find(
(ws) => ws.id === selectedWorkspaceId,
);
const enabledMcpServers = mcpServers?.filter((s) => s.enabled) ?? [];
const activeMcpServers = enabledMcpServers.filter(
(s) =>
(s.availability === "force_on" || selectedMCPServerIds?.includes(s.id)) &&
!(s.auth_type === "oauth2" && !s.auth_connected),
);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -771,9 +870,163 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
disabled={isDisabled || isLoading}
autoFocus
/>
{/* Hidden file input for image attachment */}
{onAttach && (
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*"
onChange={handleFileSelect}
className="hidden"
/>
)}
<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">
<div className="flex min-w-0 items-center gap-1">
{/* Plus menu */}
<Popover open={plusMenuOpen} onOpenChange={setPlusMenuOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="subtle"
size="icon"
className="size-7 shrink-0 rounded-full [&>svg]:!size-icon-sm [&>svg]:p-0"
disabled={isDisabled}
aria-label="More options"
>
<PlusIcon />
</Button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
className="w-auto min-w-[200px] p-1"
>
{onAttach && (
<button
type="button"
onClick={() => {
setPlusMenuOpen(false);
fileInputRef.current?.click();
}}
className="group flex h-8 w-full 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"
>
<ImageIcon className="h-3.5 w-3.5 shrink-0" />
Attach image
</button>
)}
{workspaceOptions && onWorkspaceChange && (
<Popover
open={workspacePickerOpen}
onOpenChange={setWorkspacePickerOpen}
>
<PopoverTrigger asChild>
<button
type="button"
disabled={isDisabled || isWorkspaceLoading}
className="group flex h-8 w-full 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"
>
<MonitorIcon className="h-3.5 w-3.5 shrink-0" />
<span>Attach workspace</span>
<ChevronRightIcon
className={cn(
"ml-auto size-icon-sm transition-transform",
workspacePickerOpen && "rotate-180",
)}
/>
</button>
</PopoverTrigger>
<PopoverContent
side="right"
align="start"
sideOffset={8}
className="w-64 p-0"
>
<Command loop>
<CommandInput placeholder="Search workspaces..." />
<CommandList>
<CommandEmpty>No workspaces found</CommandEmpty>
<CommandGroup>
{workspaceOptions.map((workspace) => (
<CommandItem
key={workspace.id}
value={workspace.name}
onSelect={() => {
onWorkspaceChange(workspace.id);
setWorkspacePickerOpen(false);
setPlusMenuOpen(false);
}}
>
{workspace.name}
{selectedWorkspaceId === workspace.id && (
<Check className="ml-auto size-icon-sm shrink-0" />
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
{enabledMcpServers.length > 0 && (
<>
<Separator className="my-1" />
{enabledMcpServers.map((server) => {
const isForceOn = server.availability === "force_on";
const isSelected =
isForceOn ||
(selectedMCPServerIds?.includes(server.id) ?? false);
const needsAuth =
server.auth_type === "oauth2" && !server.auth_connected;
const isConnecting = mcpConnectingId === server.id;
return (
<div
key={server.id}
className="flex items-center gap-2 px-2 py-1.5"
>
{server.icon_url ? (
<ExternalImage
src={server.icon_url}
alt=""
className="size-4 shrink-0 rounded-sm"
/>
) : (
<ServerIcon className="size-4 shrink-0 text-content-secondary" />
)}
<span className="min-w-0 flex-1 truncate text-xs text-content-primary">
{server.display_name}
</span>
{needsAuth ? (
<Button
variant="outline"
size="sm"
className="h-6 shrink-0 px-2 text-[10px] leading-none"
onClick={() => handleMcpConnect(server)}
disabled={isDisabled || mcpConnectingId !== null}
>
{isConnecting ? (
<Spinner loading className="size-2.5" />
) : null}
Auth
</Button>
) : (
<Switch
checked={isSelected}
onCheckedChange={(checked) =>
handleMcpToggle(server.id, checked)
}
disabled={isDisabled || isForceOn}
aria-label={`${isSelected ? "Disable" : "Enable"} ${server.display_name}`}
/>
)}
</div>
);
})}
</>
)}
</PopoverContent>
</Popover>
<ModelSelector
value={selectedModel}
onValueChange={onModelChange}
@@ -784,19 +1037,50 @@ 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}
{selectedWorkspace && onWorkspaceChange && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-surface-secondary px-2 py-0.5 text-xs font-medium text-content-secondary">
<MonitorIcon className="size-3" />
{selectedWorkspace.name}
<button
type="button"
onClick={() => onWorkspaceChange(null)}
className="ml-0.5 cursor-pointer rounded-full border-0 bg-transparent p-0.5 text-content-secondary transition-colors hover:bg-surface-tertiary hover:text-content-primary"
aria-label={`Remove workspace ${selectedWorkspace.name}`}
>
<XIcon className="size-3" />
</button>
</span>
)}
{activeMcpServers.map((server) => {
const isForceOn = server.availability === "force_on";
return (
<span
key={server.id}
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-surface-secondary px-2 py-0.5 text-xs font-medium text-content-secondary"
>
{server.icon_url ? (
<ExternalImage
src={server.icon_url}
alt=""
className="size-3 rounded-sm"
/>
) : (
<ServerIcon className="size-3" />
)}
{server.display_name}
{!isForceOn && (
<button
type="button"
onClick={() => handleMcpToggle(server.id, false)}
className="ml-0.5 cursor-pointer rounded-full border-0 bg-transparent p-0.5 text-content-secondary transition-colors hover:bg-surface-tertiary hover:text-content-primary"
aria-label={`Remove ${server.display_name}`}
>
<XIcon className="size-3" />
</button>
)}
</span>
);
})}
{inputStatusText && (
<span className="hidden text-xs text-content-secondary sm:inline">
{inputStatusText}
@@ -804,29 +1088,6 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
)}
</div>
<div className="flex items-center gap-2">
{onAttach && (
<>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*"
onChange={handleFileSelect}
className="hidden"
/>
<Button
type="button"
variant="subtle"
size="icon"
className="size-7 shrink-0 rounded-full [&>svg]:!size-icon-sm [&>svg]:p-0"
onClick={() => fileInputRef.current?.click()}
disabled={isDisabled}
aria-label="Attach files"
>
<ImageIcon />
</Button>
</>
)}
{speech.isSupported && !isStreaming && (
<>
<Button
@@ -1,8 +1,8 @@
import { MockWorkspace } from "testHelpers/entities";
import { withDashboardProvider } from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api";
import { expect, fn, spyOn, userEvent, waitFor, within } from "storybook/test";
import { API } from "#/api/api";
import { MockWorkspace } from "#/testHelpers/entities";
import { AgentCreateForm } from "./AgentCreateForm";
const modelOptions = [
@@ -79,14 +79,19 @@ export const WithWorkspaces: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
// Open the "+" menu first, then click the workspace trigger inside it.
await userEvent.click(canvas.getByRole("button", { name: "More options" }));
await waitFor(() => {
const trigger = canvas.getByText("Workspace").closest("button")!;
const trigger = body.getByText("Attach workspace").closest("button")!;
expect(trigger).toBeEnabled();
});
await userEvent.click(canvas.getByText("Workspace").closest("button")!);
// Wait for the portalled combobox dropdown to appear so Chromatic
// captures it.
await within(canvasElement.ownerDocument.body).findByRole("dialog");
await userEvent.click(
body.getByText("Attach workspace").closest("button")!,
);
// Wait for the workspace combobox dropdown to appear so
// Chromatic captures it.
await body.findByPlaceholderText("Search workspaces...");
},
};
@@ -100,14 +105,16 @@ export const SearchWorkspaces: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
// Open the "+" menu first, then click the workspace trigger inside it.
await userEvent.click(canvas.getByRole("button", { name: "More options" }));
await waitFor(() => {
const trigger = canvas.getByText("Workspace").closest("button")!;
const trigger = body.getByText("Attach workspace").closest("button")!;
expect(trigger).toBeEnabled();
});
await userEvent.click(canvas.getByText("Workspace").closest("button")!);
const body = within(canvasElement.ownerDocument.body);
await body.findByRole("dialog");
await userEvent.click(
body.getByText("Attach workspace").closest("button")!,
);
// Type in the search input to filter workspaces.
const searchInput = body.getByPlaceholderText("Search workspaces...");
@@ -119,7 +126,7 @@ export const SearchWorkspaces: Story = {
// "Auto-create Workspace" is filtered out, only
// "johndoe/backend-api" matches.
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent("johndoe/backend-api");
expect(options[0]).toHaveTextContent("backend-api");
});
},
};
@@ -134,28 +141,31 @@ export const SelectWorkspaceViaSearch: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
// Open the "+" menu first, then click the workspace trigger inside it.
await userEvent.click(canvas.getByRole("button", { name: "More options" }));
await waitFor(() => {
const trigger = canvas.getByText("Workspace").closest("button")!;
const trigger = body.getByText("Attach workspace").closest("button")!;
expect(trigger).toBeEnabled();
});
await userEvent.click(canvas.getByText("Workspace").closest("button")!);
await userEvent.click(
body.getByText("Attach workspace").closest("button")!,
);
const body = within(canvasElement.ownerDocument.body);
await body.findByRole("dialog");
// Search for "janedoe" and select the result.
// Search for "backend" and select the result.
const searchInput = body.getByPlaceholderText("Search workspaces...");
await userEvent.type(searchInput, "janedoe");
await userEvent.type(searchInput, "backend");
await waitFor(() => {
expect(body.getAllByRole("option")).toHaveLength(1);
});
await userEvent.click(body.getByRole("option", { name: /janedoe/ }));
await userEvent.click(body.getByRole("option", { name: /backend-api/ }));
// The trigger should now show the selected workspace.
// Re-open the "+" menu to verify the selected workspace label.
await userEvent.click(canvas.getByRole("button", { name: "More options" }));
await waitFor(() => {
expect(canvas.getByText("janedoe/my-project")).toBeInTheDocument();
expect(body.getByText("backend-api")).toBeInTheDocument();
});
},
};
@@ -1,30 +1,15 @@
import { isApiError } from "api/errors";
import { workspaces } from "api/queries/workspaces";
import type * as TypesGen from "api/typesGenerated";
import { Check, MonitorIcon } from "lucide-react";
import { useDashboard } from "modules/dashboard/useDashboard";
import { type FC, useEffect, useRef, useState } from "react";
import { useQuery } from "react-query";
import { Link } from "react-router";
import { toast } from "sonner";
import { isApiError } from "#/api/errors";
import { workspaces } from "#/api/queries/workspaces";
import type * as TypesGen from "#/api/typesGenerated";
import { Alert } from "#/components/Alert/Alert";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { ChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown";
import type { ModelSelectorOption } from "#/components/ai-elements";
import { Button } from "#/components/Button/Button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "#/components/Command/Command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "#/components/Popover/Popover";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { useFileAttachments } from "../hooks/useFileAttachments";
import {
getModelCatalogStatusMessage,
@@ -191,7 +176,6 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
modelOptions.some((modelOption) => modelOption.id === userSelectedModel)
? userSelectedModel
: preferredModelID;
const [workspacePopoverOpen, setWorkspacePopoverOpen] = useState(false);
const workspacesQuery = useQuery(workspaces({ q: "owner:me", limit: 0 }));
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string | null>(
() => {
@@ -199,7 +183,6 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
},
);
const workspaceOptions = workspacesQuery.data?.workspaces ?? [];
const autoCreateWorkspaceValue = "__auto_create_workspace__";
const hasModelOptions = modelOptions.length > 0;
const hasConfiguredModels = hasConfiguredModelsInCatalog(modelCatalog);
const modelSelectorPlaceholder = getModelSelectorPlaceholder(
@@ -261,8 +244,8 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
selectedModelRef.current = selectedModel;
selectedMCPServerIdsRef.current = effectiveMCPServerIds;
});
const handleWorkspaceChange = (value: string) => {
if (value === autoCreateWorkspaceValue) {
const handleWorkspaceChange = (value: string | null) => {
if (value === null) {
setSelectedWorkspaceId(null);
localStorage.removeItem(selectedWorkspaceIdStorageKey);
return;
@@ -294,13 +277,6 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
});
};
const selectedWorkspace = selectedWorkspaceId
? workspaceOptions.find((ws) => ws.id === selectedWorkspaceId)
: undefined;
const selectedWorkspaceLabel = selectedWorkspace
? `${selectedWorkspace.owner_name}/${selectedWorkspace.name}`
: undefined;
const {
attachments,
textContents,
@@ -363,7 +339,6 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
{workspacesQuery.isError && (
<ErrorAlert error={workspacesQuery.error} />
)}
<AgentChatInput
onSend={handleSendWithAttachments}
placeholder="Ask Coder to build, fix bugs, or explore your project..."
@@ -391,67 +366,10 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
saveMCPSelection(ids);
}}
onMCPAuthComplete={onMCPAuthComplete}
leftActions={
<Popover
open={workspacePopoverOpen}
onOpenChange={setWorkspacePopoverOpen}
>
{/* pointer-events-auto overrides the pointer-events:none
that Radix Select's DismissableLayer sets on
document.body when the Model Selector is open.
Without it the first click only dismisses the
Select and a second click is needed to open
the popover. */}
<PopoverTrigger asChild>
<button
type="button"
disabled={isCreating || workspacesQuery.isLoading}
className="pointer-events-auto group flex h-8 items-center gap-1.5 rounded-md border-none bg-transparent px-1 text-xs text-content-secondary shadow-none ring-offset-background transition-colors hover:bg-transparent hover:text-content-primary focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
>
<MonitorIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary transition-colors group-hover:text-content-primary" />
<span>{selectedWorkspaceLabel ?? "Workspace"}</span>
<ChevronDownIcon className="size-icon-sm text-content-secondary transition-colors group-hover:text-content-primary" />
</button>
</PopoverTrigger>
<PopoverContent side="top" align="start" className="w-72 p-0">
<Command loop>
<CommandInput placeholder="Search workspaces..." />
<CommandList>
<CommandEmpty>No workspaces found</CommandEmpty>
<CommandGroup>
<CommandItem
value="Auto-create Workspace"
onSelect={() => {
handleWorkspaceChange(autoCreateWorkspaceValue);
setWorkspacePopoverOpen(false);
}}
>
Auto-create Workspace
{selectedWorkspaceId == null && (
<Check className="ml-auto size-icon-sm shrink-0" />
)}
</CommandItem>
{workspaceOptions.map((workspace) => (
<CommandItem
key={workspace.id}
value={`${workspace.owner_name}/${workspace.name}`}
onSelect={() => {
handleWorkspaceChange(workspace.id);
setWorkspacePopoverOpen(false);
}}
>
{workspace.owner_name}/{workspace.name}
{selectedWorkspaceId === workspace.id && (
<Check className="ml-auto size-icon-sm shrink-0" />
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
}
workspaceOptions={workspaceOptions}
selectedWorkspaceId={selectedWorkspaceId}
onWorkspaceChange={handleWorkspaceChange}
isWorkspaceLoading={workspacesQuery.isLoading}
/>
<p className="mt-1 text-center text-xs text-content-secondary/50">
Coder Agents is available via{" "}
@@ -269,13 +269,13 @@ export const MCPServerPicker: FC<MCPServerPickerProps> = ({
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"
className="group flex h-8 w-full 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>
<span>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" />
<ChevronDownIcon className="ml-auto 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">