diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..1bcd18fa --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "playwright@claude-plugins-official": true + } +} diff --git a/.gitignore b/.gitignore index 6828e66b..e77e9dec 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules dist .env +/.playwright-mcp/ diff --git a/clients/web/.storybook/main.ts b/clients/web/.storybook/main.ts index 78cf7b39..3f89d8e6 100644 --- a/clients/web/.storybook/main.ts +++ b/clients/web/.storybook/main.ts @@ -12,6 +12,12 @@ const config: StorybookConfig = { "@storybook/addon-docs", "@storybook/addon-onboarding" ], - "framework": "@storybook/react-vite" + "framework": "@storybook/react-vite", + previewHead: (head) => ` + ${head} + + + + `, }; export default config; \ No newline at end of file diff --git a/clients/web/.storybook/preview.tsx b/clients/web/.storybook/preview.tsx index 3aed15c1..7c856228 100644 --- a/clients/web/.storybook/preview.tsx +++ b/clients/web/.storybook/preview.tsx @@ -43,14 +43,23 @@ const preview: Preview = { colorScheme: 'light', }, decorators: [ - (Story, context) => ( - - - - - - - ), + (Story, context) => { + const isFullscreen = context.parameters?.layout === 'fullscreen' + return ( + + + + {isFullscreen ? ( +
+ +
+ ) : ( + + )} +
+
+ ) + }, ], parameters: { controls: { diff --git a/clients/web/index.html b/clients/web/index.html index ae1890ac..cfd6d043 100644 --- a/clients/web/index.html +++ b/clients/web/index.html @@ -4,6 +4,9 @@ + + + MCP Inspector diff --git a/clients/web/package-lock.json b/clients/web/package-lock.json index 087803b8..799c53df 100644 --- a/clients/web/package-lock.json +++ b/clients/web/package-lock.json @@ -14,7 +14,8 @@ "@mantine/hooks": "^8.3.17", "@mantine/notifications": "^8.3.17", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "react-icons": "^5.6.0" }, "devDependencies": { "@chromatic-com/storybook": "^5.0.1", @@ -5138,6 +5139,15 @@ "react": "^19.2.4" } }, + "node_modules/react-icons": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz", + "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/clients/web/package.json b/clients/web/package.json index 74a6a58b..db3a7f63 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -20,7 +20,8 @@ "@mantine/hooks": "^8.3.17", "@mantine/notifications": "^8.3.17", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "react-icons": "^5.6.0" }, "devDependencies": { "@chromatic-com/storybook": "^5.0.1", diff --git a/clients/web/src/components/elements/AnnotationBadge/AnnotationBadge.tsx b/clients/web/src/components/elements/AnnotationBadge/AnnotationBadge.tsx index c4aa06ca..14b8ee29 100644 --- a/clients/web/src/components/elements/AnnotationBadge/AnnotationBadge.tsx +++ b/clients/web/src/components/elements/AnnotationBadge/AnnotationBadge.tsx @@ -13,16 +13,13 @@ export interface AnnotationBadgeProps { variant?: AnnotationVariant; } -const variantMap: Record< - AnnotationVariant, - { color: string; variant: string } -> = { - audience: { color: "blue", variant: "outline" }, - readOnly: { color: "green", variant: "filled" }, - destructive: { color: "red", variant: "filled" }, - longRun: { color: "yellow", variant: "filled" }, - priority: { color: "orange", variant: "filled" }, - default: { color: "gray", variant: "light" }, +const colorMap: Record = { + audience: "blue", + readOnly: "green", + destructive: "red", + longRun: "yellow", + priority: "orange", + default: "gray", }; export function AnnotationBadge({ @@ -30,15 +27,10 @@ export function AnnotationBadge({ variant = "default", }: AnnotationBadgeProps) { const colorScheme = useComputedColorScheme(); - const { color, variant: badgeVariant } = variantMap[variant]; - const textColor = - (variant === "priority" || variant === "destructive") && - colorScheme === "dark" - ? "black" - : undefined; - + const color = colorMap[variant]; + const textColor = colorScheme === "dark" ? "black" : "white"; return ( - + {label} ); diff --git a/clients/web/src/components/elements/ListToggle/ListToggle.stories.tsx b/clients/web/src/components/elements/ListToggle/ListToggle.stories.tsx new file mode 100644 index 00000000..fca3a320 --- /dev/null +++ b/clients/web/src/components/elements/ListToggle/ListToggle.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "storybook/test"; +import { ListToggle } from "./ListToggle"; + +const meta: Meta = { + title: "Elements/ListToggle", + component: ListToggle, +}; + +export default meta; +type Story = StoryObj; + +export const Expanded: Story = { + args: { + compact: false, + onToggle: fn(), + }, +}; + +export const Collapsed: Story = { + args: { + compact: true, + onToggle: fn(), + }, +}; diff --git a/clients/web/src/components/elements/ListToggle/ListToggle.tsx b/clients/web/src/components/elements/ListToggle/ListToggle.tsx new file mode 100644 index 00000000..0d12ca1c --- /dev/null +++ b/clients/web/src/components/elements/ListToggle/ListToggle.tsx @@ -0,0 +1,17 @@ +import { Button } from "@mantine/core"; +import { RiExpandVerticalLine, RiCollapseVerticalLine } from "react-icons/ri"; + +export interface ListToggleProps { + compact: boolean; + onToggle: () => void; +} + +export function ListToggle({ compact, onToggle }: ListToggleProps) { + const Icon = compact ? RiExpandVerticalLine : RiCollapseVerticalLine; + + return ( + + ); +} diff --git a/clients/web/src/components/elements/LogEntry/LogEntry.tsx b/clients/web/src/components/elements/LogEntry/LogEntry.tsx index 49b035fb..73d66915 100644 --- a/clients/web/src/components/elements/LogEntry/LogEntry.tsx +++ b/clients/web/src/components/elements/LogEntry/LogEntry.tsx @@ -1,4 +1,5 @@ -import { Badge, Group, Text } from "@mantine/core"; +import { Group, Text } from "@mantine/core"; +import { LogLevelBadge } from "../LogLevelBadge/LogLevelBadge"; export type LogLevel = | "debug" @@ -17,28 +18,6 @@ export interface LogEntryProps { logger?: string; } -const levelBadgeColor: Record = { - debug: "gray", - info: "blue", - notice: "teal", - warning: "yellow", - error: "red", - critical: "red", - alert: "red", - emergency: "red", -}; - -const levelBadgeVariant: Record = { - debug: "light", - info: "light", - notice: "light", - warning: "light", - error: "light", - critical: "filled", - alert: "filled", - emergency: "filled", -}; - const levelMessageColor: Record = { debug: "dimmed", info: "blue", @@ -50,14 +29,6 @@ const levelMessageColor: Record = { emergency: "red", }; -function isBoldLevel(level: LogLevel): boolean { - return level === "alert" || level === "emergency"; -} - -function getBoldWeight(level: LogLevel): number | undefined { - return isBoldLevel(level) ? 700 : undefined; -} - function formatLogger(logger: string): string { return `[${logger}]`; } @@ -70,6 +41,7 @@ const TimestampText = Text.withProps({ const LoggerText = Text.withProps({ size: "xs", + ff: "monospace", c: "dimmed", }); @@ -77,15 +49,9 @@ export function LogEntry({ timestamp, level, message, logger }: LogEntryProps) { return ( {timestamp} - - {level} - + {logger && {formatLogger(logger)}} - + {message} diff --git a/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.stories.tsx b/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.stories.tsx new file mode 100644 index 00000000..fda0585f --- /dev/null +++ b/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { LogLevelBadge } from "./LogLevelBadge"; + +const meta: Meta = { + title: "Elements/LogLevelBadge", + component: LogLevelBadge, +}; + +export default meta; +type Story = StoryObj; + +export const Debug: Story = { args: { level: "debug" } }; +export const Info: Story = { args: { level: "info" } }; +export const Notice: Story = { args: { level: "notice" } }; +export const Warning: Story = { args: { level: "warning" } }; +export const Error: Story = { args: { level: "error" } }; +export const Critical: Story = { args: { level: "critical" } }; +export const Alert: Story = { args: { level: "alert" } }; +export const Emergency: Story = { args: { level: "emergency" } }; diff --git a/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.tsx b/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.tsx new file mode 100644 index 00000000..3839b7f2 --- /dev/null +++ b/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.tsx @@ -0,0 +1,31 @@ +import { Badge, useComputedColorScheme } from "@mantine/core"; +import type { LogLevel } from "../LogEntry/LogEntry"; + +export interface LogLevelBadgeProps { + level: LogLevel; +} + +const levelColor: Record = { + debug: "gray", + info: "blue", + notice: "teal", + warning: "yellow", + error: "red", + critical: "red", + alert: "red", + emergency: "red", +}; + +const boldLevels: Set = new Set(["alert", "emergency"]); + +export function LogLevelBadge({ level }: LogLevelBadgeProps) { + const colorScheme = useComputedColorScheme(); + const textColor = colorScheme === "dark" ? "black" : "white"; + const fw = boldLevels.has(level) ? 500 : undefined; + + return ( + + {level} + + ); +} diff --git a/clients/web/src/components/groups/HistoryControls/HistoryControls.stories.tsx b/clients/web/src/components/groups/HistoryControls/HistoryControls.stories.tsx new file mode 100644 index 00000000..14fa3df4 --- /dev/null +++ b/clients/web/src/components/groups/HistoryControls/HistoryControls.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "storybook/test"; +import { HistoryControls } from "./HistoryControls"; + +const meta: Meta = { + title: "Groups/HistoryControls", + component: HistoryControls, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + searchText: "", + onSearchChange: fn(), + onMethodFilterChange: fn(), + onClearAll: fn(), + onExport: fn(), + }, +}; + +export const WithFilters: Story = { + args: { + searchText: "tools", + methodFilter: "tools/call", + onSearchChange: fn(), + onMethodFilterChange: fn(), + onClearAll: fn(), + onExport: fn(), + }, +}; diff --git a/clients/web/src/components/groups/HistoryControls/HistoryControls.tsx b/clients/web/src/components/groups/HistoryControls/HistoryControls.tsx new file mode 100644 index 00000000..361a3215 --- /dev/null +++ b/clients/web/src/components/groups/HistoryControls/HistoryControls.tsx @@ -0,0 +1,62 @@ +import { Button, Group, Select, Stack, TextInput, Title } from "@mantine/core"; + +const METHOD_OPTIONS = [ + "tools/call", + "tools/list", + "resources/read", + "resources/list", + "prompts/get", + "prompts/list", + "sampling/createMessage", + "elicitation/create", +]; + +const ToolbarButton = Button.withProps({ + variant: "light", + size: "sm", +}); + +export interface HistoryControlsProps { + searchText: string; + methodFilter?: string; + onSearchChange: (text: string) => void; + onMethodFilterChange: (method: string) => void; + onClearAll: () => void; + onExport: () => void; +} + +export function HistoryControls({ + searchText, + methodFilter, + onSearchChange, + onMethodFilterChange, + onClearAll, + onExport, +}: HistoryControlsProps) { + return ( + + History + + Search + onSearchChange(event.currentTarget.value)} + /> + + Filter by Method + ({ value: level, label: level }))} value={currentLevel} @@ -69,18 +78,25 @@ export function LogControls({ }} /> - Filter + Search onFilterChange(e.currentTarget.value)} /> - Show Levels + + Filter by Level + + {Object.values(visibleLevels).every(Boolean) + ? "Deselect All" + : "Select All"} + + {LOG_LEVELS.map((level) => { const style = LEVEL_COLORS[level]; return ( diff --git a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.stories.tsx b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.stories.tsx new file mode 100644 index 00000000..f78349ff --- /dev/null +++ b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.stories.tsx @@ -0,0 +1,77 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "storybook/test"; +import { LogStreamPanel } from "./LogStreamPanel"; +import type { LogEntryProps, LogLevel } from "../../elements/LogEntry/LogEntry"; + +const meta: Meta = { + title: "Groups/LogStreamPanel", + component: LogStreamPanel, + args: { + filterText: "", + visibleLevels: { + debug: true, + info: true, + notice: true, + warning: true, + error: true, + critical: true, + alert: true, + emergency: true, + }, + autoScroll: true, + onToggleAutoScroll: fn(), + onCopyAll: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +export const Empty: Story = { + args: { + entries: [], + }, +}; + +const logMessages: { level: LogLevel; message: string; logger?: string }[] = [ + { level: "info", message: "Server started on port 3000" }, + { level: "debug", message: "Loading configuration", logger: "config" }, + { + level: "warning", + message: "Deprecated API endpoint called", + logger: "http", + }, + { level: "error", message: "Failed to read resource", logger: "resources" }, + { + level: "info", + message: "Tool execution completed (245ms)", + logger: "tools", + }, + { + level: "critical", + message: "Database connection pool exhausted", + logger: "db", + }, + { level: "alert", message: "Memory usage exceeds 95%", logger: "system" }, + { level: "emergency", message: "System unresponsive", logger: "system" }, +]; + +function pad(n: number): string { + return n.toString().padStart(2, "0"); +} + +const entries: LogEntryProps[] = Array.from({ length: 30 }, (_, i) => { + const src = logMessages[i % logMessages.length]; + return { + timestamp: `2026-03-17T10:00:${pad(i + 1)}Z`, + level: src.level, + message: src.message, + logger: src.logger, + }; +}); + +export const WithEntries: Story = { + args: { + entries, + }, +}; diff --git a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx new file mode 100644 index 00000000..ae3d358c --- /dev/null +++ b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx @@ -0,0 +1,98 @@ +import { useMemo } from "react"; +import { + Button, + Checkbox, + Group, + Paper, + ScrollArea, + Stack, + Text, + Title, +} from "@mantine/core"; +import { LogEntry } from "../../elements/LogEntry/LogEntry"; +import type { LogEntryProps } from "../../elements/LogEntry/LogEntry"; + +export interface LogStreamPanelProps { + entries: LogEntryProps[]; + filterText: string; + visibleLevels: Record; + autoScroll: boolean; + onToggleAutoScroll: () => void; + onCopyAll: () => void; +} + +const PanelContainer = Paper.withProps({ + withBorder: true, + p: "lg", + flex: 1, + variant: "panel", +}); + +const ToolbarButton = Button.withProps({ + variant: "light", + size: "sm", +}); + +const EmptyCenter = Stack.withProps({ + flex: 1, + align: "center", + justify: "center", +}); + +function matchesFilters( + entry: LogEntryProps, + filterText: string, + visibleLevels: Record, +): boolean { + if (!visibleLevels[entry.level]) return false; + if (filterText) { + const term = filterText.toLowerCase(); + const searchable = + `${entry.message} ${entry.logger ?? ""} ${entry.level}`.toLowerCase(); + if (!searchable.includes(term)) return false; + } + return true; +} + +export function LogStreamPanel({ + entries, + filterText, + visibleLevels, + autoScroll, + onToggleAutoScroll, + onCopyAll, +}: LogStreamPanelProps) { + const filteredEntries = useMemo( + () => entries.filter((e) => matchesFilters(e, filterText, visibleLevels)), + [entries, filterText, visibleLevels], + ); + + return ( + + + Log Stream + + + Copy All + + + {filteredEntries.length > 0 ? ( + + + {filteredEntries.map((entry, index) => ( + + ))} + + + ) : ( + + No log entries + + )} + + ); +} diff --git a/clients/web/src/components/groups/AddServerMenu/AddServerMenu.stories.tsx b/clients/web/src/components/groups/ServerAddMenu/ServerAddMenu.stories.tsx similarity index 55% rename from clients/web/src/components/groups/AddServerMenu/AddServerMenu.stories.tsx rename to clients/web/src/components/groups/ServerAddMenu/ServerAddMenu.stories.tsx index 1374bda3..538fc1b7 100644 --- a/clients/web/src/components/groups/AddServerMenu/AddServerMenu.stories.tsx +++ b/clients/web/src/components/groups/ServerAddMenu/ServerAddMenu.stories.tsx @@ -1,10 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { fn } from "storybook/test"; -import { AddServerMenu } from "./AddServerMenu"; +import { ServerAddMenu } from "./ServerAddMenu.js"; -const meta: Meta = { - title: "Groups/AddServerMenu", - component: AddServerMenu, +const meta: Meta = { + title: "Groups/ServerAddMenu", + component: ServerAddMenu, args: { onAddManually: fn(), onImportConfig: fn(), @@ -13,6 +13,6 @@ const meta: Meta = { }; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = {}; diff --git a/clients/web/src/components/groups/AddServerMenu/AddServerMenu.tsx b/clients/web/src/components/groups/ServerAddMenu/ServerAddMenu.tsx similarity index 76% rename from clients/web/src/components/groups/AddServerMenu/AddServerMenu.tsx rename to clients/web/src/components/groups/ServerAddMenu/ServerAddMenu.tsx index 83794413..862a676f 100644 --- a/clients/web/src/components/groups/AddServerMenu/AddServerMenu.tsx +++ b/clients/web/src/components/groups/ServerAddMenu/ServerAddMenu.tsx @@ -1,4 +1,5 @@ import { Button, Menu } from "@mantine/core"; +import { RiArrowDownSLine } from "react-icons/ri"; export interface AddServerMenuProps { onAddManually: () => void; @@ -6,15 +7,16 @@ export interface AddServerMenuProps { onImportServerJson: () => void; } -export function AddServerMenu({ +export function ServerAddMenu({ onAddManually, onImportConfig, onImportServerJson, }: AddServerMenuProps) { + const Icon = RiArrowDownSLine; return ( - + + Add manually diff --git a/clients/web/src/components/groups/ServerCard/ServerCard.stories.tsx b/clients/web/src/components/groups/ServerCard/ServerCard.stories.tsx index 5fd804f7..195b0d7c 100644 --- a/clients/web/src/components/groups/ServerCard/ServerCard.stories.tsx +++ b/clients/web/src/components/groups/ServerCard/ServerCard.stories.tsx @@ -112,3 +112,29 @@ export const HttpDirect: Story = { canTestClientFeatures: false, }, }; + +export const CompactConnected: Story = { + args: { + name: "My MCP Server", + version: "1.2.0", + transport: "stdio", + connectionMode: "Subprocess", + command: "npx -y @modelcontextprotocol/server-everything", + status: "connected", + canTestClientFeatures: false, + compact: true, + }, +}; + +export const CompactDisconnected: Story = { + args: { + name: "My MCP Server", + version: "1.2.0", + transport: "stdio", + connectionMode: "Subprocess", + command: "npx -y @modelcontextprotocol/server-everything", + status: "disconnected", + canTestClientFeatures: false, + compact: true, + }, +}; diff --git a/clients/web/src/components/groups/ServerCard/ServerCard.tsx b/clients/web/src/components/groups/ServerCard/ServerCard.tsx index d5e7994b..241726ad 100644 --- a/clients/web/src/components/groups/ServerCard/ServerCard.tsx +++ b/clients/web/src/components/groups/ServerCard/ServerCard.tsx @@ -25,6 +25,7 @@ export interface ServerCardProps { onTestElicitationForm?: () => void; onTestElicitationUrl?: () => void; onConfigureRoots?: () => void; + compact?: boolean; } const HeaderLeft = Group.withProps({ @@ -80,6 +81,7 @@ export function ServerCard({ onTestElicitationForm, onTestElicitationUrl, onConfigureRoots, + compact = false, }: ServerCardProps) { const isConnected = status === "connected"; const isConnecting = status === "connecting"; @@ -103,53 +105,57 @@ export function ServerCard({ - - - {connectionMode} - + {!compact && ( + <> + + + {connectionMode} + - + - {error && ( - - )} - - - - Clone - Edit - Remove - - - Server Info - Settings - {canTestClientFeatures && ( - - - Test Client Features ▾ - - - - Simulate Sampling Request - - - Simulate Elicitation (Form) - - - Simulate Elicitation (URL) - - - Configure Roots - - - + {error && ( + )} - - + + + + Clone + Edit + Remove + + + Server Info + Settings + {canTestClientFeatures && ( + + + Test Client Features ▾ + + + + Simulate Sampling Request + + + Simulate Elicitation (Form) + + + Simulate Elicitation (URL) + + + Configure Roots + + + + )} + + + + )} ); diff --git a/clients/web/src/components/groups/ServerListControls/ServerListControls.stories.tsx b/clients/web/src/components/groups/ServerListControls/ServerListControls.stories.tsx new file mode 100644 index 00000000..e847bb5f --- /dev/null +++ b/clients/web/src/components/groups/ServerListControls/ServerListControls.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "storybook/test"; +import { ServerListControls } from "./ServerListControls"; + +const meta: Meta = { + title: "Groups/ServerListControls", + component: ServerListControls, +}; + +export default meta; +type Story = StoryObj; + +export const WithServers: Story = { + args: { + serverCount: 5, + compact: false, + onToggleList: fn(), + onAddManually: fn(), + onImportConfig: fn(), + onImportServerJson: fn(), + }, +}; + +export const WithoutServers: Story = { + args: { + serverCount: 0, + compact: true, + onToggleList: fn(), + onAddManually: fn(), + onImportConfig: fn(), + onImportServerJson: fn(), + }, +}; diff --git a/clients/web/src/components/groups/ServerListControls/ServerListControls.tsx b/clients/web/src/components/groups/ServerListControls/ServerListControls.tsx new file mode 100644 index 00000000..6dc41815 --- /dev/null +++ b/clients/web/src/components/groups/ServerListControls/ServerListControls.tsx @@ -0,0 +1,34 @@ +import { Group } from "@mantine/core"; +import { ListToggle } from "../../elements/ListToggle/ListToggle"; +import { + ServerAddMenu, + type AddServerMenuProps, +} from "../ServerAddMenu/ServerAddMenu.js"; + +export interface ServerListControlsProps extends AddServerMenuProps { + compact: boolean; + serverCount: number; + onToggleList: () => void; +} + +export function ServerListControls({ + compact, + serverCount, + onToggleList, + onAddManually, + onImportConfig, + onImportServerJson, +}: ServerListControlsProps) { + return ( + + {serverCount > 0 && ( + + )} + + + ); +} diff --git a/clients/web/src/components/groups/ViewHeader/ViewHeader.stories.tsx b/clients/web/src/components/groups/ViewHeader/ViewHeader.stories.tsx new file mode 100644 index 00000000..d5860c13 --- /dev/null +++ b/clients/web/src/components/groups/ViewHeader/ViewHeader.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "storybook/test"; +import { AppShell } from "@mantine/core"; +import { ViewHeader } from "./ViewHeader"; + +const meta: Meta = { + title: "Groups/ViewHeader", + component: ViewHeader, + decorators: [ + (Story) => ( + + + + + + ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Connected: Story = { + args: { + connected: true, + serverName: "my-mcp-server", + status: "connected", + latencyMs: 23, + activeTab: "Tools", + availableTabs: ["Tools", "Resources", "Prompts", "Logging", "Tasks", "History"], + onTabChange: fn(), + onDisconnect: fn(), + onToggleTheme: fn(), + }, +}; + +export const Unconnected: Story = { + args: { + connected: false, + onToggleTheme: fn(), + }, +}; diff --git a/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx b/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx new file mode 100644 index 00000000..b2e9f85a --- /dev/null +++ b/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx @@ -0,0 +1,146 @@ +import { + ActionIcon, + Anchor, + Button, + Group, + Image, + SegmentedControl, + Text, + Title, +} from "@mantine/core"; +import { useComputedColorScheme } from "@mantine/core"; +import { MdLightMode, MdDarkMode } from "react-icons/md"; +import { StatusIndicator } from "../../elements/StatusIndicator/StatusIndicator"; +import mcpLogo from "../../../theme/assets/MCP.svg"; +import mcpLogoDark from "../../../theme/assets/MCP-dark.svg"; + +interface ConnectedProps { + connected: true; + serverName: string; + status: "connected" | "connecting" | "failed"; + latencyMs?: number; + activeTab: string; + availableTabs: string[]; + onTabChange: (tab: string) => void; + onDisconnect: () => void; + onToggleTheme: () => void; +} + +interface UnconnectedProps { + connected: false; + onToggleTheme: () => void; +} + +export type ViewHeaderProps = ConnectedProps | UnconnectedProps; + +const HeaderBar = Group.withProps({ + h: "100%", + px: "md", + wrap: "nowrap", + gap: 0, +}); + +const LeftSection = Group.withProps({ + gap: "md", + wrap: "nowrap", + w: "33.33%", + miw: 0, +}); + +const LogoLink = Anchor.withProps({ + href: "https://modelcontextprotocol.io", + target: "_blank", + rel: "nofollow noopener noreferrer", +}); + +const LogoImage = Image.withProps({ + alt: "MCP", + w: 28, + h: 28, + fit: "contain", +}); + +const ServerName = Text.withProps({ + fw: 600, + size: "lg", + truncate: "end", + maw: "calc(100% - 40px)", +}); + +const CenterSection = Group.withProps({ + w: "33.33%", + justify: "center", +}); + +const RightSection = Group.withProps({ + gap: "sm", + w: "33.33%", + justify: "flex-end", +}); + +const DisconnectButton = Button.withProps({ + variant: "outline", + c: "red", + size: "sm", +}); + +const ThemeToggle = ActionIcon.withProps({ + variant: "outline", + size: 36, + "aria-label": "Toggle color scheme", +}); + +const UnconnectedBar = Group.withProps({ + h: "100%", + px: "md", + justify: "space-between", +}); + +export function ViewHeader(props: ViewHeaderProps) { + const colorScheme = useComputedColorScheme(); + const ThemeIcon = colorScheme === "dark" ? MdLightMode : MdDarkMode; + + if (!props.connected) { + return ( + + + + + MCP Inspector + + + + + ); + } + + return ( + + + + + + {props.serverName} + + + + + + + + + + Disconnect + + + + + + + ); +} diff --git a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.stories.tsx b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.stories.tsx index 955c55b1..0d66d218 100644 --- a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.stories.tsx +++ b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.stories.tsx @@ -8,10 +8,6 @@ const meta: Meta = { component: HistoryScreen, parameters: { layout: "fullscreen" }, args: { - searchText: "", - onSearchChange: fn(), - onMethodFilterChange: fn(), - onLoadMore: fn(), onClearAll: fn(), onExport: fn(), }, @@ -29,8 +25,7 @@ const entry1: HistoryEntryProps = { parameters: { message: "Hello, world!" }, response: { result: "Message sent successfully" }, isPinned: false, - isExpanded: false, - onToggleExpand: fn(), + isListExpanded: false, onReplay: fn(), onTogglePin: fn(), }; @@ -44,8 +39,7 @@ const entry2: HistoryEntryProps = { parameters: { uri: "file:///config.json" }, response: { contents: [{ uri: "file:///config.json", text: "{}" }] }, isPinned: false, - isExpanded: false, - onToggleExpand: fn(), + isListExpanded: false, onReplay: fn(), onTogglePin: fn(), }; @@ -59,8 +53,7 @@ const entry3: HistoryEntryProps = { parameters: { ids: [1, 2, 3] }, response: { error: "Permission denied" }, isPinned: false, - isExpanded: true, - onToggleExpand: fn(), + isListExpanded: true, onReplay: fn(), onTogglePin: fn(), }; @@ -72,9 +65,7 @@ const pinnedEntry1: HistoryEntryProps = { durationMs: 80, response: { tools: ["send_message", "list_users"] }, isPinned: true, - pinLabel: "Tool discovery", - isExpanded: false, - onToggleExpand: fn(), + isListExpanded: false, onReplay: fn(), onTogglePin: fn(), }; @@ -90,9 +81,7 @@ const pinnedEntry2: HistoryEntryProps = { messages: [{ role: "user", content: { type: "text", text: "Hello!" } }], }, isPinned: true, - pinLabel: "Greeting prompt", - isExpanded: false, - onToggleExpand: fn(), + isListExpanded: false, onReplay: fn(), onTogglePin: fn(), }; @@ -101,8 +90,6 @@ export const WithEntries: Story = { args: { entries: [entry1, entry2, entry3], pinnedEntries: [pinnedEntry1, pinnedEntry2], - totalCount: 25, - displayedCount: 3, }, }; @@ -110,17 +97,5 @@ export const Empty: Story = { args: { entries: [], pinnedEntries: [], - totalCount: 0, - displayedCount: 0, - }, -}; - -export const WithFilter: Story = { - args: { - entries: [entry1, entry3], - pinnedEntries: [], - methodFilter: "tools/call", - totalCount: 10, - displayedCount: 2, }, }; diff --git a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.tsx b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.tsx index 3fce13d6..ca7436fd 100644 --- a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.tsx +++ b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.tsx @@ -1,155 +1,64 @@ -import { - Button, - Container, - Divider, - Group, - Paper, - Select, - Stack, - Text, - TextInput, - Title, -} from "@mantine/core"; -import { HistoryEntry } from "../../groups/HistoryEntry/HistoryEntry"; +import { useState } from "react"; +import { Card, Flex, Stack } from "@mantine/core"; +import { HistoryControls } from "../../groups/HistoryControls/HistoryControls"; +import { HistoryListPanel } from "../../groups/HistoryListPanel/HistoryListPanel.js"; import type { HistoryEntryProps } from "../../groups/HistoryEntry/HistoryEntry"; export interface HistoryScreenProps { entries: HistoryEntryProps[]; pinnedEntries: HistoryEntryProps[]; - searchText: string; - methodFilter?: string; - totalCount: number; - displayedCount: number; - onSearchChange: (text: string) => void; - onMethodFilterChange: (method: string) => void; - onLoadMore: () => void; onClearAll: () => void; onExport: () => void; } -const METHOD_OPTIONS = [ - "tools/call", - "tools/list", - "resources/read", - "resources/list", - "prompts/get", - "prompts/list", - "sampling/createMessage", - "elicitation/create", -]; - -const PageContainer = Container.withProps({ - size: "xl", - py: "xl", +const ScreenLayout = Flex.withProps({ + variant: "screen", + h: "calc(100vh - var(--app-shell-header-height, 0px))", + gap: "xl", + p: "xl", }); -const ContentPanel = Paper.withProps({ +const Sidebar = Stack.withProps({ + w: 280, + flex: "0 0 auto", +}); + +const SidebarCard = Card.withProps({ withBorder: true, - p: "md", + padding: "lg", }); -const HeaderRow = Group.withProps({ - justify: "space-between", - wrap: "wrap", -}); - -const ToolbarButton = Button.withProps({ - variant: "light", - size: "sm", -}); - -const EmptyState = Text.withProps({ - c: "dimmed", - ta: "center", - py: "xl", -}); - -const CountText = Text.withProps({ - size: "sm", - c: "dimmed", -}); - -function entryKey(entry: HistoryEntryProps): string { - return `${entry.timestamp}-${entry.method}`; -} - -function formatPinnedTitle(count: number): string { - return `Pinned Requests (${count})`; -} - -function formatPagination(displayed: number, total: number): string { - return `Showing ${displayed} of ${total} entries`; -} - export function HistoryScreen({ entries, pinnedEntries, - searchText, - methodFilter, - totalCount, - displayedCount, - onSearchChange, - onMethodFilterChange, - onLoadMore, onClearAll, onExport, }: HistoryScreenProps) { + const [searchText, setSearchText] = useState(""); + const [methodFilter, setMethodFilter] = useState(); + return ( - - - - - Request History - - onSearchChange(event.currentTarget.value)} - /> -