feat(ui): chat/MCPConnectPicker to shadcn

- antd Switch/Spin \u2192 shadcn Switch + lucide Loader2 (replacing the
  Switch's loading prop with a separate inline spinner since shadcn
  Switch has no loading slot).
- Inline style attributes replaced with Tailwind utilities + semantic
  tokens (text-muted-foreground / text-foreground).

Gates: TS \u2713 | Build \u2713.

Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-04-23 14:00:23 +00:00
parent e1c0b5291b
commit 4d9072bf3e
No known key found for this signature in database

View file

@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react";
import { Switch, Spin } from "antd";
import { Switch } from "@/components/ui/switch";
import { Loader2 } from "lucide-react";
import MessageManager from "@/components/molecules/message_manager";
import { fetchMCPServers, listMCPTools } from "../networking";
import { MCPServer } from "../mcp_tools/types";
@ -10,10 +11,13 @@ interface Props {
onChange: (servers: string[]) => void;
}
const MCPConnectPicker: React.FC<Props> = ({ accessToken, selectedServers, onChange }) => {
const MCPConnectPicker: React.FC<Props> = ({
accessToken,
selectedServers,
onChange,
}) => {
const [servers, setServers] = useState<MCPServer[]>([]);
const [loadingServers, setLoadingServers] = useState(true);
// Track which individual servers are being toggled on (verifying tools)
const [togglingOn, setTogglingOn] = useState<Set<string>>(new Set());
useEffect(() => {
@ -24,8 +28,9 @@ const MCPConnectPicker: React.FC<Props> = ({ accessToken, selectedServers, onCha
try {
const data = await fetchMCPServers(accessToken);
if (cancelled) return;
// API returns { data: MCPServer[] } or MCPServer[]
const list: MCPServer[] = Array.isArray(data) ? data : (data?.data ?? []);
const list: MCPServer[] = Array.isArray(data)
? data
: (data?.data ?? []);
setServers(list);
} catch {
if (!cancelled) {
@ -47,29 +52,24 @@ const MCPConnectPicker: React.FC<Props> = ({ accessToken, selectedServers, onCha
const handleToggle = async (serverName: string, checked: boolean) => {
if (!checked) {
// Toggle OFF — remove immediately, no tool fetch needed
onChange(selectedServers.filter((s) => s !== serverName));
return;
}
// Toggle ON — verify tools are reachable first
setTogglingOn((prev) => new Set(prev).add(serverName));
try {
const result = await listMCPTools(accessToken, serverName);
// listMCPTools never throws; it returns { tools, error, message } on failure
if (result?.error) {
MessageManager.warning(
`Could not load tools for ${serverName} — it will be excluded from this message.`
`Could not load tools for ${serverName} — it will be excluded from this message.`,
);
// Do not add to selectedServers
return;
}
onChange([...selectedServers, serverName]);
} catch {
MessageManager.warning(
`Could not load tools for ${serverName} — it will be excluded from this message.`
`Could not load tools for ${serverName} — it will be excluded from this message.`,
);
// Do not add to selectedServers
} finally {
setTogglingOn((prev) => {
const next = new Set(prev);
@ -80,20 +80,13 @@ const MCPConnectPicker: React.FC<Props> = ({ accessToken, selectedServers, onCha
};
return (
<div
style={{
maxWidth: 320,
maxHeight: 400,
overflowY: "auto",
padding: "8px 0",
}}
>
<div className="max-w-[320px] max-h-[400px] overflow-y-auto py-2">
{loadingServers ? (
<div style={{ display: "flex", justifyContent: "center", padding: "24px 0" }}>
<Spin />
<div className="flex justify-center py-6">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : servers.length === 0 ? (
<div style={{ padding: "16px 12px", color: "#8c8c8c", fontSize: 13, textAlign: "center" }}>
<div className="px-3 py-4 text-muted-foreground text-sm text-center">
No MCP servers configured
</div>
) : (
@ -105,60 +98,39 @@ const MCPConnectPicker: React.FC<Props> = ({ accessToken, selectedServers, onCha
return (
<div
key={server.server_id}
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
padding: "8px 12px",
gap: 12,
}}
className="flex items-start justify-between px-3 py-2 gap-3"
>
{server.mcp_info?.logo_url && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={server.mcp_info.logo_url}
alt={`${name} logo`}
style={{
width: 24, height: 24, borderRadius: 6,
objectFit: "contain", flexShrink: 0,
marginTop: 1,
className="w-6 h-6 rounded-md object-contain shrink-0 mt-0.5"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontWeight: 500,
fontSize: 13,
color: "#1f1f1f",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm text-foreground truncate">
{name}
</div>
{server.description && (
<div
style={{
fontSize: 12,
color: "#8c8c8c",
marginTop: 2,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
<div className="text-xs text-muted-foreground mt-0.5 truncate">
{server.description}
</div>
)}
</div>
<Switch
size="small"
checked={isSelected}
loading={isTogglingOn}
onChange={(checked) => handleToggle(name, checked)}
/>
<div className="flex items-center">
{isTogglingOn && (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground mr-1" />
)}
<Switch
checked={isSelected}
onCheckedChange={(checked) => handleToggle(name, checked)}
disabled={isTogglingOn}
/>
</div>
</div>
);
})