Refactor renamed the component groups to something less obtuse:

Elements -> Groups -> Screens -> Views
This commit is contained in:
cliffhall 2026-03-28 12:58:47 -04:00
commit dd46acba87
14 changed files with 151 additions and 144 deletions

View file

@ -0,0 +1,126 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { HistoryScreen } from "./HistoryScreen";
import type { HistoryEntryProps } from "../../groups/HistoryEntry/HistoryEntry";
const meta: Meta<typeof HistoryScreen> = {
title: "Screens/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,155 @@
import {
Button,
Container,
Divider,
Group,
Paper,
Select,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { HistoryEntry } from "../../groups/HistoryEntry/HistoryEntry";
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 ContentPanel = Paper.withProps({
withBorder: true,
p: "md",
});
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) {
return (
<PageContainer>
<ContentPanel>
<Stack gap="md">
<HeaderRow>
<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
/>
<ToolbarButton onClick={onExport}>Export JSON</ToolbarButton>
<ToolbarButton onClick={onClearAll}>Clear All</ToolbarButton>
</Group>
</HeaderRow>
{entries.length === 0 ? (
<EmptyState>No request history</EmptyState>
) : (
<Stack gap="md">
{entries.map((entry) => (
<HistoryEntry key={entryKey(entry)} {...entry} />
))}
</Stack>
)}
{pinnedEntries.length > 0 && (
<>
<Divider />
<Title order={4}>{formatPinnedTitle(pinnedEntries.length)}</Title>
<Stack gap="sm">
{pinnedEntries.map((entry) => (
<HistoryEntry key={entryKey(entry)} {...entry} />
))}
</Stack>
</>
)}
<Group justify="flex-end">
<CountText>
{formatPagination(displayedCount, totalCount)}
</CountText>
{displayedCount < totalCount && (
<ToolbarButton onClick={onLoadMore}>Load More</ToolbarButton>
)}
</Group>
</Stack>
</ContentPanel>
</PageContainer>
);
}

View file

@ -0,0 +1,246 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { LoggingScreen } from "./LoggingScreen";
import type { LogControlsProps } from "../../groups/LogControls/LogControls";
import type { LogEntryProps, LogLevel } from "../../elements/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> = {
title: "Screens/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 logMessages: { level: LogLevel; message: string; logger?: string }[] = [
{ level: "info", message: "Server started on port 3000" },
{
level: "debug",
message: "Loading configuration from /etc/mcp/config.json",
logger: "config",
},
{
level: "warning",
message: "Deprecated API endpoint called: /v1/tools",
logger: "http",
},
{ level: "info", message: "Client connected: inspector-web-ui" },
{
level: "error",
message: "Failed to read resource: file not found at /data/missing.txt",
logger: "resources",
},
{
level: "info",
message: "Tool execution completed: search_files (245ms)",
logger: "tools",
},
{
level: "debug",
message: "Parsing JSON-RPC request body",
logger: "transport",
},
{
level: "info",
message: "Listing available tools for client session",
logger: "tools",
},
{
level: "warning",
message: "Slow query detected: 1200ms for resource lookup",
logger: "db",
},
{
level: "info",
message: "Resource subscription added: file:///config.json",
logger: "resources",
},
{
level: "debug",
message: "Heartbeat ping received from client",
logger: "session",
},
{
level: "error",
message: "Permission denied reading /etc/secrets.env",
logger: "resources",
},
{
level: "info",
message: "Prompt 'summarize' resolved with 2 messages",
logger: "prompts",
},
{
level: "debug",
message: "Cache hit for resource: db://users/42",
logger: "cache",
},
{
level: "info",
message: "Tool 'create_record' executed successfully in 89ms",
logger: "tools",
},
{
level: "warning",
message: "Client reconnection attempt #3",
logger: "session",
},
{
level: "info",
message: "New resource detected: file:///data/output.csv",
logger: "resources",
},
{
level: "debug",
message: "Serializing response payload (2.4KB)",
logger: "transport",
},
{
level: "error",
message: "Timeout waiting for tool response after 30s",
logger: "tools",
},
{
level: "info",
message: "Session initialized with capabilities: tools, resources, prompts",
},
{
level: "debug",
message: "Validating input schema for tool 'batch_process'",
logger: "tools",
},
{
level: "info",
message: "Resource template resolved: db://tables/users/rows/15",
logger: "resources",
},
{
level: "warning",
message: "Memory usage at 78% — consider increasing limits",
logger: "system",
},
{
level: "info",
message: "Client disconnected gracefully",
logger: "session",
},
{
level: "debug",
message: "Flushing log buffer to disk (128 entries)",
logger: "logging",
},
];
function pad(n: number): string {
return n.toString().padStart(2, "0");
}
const mixedEntries: LogEntryProps[] = Array.from({ length: 50 }, (_, i) => {
const src = logMessages[i % logMessages.length];
const seconds = pad(i + 1);
return {
timestamp: `2026-03-17T10:00:${seconds}Z`,
level: src.level,
message: src.message,
logger: src.logger,
};
});
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,106 @@
import {
Button,
Card,
Checkbox,
Container,
Grid,
Group,
ScrollArea,
Stack,
Text,
Title,
} from "@mantine/core";
import { LogControls } from "../../groups/LogControls/LogControls";
import { LogEntry } from "../../elements/LogEntry/LogEntry";
import type { LogControlsProps } from "../../groups/LogControls/LogControls";
import type { LogEntryProps } from "../../elements/LogEntry/LogEntry";
export interface LoggingScreenProps {
controls: LogControlsProps;
entries: LogEntryProps[];
autoScroll: boolean;
onToggleAutoScroll: () => void;
onCopyAll: () => void;
}
const PageContainer = Container.withProps({
size: "xl",
py: "xl",
});
const SidebarCard = Card.withProps({
withBorder: true,
padding: "lg",
});
const FullHeightCard = Card.withProps({
withBorder: true,
padding: "lg",
h: "100%",
});
const FullHeightStack = Stack.withProps({
gap: "sm",
h: "100%",
});
const ToolbarButton = Button.withProps({
variant: "light",
size: "sm",
});
const EmptyCenter = Stack.withProps({
flex: 1,
align: "center",
justify: "center",
});
export function LoggingScreen({
controls,
entries,
autoScroll,
onToggleAutoScroll,
onCopyAll,
}: LoggingScreenProps) {
return (
<PageContainer>
<Grid align="stretch">
<Grid.Col span={3}>
<SidebarCard>
<LogControls {...controls} />
</SidebarCard>
</Grid.Col>
<Grid.Col span={9}>
<FullHeightCard>
<FullHeightStack>
<Group justify="space-between">
<Title order={4}>Log Stream</Title>
<Group>
<Checkbox
label="Auto-scroll"
checked={autoScroll}
onChange={onToggleAutoScroll}
/>
<ToolbarButton onClick={onCopyAll}>Copy All</ToolbarButton>
</Group>
</Group>
{entries.length > 0 ? (
<ScrollArea flex={1}>
<Stack gap="xs">
{entries.map((entry, index) => (
<LogEntry key={index} {...entry} />
))}
</Stack>
</ScrollArea>
) : (
<EmptyCenter>
<Text c="dimmed">No log entries</Text>
</EmptyCenter>
)}
</FullHeightStack>
</FullHeightCard>
</Grid.Col>
</Grid>
</PageContainer>
);
}

