diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx index 6841461add..2b5e169569 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx @@ -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" })); + }, +}; diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index 0df5fbf272..250531de2f 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -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; @@ -448,7 +473,10 @@ export const AgentChatInput: FC = ({ isStreaming = false, onInterrupt, isInterruptPending = false, - leftActions, + workspaceOptions, + selectedWorkspaceId, + onWorkspaceChange, + isWorkspaceLoading, queuedMessages = [], onDeleteQueuedMessage, onPromoteQueuedMessage, @@ -476,6 +504,10 @@ export const AgentChatInput: FC = ({ const [previewTextFileName, setPreviewTextFileName] = useState( null, ); + const [plusMenuOpen, setPlusMenuOpen] = useState(false); + const [workspacePickerOpen, setWorkspacePickerOpen] = useState(false); + const [mcpConnectingId, setMcpConnectingId] = useState(null); + const mcpPopupRef = useRef(null); const [hasFileReferences, setHasFileReferences] = useState(false); @@ -499,6 +531,73 @@ export const AgentChatInput: FC = ({ // 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(null); const handleFileSelect = (e: React.ChangeEvent) => { @@ -771,9 +870,163 @@ export const AgentChatInput: FC = ({ disabled={isDisabled || isLoading} autoFocus /> - + {/* Hidden file input for image attachment */} + {onAttach && ( + + )}
-
+
+ {/* Plus menu */} + + + + + + {onAttach && ( + + )} + {workspaceOptions && onWorkspaceChange && ( + + + + + + + + + No workspaces found + + {workspaceOptions.map((workspace) => ( + { + onWorkspaceChange(workspace.id); + setWorkspacePickerOpen(false); + setPlusMenuOpen(false); + }} + > + {workspace.name} + {selectedWorkspaceId === workspace.id && ( + + )} + + ))} + + + + + + )} + {enabledMcpServers.length > 0 && ( + <> + + {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 ( +
+ {server.icon_url ? ( + + ) : ( + + )} + + {server.display_name} + + {needsAuth ? ( + + ) : ( + + handleMcpToggle(server.id, checked) + } + disabled={isDisabled || isForceOn} + aria-label={`${isSelected ? "Disable" : "Enable"} ${server.display_name}`} + /> + )} +
+ ); + })} + + )} +
+
= ({ dropdownSide="top" dropdownAlign="center" /> - {mcpServers && - mcpServers.length > 0 && - onMCPSelectionChange && - onMCPAuthComplete && ( - - )} - {leftActions} + {selectedWorkspace && onWorkspaceChange && ( + + + {selectedWorkspace.name} + + + )} + {activeMcpServers.map((server) => { + const isForceOn = server.availability === "force_on"; + return ( + + {server.icon_url ? ( + + ) : ( + + )} + {server.display_name} + {!isForceOn && ( + + )} + + ); + })} {inputStatusText && ( {inputStatusText} @@ -804,29 +1088,6 @@ export const AgentChatInput: FC = ({ )}
- {onAttach && ( - <> - - - - )} {speech.isSupported && !isStreaming && ( <> - - - - - - No workspaces found - - { - handleWorkspaceChange(autoCreateWorkspaceValue); - setWorkspacePopoverOpen(false); - }} - > - Auto-create Workspace - {selectedWorkspaceId == null && ( - - )} - - {workspaceOptions.map((workspace) => ( - { - handleWorkspaceChange(workspace.id); - setWorkspacePopoverOpen(false); - }} - > - {workspace.owner_name}/{workspace.name} - {selectedWorkspaceId === workspace.id && ( - - )} - - ))} - - - - - - } + workspaceOptions={workspaceOptions} + selectedWorkspaceId={selectedWorkspaceId} + onWorkspaceChange={handleWorkspaceChange} + isWorkspaceLoading={workspacesQuery.isLoading} />

Coder Agents is available via{" "} diff --git a/site/src/pages/AgentsPage/components/MCPServerPicker.tsx b/site/src/pages/AgentsPage/components/MCPServerPicker.tsx index 51b456ee90..24c3883a41 100644 --- a/site/src/pages/AgentsPage/components/MCPServerPicker.tsx +++ b/site/src/pages/AgentsPage/components/MCPServerPicker.tsx @@ -269,13 +269,13 @@ export const MCPServerPicker: FC = ({ 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" > - MCP + MCP {activeServers.length > 0 && ( )} - +