Phase 1: Refactor all 16 element component interfaces to use MCP SDK types

Replace ad-hoc local type definitions in element components with real
MCP SDK types and Inspector wrapper types from core/mcp/types.ts:

Schema-typed primitives:
- LogLevelBadge: LogLevel → LoggingLevel (SDK)
- TransportBadge: "stdio"|"http" → ServerType (core), add sse/streamable-http
- TaskStatusBadge: local TaskStatus → SDK TaskStatus (working/input_required/...)
- ServerStatusIndicator: inline union → ConnectionStatus (core), "failed"→"error"

MCP wrapper consumers:
- AnnotationBadge: label+variant → facet+value with SDK Role type
- CapabilityItem: name:string → capability:keyof ServerCapabilities|ClientCapabilities
- LogEntry: flat props → entry:{receivedAt:Date, params:LoggingMessageNotification["params"]}
- MessageBubble: flat props → message:SamplingMessage|PromptMessage
- ProgressDisplay: flat props → params:ProgressNotification["params"]

Composite-input elements:
- ContentViewer: type+content+mimeType → block:ContentBlock (SDK discriminated union)
- InlineError: message+details → error:{message,data?}
- ConnectionToggle: checked+loading+onChange → status:ConnectionStatus+onConnect/onDisconnect

Also updates all group/screen/view consumers to keep the build green,
and renames CSS variable --inspector-status-failed → --inspector-status-error.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
cliffhall 2026-04-16 22:25:23 -04:00
commit 2d4fca4fa6
54 changed files with 905 additions and 648 deletions

View file

@ -34,7 +34,7 @@
--inspector-status-connected: var(--mantine-color-green-6);
--inspector-status-connecting: var(--mantine-color-yellow-6);
--inspector-status-disconnected: var(--mantine-color-gray-5);
--inspector-status-failed: var(--mantine-color-red-6);
--inspector-status-error: var(--mantine-color-red-6);
/* ── Log levels (RFC 5424) ─────────────────────────────── */
--inspector-log-debug: var(--mantine-color-gray-6);

View file

