feat: Add 7 organism screen components with Storybook stories

- ServerListScreen: Home page with server card grid and add server menu
- ToolsScreen: Three-panel layout (tool list, detail/form, results)
- ResourcesScreen: Two-panel with accordion sections and content preview
- PromptsScreen: Two-panel with prompt selection and message display
- LoggingScreen: Two-panel with log controls and real-time log stream
- TasksScreen: Active and completed task card sections
- HistoryScreen: Request history with search, filter, and pinned entries

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
cliffhall 2026-03-17 10:27:50 -04:00
commit 75f6f6452a
14 changed files with 1229 additions and 0 deletions

View file

@ -0,0 +1,123 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { fn } from 'storybook/test'
import { HistoryScreen } from './HistoryScreen'
import type { HistoryEntryProps } from '../../molecules/HistoryEntry/HistoryEntry'
const meta: Meta<typeof HistoryScreen> = {
component: HistoryScreen,
parameters: { layout: 'fullscreen' },
args: {
searchText: '',
onSearchChange: fn(),
onMethodFilterChange: fn(),
onLoadMore: fn(),
onClearAll: fn(),
onExport: fn(),
},
}
export default meta
type Story = StoryObj<typeof HistoryScreen>
const entry1: HistoryEntryProps = {
timestamp: '2026-03-17T10:00:00Z',
method: 'tools/call',
target: 'send_message',
status: 'success',
durationMs: 120,
parameters: { message: 'Hello, world!' },
response: { result: 'Message sent successfully' },
isPinned: false,
isExpanded: false,
onToggleExpand: fn(),
onReplay: fn(),
onTogglePin: fn(),
}
const entry2: HistoryEntryProps = {
timestamp: '2026-03-17T10:01:00Z',
method: 'resources/read',
target: 'config.json',
status: 'success',
durationMs: 45,
parameters: { uri: 'file:///config.json' },
response: { contents: [{ uri: 'file:///config.json', text: '{}' }] },
isPinned: false,
isExpanded: false,
onToggleExpand: fn(),
onReplay: fn(),
onTogglePin: fn(),
}
const entry3: HistoryEntryProps = {
timestamp: '2026-03-17T10:02:00Z',
method: 'tools/call',
target: 'delete_records',
status: 'error',
durationMs: 350,
parameters: { ids: [1, 2, 3] },
response: { error: 'Permission denied' },
isPinned: false,
isExpanded: true,
onToggleExpand: fn(),
onReplay: fn(),
onTogglePin: fn(),
}
const pinnedEntry1: HistoryEntryProps = {
timestamp: '2026-03-17T09:30:00Z',
method: 'tools/list',
status: 'success',
durationMs: 80,
response: { tools: ['send_message', 'list_users'] },
isPinned: true,
pinLabel: 'Tool discovery',
isExpanded: false,
onToggleExpand: fn(),
onReplay: fn(),
onTogglePin: fn(),
}
const pinnedEntry2: HistoryEntryProps = {
timestamp: '2026-03-17T09:35:00Z',
method: 'prompts/get',
target: 'greeting',
status: 'success',
durationMs: 60,
parameters: { name: 'greeting' },
response: { messages: [{ role: 'user', content: { type: 'text', text: 'Hello!' } }] },
isPinned: true,
pinLabel: 'Greeting prompt',
isExpanded: false,
onToggleExpand: fn(),
onReplay: fn(),
onTogglePin: fn(),
}
export const WithEntries: Story = {
args: {
entries: [entry1, entry2, entry3],
pinnedEntries: [pinnedEntry1, pinnedEntry2],
totalCount: 25,
displayedCount: 3,
},
}
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,
},
}

View file

