Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import { fn, userEvent, within } from "storybook/test";
import { AppControls } from "./AppControls";
import { SUN_ICON_SVG } from "../../../test/fixtures/storyIcons";

const sampleApps: Tool[] = [
{
name: "get-cohort-data",
title: "Cohort Data",
description: "Returns cohort retention heatmap data.",
inputSchema: { type: "object" },
_meta: { ui: { resourceUri: "ui://apps/cohort" } },
},
{
name: "weather-widget",
title: "Weather Widget",
description: "Live weather and a five-day forecast for any city.",
icons: [{ src: SUN_ICON_SVG, mimeType: "image/svg+xml" }],
inputSchema: { type: "object" },
_meta: { ui: { resourceUri: "ui://apps/weather" } },
},
{
name: "ops-dashboard",
title: "Ops Dashboard",
description: "Current operational status across services.",
inputSchema: { type: "object" },
_meta: { ui: { resourceUri: "ui://apps/ops" } },
},
{
name: "git_log",
description: "Recent commits on the current branch.",
inputSchema: { type: "object" },
_meta: { ui: { resourceUri: "ui://apps/git-log" } },
},
];

const meta: Meta<typeof AppControls> = {
title: "Groups/AppControls",
component: AppControls,
args: {
onRefreshList: fn(),
onSelectApp: fn(),
listChanged: false,
},
};

export default meta;
type Story = StoryObj<typeof AppControls>;

export const Default: Story = {
args: {
tools: sampleApps,
},
};

export const WithSelection: Story = {
args: {
tools: sampleApps,
selectedName: "weather-widget",
},
};

export const WithSearch: Story = {
args: {
tools: sampleApps,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(
await canvas.findByPlaceholderText("Search apps..."),
"git",
);
},
};

export const ListChanged: Story = {
args: {
tools: sampleApps,
listChanged: true,
},
};

export const Empty: Story = {
args: {
tools: [],
},
};
131 changes: 131 additions & 0 deletions clients/web/src/components/groups/AppControls/AppControls.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { AppControls } from "./AppControls";

const sampleApps: Tool[] = [
{
name: "weather",
title: "Weather Widget",
inputSchema: { type: "object" },
_meta: { ui: { resourceUri: "ui://apps/weather" } },
},
{
name: "ops",
title: "Ops Dashboard",
inputSchema: { type: "object" },
_meta: { ui: { resourceUri: "ui://apps/ops" } },
},
{
name: "git_status",
inputSchema: { type: "object" },
_meta: { ui: { resourceUri: "ui://apps/git-status" } },
},
];

const baseProps = {
tools: sampleApps,
listChanged: false,
onRefreshList: vi.fn(),
onSelectApp: vi.fn(),
};