@ -11,42 +11,63 @@ type Story = StoryObj<typeof AnnotationBadge>;
export const Audience: Story = {
args: {
label: "user",
variant: "audience",
facet: "audience",
value: ["user"],
},
};
export const AudienceMultiple: Story = {
args: {
facet: "audience",
value: ["user", "assistant"],
},
};
export const ReadOnly: Story = {
args: {
label: "read-only",
variant: "readOnly",
facet: "readOnlyHint",
value: true,
},
};
export const Destructive: Story = {
args: {
label: "destructive",
variant: "destructive",
facet: "destructiveHint",
value: true,
},
};
export const LongRun: Story = {
export const Idempotent: Story = {
args: {
label: "long-run",
variant: "longRun",
facet: "idempotentHint",
value: true,
},
};
export const OpenWorld: Story = {
args: {
facet: "openWorldHint",
value: true,
},
};
export const LongRunning: Story = {
args: {
facet: "longRunHint",
value: true,
},
};
export const PriorityHigh: Story = {
args: {
label: "priority: high",
variant: "priority",
facet: "priority",
value: 0.9,
},
};
export const Default: Story = {
export const PriorityLow: Story = {
args: {
label: "custom",
variant: "default",
facet: "priority",
value: 0.2,
},
};

View file

@ -1,37 +1,59 @@
import { Badge, useComputedColorScheme } from "@mantine/core";
import type { Role } from "@modelcontextprotocol/sdk/types.js";
export type AnnotationVariant =
export type AnnotationFacet =
| "audience"
| "readOnly"
| "destructive"
| "longRun"
| "priority"
| "default";
| "readOnlyHint"
| "destructiveHint"
| "idempotentHint"
| "openWorldHint"
| "longRunHint";
export interface AnnotationBadgeProps {
label: string;
variant?: AnnotationVariant;
facet: AnnotationFacet;
value: Role[] | number | boolean;
}
const colorMap: Record<AnnotationVariant, string> = {
const colorMap: Record<AnnotationFacet, string> = {
audience: "blue",
readOnly: "green",
destructive: "red",
longRun: "yellow",
priority: "orange",
default: "gray",
readOnlyHint: "green",
destructiveHint: "red",
idempotentHint: "teal",
openWorldHint: "grape",
longRunHint: "yellow",
};
export function AnnotationBadge({
label,
variant = "default",
}: AnnotationBadgeProps) {
function formatLabel(
facet: AnnotationFacet,
value: Role[] | number | boolean,
): string {
switch (facet) {
case "audience":
return `audience: ${(value as Role[]).join(", ")}`;
case "priority":
return `priority: ${value}`;
case "readOnlyHint":
return "read-only";
case "destructiveHint":
return "destructive";
case "idempotentHint":
return "idempotent";
case "openWorldHint":
return "open-world";
case "longRunHint":
return "long-running";
}
}
export function AnnotationBadge({ facet, value }: AnnotationBadgeProps) {
const colorScheme = useComputedColorScheme();
const color = colorMap[variant];
const color = colorMap[facet];
const textColor = colorScheme === "dark" ? "black" : "white";
return (
<Badge color={color} variant="filled" fw={500} c={textColor}>
{label}
{formatLabel(facet, value)}
</Badge>
);
}

View file

@ -11,14 +11,14 @@ type Story = StoryObj<typeof CapabilityItem>;
export const Supported: Story = {
args: {
name: "Tools",
capability: "tools",
supported: true,
},
};
export const SupportedWithCount: Story = {
args: {
name: "Tools",
capability: "tools",
supported: true,
count: 4,
},
@ -26,7 +26,7 @@ export const SupportedWithCount: Story = {
export const NotSupported: Story = {
args: {
name: "Completions",
capability: "completions",
supported: false,
},
};

View file

@ -1,16 +1,36 @@
import { Group, Text } from "@mantine/core";
import type {
ClientCapabilities,
ServerCapabilities,
} from "@modelcontextprotocol/sdk/types.js";
export type CapabilityKey = keyof ServerCapabilities | keyof ClientCapabilities;
export interface CapabilityItemProps {
name: string;
capability: CapabilityKey;
supported: boolean;
count?: number;
}
const displayLabel: Record<string, string> = {
tools: "Tools",
resources: "Resources",
prompts: "Prompts",
logging: "Logging",
completions: "Completions",
tasks: "Tasks",
experimental: "Experimental",
roots: "Roots",
sampling: "Sampling",
elicitation: "Elicitation",
};
export function CapabilityItem({
name,
capability,
supported,
count,
}: CapabilityItemProps) {
const name = displayLabel[capability] ?? String(capability);
const label = count != null ? `${name} (${count})` : name;
return (

View file

@ -6,7 +6,8 @@ const meta: Meta<typeof ConnectionToggle> = {
title: "Elements/ConnectionToggle",
component: ConnectionToggle,
args: {
onChange: fn(),
onConnect: fn(),
onDisconnect: fn(),
},
};
@ -15,32 +16,31 @@ type Story = StoryObj<typeof ConnectionToggle>;
export const Connected: Story = {
args: {
checked: true,
loading: false,
disabled: false,
status: "connected",
},
};
export const Disconnected: Story = {
args: {
checked: false,
loading: false,
disabled: false,
status: "disconnected",
},
};
export const Loading: Story = {
export const Connecting: Story = {
args: {
checked: false,
loading: true,
disabled: false,
status: "connecting",
},
};
export const Disabled: Story = {
args: {
checked: false,
loading: false,
status: "disconnected",
disabled: true,
},
};
export const Error: Story = {
args: {
status: "error",
},
};

View file

@ -1,24 +1,34 @@
import { Switch } from "@mantine/core";
import type { ConnectionStatus } from "@inspector/core/mcp/types.js";
export interface ConnectionToggleProps {
checked: boolean;
loading: boolean;
disabled: boolean;
onChange: (checked: boolean) => void;
status: ConnectionStatus;
disabled?: boolean;
onConnect: () => void;
onDisconnect: () => void;
}
export function ConnectionToggle({
checked,
loading,
disabled,
onChange,
status,
disabled = false,
onConnect,
onDisconnect,
}: ConnectionToggleProps) {
const isConnected = status === "connected";
const isConnecting = status === "connecting";
return (
<Switch
size="lg"
checked={checked || loading}
disabled={disabled || loading}
onChange={(event) => onChange(event.currentTarget.checked)}
checked={isConnected || isConnecting}
disabled={disabled || isConnecting}
onChange={(event) => {
if (event.currentTarget.checked) {
onConnect();
} else {
onDisconnect();
}
}}
/>
);
}

View file

@ -11,77 +11,66 @@ type Story = StoryObj<typeof ContentViewer>;
export const PlainText: Story = {
args: {
type: "text",
content: "Hello, world!\nThis is plain text.",
block: { type: "text", text: "Hello, world!\nThis is plain text." },
},
};
export const JsonContent: Story = {
args: {
type: "json",
content:
'{"name":"my-app","version":"1.0.0","description":"Sample configuration file"}',
block: {
type: "text",
text: '{"name":"my-app","version":"1.0.0","description":"Sample configuration file"}',
},
},
};
export const ImagePreview: Story = {
args: {
type: "image",
content:
"iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAABWUlEQVR4nO3YsQnAMAwAQe+/dLKCRZpD+UA6cVh8p/Oc81z9t1/eJ+/oD/ybVxDMKwjmFQTzCoJ5BcG8gmBeQTCvIJg3mLQX2eIVBPMKgnkFwbyCYF5BMK8gmFcQzCsI5hUE8zqdYF5BMK8gmFcQzCsI5hUE8wqCeQXBvIJgXkEwbzBpL7LFKwjmFQTzCoJ5BcG8gmBeQTCvIJhXEMwrCOZ1OsG8gmBeQTCvIJhXEMwrCOYVBPMKgnkFwbyCYN5g0l5ki1cQzCsI5hUE8wqCeQXBvIJgXkEwryCYVxDM63SCeQXBvIJgXkEwryCYVxDMKwjmFQTzCoJ5BcG8waS9yBavIJhXEMwrCOYVBPMKgnkFwbyCYF5BMK8gmNfpBPMKgnkFwbyCYF5BMK8gmFcQzCsI5hUE8wqCeYNJe5EtXkEwryCYVxDMKwjmFQTzCoJ5BcG8gmBeQTDvBaFNwZ0T5gfxAAAAAElFTkSuQmCC",
mimeType: "image/png",
block: {
type: "image",
data: "iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAABWUlEQVR4nO3YsQnAMAwAQe+/dLKCRZpD+UA6cVh8p/Oc81z9t1/eJ+/oD/ybVxDMKwjmFQTzCoJ5BcG8gmBeQTCvIJg3mLQX2eIVBPMKgnkFwbyCYF5BMK8gmFcQzCsI5hUE8zqdYF5BMK8gmFcQzCsI5hUE8wqCeQXBvIJgXkEwbzBpL7LFKwjmFQTzCoJ5BcG8gmBeQTCvIJhXEMwrCOZ1OsG8gmBeQTCvIJhXEMwrCOYVBPMKgnkFwbyCYN5g0l5ki1cQzCsI5hUE8wqCeQXBvIJgXkEwryCYVxDM63SCeQXBvIJgXkEwryCYVxDMKwjmFQTzCoJ5BcG8waS9yBavIJhXEMwrCOYVBPMKgnkFwbyCYF5BMK8gmNfpBPMKgnkFwbyCYF5BMK8gmFcQzCsI5hUE8wqCeYNJe5EtXkEwryCYVxDMKwjmFQTzCoJ5BcG8gmBeQTDvBaFNwZ0T5gfxAAAAAElFTkSuQmCC",
mimeType: "image/png",
},
},
};
export const LongContent: Story = {
export const LongJson: Story = {
args: {
type: "json",
content: JSON.stringify({
name: "my-app",
version: "2.5.0",
description: "A comprehensive application configuration",
author: "Inspector Team",
license: "MIT",
repository: "https://github.com/example/my-app",
dependencies: {
react: "^18.0.0",
typescript: "^5.0.0",
mantine: "^7.0.0",
},
devDependencies: {
vitest: "^1.0.0",
storybook: "^8.0.0",
eslint: "^9.0.0",
},
scripts: {
dev: "vite",
build: "tsc && vite build",
test: "vitest",
lint: "eslint .",
},
settings: {
theme: "dark",
language: "en",
notifications: true,
maxRetries: 3,
},
}),
block: {
type: "text",
text: JSON.stringify({
name: "my-app",
version: "2.5.0",
description: "A comprehensive application configuration",
dependencies: {
react: "^18.0.0",
typescript: "^5.0.0",
},
scripts: {
dev: "vite",
build: "tsc && vite build",
},
}),
},
},
};
export const WithCopyButton: Story = {
args: {
type: "text",
content: "Hello, world!\nThis text can be copied.",
block: {
type: "text",
text: "Hello, world!\nThis text can be copied.",
},
copyable: true,
},
};
export const JsonWithCopy: Story = {
args: {
type: "json",
content:
'{"name":"my-app","version":"1.0.0","description":"Sample configuration file"}',
block: {
type: "text",
text: '{"name":"my-app","version":"1.0.0","description":"Sample configuration file"}',
},
copyable: true,
},
};

View file

@ -1,10 +1,9 @@
import { Code, Flex, Image, Stack } from "@mantine/core";
import { Code, Flex, Image, Stack, Text } from "@mantine/core";
import type { ContentBlock } from "@modelcontextprotocol/sdk/types.js";
import { CopyButton } from "../CopyButton/CopyButton";
export interface ContentViewerProps {
type: "text" | "json" | "image" | "audio";
content: string;
mimeType?: string;
block: ContentBlock;
copyable?: boolean;
}
@ -16,12 +15,24 @@ function formatJson(content: string): string {
}
}
function buildDataUri(mimeType: string, content: string): string {
return `data:${mimeType};base64,${content}`;
function isJsonText(block: ContentBlock): boolean {
if (block.type !== "text") return false;
if (
"annotations" in block &&
block.annotations &&
"mimeType" in block.annotations
) {
return (
(block.annotations as Record<string, unknown>).mimeType ===
"application/json"
);
}
const trimmed = block.text.trimStart();
return trimmed.startsWith("{") || trimmed.startsWith("[");
}
function formatContent(type: "text" | "json", content: string): string {
return type === "json" ? formatJson(content) : content;
function buildDataUri(mimeType: string, data: string): string {
return `data:${mimeType};base64,${data}`;
}
const ContentWrapper = Flex.withProps({
@ -41,36 +52,61 @@ const PreviewImage = Image.withProps({
radius: "md",
});
export function ContentViewer({
type,
content,
mimeType,
copyable = false,
}: ContentViewerProps) {
const showCopy = copyable && (type === "text" || type === "json");
return (
<Stack gap="xs">
{(type === "json" || type === "text") && (
<ContentWrapper>
<Code block p={36}>
{formatContent(type, content)}
</Code>
{showCopy && (
<CopyOverlay>
<CopyButton value={content} />
</CopyOverlay>
)}
</ContentWrapper>
)}
{type === "image" && (
<PreviewImage src={buildDataUri(mimeType || "image/png", content)} />
)}
{type === "audio" && (
<audio controls>
<source src={buildDataUri(mimeType || "audio/wav", content)} />
</audio>
)}
</Stack>
);
export function ContentViewer({ block, copyable = false }: ContentViewerProps) {
switch (block.type) {
case "text": {
const isJson = isJsonText(block);
const displayText = isJson ? formatJson(block.text) : block.text;
return (
<Stack gap="xs">
<ContentWrapper>
<Code block p={36}>
{displayText}
</Code>
{copyable && (
<CopyOverlay>
<CopyButton value={block.text} />
</CopyOverlay>
)}
</ContentWrapper>
</Stack>
);
}
case "image":
return (
<Stack gap="xs">
<PreviewImage src={buildDataUri(block.mimeType, block.data)} />
</Stack>
);
case "audio":
return (
<Stack gap="xs">
<audio controls>
<source src={buildDataUri(block.mimeType, block.data)} />
</audio>
</Stack>
);
case "resource":
return (
<Stack gap="xs">
<ContentWrapper>
<Code block p={36}>
{"text" in block.resource
? block.resource.text
: `[blob: ${block.resource.uri}]`}
</Code>
</ContentWrapper>
</Stack>
);
case "resource_link":
return (
<Stack gap="xs">
<Text size="sm" c="blue">
{block.name ?? block.uri}
</Text>
</Stack>
);
default:
return null;
}
}

View file

@ -11,13 +11,13 @@ type Story = StoryObj<typeof InlineError>;
export const ShortError: Story = {
args: {
message: "Connection timeout after 20s",
error: { message: "Connection timeout after 20s" },
},
};
export const WithRetryCount: Story = {
args: {
message: "Connection failed",
error: { message: "Connection failed" },
retryCount: 3,
maxRetries: 5,
},
@ -25,24 +25,26 @@ export const WithRetryCount: Story = {
export const WithDetails: Story = {
args: {
message: "Connection refused",
details:
"Error: ECONNREFUSED 127.0.0.1:3000\n at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1494:16)",
error: {
message: "Connection refused",
data: "Error: ECONNREFUSED 127.0.0.1:3000\n at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1494:16)",
},
},
};
export const WithDocLink: Story = {
args: {
message: "Connection failed",
error: { message: "Connection failed" },
docLink: "https://example.com/troubleshooting",
},
};
export const FullError: Story = {
args: {
message: "Connection refused",
details:
"Error: ECONNREFUSED 127.0.0.1:3000\n at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1494:16)",
error: {
message: "Connection refused",
data: "Error: ECONNREFUSED 127.0.0.1:3000\n at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1494:16)",
},
retryCount: 3,
maxRetries: 5,
docLink: "https://example.com/troubleshooting",

View file

@ -2,8 +2,7 @@ import { Alert, Button, Collapse, Group, Stack, Text } from "@mantine/core";
import { useState } from "react";
export interface InlineErrorProps {
message: string;
details?: string;
error: { message: string; data?: unknown };
retryCount?: number;
maxRetries?: number;
docLink?: string;
@ -49,14 +48,20 @@ function formatRetryLabel(retryCount: number, maxRetries?: number): string {
: `Retry attempt ${retryCount}`;
}
function formatDetails(data: unknown): string {
if (typeof data === "string") return data;
return JSON.stringify(data, null, 2);
}
export function InlineError({
message,
details,
error,
retryCount,
maxRetries,
docLink,
}: InlineErrorProps) {
const [expanded, setExpanded] = useState(false);
const details =
error.data !== undefined ? formatDetails(error.data) : undefined;
const hasExpandable = details !== undefined || docLink !== undefined;
return (
@ -64,7 +69,7 @@ export function InlineError({
<Stack gap="xs">
<HeaderRow>
<MessageGroup>
<ErrorMessage>{message}</ErrorMessage>
<ErrorMessage>{error.message}</ErrorMessage>
{retryCount !== undefined && (
<RetryText>{formatRetryLabel(retryCount, maxRetries)}</RetryText>
)}

View file

@ -11,73 +11,70 @@ type Story = StoryObj<typeof LogEntry>;
export const Debug: Story = {
args: {
timestamp: "2026-03-17T10:00:00Z",
level: "debug",
message: "Initializing connection pool",
entry: {
receivedAt: new Date("2026-03-29T20:18:20.000Z"),
params: { level: "debug", data: "Initializing connection pool" },
},
},
};
export const Info: Story = {
args: {
timestamp: "2026-03-17T10:00:01Z",
level: "info",
message: "Server started on port 3000",
},
};
export const Notice: Story = {
args: {
timestamp: "2026-03-17T10:00:02Z",
level: "notice",
message: "Configuration reloaded successfully",
entry: {
receivedAt: new Date("2026-03-29T20:18:21.000Z"),
params: { level: "info", data: "Server started on port 3000" },
},
},
};
export const Warning: Story = {
args: {
timestamp: "2026-03-17T10:00:03Z",
level: "warning",
message: "Memory usage above 80%",
entry: {
receivedAt: new Date("2026-03-29T20:18:22.000Z"),
params: {
level: "warning",
data: "Connection pool nearing capacity",
logger: "pool",
},
},
},
};
export const Error: Story = {
args: {
timestamp: "2026-03-17T10:00:04Z",
level: "error",
message: "Failed to connect to database",
},
};
export const Critical: Story = {
args: {
timestamp: "2026-03-17T10:00:05Z",
level: "critical",
message: "Disk space critically low",
},
};
export const Alert: Story = {
args: {
timestamp: "2026-03-17T10:00:06Z",
level: "alert",
message: "Service degradation detected",
entry: {
receivedAt: new Date("2026-03-29T20:18:23.000Z"),
params: {
level: "error",
data: "Failed to connect to database",
logger: "db",
},
},
},
};
export const Emergency: Story = {
args: {
timestamp: "2026-03-17T10:00:07Z",
level: "emergency",
message: "System is unusable",
entry: {
receivedAt: new Date("2026-03-29T20:18:24.000Z"),
params: {
level: "emergency",
data: "System is unusable",
logger: "kernel",
},
},
},
};
export const WithLogger: Story = {
export const WithJsonData: Story = {
args: {
timestamp: "2026-03-17T10:00:08Z",
level: "info",
message: "Tool execution completed",
logger: "mcp-server",
entry: {
receivedAt: new Date("2026-03-29T20:18:25.000Z"),
params: {
level: "info",
data: { key: "value", count: 42 },
logger: "api",
},
},
},
};

View file

@ -1,24 +1,20 @@
import { Group, Text } from "@mantine/core";
import type {
LoggingLevel,
LoggingMessageNotification,
} from "@modelcontextprotocol/sdk/types.js";
import { LogLevelBadge } from "../LogLevelBadge/LogLevelBadge";
export type LogLevel =
| "debug"
| "info"
| "notice"
| "warning"
| "error"
| "critical"
| "alert"
| "emergency";
export interface LogEntryProps {
timestamp: string;
level: LogLevel;
message: string;
logger?: string;
export interface LogEntryData {
receivedAt: Date;
params: LoggingMessageNotification["params"];
}
const levelMessageColor: Record<LogLevel, string | undefined> = {
export interface LogEntryProps {
entry: LogEntryData;
}
const levelMessageColor: Record<LoggingLevel, string | undefined> = {
debug: "dimmed",
info: "blue",
notice: undefined,
@ -29,10 +25,20 @@ const levelMessageColor: Record<LogLevel, string | undefined> = {
emergency: "red",
};
function formatTimestamp(date: Date): string {
return date.toLocaleTimeString();
}
function formatLogger(logger: string): string {
return `[${logger}]`;
}
function formatData(data: unknown): string {
if (data === undefined || data === null) return "";
if (typeof data === "string") return data;
return JSON.stringify(data);
}
const TimestampText = Text.withProps({
size: "sm",
ff: "monospace",
@ -45,13 +51,16 @@ const LoggerText = Text.withProps({
c: "dimmed",
});
export function LogEntry({ timestamp, level, message, logger }: LogEntryProps) {
export function LogEntry({ entry }: LogEntryProps) {
const { receivedAt, params } = entry;
const message = formatData(params.data);
return (
<Group gap="sm" wrap="nowrap">
<TimestampText>{timestamp}</TimestampText>
<LogLevelBadge level={level} />
{logger && <LoggerText>{formatLogger(logger)}</LoggerText>}
<Text size="sm" ff="monospace" c={levelMessageColor[level]}>
<TimestampText>{formatTimestamp(receivedAt)}</TimestampText>
<LogLevelBadge level={params.level} />
{params.logger && <LoggerText>{formatLogger(params.logger)}</LoggerText>}
<Text size="sm" ff="monospace" c={levelMessageColor[params.level]}>
{message}
</Text>
</Group>

View file

@ -1,11 +1,11 @@
import { Badge, useComputedColorScheme } from "@mantine/core";
import type { LogLevel } from "../LogEntry/LogEntry";
import type { LoggingLevel } from "@modelcontextprotocol/sdk/types.js";
export interface LogLevelBadgeProps {
level: LogLevel;
level: LoggingLevel;
}
const levelColor: Record<LogLevel, string> = {
const levelColor: Record<LoggingLevel, string> = {
debug: "gray",
info: "blue",
notice: "teal",
@ -16,7 +16,7 @@ const levelColor: Record<LogLevel, string> = {
emergency: "red",
};
const boldLevels: Set<LogLevel> = new Set(["alert", "emergency"]);
const boldLevels: Set<LoggingLevel> = new Set(["alert", "emergency"]);
export function LogLevelBadge({ level }: LogLevelBadgeProps) {
const colorScheme = useComputedColorScheme();

View file

@ -12,28 +12,39 @@ type Story = StoryObj<typeof MessageBubble>;
export const UserText: Story = {
args: {
index: 0,
role: "user",
content: "Hello, my name is John and I like cats",
message: {
role: "user",
content: { type: "text", text: "Hello, my name is John and I like cats" },
},
},
};
export const AssistantText: Story = {
args: {
index: 1,
role: "assistant",
content:
"Nice to meet you, John! It's wonderful that you enjoy cats. They're such fascinating and independent creatures. Do you have any cats of your own?",
message: {
role: "assistant",
content: {
type: "text",
text: "Nice to meet you, John! It's wonderful that you enjoy cats. They're such fascinating and independent creatures. Do you have any cats of your own?",
},
},
},
};
export const UserWithImage: Story = {
args: {
index: 0,
role: "user",
content: "Check this out",
imageContent: {
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
mimeType: "image/png",
message: {
role: "user",
content: [
{ type: "text", text: "Check this out" },
{
type: "image",
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
mimeType: "image/png",
},
],
},
},
};
@ -41,8 +52,12 @@ export const UserWithImage: Story = {
export const LongMessage: Story = {
args: {
index: 0,
role: "user",
content:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.",
message: {
role: "user",
content: {
type: "text",
text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.",
},
},
},
};

View file

@ -1,12 +1,13 @@
import { Group, Image, Paper, Stack, Text } from "@mantine/core";
import type {
PromptMessage,
SamplingMessage,
} from "@modelcontextprotocol/sdk/types.js";
import { CopyButton } from "../CopyButton/CopyButton";
export interface MessageBubbleProps {
index: number;
role: "user" | "assistant";
content: string;
imageContent?: { data: string; mimeType: string };
audioContent?: { data: string; mimeType: string };
message: SamplingMessage | PromptMessage;
}
function buildDataUri(mimeType: string, data: string): string {
@ -21,6 +22,41 @@ function formatQuotedContent(content: string): string {
return `"${content}"`;
}
interface ContentBlockRendered {
text: string;
imageUri?: string;
audioUri?: string;
audioMime?: string;
}
function extractContent(
message: SamplingMessage | PromptMessage,
): ContentBlockRendered {
const content = message.content;
const blocks = Array.isArray(content) ? content : [content];
let text = "";
let imageUri: string | undefined;
let audioUri: string | undefined;
let audioMime: string | undefined;
for (const block of blocks) {
switch (block.type) {
case "text":
text += block.text;
break;
case "image":
imageUri = buildDataUri(block.mimeType, block.data);
break;
case "audio":
audioUri = buildDataUri(block.mimeType, block.data);
audioMime = block.mimeType;
break;
}
}
return { text, imageUri, audioUri, audioMime };
}
const BubbleContainer = Paper.withProps({
p: "md",
radius: "md",
@ -38,32 +74,21 @@ const PreviewImage = Image.withProps({
mt: "xs",
});
export function MessageBubble({
index,
role,
content,
imageContent,
audioContent,
}: MessageBubbleProps) {
export function MessageBubble({ index, message }: MessageBubbleProps) {
const { text, imageUri, audioUri, audioMime } = extractContent(message);
return (
<BubbleContainer>
<Stack gap="xs">
<Group justify="space-between">
<RoleLabel>{formatRoleLabel(index, role)}</RoleLabel>
<CopyButton value={content} />
<RoleLabel>{formatRoleLabel(index, message.role)}</RoleLabel>
{text && <CopyButton value={text} />}
</Group>
<Text size="sm">{formatQuotedContent(content)}</Text>
{imageContent && (
<PreviewImage
src={buildDataUri(imageContent.mimeType, imageContent.data)}
/>
)}
{audioContent && (
{text && <Text size="sm">{formatQuotedContent(text)}</Text>}
{imageUri && <PreviewImage src={imageUri} />}
{audioUri && (
<audio controls>
<source
src={buildDataUri(audioContent.mimeType, audioContent.data)}
type={audioContent.mimeType}
/>
<source src={audioUri} type={audioMime} />
</audio>
)}
</Stack>

View file

@ -11,29 +11,51 @@ type Story = StoryObj<typeof ProgressDisplay>;
export const ZeroPercent: Story = {
args: {
progress: 0,
params: { progressToken: "t1", progress: 0, total: 100 },
},
};
export const HalfComplete: Story = {
args: {
progress: 50,
description: "Processing...",
params: {
progressToken: "t2",
progress: 50,
total: 100,
message: "Processing...",
},
},
};
export const NearComplete: Story = {
args: {
progress: 95,
description: "Almost done",
params: {
progressToken: "t3",
progress: 95,
total: 100,
message: "Almost done",
},
elapsed: "1m 30s",
},
};
export const Complete: Story = {
args: {
progress: 100,
description: "Done",
params: {
progressToken: "t4",
progress: 100,
total: 100,
message: "Done",
},
elapsed: "2m 15s",
},
};
export const WithoutTotal: Story = {
args: {
params: {
progressToken: "t5",
progress: 42,
message: "Working...",
},
},
};

View file

@ -1,13 +1,20 @@
import { Group, Progress, Stack, Text } from "@mantine/core";
import type { ProgressNotification } from "@modelcontextprotocol/sdk/types.js";
export interface ProgressDisplayProps {
progress: number;
description?: string;
params: ProgressNotification["params"];
elapsed?: string;
}
function formatPercent(progress: number): string {
return `${progress}%`;
function computePercent(progress: number, total?: number): number {
if (total != null && total > 0) {
return Math.round((progress / total) * 100);
}
return progress;
}
function formatPercent(percent: number): string {
return `${percent}%`;
}
const ProgressLabel = Text.withProps({
@ -20,18 +27,16 @@ const ElapsedText = Text.withProps({
c: "dimmed",
});
export function ProgressDisplay({
progress,
description,
elapsed,
}: ProgressDisplayProps) {
export function ProgressDisplay({ params, elapsed }: ProgressDisplayProps) {
const percent = computePercent(params.progress, params.total);
return (
<Stack gap="xs">
<Group justify="space-between">
{description && <ProgressLabel>{description}</ProgressLabel>}
<ProgressLabel>{formatPercent(progress)}</ProgressLabel>
{params.message && <ProgressLabel>{params.message}</ProgressLabel>}
<ProgressLabel>{formatPercent(percent)}</ProgressLabel>
</Group>
<Progress value={progress} size="sm" />
<Progress value={percent} size="sm" />
{elapsed && <ElapsedText>{elapsed}</ElapsedText>}
</Stack>
);

View file

@ -28,15 +28,15 @@ export const Disconnected: Story = {
},
};
export const Failed: Story = {
export const Error: Story = {
args: {
status: "failed",
status: "error",
},
};
export const FailedWithRetries: Story = {
export const ErrorWithRetries: Story = {
args: {
status: "failed",
status: "error",
retryCount: 3,
},
};

View file

@ -1,20 +1,21 @@
import { Group, Paper, Text } from "@mantine/core";
import type { ConnectionStatus } from "@inspector/core/mcp/types.js";
export interface ServerStatusIndicatorProps {
status: "connected" | "connecting" | "disconnected" | "failed";
status: ConnectionStatus;
latencyMs?: number;
retryCount?: number;
}
const statusColorVar: Record<ServerStatusIndicatorProps["status"], string> = {
const statusColorVar: Record<ConnectionStatus, string> = {
connected: "var(--inspector-status-connected)",
connecting: "var(--inspector-status-connecting)",
disconnected: "var(--inspector-status-disconnected)",
failed: "var(--inspector-status-failed)",
error: "var(--inspector-status-error)",
};
function getLabel(
status: ServerStatusIndicatorProps["status"],
status: ConnectionStatus,
latencyMs?: number,
retryCount?: number,
): string {
@ -25,8 +26,8 @@ function getLabel(
return "Connecting...";
case "disconnected":
return "Disconnected";
case "failed":
return retryCount != null ? `Failed (${retryCount})` : "Failed";
case "error":
return retryCount != null ? `Error (${retryCount})` : "Error";
}
}

Some files were not shown because too many files have changed in this diff Show more