@ -0,0 +1,107 @@
import { Button, Divider, Group, Paper, Select, Stack, Text, TextInput, Title } from '@mantine/core'
import { HistoryEntry } from '../../molecules/HistoryEntry/HistoryEntry'
import type { HistoryEntryProps } from '../../molecules/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',
]
export function HistoryScreen({
entries,
pinnedEntries,
searchText,
methodFilter,
totalCount,
displayedCount,
onSearchChange,
onMethodFilterChange,
onLoadMore,
onClearAll,
onExport,
}: HistoryScreenProps) {
return (
<Paper withBorder p="md">
<Stack gap="md">
<Group justify="space-between" wrap="wrap">
<Title order={3}>Request History</Title>
<Group>
<TextInput
placeholder="Search..."
value={searchText}
onChange={(event) => onSearchChange(event.currentTarget.value)}
/>
<Select
placeholder="Filter by method"
data={METHOD_OPTIONS}
value={methodFilter}
onChange={(value) => onMethodFilterChange(value ?? '')}
clearable
/>
<Button variant="light" size="sm" onClick={onExport}>
Export JSON
</Button>
<Button variant="light" size="sm" onClick={onClearAll}>
Clear All
</Button>
</Group>
</Group>
{entries.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
No request history
</Text>
) : (
<Stack gap="md">
{entries.map((entry) => (
<HistoryEntry key={`${entry.timestamp}-${entry.method}`} {...entry} />
))}
</Stack>
)}
{pinnedEntries.length > 0 && (
<>
<Divider />
<Title order={4}>Pinned Requests ({pinnedEntries.length})</Title>
<Stack gap="sm">
{pinnedEntries.map((entry) => (
<HistoryEntry key={`${entry.timestamp}-${entry.method}`} {...entry} />
))}
</Stack>
</>
)}
<Group justify="flex-end">
<Text size="sm" c="dimmed">
Showing {displayedCount} of {totalCount} entries
</Text>
{displayedCount < totalCount && (
<Button variant="light" size="sm" onClick={onLoadMore}>
Load More
</Button>
)}
</Group>
</Stack>
</Paper>
)
}

View file

@ -0,0 +1,81 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { fn } from 'storybook/test';
import { LoggingScreen } from './LoggingScreen';
import type { LogControlsProps } from '../../molecules/LogControls/LogControls';
import type { LogEntryProps } from '../../atoms/LogEntry/LogEntry';
function makeControls(): LogControlsProps {
return {
currentLevel: 'info',
filterText: '',
visibleLevels: {
debug: true,
info: true,
notice: true,
warning: true,
error: true,
critical: true,
alert: true,
emergency: true,
},
onSetLevel: fn(),
onFilterChange: fn(),
onToggleLevel: fn(),
onClear: fn(),
onExport: fn(),
};
}
const meta: Meta<typeof LoggingScreen> = {
component: LoggingScreen,
parameters: { layout: 'fullscreen' },
args: {
onToggleAutoScroll: fn(),
onCopyAll: fn(),
autoScroll: true,
},
};
export default meta;
type Story = StoryObj<typeof LoggingScreen>;
export const Empty: Story = {
args: {
controls: makeControls(),
entries: [],
},
};
const mixedEntries: LogEntryProps[] = [
{ timestamp: '2026-03-17T10:00:01Z', level: 'info', message: 'Server started on port 3000' },
{ timestamp: '2026-03-17T10:00:02Z', level: 'debug', message: 'Loading configuration from /etc/mcp/config.json', logger: 'config' },
{ timestamp: '2026-03-17T10:00:03Z', level: 'warning', message: 'Deprecated API endpoint called: /v1/tools', logger: 'http' },
{ timestamp: '2026-03-17T10:00:04Z', level: 'info', message: 'Client connected: inspector-web-ui' },
{ timestamp: '2026-03-17T10:00:05Z', level: 'error', message: 'Failed to read resource: file not found at /data/missing.txt', logger: 'resources' },
{ timestamp: '2026-03-17T10:00:06Z', level: 'info', message: 'Tool execution completed: search_files (245ms)', logger: 'tools' },
];
export const WithEntries: Story = {
args: {
controls: makeControls(),
entries: mixedEntries,
},
};
const allLevelEntries: LogEntryProps[] = [
{ timestamp: '2026-03-17T10:00:01Z', level: 'debug', message: 'Resolving transport handler for stdio connection', logger: 'transport' },
{ timestamp: '2026-03-17T10:00:02Z', level: 'info', message: 'MCP session initialized successfully' },
{ timestamp: '2026-03-17T10:00:03Z', level: 'notice', message: 'Server capabilities negotiated: tools, resources, prompts', logger: 'session' },
{ timestamp: '2026-03-17T10:00:04Z', level: 'warning', message: 'Rate limit approaching: 85% of quota used', logger: 'ratelimit' },
{ timestamp: '2026-03-17T10:00:05Z', level: 'error', message: 'Tool execution failed: timeout after 30s', logger: 'tools' },
{ timestamp: '2026-03-17T10:00:06Z', level: 'critical', message: 'Database connection pool exhausted', logger: 'db' },
{ timestamp: '2026-03-17T10:00:07Z', level: 'alert', message: 'Memory usage exceeds 95% threshold', logger: 'system' },
{ timestamp: '2026-03-17T10:00:08Z', level: 'emergency', message: 'System unresponsive - initiating graceful shutdown', logger: 'system' },
];
export const MixedLevels: Story = {
args: {
controls: makeControls(),
entries: allLevelEntries,
},
};