describe("AppControls", () => {
it("renders the title with the app count and a search input", () => {
renderWithMantine(<AppControls {...baseProps} />);
expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Search apps...")).toBeInTheDocument();
});

it("renders all apps by default", () => {
renderWithMantine(<AppControls {...baseProps} />);
expect(screen.getByText("Weather Widget")).toBeInTheDocument();
expect(screen.getByText("Ops Dashboard")).toBeInTheDocument();
expect(screen.getByText("git_status")).toBeInTheDocument();
});

it("filters apps by name when typing in the search input", async () => {
const user = userEvent.setup();
renderWithMantine(<AppControls {...baseProps} />);
await user.type(screen.getByPlaceholderText("Search apps..."), "git");
expect(screen.getByText("git_status")).toBeInTheDocument();
expect(screen.queryByText("Weather Widget")).not.toBeInTheDocument();
});

it("filters apps by title when typing in the search input", async () => {
const user = userEvent.setup();
renderWithMantine(<AppControls {...baseProps} />);
await user.type(
screen.getByPlaceholderText("Search apps..."),
"weather widget",
);
expect(screen.getByText("Weather Widget")).toBeInTheDocument();
expect(screen.queryByText("Ops Dashboard")).not.toBeInTheDocument();
});

it("shows 'No apps available' when the tool list is empty", () => {
renderWithMantine(<AppControls {...baseProps} tools={[]} />);
expect(screen.getByText("No apps available")).toBeInTheDocument();
expect(screen.getByText("MCP Apps (0)")).toBeInTheDocument();
});

it("shows 'No matching apps' when search yields no results", async () => {
const user = userEvent.setup();
renderWithMantine(<AppControls {...baseProps} />);
await user.type(screen.getByPlaceholderText("Search apps..."), "zzz");
expect(screen.getByText("No matching apps")).toBeInTheDocument();
});

it("invokes onSelectApp when an unselected app is clicked", async () => {
const user = userEvent.setup();
const onSelectApp = vi.fn();
renderWithMantine(<AppControls {...baseProps} onSelectApp={onSelectApp} />);
await user.click(screen.getByText("git_status"));
expect(onSelectApp).toHaveBeenCalledWith("git_status");
});

it("does not invoke onSelectApp when the already-selected app is clicked", async () => {
const user = userEvent.setup();
const onSelectApp = vi.fn();
renderWithMantine(
<AppControls
{...baseProps}
selectedName="git_status"
onSelectApp={onSelectApp}
/>,
);
await user.click(screen.getByText("git_status"));
expect(onSelectApp).not.toHaveBeenCalled();
});

it("invokes onRefreshList when the toolbar Refresh button is clicked", async () => {
const user = userEvent.setup();
const onRefreshList = vi.fn();
renderWithMantine(
<AppControls {...baseProps} onRefreshList={onRefreshList} />,
);
await user.click(screen.getByRole("button", { name: "Refresh" }));
expect(onRefreshList).toHaveBeenCalledTimes(1);
});

it("does not show the list-changed indicator when listChanged is false", () => {
renderWithMantine(<AppControls {...baseProps} />);
expect(screen.queryByText("List updated")).not.toBeInTheDocument();
});

it("shows the list-changed indicator when listChanged is true", async () => {
const user = userEvent.setup();
const onRefreshList = vi.fn();
renderWithMantine(
<AppControls {...baseProps} listChanged onRefreshList={onRefreshList} />,
);
expect(screen.getByText("List updated")).toBeInTheDocument();
// Both the toolbar button and the list-changed indicator's button render
// as "Refresh"; either one should drive onRefreshList.
const refreshButtons = screen.getAllByRole("button", { name: "Refresh" });
expect(refreshButtons).toHaveLength(2);
await user.click(refreshButtons[1]);
expect(onRefreshList).toHaveBeenCalledTimes(1);
});
});
88 changes: 88 additions & 0 deletions clients/web/src/components/groups/AppControls/AppControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { useState } from "react";
import {
Button,
Group,
ScrollArea,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator";
import { AppListItem } from "../AppListItem/AppListItem";

export interface AppControlsProps {
tools: Tool[];
selectedName?: string;
listChanged: boolean;
onRefreshList: () => void;
onSelectApp: (name: string) => void;
}

const LIST_MAX_HEIGHT =
"calc(100vh - var(--app-shell-header-height, 0px) - var(--mantine-spacing-xl) * 2 - 220px)";

const ToolbarButton = Button.withProps({
variant: "subtle",
size: "sm",
});

const EmptyState = Text.withProps({
c: "dimmed",
ta: "center",
py: "xl",
});

export function AppControls({
tools,
selectedName,
listChanged,
onRefreshList,
onSelectApp,
}: AppControlsProps) {
const [searchText, setSearchText] = useState("");
const query = searchText.toLowerCase();
const filteredTools = searchText
? tools.filter(
(tool) =>
tool.name.toLowerCase().includes(query) ||
(tool.title?.toLowerCase().includes(query) ?? false),
)
: tools;

return (
<Stack gap="sm">
<Group justify="space-between">
<Title order={4}>MCP Apps ({tools.length})</Title>
<ToolbarButton onClick={onRefreshList}>Refresh</ToolbarButton>
</Group>
<ListChangedIndicator visible={listChanged} onRefresh={onRefreshList} />
<TextInput
placeholder="Search apps..."
value={searchText}
onChange={(e) => setSearchText(e.currentTarget.value)}
/>
<ScrollArea.Autosize mah={LIST_MAX_HEIGHT}>
<Stack gap="xs">
{filteredTools.length === 0 ? (
<EmptyState>
{tools.length === 0 ? "No apps available" : "No matching apps"}
</EmptyState>
) : (
filteredTools.map((tool) => (
<AppListItem
key={tool.name}
tool={tool}
selected={tool.name === selectedName}
onClick={() => {
if (tool.name !== selectedName) onSelectApp(tool.name);
}}
/>
))
)}
</Stack>
</ScrollArea.Autosize>
</Stack>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { AppDetailPanel } from "./AppDetailPanel";

const ICON_SRC = "data:image/svg+xml,%3Csvg/%3E";

const noFieldsTool: Tool = {
name: "no_input_app",
title: "No Input App",
Expand All @@ -25,16 +23,6 @@ const requiredFieldTool: Tool = {
},
};

const optionalFieldTool: Tool = {
name: "greet",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "The name to greet" },
},
},
};

const baseProps = {
formValues: {},
isOpening: false,
Expand All @@ -43,20 +31,6 @@ const baseProps = {
};

describe("AppDetailPanel", () => {
it("prefers the title over the name", () => {
renderWithMantine(
<AppDetailPanel {...baseProps} tool={requiredFieldTool} />,
);
expect(screen.getByText("Greet")).toBeInTheDocument();
});

it("falls back to the name when title is missing", () => {
renderWithMantine(
<AppDetailPanel {...baseProps} tool={optionalFieldTool} />,
);
expect(screen.getByText("greet")).toBeInTheDocument();
});

it("renders the description when provided", () => {
renderWithMantine(<AppDetailPanel {...baseProps} tool={noFieldsTool} />);
expect(screen.getByText("Takes no parameters")).toBeInTheDocument();
Expand All @@ -69,22 +43,6 @@ describe("AppDetailPanel", () => {
expect(screen.queryByText("Takes no parameters")).not.toBeInTheDocument();
});

it("renders the first icon when tool.icons is present", () => {
renderWithMantine(
<AppDetailPanel
{...baseProps}
tool={{ ...noFieldsTool, icons: [{ src: ICON_SRC }] }}
/>,
);
const img = screen.getByRole("presentation");
expect(img).toHaveAttribute("src", ICON_SRC);
});

it("does not render an icon when tool.icons is missing", () => {
renderWithMantine(<AppDetailPanel {...baseProps} tool={noFieldsTool} />);
expect(screen.queryByRole("presentation")).not.toBeInTheDocument();
});

it("renders the schema form using the tool's inputSchema", () => {
renderWithMantine(
<AppDetailPanel {...baseProps} tool={requiredFieldTool} />,
Expand Down
Loading