View file

@ -0,0 +1,84 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { PromptsScreen } from "./PromptsScreen";
const meta: Meta<typeof PromptsScreen> = {
title: "Screens/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: {
onCopyAll: fn(),
messages: [
{
role: "user",
content:
'Translate the following text to Spanish: "Hello, how are you?"',
},
{
role: "assistant",
content: "Hola, como estas?",
},
],
},
listChanged: false,
},
};

View file

@ -0,0 +1,66 @@
import { Card, Container, Grid, Stack, Text } from "@mantine/core";
import { PromptArgumentsForm } from "../../groups/PromptArgumentsForm/PromptArgumentsForm";
import { PromptMessagesDisplay } from "../../groups/PromptMessagesDisplay/PromptMessagesDisplay";
import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator";
import type { PromptArgumentsFormProps } from "../../groups/PromptArgumentsForm/PromptArgumentsForm";
import type { PromptMessagesDisplayProps } from "../../groups/PromptMessagesDisplay/PromptMessagesDisplay";
export interface PromptsScreenProps {
promptForm: PromptArgumentsFormProps;
messages?: PromptMessagesDisplayProps;
listChanged: boolean;
onRefreshList: () => void;
}
const PageContainer = Container.withProps({
size: "xl",
py: "xl",
});
const FullHeightCard = Card.withProps({
withBorder: true,
padding: "lg",
h: "100%",
});
const EmptyState = Text.withProps({
c: "dimmed",
ta: "center",
py: "xl",
});
export function PromptsScreen({
promptForm,
messages,
listChanged,
onRefreshList,
}: PromptsScreenProps) {
return (
<PageContainer>
<Grid align="stretch">
<Grid.Col span={5}>
<FullHeightCard>
<Stack gap="md">
<ListChangedIndicator
visible={listChanged}
onRefresh={onRefreshList}
/>
<PromptArgumentsForm {...promptForm} />
</Stack>
</FullHeightCard>
</Grid.Col>
<Grid.Col span={7}>
<FullHeightCard>
{messages ? (
<PromptMessagesDisplay {...messages} />
) : (
<EmptyState>
Select a prompt and click Get Prompt to see messages
</EmptyState>
)}
</FullHeightCard>
</Grid.Col>
</Grid>
</PageContainer>
);
}

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 "../../groups/ResourceListItem/ResourceListItem";
import type { ResourceTemplateInputProps } from "../../groups/ResourceTemplateInput/ResourceTemplateInput";
import type { ResourcePreviewPanelProps } from "../../groups/ResourcePreviewPanel/ResourcePreviewPanel";
const meta: Meta<typeof ResourcesScreen> = {
title: "Screens/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,
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,174 @@
import {
Accordion,
Card,
Container,
Grid,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator";
import { ResourceListItem } from "../../groups/ResourceListItem/ResourceListItem";
import { ResourcePreviewPanel } from "../../groups/ResourcePreviewPanel/ResourcePreviewPanel";
import { ResourceTemplateInput } from "../../groups/ResourceTemplateInput/ResourceTemplateInput";
import type { ResourceListItemProps } from "../../groups/ResourceListItem/ResourceListItem";
import type { ResourcePreviewPanelProps } from "../../groups/ResourcePreviewPanel/ResourcePreviewPanel";
import type { ResourceTemplateInputProps } from "../../groups/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;
}
const PageContainer = Container.withProps({
size: "xl",
py: "xl",
});
const FullHeightCard = Card.withProps({
withBorder: true,
padding: "lg",
h: "100%",
});
const DetailCard = Card.withProps({
withBorder: true,
padding: "lg",
});
const EmptyState = Text.withProps({
c: "dimmed",
ta: "center",
py: "xl",
});
const SubscriptionMeta = Text.withProps({
span: true,
c: "dimmed",
size: "xs",
});
function formatSectionCount(label: string, count: number): string {
return `${label} (${count})`;
}
function formatLastUpdated(lastUpdated: string): string {
return ` — ${lastUpdated}`;
}
export function ResourcesScreen({
resources,
templates,
subscriptions,
selectedResource,
listChanged,
searchText,
onSearchChange,
onRefreshList,
}: ResourcesScreenProps) {
const filteredResources = resources.filter((r) =>
r.name.toLowerCase().includes(searchText.toLowerCase()),
);
const openSections = [
...(filteredResources.length > 0 ? ["resources"] : []),
...(templates.length > 0 ? ["templates"] : []),
...(subscriptions.length > 0 ? ["subscriptions"] : []),
];
return (
<PageContainer>
<Grid align="stretch">
<Grid.Col span={4}>
<FullHeightCard>
<Stack gap="sm">
<Title order={4}>Resources</Title>
<ListChangedIndicator
visible={listChanged}
onRefresh={onRefreshList}
/>
<TextInput
placeholder="Search..."
value={searchText}
onChange={(e) => onSearchChange(e.currentTarget.value)}
/>
<Accordion multiple defaultValue={openSections}>
<Accordion.Item value="resources">
<Accordion.Control disabled={filteredResources.length === 0}>
{formatSectionCount("URIs", 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 disabled={templates.length === 0}>
{formatSectionCount("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 disabled={subscriptions.length === 0}>
{formatSectionCount("Subscriptions", subscriptions.length)}
</Accordion.Control>
<Accordion.Panel>
<Stack gap="xs">
{subscriptions.map((sub) => (
<Text key={sub.name} size="sm">
{sub.name}
{sub.lastUpdated && (
<SubscriptionMeta>
{formatLastUpdated(sub.lastUpdated)}
</SubscriptionMeta>
)}
</Text>
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
</FullHeightCard>
</Grid.Col>
<Grid.Col span={8}>
<DetailCard>
{selectedResource ? (
<ResourcePreviewPanel {...selectedResource} />
) : (
<EmptyState>Select a resource to preview</EmptyState>
)}
</DetailCard>
</Grid.Col>
</Grid>
</PageContainer>
);
}

View file

@ -0,0 +1,122 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { ServerListScreen } from "./ServerListScreen";
import type { ServerCardProps } from "../../groups/ServerCard/ServerCard";
const meta: Meta<typeof ServerListScreen> = {
title: "Screens/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"
| "onServerInfo"
| "onSettings"
| "onEdit"
| "onClone"
| "onRemove"
| "onTestSampling"
| "onTestElicitationForm"
| "onTestElicitationUrl"
| "onConfigureRoots"
> {
return {
onToggleConnection: 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,59 @@
import { Container, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { ServerCard } from "../../groups/ServerCard/ServerCard";
import { AddServerMenu } from "../../groups/AddServerMenu/AddServerMenu";
import type { ServerCardProps } from "../../groups/ServerCard/ServerCard";
export interface ServerListScreenProps {
servers: ServerCardProps[];
onAddManually: () => void;
onImportConfig: () => void;
onImportServerJson: () => void;
}
const PageContainer = Container.withProps({
size: "xl",
py: "xl",
});
const EmptyState = Text.withProps({
c: "dimmed",
ta: "center",
py: "xl",
});
export function ServerListScreen({
servers,
onAddManually,
onImportConfig,
onImportServerJson,
}: ServerListScreenProps) {
return (
<PageContainer>
<Stack gap="lg">
<Group justify="flex-end">
<AddServerMenu
onAddManually={onAddManually}
onImportConfig={onImportConfig}
onImportServerJson={onImportServerJson}
/>
</Group>
{servers.length === 0 ? (
<EmptyState>
No servers configured. Add a server to get started.
</EmptyState>
) : (
<SimpleGrid
cols={{ base: 1, md: 2 }}
spacing="lg"
className="grid-align-start"
>
{servers.map((server) => (
<ServerCard key={server.name} {...server} />
))}
</SimpleGrid>
)}
</Stack>
</PageContainer>
);
}

View file

@ -0,0 +1,100 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { TasksScreen } from "./TasksScreen";
import type { TaskCardProps } from "../../groups/TaskCard/TaskCard";
const meta: Meta<typeof TasksScreen> = {
title: "Screens/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,88 @@
import {
Button,
Container,
Group,
Paper,
Stack,
Text,
Title,
} from "@mantine/core";
import { TaskCard } from "../../groups/TaskCard/TaskCard";
import type { TaskCardProps } from "../../groups/TaskCard/TaskCard";
export interface TasksScreenProps {
activeTasks: TaskCardProps[];
completedTasks: TaskCardProps[];
onRefresh: () => void;
onClearHistory: () => void;
}
const PageContainer = Container.withProps({
size: "xl",
py: "xl",
});
const ContentPanel = Paper.withProps({
withBorder: true,
p: "md",
});
const CompactButton = Button.withProps({
variant: "light",
size: "xs",
});
function formatSectionTitle(label: string, count: number): string {
return `${label} (${count})`;
}
export function TasksScreen({
activeTasks,
completedTasks,
onRefresh,
onClearHistory,
}: TasksScreenProps) {
return (
<PageContainer>
<ContentPanel>
<Stack gap="lg">
<Group justify="space-between">
<Title order={4}>
{formatSectionTitle("Active Tasks", activeTasks.length)}
</Title>
<CompactButton onClick={onRefresh}>Refresh Tasks</CompactButton>
</Group>
{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}>
{formatSectionTitle("Completed Tasks", completedTasks.length)}
</Title>
{completedTasks.length > 0 && (
<CompactButton onClick={onClearHistory}>
Clear History
</CompactButton>
)}
</Group>
{completedTasks.length === 0 ? (
<Text c="dimmed">No completed tasks</Text>
) : (
<Stack gap="md">
{completedTasks.map((task) => (
<TaskCard key={task.taskId} {...task} />
))}
</Stack>
)}
</Stack>
</ContentPanel>
</PageContainer>
);
}

View file

@ -0,0 +1,169 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { ToolsScreen } from "./ToolsScreen";
import type { ToolListItemProps } from "../../groups/ToolListItem/ToolListItem";
import type { ToolDetailPanelProps } from "../../groups/ToolDetailPanel/ToolDetailPanel";
import type { ResultPanelProps } from "../../groups/ResultPanel/ResultPanel";
const meta: Meta<typeof ToolsScreen> = {
title: "Screens/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",
title: "Send Message",
selected: false,
onClick: fn(),
},
{
name: "create_record",
title: "Create Record",
selected: false,
onClick: fn(),
},
{
name: "delete_records",
selected: false,
onClick: fn(),
},
{ name: "list_users", selected: false, onClick: fn() },
{
name: "batch_process",
selected: false,
onClick: fn(),
},
];
const selectedToolData: ToolDetailPanelProps = {
name: "create_record",
title: "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,
),
},
],
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 LongToolName: Story = {
args: {
tools: [
...sampleTools,
{
name: "organization_internal_database_complex_multi_table_join_query_with_aggregation_and_filtering",
selected: true,
onClick: fn(),
},
],
selectedTool: {
name: "organization_internal_database_complex_multi_table_join_query_with_aggregation_and_filtering",
annotations: {
readOnly: true,
longRunning: true,
},
description:
"Executes a complex multi-table join query across the organization's internal database with support for aggregation functions, nested filtering, and pagination of large result sets.",
schema: {
type: "object",
properties: {
primary_table_name: {
type: "string",
description: "The main table to query from",
},
join_configuration: {
type: "string",
description: "JSON configuration for table joins",
},
aggregation_functions: {
type: "string",
description:
"Comma-separated list of aggregation functions to apply",
},
},
required: ["primary_table_name"],
},
formValues: {},
isExecuting: false,
onFormChange: fn(),
onExecute: fn(),
onCancel: fn(),
},
},
};
export const WithListChanged: Story = {
args: {
tools: sampleTools,
listChanged: true,
},
};

View file

@ -0,0 +1,111 @@
import {
Card,
Container,
Grid,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator";
import { ToolListItem } from "../../groups/ToolListItem/ToolListItem";
import { ToolDetailPanel } from "../../groups/ToolDetailPanel/ToolDetailPanel";
import { ResultPanel } from "../../groups/ResultPanel/ResultPanel";
import type { ToolListItemProps } from "../../groups/ToolListItem/ToolListItem";
import type { ToolDetailPanelProps } from "../../groups/ToolDetailPanel/ToolDetailPanel";
import type { ResultPanelProps } from "../../groups/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;
}
const PageContainer = Container.withProps({
size: "xl",
py: "xl",
});
const FullHeightCard = Card.withProps({
withBorder: true,
padding: "lg",
h: "100%",
});
const EmptyState = Text.withProps({
c: "dimmed",
ta: "center",
py: "xl",
});
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 (
<PageContainer>
<Grid align="stretch">
<Grid.Col span={3}>
<FullHeightCard>
<Stack gap="sm">
<Title order={4}>Tools</Title>
<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>
</FullHeightCard>
</Grid.Col>
<Grid.Col span={5}>
<FullHeightCard>
{selectedTool ? (
<ToolDetailPanel {...selectedTool} />
) : (
<EmptyState>Select a tool to view details</EmptyState>
)}
</FullHeightCard>
</Grid.Col>
<Grid.Col span={4}>
<FullHeightCard>
{result ? (
<ResultPanel {...result} />
) : (
<EmptyState>Results will appear here</EmptyState>
)}
</FullHeightCard>
</Grid.Col>
</Grid>
</PageContainer>
);
}