View file

@ -0,0 +1,62 @@
import { Button, Checkbox, Grid, Group, Paper, ScrollArea, Stack, Text, Title } from '@mantine/core';
import { LogControls } from '../../molecules/LogControls/LogControls';
import { LogEntry } from '../../atoms/LogEntry/LogEntry';
import type { LogControlsProps } from '../../molecules/LogControls/LogControls';
import type { LogEntryProps } from '../../atoms/LogEntry/LogEntry';
export interface LoggingScreenProps {
controls: LogControlsProps;
entries: LogEntryProps[];
autoScroll: boolean;
onToggleAutoScroll: () => void;
onCopyAll: () => void;
}
export function LoggingScreen({
controls,
entries,
autoScroll,
onToggleAutoScroll,
onCopyAll,
}: LoggingScreenProps) {
return (
<Grid>
<Grid.Col span={3}>
<Paper withBorder p="md">
<LogControls {...controls} />
</Paper>
</Grid.Col>
<Grid.Col span={9}>
<Paper withBorder p="md">
<Stack gap="sm">
<Group justify="space-between">
<Title order={4}>Log Stream</Title>
<Group>
<Checkbox
label="Auto-scroll"
checked={autoScroll}
onChange={onToggleAutoScroll}
/>
<Button variant="light" size="sm" onClick={onCopyAll}>
Copy All
</Button>
</Group>
</Group>
<ScrollArea h={500}>
<Stack gap="xs">
{entries.map((entry, index) => (
<LogEntry key={index} {...entry} />
))}
</Stack>
</ScrollArea>
{entries.length === 0 && (
<Text c="dimmed" ta="center">
No log entries
</Text>
)}
</Stack>
</Paper>
</Grid.Col>
</Grid>
);
}

View file

@ -0,0 +1,72 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { fn } from 'storybook/test';
import { PromptsScreen } from './PromptsScreen';
const meta: Meta<typeof PromptsScreen> = {
component: PromptsScreen,
parameters: { layout: 'fullscreen' },
args: {
onRefreshList: fn(),
},
};
export default meta;
type Story = StoryObj<typeof PromptsScreen>;
export const NoSelection: Story = {
args: {
promptForm: {
prompts: [
{ name: 'summarize', description: 'Summarize a document' },
{ name: 'translate', description: 'Translate text to another language' },
{ name: 'code-review', description: 'Review code for issues' },
],
selectedPrompt: undefined,
arguments: [],
argumentValues: {},
onSelectPrompt: fn(),
onArgumentChange: fn(),
onGetPrompt: fn(),
},
messages: undefined,
listChanged: false,
},
};
export const WithResult: Story = {
args: {
promptForm: {
prompts: [
{ name: 'summarize', description: 'Summarize a document' },
{ name: 'translate', description: 'Translate text to another language' },
{ name: 'code-review', description: 'Review code for issues' },
],
selectedPrompt: 'translate',
arguments: [
{ name: 'text', required: true, description: 'The text to translate' },
{ name: 'targetLanguage', required: true, description: 'Target language code' },
],
argumentValues: {
text: 'Hello, how are you?',
targetLanguage: 'es',
},
onSelectPrompt: fn(),
onArgumentChange: fn(),
onGetPrompt: fn(),
},
messages: {
messages: [
{
role: 'user',
content: 'Translate the following text to Spanish: "Hello, how are you?"',
},
{
role: 'assistant',
content: 'Hola, como estas?',
},
],
onCopy: fn(),
},
listChanged: false,
},
};

View file

