Refactor ServerCard with active server logic, rename StatusIndicator, and polish UI panels

- Rename StatusIndicator to ServerStatusIndicator across all files
- ServerCard: add activeServer/onSetActiveServer props for coordinated
  connection flow — when a card's toggle is switched on, it sets itself
  as the active server, shows "Connecting" status, and dims all other
  cards (opacity + pointerEvents none + inert for full keyboard blocking)
- ServerListScreen: manage activeServer state internally, pass to cards;
  switch to full-width layout with 3-column grid and scrollable card area
- ServerCard: move "Test Client Features" dropdown above ContentViewer
  with min-height row for consistent card heights
- ToolResultPanel: move Clear button to top-right header row
- ToolDetailPanel: right-align Execute Tool button, size md, not full-width
- ListChangedIndicator: bump Refresh button to size sm
- ToolsActive story: expand to 30 tools with titles
- UnconnectedView stories: all servers disconnected, remove Connecting story

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
cliffhall 2026-04-02 18:00:18 -04:00
commit 8e73bc6ffb
12 changed files with 295 additions and 117 deletions

View file

@ -16,7 +16,7 @@ export function ConnectionToggle({
return (
<Switch
size="lg"
checked={checked}
checked={checked || loading}
disabled={disabled || loading}
onChange={(event) => onChange(event.currentTarget.checked)}
/>

View file

@ -18,7 +18,7 @@ const UpdateLabel = Text.withProps({
});
const RefreshButton = Button.withProps({
size: "xs",
size: "sm",
variant: "light",
});

View file

@ -0,0 +1,42 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ServerStatusIndicator } from "./ServerStatusIndicator";
const meta: Meta<typeof ServerStatusIndicator> = {
title: "Elements/ServerStatusIndicator",
component: ServerStatusIndicator,
};
export default meta;
type Story = StoryObj<typeof ServerStatusIndicator>;
export const Connected: Story = {
args: {
status: "connected",
latencyMs: 23,
},
};
export const Connecting: Story = {
args: {
status: "connecting",
},
};
export const Disconnected: Story = {
args: {
status: "disconnected",
},
};
export const Failed: Story = {
args: {
status: "failed",
},
};
export const FailedWithRetries: Story = {
args: {
status: "failed",
retryCount: 3,
},
};

View file

@ -0,0 +1,53 @@
import { Group, Paper, Text } from "@mantine/core";
export interface ServerStatusIndicatorProps {
status: "connected" | "connecting" | "disconnected" | "failed";
latencyMs?: number;
retryCount?: number;
}
const statusColorVar: Record<ServerStatusIndicatorProps["status"], string> = {
connected: "var(--inspector-status-connected)",
connecting: "var(--inspector-status-connecting)",
disconnected: "var(--inspector-status-disconnected)",
failed: "var(--inspector-status-failed)",
};
function getLabel(
status: ServerStatusIndicatorProps["status"],
latencyMs?: number,
retryCount?: number,
): string {
switch (status) {
case "connected":
return latencyMs != null ? `Connected (${latencyMs}ms)` : "Connected";
case "connecting":
return "Connecting...";
case "disconnected":
return "Disconnected";
case "failed":
return retryCount != null ? `Failed (${retryCount})` : "Failed";
}
}
const Dot = Paper.withProps({
w: 10,
h: 10,
radius: "xl",
});
export function ServerStatusIndicator({
status,
latencyMs,
retryCount,
}: ServerStatusIndicatorProps) {
return (
<Group gap="xs">
<Dot
bg={statusColorVar[status]}
className={status === "connecting" ? "inspector-pulse" : undefined}
/>
<Text size="sm">{getLabel(status, latencyMs, retryCount)}</Text>
</Group>
);
}

View file

@ -7,6 +7,7 @@ const meta: Meta<typeof ServerCard> = {
component: ServerCard,
args: {
onToggleConnection: fn(),
onSetConnectingServer: fn(),
onServerInfo: fn(),
onSettings: fn(),
onEdit: fn(),

View file

@ -1,5 +1,6 @@
import { useEffect } from "react";
import { Badge, Button, Card, Group, Menu, Stack, Text } from "@mantine/core";
import { StatusIndicator } from "../../elements/StatusIndicator/StatusIndicator";
import { ServerStatusIndicator } from "../../elements/ServerStatusIndicator/ServerStatusIndicator";
import { TransportBadge } from "../../elements/TransportBadge/TransportBadge";
import { ConnectionToggle } from "../../elements/ConnectionToggle/ConnectionToggle";
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
@ -15,6 +16,8 @@ export interface ServerCardProps {
retryCount?: number;
error?: { message: string; details?: string };
canTestClientFeatures: boolean;
activeServer?: string;
onSetActiveServer?: (name: string | undefined) => void;
onToggleConnection: (connect: boolean) => void;
onServerInfo: () => void;
onSettings: () => void;
@ -71,6 +74,8 @@ export function ServerCard({
retryCount,
error,
canTestClientFeatures,
activeServer,
onSetActiveServer,
onToggleConnection,
onServerInfo,
onSettings,
@ -83,11 +88,35 @@ export function ServerCard({
onConfigureRoots,
compact = false,
}: ServerCardProps) {
const isThisConnecting = activeServer === name;
const isDimmed = activeServer !== undefined && activeServer !== name;
const isConnected = status === "connected";
const isConnecting = status === "connecting";
const isConnecting = status === "connecting" || isThisConnecting;
const displayStatus = isThisConnecting ? "connecting" : status;
useEffect(() => {
if (isThisConnecting) {
onToggleConnection(true);
}
}, [isThisConnecting, onToggleConnection]);
function handleToggle(connect: boolean) {
if (connect && !activeServer) {
onSetActiveServer?.(name);
} else if (!connect && activeServer && activeServer === name) {
onSetActiveServer?.(undefined);
onToggleConnection(false);
}
}
return (
<Card withBorder padding="lg">
<Card
withBorder
padding="lg"
opacity={isDimmed ? 0.4 : 1}
style={isDimmed ? { pointerEvents: "none" } : undefined}
{...(isDimmed ? { "aria-disabled": true, inert: "" } : {})}
>
<Stack gap="sm">
<Group justify="space-between" wrap="wrap">
<HeaderLeft>
@ -95,21 +124,47 @@ export function ServerCard({
{version && <Badge variant="outline">{version}</Badge>}
</HeaderLeft>
<HeaderRight>
<StatusIndicator status={status} retryCount={retryCount} />
<ServerStatusIndicator
status={displayStatus}
retryCount={retryCount}
/>
<ConnectionToggle
checked={isConnected}
loading={isConnecting}
disabled={false}
onChange={onToggleConnection}
disabled={isDimmed}
onChange={handleToggle}
/>
</HeaderRight>
</Group>
{!compact && (
<>
<Group gap="sm">
<TransportBadge transport={transport} />
<ModeText>{connectionMode}</ModeText>
<Group justify="space-between" mih={30}>
<Group gap="sm">
<TransportBadge transport={transport} />
<ModeText>{connectionMode}</ModeText>
</Group>
{canTestClientFeatures && (
<Menu>
<Menu.Target>
<SubtleButton>Test Client Features &#x25BE;</SubtleButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={onTestSampling}>
Simulate Sampling Request
</Menu.Item>
<Menu.Item onClick={onTestElicitationForm}>
Simulate Elicitation (Form)
</Menu.Item>
<Menu.Item onClick={onTestElicitationUrl}>
Simulate Elicitation (URL)
</Menu.Item>
<Menu.Item onClick={onConfigureRoots}>
Configure Roots
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Group>
<ContentViewer type="text" content={command} copyable />
@ -131,27 +186,6 @@ export function ServerCard({
<ActionsRow>
<SubtleButton onClick={onServerInfo}>Server Info</SubtleButton>
<SubtleButton onClick={onSettings}>Settings</SubtleButton>
{canTestClientFeatures && (
<Menu>
<Menu.Target>
<SubtleButton>Test Client Features &#x25BE;</SubtleButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={onTestSampling}>
Simulate Sampling Request
</Menu.Item>
<Menu.Item onClick={onTestElicitationForm}>
Simulate Elicitation (Form)
</Menu.Item>
<Menu.Item onClick={onTestElicitationUrl}>
Simulate Elicitation (URL)
</Menu.Item>
<Menu.Item onClick={onConfigureRoots}>
Configure Roots
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</ActionsRow>
</Group>
</>

View file

@ -115,16 +115,16 @@ export function ToolDetailPanel({
/>
)}
<Group>
<Group justify="flex-end">
{isExecuting && <CancelButton onClick={onCancel}>Cancel</CancelButton>}
<Button
fullWidth
size="md"
onClick={onExecute}
disabled={isExecuting}
loading={isExecuting}
>
Execute Tool
</Button>
{isExecuting && <CancelButton onClick={onCancel}>Cancel</CancelButton>}
</Group>
</Stack>
);

View file

@ -29,7 +29,10 @@ function resolveMediaType(mimeType?: string): "image" | "audio" {
export function ToolResultPanel({ content, onClear }: ToolResultPanelProps) {
return (
<Stack>
<Title order={4}>Results</Title>
<Group justify="space-between">
<Title order={4}>Results</Title>
<ClearButton onClick={onClear}>Clear</ClearButton>
</Group>
{content.length === 0 ? (
<Text c="dimmed">No results yet</Text>
) : (
@ -59,9 +62,6 @@ export function ToolResultPanel({ content, onClear }: ToolResultPanelProps) {
})}
</>
)}
<Group>
<ClearButton onClick={onClear}>Clear</ClearButton>
</Group>
</Stack>
);
}

View file

@ -10,7 +10,7 @@ import {
} from "@mantine/core";
import { useComputedColorScheme } from "@mantine/core";
import { MdLightMode, MdDarkMode } from "react-icons/md";
import { StatusIndicator } from "../../elements/StatusIndicator/StatusIndicator";
import { ServerStatusIndicator } from "../../elements/ServerStatusIndicator/ServerStatusIndicator";
import mcpLogo from "../../../theme/assets/MCP.svg";
import mcpLogoDark from "../../../theme/assets/MCP-dark.svg";
@ -133,7 +133,7 @@ export function ViewHeader(props: ViewHeaderProps) {
</CenterSection>
<RightSection>
<StatusIndicator status={props.status} latencyMs={props.latencyMs} />
<ServerStatusIndicator status={props.status} latencyMs={props.latencyMs} />
<DisconnectButton onClick={props.onDisconnect}>
Disconnect
</DisconnectButton>

View file

@ -1,5 +1,5 @@
import { useState } from "react";
import { Container, SimpleGrid, Stack, Text } from "@mantine/core";
import { ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { ServerCard } from "../../groups/ServerCard/ServerCard";
import { ServerListControls } from "../../groups/ServerListControls/ServerListControls";
import type { ServerCardProps } from "../../groups/ServerCard/ServerCard";
@ -11,9 +11,8 @@ export interface ServerListScreenProps {
onImportServerJson: () => void;
}
const PageContainer = Container.withProps({
size: "xl",
py: "xl",
const PageContainer = Stack.withProps({
p: "xl",
});
const EmptyState = Text.withProps({
@ -29,6 +28,9 @@ export function ServerListScreen({
onImportServerJson,
}: ServerListScreenProps) {
const [compact, setCompact] = useState<boolean>(false);
const [activeServer, setActiveServer] = useState<
string | undefined
>();
function handleToggleList() {
setCompact((prev) => (prev === true ? false : true));
@ -36,32 +38,38 @@ export function ServerListScreen({
return (
<PageContainer>
<Stack gap="lg">
<ServerListControls
serverCount={servers.length}
compact={compact}
onToggleList={handleToggleList}
onAddManually={onAddManually}
onImportConfig={onImportConfig}
onImportServerJson={onImportServerJson}
/>
<ServerListControls
serverCount={servers.length}
compact={compact}
onToggleList={handleToggleList}
onAddManually={onAddManually}
onImportConfig={onImportConfig}
onImportServerJson={onImportServerJson}
/>
<ScrollArea.Autosize mah="calc(100vh - var(--app-shell-header-height, 60px) - var(--mantine-spacing-xl) * 2 - 60px)">
{servers.length === 0 ? (
<EmptyState>
No servers configured. Add a server to get started.
</EmptyState>
) : (
<SimpleGrid
cols={{ base: 1, md: 2 }}
cols={{ base: 1, sm: 2, lg: 3 }}
spacing="lg"
className="grid-align-start"
>
{servers.map((server) => (
<ServerCard key={server.name} compact={compact} {...server} />
<ServerCard
key={server.name}
compact={compact}
activeServer={activeServer}
onSetActiveServer={setActiveServer}
{...server}
/>
))}
</SimpleGrid>
)}
</Stack>
</ScrollArea.Autosize>
</PageContainer>
);
}

View file

@ -184,9 +184,174 @@ export const ToolsActive: Story = {
selected: true,
onClick: fn(),
},
{ name: "delete_records", selected: false, onClick: fn() },
{ name: "list_users", selected: false, onClick: fn() },
{ name: "batch_process", selected: false, onClick: fn() },
{
name: "delete_records",
title: "Delete Records",
selected: false,
onClick: fn(),
},
{
name: "list_users",
title: "List Users",
selected: false,
onClick: fn(),
},
{
name: "batch_process",
title: "Batch Process",
selected: false,
onClick: fn(),
},
{
name: "search_files",
title: "Search Files",
selected: false,
onClick: fn(),
},
{
name: "read_file",
title: "Read File",
selected: false,
onClick: fn(),
},
{
name: "write_file",
title: "Write File",
selected: false,
onClick: fn(),
},
{
name: "get_schema",
title: "Get Schema",
selected: false,
onClick: fn(),
},
{
name: "run_query",
title: "Run Query",
selected: false,
onClick: fn(),
},
{
name: "create_migration",
title: "Create Migration",
selected: false,
onClick: fn(),
},
{
name: "validate_config",
title: "Validate Config",
selected: false,
onClick: fn(),
},
{
name: "deploy_service",
title: "Deploy Service",
selected: false,
onClick: fn(),
},
{
name: "rollback_deploy",
title: "Rollback Deploy",
selected: false,
onClick: fn(),
},
{
name: "check_health",
title: "Check Health",
selected: false,
onClick: fn(),
},
{
name: "generate_report",
title: "Generate Report",
selected: false,
onClick: fn(),
},
{
name: "export_csv",
title: "Export CSV",
selected: false,
onClick: fn(),
},
{
name: "import_data",
title: "Import Data",
selected: false,
onClick: fn(),
},
{
name: "send_notification",
title: "Send Notification",
selected: false,
onClick: fn(),
},
{
name: "schedule_task",
title: "Schedule Task",
selected: false,
onClick: fn(),
},
{
name: "cancel_task",
title: "Cancel Task",
selected: false,
onClick: fn(),
},
{
name: "list_connections",
title: "List Connections",
selected: false,
onClick: fn(),
},
{
name: "refresh_cache",
title: "Refresh Cache",
selected: false,
onClick: fn(),
},
{
name: "purge_logs",
title: "Purge Logs",
selected: false,
onClick: fn(),
},
{
name: "encrypt_secret",
title: "Encrypt Secret",
selected: false,
onClick: fn(),
},
{
name: "rotate_keys",
title: "Rotate Keys",
selected: false,
onClick: fn(),
},
{
name: "sync_resources",
title: "Sync Resources",
selected: false,
onClick: fn(),
},
{
name: "compile_template",
title: "Compile Template",
selected: false,
onClick: fn(),
},
{
name: "analyze_performance",
title: "Analyze Performance",
selected: false,
onClick: fn(),
},
{
name: "resize_image",
title: "Resize Image",
selected: false,
onClick: fn(),
},
]}
selectedTool={{
name: "create_record",

View file

@ -59,8 +59,8 @@ export const WithServers: Story = {
transport: "stdio",
connectionMode: "Via Proxy",
command: "npx -y @modelcontextprotocol/server-everything",
status: "connected",
canTestClientFeatures: true,
status: "disconnected",
canTestClientFeatures: false,
...makeServerCallbacks(),
},
{
@ -80,9 +80,7 @@ export const WithServers: Story = {
transport: "http",
connectionMode: "Direct",
command: "https://api.example.com/mcp",
status: "failed",
retryCount: 3,
error: { message: "Connection timeout after 20s" },
status: "disconnected",
canTestClientFeatures: false,
...makeServerCallbacks(),
},
@ -106,8 +104,8 @@ export const ManyServers: Story = {
transport: "stdio",
connectionMode: "Via Proxy",
command: "npx -y @modelcontextprotocol/server-everything",
status: "connected",
canTestClientFeatures: true,
status: "disconnected",
canTestClientFeatures: false,
...makeServerCallbacks(),
},
{
@ -127,8 +125,8 @@ export const ManyServers: Story = {
transport: "http",
connectionMode: "Direct",
command: "https://api.example.com/mcp",
status: "connected",
canTestClientFeatures: true,
status: "disconnected",
canTestClientFeatures: false,
...makeServerCallbacks(),
},
{
@ -137,7 +135,7 @@ export const ManyServers: Story = {
transport: "stdio",
connectionMode: "Via Proxy",
command: "npx -y @modelcontextprotocol/server-postgres",
status: "connecting",
status: "disconnected",
canTestClientFeatures: false,
...makeServerCallbacks(),
},
@ -147,8 +145,8 @@ export const ManyServers: Story = {
transport: "stdio",
connectionMode: "Via Proxy",
command: "npx -y @modelcontextprotocol/server-github",
status: "connected",
canTestClientFeatures: true,
status: "disconnected",
canTestClientFeatures: false,
...makeServerCallbacks(),
},
{
@ -157,9 +155,7 @@ export const ManyServers: Story = {
transport: "http",
connectionMode: "Direct",
command: "https://slack-mcp.example.com/mcp",
status: "failed",
retryCount: 5,
error: { message: "Authentication failed" },
status: "disconnected",
canTestClientFeatures: false,
...makeServerCallbacks(),
},
@ -169,7 +165,7 @@ export const ManyServers: Story = {
transport: "stdio",
connectionMode: "Via Proxy",
command: "npx -y @modelcontextprotocol/server-memory",
status: "connected",
status: "disconnected",
canTestClientFeatures: false,
...makeServerCallbacks(),
},
@ -189,8 +185,8 @@ export const ManyServers: Story = {
transport: "stdio",
connectionMode: "Via Proxy",
command: "npx -y @modelcontextprotocol/server-sqlite ./data.db",
status: "connected",
canTestClientFeatures: true,
status: "disconnected",
canTestClientFeatures: false,
...makeServerCallbacks(),
},
{
@ -199,9 +195,7 @@ export const ManyServers: Story = {
transport: "stdio",
connectionMode: "Via Proxy",
command: "npx -y @modelcontextprotocol/server-brave-search",
status: "failed",
retryCount: 2,
error: { message: "API key expired" },
status: "disconnected",
canTestClientFeatures: false,
...makeServerCallbacks(),
},
@ -221,7 +215,7 @@ export const ManyServers: Story = {
transport: "stdio",
connectionMode: "Via Proxy",
command: "npx -y @modelcontextprotocol/server-sequential-thinking",
status: "connecting",
status: "disconnected",
canTestClientFeatures: false,
...makeServerCallbacks(),
},
@ -232,28 +226,4 @@ export const ManyServers: Story = {
/>
),
},
};
export const Connecting: Story = {
args: {
children: (
<ServerListScreen
servers={[
{
name: "everything-server",
version: "1.0.0",
transport: "stdio",
connectionMode: "Via Proxy",
command: "npx -y @modelcontextprotocol/server-everything",
status: "connecting",
canTestClientFeatures: false,
...makeServerCallbacks(),
},
]}
onAddManually={fn()}
onImportConfig={fn()}
onImportServerJson={fn()}
/>
),
},
};
};