mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
b411f09383
Converts all title-case UI labels in the Coder Agents area to sentence
case for consistency. Also renames the "New Agent" sidebar button to
"New chat".
## Changes
### Settings headings
| Before | After |
|---|---|
| Personal Instructions | Personal instructions |
| Chat Layout | Chat layout |
| Keyboard Shortcuts | Keyboard shortcuts |
| Thinking Display | Thinking display |
| Shell Output Display | Shell output display |
| Code Diff Display | Code diff display |
| Autostop Fallback | Autostop fallback |
| Workspace Autostop Fallback | Workspace autostop fallback |
| Auto-Archive Inactive Conversations | Auto-archive inactive
conversations |
| Conversation Retention Period | Conversation retention period |
| Chat Debug Data Retention | Chat debug data retention |
| System Instructions | System instructions |
| Context Compaction | Context compaction |
| Cost Tracking | Cost tracking |
| Provider Configuration | Provider configuration |
| Virtual Desktop | Virtual desktop |
### Select/option labels
| Before | After |
|---|---|
| Always Expanded | Always expanded |
| Always Collapsed | Always collapsed |
### Sidebar and nav labels
| Before | After |
|---|---|
| New Agent | New chat |
| Personal Skills | Personal skills |
| Manage Agents | Manage agents |
| MCP Servers | MCP servers |
| Back to Settings | Back to settings |
| Back to Agents | Back to agents |
### Form field labels
| Before | After |
|---|---|
| Display Name | Display name |
| Client Secret | Client secret |
| Header Name | Header name |
| Tool Allow List | Tool allow list |
| Tool Deny List | Tool deny list |
| Spend Limit | Spend limit |
| Cache Read | Cache read |
| Cache Write | Cache write |
| Model Identifier | Model identifier |
| Context Limit | Context limit |
| Compression Threshold | Compression threshold |
### Model form titles
| Before | After |
|---|---|
| Add Model | Add model |
| Edit Model | Edit model |
| Duplicate Model | Duplicate model |
### Admin/limits labels
| Before | After |
|---|---|
| Group Limits | Group limits |
| Per-User Overrides | Per-user overrides |
| Default Spend Limit | Default spend limit |
### Other
| Before | After |
|---|---|
| Weekly/Workspace Usage | Weekly/Workspace usage |
| View Usage | View usage |
| attached image / attached file | Attached image / Attached file |
### Not changed (server-provided labels)
Model config field labels like "Reasoning Effort", "Max Completion
Tokens", "Send Reasoning", etc. are provided by the server via
`field.label` and rendered as-is by `snakeToPrettyLabel`. These require
a server-side change to use sentence case.
All corresponding story and test assertions updated to match.
> 🤖 Generated by Coder Agents on behalf of @tracyjohnsonux
373 lines
11 KiB
TypeScript
373 lines
11 KiB
TypeScript
import { ChevronDownIcon, LockIcon, ServerIcon } from "lucide-react";
|
|
import { type FC, useEffect, useRef, useState } from "react";
|
|
import type * as TypesGen from "#/api/typesGenerated";
|
|
import { Button } from "#/components/Button/Button";
|
|
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "#/components/Popover/Popover";
|
|
import { Spinner } from "#/components/Spinner/Spinner";
|
|
import { Switch } from "#/components/Switch/Switch";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "#/components/Tooltip/Tooltip";
|
|
import { cn } from "#/utils/cn";
|
|
|
|
// ── Types ──────────────────────────────────────────────────────
|
|
|
|
interface MCPServerPickerProps {
|
|
/** All MCP server configs from the API. Will be filtered to enabled only. */
|
|
servers: readonly TypesGen.MCPServerConfig[];
|
|
/** Currently selected server IDs. */
|
|
selectedServerIds: readonly string[];
|
|
/** Called when the user toggles a server. */
|
|
onSelectionChange: (ids: string[]) => void;
|
|
/** Called when an OAuth2 auth flow completes (server should be refetched). */
|
|
onAuthComplete: (serverId: string) => void;
|
|
/** Whether the picker is disabled (e.g. during submission). */
|
|
disabled?: boolean;
|
|
}
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────
|
|
|
|
const availabilityLabel = (a: string) => {
|
|
switch (a) {
|
|
case "force_on":
|
|
return "Always on";
|
|
case "default_on":
|
|
return "On by default";
|
|
case "default_off":
|
|
return "Optional";
|
|
default:
|
|
return a;
|
|
}
|
|
};
|
|
|
|
const MCPIcon: FC<{ iconUrl: string; name: string; className?: string }> = ({
|
|
iconUrl,
|
|
name,
|
|
className,
|
|
}) => {
|
|
const icon = iconUrl ? (
|
|
<ExternalImage src={iconUrl} alt={`${name} icon`} className="size-3/5" />
|
|
) : (
|
|
<ServerIcon className="size-3/5 text-content-secondary" />
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"flex shrink-0 items-center justify-center rounded-full bg-surface-secondary",
|
|
className,
|
|
)}
|
|
>
|
|
{icon}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Compute the default selection based on server availability policies.
|
|
* force_on and default_on servers are selected by default.
|
|
*/
|
|
export const getDefaultMCPSelection = (
|
|
servers: readonly TypesGen.MCPServerConfig[],
|
|
): string[] => {
|
|
const ids: string[] = [];
|
|
for (const server of servers) {
|
|
if (
|
|
server.enabled &&
|
|
(server.availability === "force_on" ||
|
|
server.availability === "default_on")
|
|
) {
|
|
ids.push(server.id);
|
|
}
|
|
}
|
|
return ids;
|
|
};
|
|
|
|
/** localStorage key for persisting the user's MCP server selection. */
|
|
export const mcpSelectionStorageKey = "agents.selected-mcp-server-ids";
|
|
|
|
/**
|
|
* Read the persisted MCP selection from localStorage, filtered to only
|
|
* include IDs that still exist in the current server list.
|
|
* Returns `null` when nothing is stored (caller should fall back to defaults).
|
|
*/ export const getSavedMCPSelection = (
|
|
servers: readonly TypesGen.MCPServerConfig[],
|
|
): string[] | null => {
|
|
const raw = localStorage.getItem(mcpSelectionStorageKey);
|
|
if (raw === null) {
|
|
return null;
|
|
}
|
|
// If the server list is empty (e.g. the query hasn't loaded yet),
|
|
// we can't validate any IDs so signal "unknown" rather than
|
|
// returning an empty array that would be mistaken for "user
|
|
// deliberately deselected everything".
|
|
if (servers.length === 0) {
|
|
return null;
|
|
}
|
|
try {
|
|
const parsed: unknown = JSON.parse(raw);
|
|
if (!Array.isArray(parsed)) {
|
|
return null;
|
|
}
|
|
const enabledIds = new Set<string>();
|
|
const forceOnIds: string[] = [];
|
|
for (const server of servers) {
|
|
if (!server.enabled) continue;
|
|
enabledIds.add(server.id);
|
|
if (server.availability === "force_on") {
|
|
forceOnIds.push(server.id);
|
|
}
|
|
}
|
|
const restored = parsed.filter(
|
|
(id): id is string => typeof id === "string" && enabledIds.has(id),
|
|
);
|
|
// Merge force_on servers that might not be in the saved list.
|
|
for (const id of forceOnIds) {
|
|
if (!restored.includes(id)) {
|
|
restored.push(id);
|
|
}
|
|
}
|
|
return restored;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Persist the current MCP selection to localStorage.
|
|
*/ export const saveMCPSelection = (ids: readonly string[]): void => {
|
|
localStorage.setItem(mcpSelectionStorageKey, JSON.stringify(ids));
|
|
};
|
|
|
|
// ── Overlapping icon stack for the trigger ─────────────────────
|
|
|
|
const ICON_STACK_MAX = 3;
|
|
|
|
const TriggerIconStack: FC<{
|
|
servers: readonly TypesGen.MCPServerConfig[];
|
|
}> = ({ servers }) => {
|
|
const visible = servers.slice(0, ICON_STACK_MAX);
|
|
return (
|
|
<span className="inline-flex items-center">
|
|
{visible.map((s, i) => (
|
|
<span
|
|
key={s.id}
|
|
className={cn(
|
|
"inline-flex rounded-full ring-1 ring-surface-primary",
|
|
i > 0 && "-ml-1.5",
|
|
)}
|
|
>
|
|
<MCPIcon
|
|
iconUrl={s.icon_url}
|
|
name={s.display_name}
|
|
className="size-4"
|
|
/>
|
|
</span>
|
|
))}
|
|
{servers.length > ICON_STACK_MAX && (
|
|
<span className="-ml-1 inline-flex size-4 items-center justify-center rounded-full bg-surface-secondary text-[9px] font-medium text-content-secondary ring-1 ring-surface-primary">
|
|
+{servers.length - ICON_STACK_MAX}
|
|
</span>
|
|
)}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
// ── Component ──────────────────────────────────────────────────
|
|
|
|
export const MCPServerPicker: FC<MCPServerPickerProps> = ({
|
|
servers,
|
|
selectedServerIds,
|
|
onSelectionChange,
|
|
onAuthComplete,
|
|
disabled = false,
|
|
}) => {
|
|
const [open, setOpen] = useState(false);
|
|
const [connectingServerId, setConnectingServerId] = useState<string | null>(
|
|
null,
|
|
);
|
|
const popupRef = useRef<Window | null>(null);
|
|
|
|
// Filter to enabled servers only.
|
|
const enabledServers = servers.filter((s) => s.enabled);
|
|
|
|
// Servers shown in the trigger icon stack: selected and
|
|
// fully ready (no outstanding auth required).
|
|
const activeServers = enabledServers.filter(
|
|
(s) =>
|
|
(s.availability === "force_on" || selectedServerIds.includes(s.id)) &&
|
|
!(s.auth_type === "oauth2" && !s.auth_connected),
|
|
);
|
|
|
|
// Listen for OAuth2 completion postMessage from popup.
|
|
useEffect(() => {
|
|
const handler = (event: MessageEvent) => {
|
|
if (event.origin !== location.origin) return;
|
|
if (
|
|
event.data?.type === "mcp-oauth2-complete" &&
|
|
typeof event.data.serverID === "string"
|
|
) {
|
|
setConnectingServerId(null);
|
|
onAuthComplete(event.data.serverID);
|
|
popupRef.current = null;
|
|
}
|
|
};
|
|
window.addEventListener("message", handler);
|
|
return () => window.removeEventListener("message", handler);
|
|
}, [onAuthComplete]);
|
|
|
|
// Poll for popup close and clean up on unmount.
|
|
useEffect(() => {
|
|
if (!connectingServerId || !popupRef.current) return;
|
|
const interval = setInterval(() => {
|
|
if (popupRef.current?.closed) {
|
|
setConnectingServerId(null);
|
|
popupRef.current = null;
|
|
}
|
|
}, 500);
|
|
return () => {
|
|
clearInterval(interval);
|
|
// Close the popup if the component unmounts while
|
|
// an auth flow is still in progress.
|
|
if (popupRef.current && !popupRef.current.closed) {
|
|
popupRef.current.close();
|
|
popupRef.current = null;
|
|
}
|
|
};
|
|
}, [connectingServerId]);
|
|
|
|
const handleToggle = (serverId: string, checked: boolean) => {
|
|
if (checked) {
|
|
onSelectionChange([...selectedServerIds, serverId]);
|
|
} else {
|
|
onSelectionChange(selectedServerIds.filter((id) => id !== serverId));
|
|
}
|
|
};
|
|
|
|
const handleConnect = (server: TypesGen.MCPServerConfig) => {
|
|
setConnectingServerId(server.id);
|
|
const connectUrl = `/api/experimental/mcp/servers/${encodeURIComponent(server.id)}/oauth2/connect`;
|
|
popupRef.current = window.open(
|
|
connectUrl,
|
|
"_blank",
|
|
"width=900,height=600",
|
|
);
|
|
};
|
|
|
|
if (enabledServers.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
aria-label="MCP servers"
|
|
className="group flex h-8 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>MCP</span>
|
|
{activeServers.length > 0 && (
|
|
<TriggerIconStack servers={activeServers} />
|
|
)}
|
|
<ChevronDownIcon className="ml-auto size-3.5 text-content-secondary transition-colors group-hover:text-content-primary" />
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="start" className="w-52 p-0">
|
|
<TooltipProvider delayDuration={300}>
|
|
<div className="max-h-64 overflow-y-auto py-1 [scrollbar-width:thin]">
|
|
{enabledServers.map((server) => {
|
|
const isForceOn = server.availability === "force_on";
|
|
const isSelected =
|
|
isForceOn || selectedServerIds.includes(server.id);
|
|
const needsAuth =
|
|
server.auth_type === "oauth2" && !server.auth_connected;
|
|
const isConnecting = connectingServerId === server.id;
|
|
|
|
return (
|
|
<Tooltip key={server.id}>
|
|
<TooltipTrigger asChild>
|
|
<div className="flex items-center gap-2 px-2.5 py-1.5">
|
|
<MCPIcon
|
|
iconUrl={server.icon_url}
|
|
name={server.display_name}
|
|
className="size-5"
|
|
/>
|
|
<span className="min-w-0 flex-1 truncate text-xs text-content-primary">
|
|
{server.display_name}
|
|
</span>
|
|
{isForceOn && (
|
|
<LockIcon className="size-3 shrink-0 text-content-secondary" />
|
|
)}
|
|
{needsAuth ? (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-6 w-fit min-w-0 shrink-0 gap-0 px-2 text-[10px] leading-none border-border/50"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleConnect(server);
|
|
}}
|
|
disabled={disabled || connectingServerId !== null}
|
|
aria-label={`Authenticate with ${server.display_name}`}
|
|
>
|
|
{isConnecting ? (
|
|
<Spinner loading className="h-2.5 w-2.5" />
|
|
) : null}
|
|
Auth
|
|
</Button>
|
|
) : (
|
|
<Switch
|
|
checked={isSelected}
|
|
onCheckedChange={(checked) =>
|
|
handleToggle(server.id, checked)
|
|
}
|
|
disabled={disabled || isForceOn}
|
|
aria-label={`${isSelected ? "Disable" : "Enable"} ${server.display_name}`}
|
|
/>
|
|
)}
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent
|
|
side="right"
|
|
sideOffset={8}
|
|
className="max-w-[220px] px-2.5 py-1.5"
|
|
>
|
|
<span className="block font-semibold leading-tight text-content-primary">
|
|
{server.display_name}
|
|
</span>
|
|
{server.description && (
|
|
<span className="block leading-tight text-content-secondary">
|
|
{server.description}
|
|
</span>
|
|
)}
|
|
<span className="mt-1 block text-content-secondary leading-tight">
|
|
{availabilityLabel(server.availability)}
|
|
</span>
|
|
{server.auth_type !== "none" && (
|
|
<span className="block text-content-secondary leading-tight">
|
|
{server.auth_connected
|
|
? "Authenticated"
|
|
: "Not authenticated"}
|
|
</span>
|
|
)}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
})}
|
|
</div>
|
|
</TooltipProvider>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
};
|