@ -0,0 +1,44 @@
import { Grid, Paper, Stack, Text } from '@mantine/core';
import { PromptArgumentsForm } from '../../molecules/PromptArgumentsForm/PromptArgumentsForm';
import { PromptMessagesDisplay } from '../../molecules/PromptMessagesDisplay/PromptMessagesDisplay';
import { ListChangedIndicator } from '../../atoms/ListChangedIndicator/ListChangedIndicator';
import type { PromptArgumentsFormProps } from '../../molecules/PromptArgumentsForm/PromptArgumentsForm';
import type { PromptMessagesDisplayProps } from '../../molecules/PromptMessagesDisplay/PromptMessagesDisplay';
export interface PromptsScreenProps {
promptForm: PromptArgumentsFormProps;
messages?: PromptMessagesDisplayProps;
listChanged: boolean;
onRefreshList: () => void;
}
export function PromptsScreen({
promptForm,
messages,
listChanged,
onRefreshList,
}: PromptsScreenProps) {
return (
<Grid>
<Grid.Col span={5}>
<Paper withBorder p="md">
<Stack gap="md">
<ListChangedIndicator visible={listChanged} onRefresh={onRefreshList} />
<PromptArgumentsForm {...promptForm} />
</Stack>
</Paper>
</Grid.Col>
<Grid.Col span={7}>
<Paper withBorder p="md">
{messages ? (
<PromptMessagesDisplay {...messages} />
) : (
<Text c="dimmed" ta="center" py="xl">
Select a prompt and click Get Prompt to see messages
</Text>
)}
</Paper>
</Grid.Col>
</Grid>
);
}

View file

@ -0,0 +1,112 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { fn } from 'storybook/test'
import { ResourcesScreen } from './ResourcesScreen'
import type { ResourceListItemProps } from '../../molecules/ResourceListItem/ResourceListItem'
import type { ResourceTemplateInputProps } from '../../molecules/ResourceTemplateInput/ResourceTemplateInput'
import type { ResourcePreviewPanelProps } from '../../molecules/ResourcePreviewPanel/ResourcePreviewPanel'
const meta: Meta<typeof ResourcesScreen> = {
component: ResourcesScreen,
parameters: { layout: 'fullscreen' },
args: {
searchText: '',
onSearchChange: fn(),
onRefreshList: fn(),
onSelectResource: fn(),
listChanged: false,
subscriptions: [],
templates: [],
},
}
export default meta
type Story = StoryObj<typeof ResourcesScreen>
const sampleResources: ResourceListItemProps[] = [
{
name: 'config.json',
uri: 'file:///config.json',
annotations: { audience: 'developer', priority: 0.8 },
selected: false,
onClick: fn(),
},
{
name: 'README.md',
uri: 'file:///README.md',
selected: false,
onClick: fn(),
},
{
name: 'schema.sql',
uri: 'file:///schema.sql',
annotations: { priority: 0.5 },
selected: false,
onClick: fn(),
},
]
const sampleTemplates: ResourceTemplateInputProps[] = [
{
template: 'file:///users/{userId}/profile',
variables: { userId: '' },
onVariableChange: fn(),
onSubmit: fn(),
},
{
template: 'db://tables/{tableName}/rows/{rowId}',
variables: { tableName: '', rowId: '' },
onVariableChange: fn(),
onSubmit: fn(),
},
]
const selectedResourceProps: ResourcePreviewPanelProps = {
uri: 'file:///config.json',
mimeType: 'application/json',
annotations: { audience: 'developer', priority: 0.8 },
content: JSON.stringify(
{
name: 'my-project',
version: '1.0.0',
settings: { debug: true, logLevel: 'info' },
},
null,
2
),
lastUpdated: '2026-03-17T10:30:00Z',
isSubscribed: true,
onCopy: fn(),
onSubscribe: fn(),
onUnsubscribe: fn(),
}
export const WithResources: Story = {
args: {
resources: sampleResources,
},
}
export const ResourceSelected: Story = {
args: {
resources: sampleResources.map((r) =>
r.uri === 'file:///config.json' ? { ...r, selected: true } : r
),
selectedResource: selectedResourceProps,
subscriptions: [
{ name: 'config.json', lastUpdated: '2026-03-17T10:30:00Z' },
],
},
}
export const WithTemplates: Story = {
args: {
resources: sampleResources,
templates: sampleTemplates,
},
}
export const Empty: Story = {
args: {
resources: [],
},
}

View file

@ -0,0 +1,111 @@
import { Accordion, Grid, Paper, Stack, Text, TextInput } from '@mantine/core'
import { ListChangedIndicator } from '../../atoms/ListChangedIndicator/ListChangedIndicator'
import { ResourceListItem } from '../../molecules/ResourceListItem/ResourceListItem'
import { ResourcePreviewPanel } from '../../molecules/ResourcePreviewPanel/ResourcePreviewPanel'
import { ResourceTemplateInput } from '../../molecules/ResourceTemplateInput/ResourceTemplateInput'
import type { ResourceListItemProps } from '../../molecules/ResourceListItem/ResourceListItem'
import type { ResourcePreviewPanelProps } from '../../molecules/ResourcePreviewPanel/ResourcePreviewPanel'
import type { ResourceTemplateInputProps } from '../../molecules/ResourceTemplateInput/ResourceTemplateInput'
export interface SubscriptionInfo {
name: string
lastUpdated?: string
}
export interface ResourcesScreenProps {
resources: ResourceListItemProps[]
templates: ResourceTemplateInputProps[]
subscriptions: SubscriptionInfo[]
selectedResource?: ResourcePreviewPanelProps
listChanged: boolean
searchText: string
onSearchChange: (text: string) => void
onRefreshList: () => void
onSelectResource: (uri: string) => void
}
export function ResourcesScreen({
resources,
templates,
subscriptions,
selectedResource,
listChanged,
searchText,
onSearchChange,
onRefreshList,
}: ResourcesScreenProps) {
const filteredResources = resources.filter((r) =>
r.name.toLowerCase().includes(searchText.toLowerCase())
)
return (
<Grid>
<Grid.Col span={4}>
<Paper withBorder p="md">
<Stack gap="sm">
<ListChangedIndicator visible={listChanged} onRefresh={onRefreshList} />
<TextInput
placeholder="Search..."
value={searchText}
onChange={(e) => onSearchChange(e.currentTarget.value)}
/>
<Accordion multiple defaultValue={['resources', 'templates', 'subscriptions']}>
<Accordion.Item value="resources">
<Accordion.Control>Resources ({filteredResources.length})</Accordion.Control>
<Accordion.Panel>
<Stack gap="xs">
{filteredResources.map((resource) => (
<ResourceListItem key={resource.uri} {...resource} />
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="templates">
<Accordion.Control>Templates ({templates.length})</Accordion.Control>
<Accordion.Panel>
<Stack gap="xs">
{templates.map((template) => (
<ResourceTemplateInput key={template.template} {...template} />
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="subscriptions">
<Accordion.Control>Subscriptions ({subscriptions.length})</Accordion.Control>
<Accordion.Panel>
<Stack gap="xs">
{subscriptions.map((sub) => (
<Text key={sub.name} size="sm">
{sub.name}
{sub.lastUpdated && (
<Text span c="dimmed" size="xs">
{' '}
— {sub.lastUpdated}
</Text>
)}
</Text>
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
</Paper>
</Grid.Col>
<Grid.Col span={8}>
<Paper withBorder p="md">
{selectedResource ? (
<ResourcePreviewPanel {...selectedResource} />
) : (
<Text c="dimmed" ta="center" py="xl">
Select a resource to preview
</Text>
)}
</Paper>
</Grid.Col>
</Grid>
)
}

View file

@ -0,0 +1,123 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { fn } from 'storybook/test';
import { ServerListScreen } from './ServerListScreen';
import type { ServerCardProps } from '../../molecules/ServerCard/ServerCard';
const meta: Meta<typeof ServerListScreen> = {
component: ServerListScreen,
parameters: { layout: 'fullscreen' },
args: {
onAddManually: fn(),
onImportConfig: fn(),
onImportServerJson: fn(),
},
};
export default meta;
type Story = StoryObj<typeof ServerListScreen>;
function makeServerCallbacks(): Pick<
ServerCardProps,
| 'onToggleConnection'
| 'onCopyCommand'
| 'onServerInfo'
| 'onSettings'
| 'onEdit'
| 'onClone'
| 'onRemove'
| 'onTestSampling'
| 'onTestElicitationForm'
| 'onTestElicitationUrl'
| 'onConfigureRoots'
> {
return {
onToggleConnection: fn(),
onCopyCommand: fn(),
onServerInfo: fn(),
onSettings: fn(),
onEdit: fn(),
onClone: fn(),
onRemove: fn(),
onTestSampling: fn(),
onTestElicitationForm: fn(),
onTestElicitationUrl: fn(),
onConfigureRoots: fn(),
};
}
const connectedStdioServer: ServerCardProps = {
name: 'Local Dev Server',
version: '1.2.0',
transport: 'stdio',
connectionMode: 'Direct',
command: 'npx @modelcontextprotocol/server-filesystem /home/user/projects',
status: 'connected',
canTestClientFeatures: true,
...makeServerCallbacks(),
};
const disconnectedStdioServer: ServerCardProps = {
name: 'Database Tools',
version: '0.9.1',
transport: 'stdio',
connectionMode: 'Direct',
command: 'python -m mcp_server_sqlite --db-path ./data.db',
status: 'disconnected',
canTestClientFeatures: false,
...makeServerCallbacks(),
};
const failedHttpServer: ServerCardProps = {
name: 'Remote API Server',
version: '2.0.0',
transport: 'http',
connectionMode: 'Streamable HTTP',
command: 'https://api.example.com/mcp',
status: 'failed',
retryCount: 3,
error: {
message: 'Connection refused',
details: 'ECONNREFUSED 127.0.0.1:8080 - The server may not be running.',
},
canTestClientFeatures: false,
...makeServerCallbacks(),
};
const connectingHttpServer: ServerCardProps = {
name: 'Staging Server',
transport: 'http',
connectionMode: 'Streamable HTTP',
command: 'https://staging.example.com/mcp',
status: 'connecting',
canTestClientFeatures: false,
...makeServerCallbacks(),
};
export const MultipleServers: Story = {
args: {
servers: [connectedStdioServer, disconnectedStdioServer, failedHttpServer],
},
};
export const SingleServer: Story = {
args: {
servers: [connectedStdioServer],
},
};
export const Empty: Story = {
args: {
servers: [],
},
};
export const MixedStates: Story = {
args: {
servers: [
connectedStdioServer,
disconnectedStdioServer,
failedHttpServer,
connectingHttpServer,
],
},
};

View file

@ -0,0 +1,45 @@
import { Container, Group, SimpleGrid, Stack, Text, Title } from '@mantine/core';
import { ServerCard } from '../../molecules/ServerCard/ServerCard';
import { AddServerMenu } from '../../molecules/AddServerMenu/AddServerMenu';
import type { ServerCardProps } from '../../molecules/ServerCard/ServerCard';
export interface ServerListScreenProps {
servers: ServerCardProps[];
onAddManually: () => void;
onImportConfig: () => void;
onImportServerJson: () => void;
}
export function ServerListScreen({
servers,
onAddManually,
onImportConfig,
onImportServerJson,
}: ServerListScreenProps) {
return (
<Container size="xl" py="xl">
<Stack gap="lg">
<Group justify="space-between">
<Title order={1}>MCP Inspector</Title>
<AddServerMenu
onAddManually={onAddManually}
onImportConfig={onImportConfig}
onImportServerJson={onImportServerJson}
/>
</Group>
{servers.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
No servers configured. Add a server to get started.
</Text>
) : (
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg">
{servers.map((server) => (
<ServerCard key={server.name} {...server} />
))}
</SimpleGrid>
)}
</Stack>
</Container>
);
}

View file

@ -0,0 +1,99 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { fn } from 'storybook/test'
import { TasksScreen } from './TasksScreen'
import type { TaskCardProps } from '../../molecules/TaskCard/TaskCard'
const meta: Meta<typeof TasksScreen> = {
component: TasksScreen,
parameters: { layout: 'fullscreen' },
args: {
onRefresh: fn(),
onClearHistory: fn(),
},
}
export default meta
type Story = StoryObj<typeof TasksScreen>
const activeTask1: TaskCardProps = {
taskId: 'task-001',
status: 'running',
method: 'tools/call',
target: 'send_message',
progress: 45,
progressDescription: 'Sending message to remote server...',
startedAt: '2026-03-17T10:00:00Z',
onViewDetails: fn(),
onViewResult: fn(),
onCancel: fn(),
onDismiss: fn(),
}
const activeTask2: TaskCardProps = {
taskId: 'task-002',
status: 'waiting',
method: 'resources/read',
target: 'config.json',
startedAt: '2026-03-17T10:01:00Z',
onViewDetails: fn(),
onViewResult: fn(),
onCancel: fn(),
onDismiss: fn(),
}
const completedTask1: TaskCardProps = {
taskId: 'task-003',
status: 'completed',
method: 'tools/call',
target: 'list_users',
startedAt: '2026-03-17T09:50:00Z',
completedAt: '2026-03-17T09:50:02Z',
elapsed: '2.1s',
onViewDetails: fn(),
onViewResult: fn(),
onCancel: fn(),
onDismiss: fn(),
}
const completedTask2: TaskCardProps = {
taskId: 'task-004',
status: 'failed',
method: 'tools/call',
target: 'delete_records',
startedAt: '2026-03-17T09:45:00Z',
completedAt: '2026-03-17T09:45:01Z',
elapsed: '0.8s',
error: 'Permission denied: insufficient privileges',
onViewDetails: fn(),
onViewResult: fn(),
onCancel: fn(),
onDismiss: fn(),
}
export const Mixed: Story = {
args: {
activeTasks: [activeTask1, activeTask2],
completedTasks: [completedTask1, completedTask2],
},
}
export const ActiveOnly: Story = {
args: {
activeTasks: [activeTask1, activeTask2],
completedTasks: [],
},
}
export const CompletedOnly: Story = {
args: {
activeTasks: [],
completedTasks: [completedTask1, completedTask2],
},
}
export const Empty: Story = {
args: {
activeTasks: [],
completedTasks: [],
},
}

View file

@ -0,0 +1,55 @@
import { Button, Group, Stack, Text, Title } from '@mantine/core'
import { TaskCard } from '../../molecules/TaskCard/TaskCard'
import type { TaskCardProps } from '../../molecules/TaskCard/TaskCard'
export interface TasksScreenProps {
activeTasks: TaskCardProps[]
completedTasks: TaskCardProps[]
onRefresh: () => void
onClearHistory: () => void
}
export function TasksScreen({
activeTasks,
completedTasks,
onRefresh,
onClearHistory,
}: TasksScreenProps) {
return (
<Stack gap="lg">
<Group justify="space-between">
<Title order={2}>Tasks</Title>
<Button variant="light" onClick={onRefresh}>
Refresh
</Button>
</Group>
<Title order={4}>Active Tasks ({activeTasks.length})</Title>
{activeTasks.length === 0 ? (
<Text c="dimmed">No active tasks</Text>
) : (
<Stack gap="md">
{activeTasks.map((task) => (
<TaskCard key={task.taskId} {...task} />
))}
</Stack>
)}
<Group justify="space-between">
<Title order={4}>Completed Tasks ({completedTasks.length})</Title>
<Button variant="light" size="xs" onClick={onClearHistory}>
Clear History
</Button>
</Group>
{completedTasks.length === 0 ? (
<Text c="dimmed">No completed tasks</Text>
) : (
<Stack gap="md">
{completedTasks.map((task) => (
<TaskCard key={task.taskId} {...task} />
))}
</Stack>
)}
</Stack>
)
}

View file

@ -0,0 +1,113 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { fn } from 'storybook/test'
import { ToolsScreen } from './ToolsScreen'
import type { ToolListItemProps } from '../../molecules/ToolListItem/ToolListItem'
import type { ToolDetailPanelProps } from '../../molecules/ToolDetailPanel/ToolDetailPanel'
import type { ResultPanelProps } from '../../molecules/ResultPanel/ResultPanel'
const meta: Meta<typeof ToolsScreen> = {
component: ToolsScreen,
parameters: { layout: 'fullscreen' },
args: {
searchText: '',
listChanged: false,
onSearchChange: fn(),
onRefreshList: fn(),
onSelectTool: fn(),
},
}
export default meta
type Story = StoryObj<typeof ToolsScreen>
const sampleTools: ToolListItemProps[] = [
{ name: 'send_message', selected: false, onClick: fn() },
{
name: 'create_record',
annotations: [{ label: 'read-only' }],
selected: false,
onClick: fn(),
},
{
name: 'delete_records',
annotations: [{ label: 'destructive', variant: 'destructive' }],
selected: false,
onClick: fn(),
},
{ name: 'list_users', selected: false, onClick: fn() },
{
name: 'batch_process',
annotations: [{ label: 'long-run', variant: 'longRun' }],
selected: false,
onClick: fn(),
},
]
const selectedToolData: ToolDetailPanelProps = {
name: 'create_record',
description: 'Creates a new record with the given parameters',
schema: {
type: 'object',
properties: {
title: { type: 'string', description: 'Record title' },
count: { type: 'number', description: 'Number of items' },
enabled: { type: 'boolean', description: 'Whether the record is active' },
},
required: ['title'],
},
formValues: {},
isExecuting: false,
onFormChange: fn(),
onExecute: fn(),
onCancel: fn(),
}
const resultData: ResultPanelProps = {
content: [
{
type: 'text',
text: JSON.stringify(
{ id: 42, title: 'New Record', count: 5, enabled: true, createdAt: '2026-03-17T12:00:00Z' },
null,
2
),
},
],
onCopy: fn(),
onClear: fn(),
}
function toolsWithSelected(selectedName: string): ToolListItemProps[] {
return sampleTools.map((tool) => ({
...tool,
selected: tool.name === selectedName,
}))
}
export const NoSelection: Story = {
args: {
tools: sampleTools,
},
}
export const ToolSelected: Story = {
args: {
tools: toolsWithSelected('create_record'),
selectedTool: selectedToolData,
},
}
export const WithResult: Story = {
args: {
tools: toolsWithSelected('create_record'),
selectedTool: selectedToolData,
result: resultData,
},
}
export const WithListChanged: Story = {
args: {
tools: sampleTools,
listChanged: true,
},
}

View file

@ -0,0 +1,82 @@
import { Grid, Paper, Stack, Text, TextInput } from '@mantine/core'
import { ListChangedIndicator } from '../../atoms/ListChangedIndicator/ListChangedIndicator'
import { ToolListItem } from '../../molecules/ToolListItem/ToolListItem'
import { ToolDetailPanel } from '../../molecules/ToolDetailPanel/ToolDetailPanel'
import { ResultPanel } from '../../molecules/ResultPanel/ResultPanel'
import type { ToolListItemProps } from '../../molecules/ToolListItem/ToolListItem'
import type { ToolDetailPanelProps } from '../../molecules/ToolDetailPanel/ToolDetailPanel'
import type { ResultPanelProps } from '../../molecules/ResultPanel/ResultPanel'
export interface ToolsScreenProps {
tools: ToolListItemProps[]
selectedTool?: ToolDetailPanelProps
result?: ResultPanelProps
listChanged: boolean
searchText: string
onSearchChange: (text: string) => void
onRefreshList: () => void
onSelectTool: (name: string) => void
}
export function ToolsScreen({
tools,
selectedTool,
result,
listChanged,
searchText,
onSearchChange,
onRefreshList,
onSelectTool,
}: ToolsScreenProps) {
const filteredTools = searchText
? tools.filter((tool) => tool.name.toLowerCase().includes(searchText.toLowerCase()))
: tools
return (
<Grid>
<Grid.Col span={3}>
<Paper withBorder p="md">
<Stack gap="sm">
<ListChangedIndicator visible={listChanged} onRefresh={onRefreshList} />
<TextInput
placeholder="Search tools..."
value={searchText}
onChange={(event) => onSearchChange(event.currentTarget.value)}
/>
{filteredTools.map((tool) => (
<ToolListItem
key={tool.name}
{...tool}
onClick={() => onSelectTool(tool.name)}
/>
))}
</Stack>
</Paper>
</Grid.Col>
<Grid.Col span={5}>
<Paper withBorder p="md">
{selectedTool ? (
<ToolDetailPanel {...selectedTool} />
) : (
<Text c="dimmed" ta="center" py="xl">
Select a tool to view details
</Text>
)}
</Paper>
</Grid.Col>
<Grid.Col span={4}>
<Paper withBorder p="md">
{result ? (
<ResultPanel {...result} />
) : (
<Text c="dimmed" ta="center" py="xl">
Results will appear here
</Text>
)}
</Paper>
</Grid.Col>
</Grid>
)
}