diff --git a/clients/web/src/components/groups/AppControls/AppControls.stories.tsx b/clients/web/src/components/groups/AppControls/AppControls.stories.tsx new file mode 100644 index 000000000..925323480 --- /dev/null +++ b/clients/web/src/components/groups/AppControls/AppControls.stories.tsx @@ -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 = { + title: "Groups/AppControls", + component: AppControls, + args: { + onRefreshList: fn(), + onSelectApp: fn(), + listChanged: false, + }, +}; + +export default meta; +type Story = StoryObj; + +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: [], + }, +}; diff --git a/clients/web/src/components/groups/AppControls/AppControls.test.tsx b/clients/web/src/components/groups/AppControls/AppControls.test.tsx new file mode 100644 index 000000000..c66ce1224 --- /dev/null +++ b/clients/web/src/components/groups/AppControls/AppControls.test.tsx @@ -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(); + expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Search apps...")).toBeInTheDocument(); + }); + + it("renders all apps by default", () => { + renderWithMantine(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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( + , + ); + 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( + , + ); + 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(); + 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( + , + ); + 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); + }); +}); diff --git a/clients/web/src/components/groups/AppControls/AppControls.tsx b/clients/web/src/components/groups/AppControls/AppControls.tsx new file mode 100644 index 000000000..59a29329d --- /dev/null +++ b/clients/web/src/components/groups/AppControls/AppControls.tsx @@ -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 ( + + + MCP Apps ({tools.length}) + Refresh + + + setSearchText(e.currentTarget.value)} + /> + + + {filteredTools.length === 0 ? ( + + {tools.length === 0 ? "No apps available" : "No matching apps"} + + ) : ( + filteredTools.map((tool) => ( + { + if (tool.name !== selectedName) onSelectApp(tool.name); + }} + /> + )) + )} + + + + ); +} diff --git a/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.test.tsx b/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.test.tsx index a71c2972a..9ba090b88 100644 --- a/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.test.tsx +++ b/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.test.tsx @@ -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", @@ -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, @@ -43,20 +31,6 @@ const baseProps = { }; describe("AppDetailPanel", () => { - it("prefers the title over the name", () => { - renderWithMantine( - , - ); - expect(screen.getByText("Greet")).toBeInTheDocument(); - }); - - it("falls back to the name when title is missing", () => { - renderWithMantine( - , - ); - expect(screen.getByText("greet")).toBeInTheDocument(); - }); - it("renders the description when provided", () => { renderWithMantine(); expect(screen.getByText("Takes no parameters")).toBeInTheDocument(); @@ -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( - , - ); - const img = screen.getByRole("presentation"); - expect(img).toHaveAttribute("src", ICON_SRC); - }); - - it("does not render an icon when tool.icons is missing", () => { - renderWithMantine(); - expect(screen.queryByRole("presentation")).not.toBeInTheDocument(); - }); - it("renders the schema form using the tool's inputSchema", () => { renderWithMantine( , diff --git a/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx b/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx index 8aea75200..d9fa34904 100644 --- a/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx +++ b/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx @@ -1,8 +1,8 @@ -import { Button, Divider, Group, Image, Stack, Text } from "@mantine/core"; +import { Button, Divider, Stack, Text } from "@mantine/core"; import { MdPlayArrow } from "react-icons/md"; import type { Tool } from "@modelcontextprotocol/sdk/types.js"; -import { resolveDisplayLabel } from "../../../utils/toolUtils"; import { SchemaForm } from "../SchemaForm/SchemaForm"; +import { hasInputFields } from "../../../utils/toolUtils"; export interface AppDetailPanelProps { tool: Tool; @@ -12,29 +12,11 @@ export interface AppDetailPanelProps { onOpenApp: () => void; } -const PanelTitle = Text.withProps({ - fw: 700, - size: "lg", - truncate: true, -}); - const DescriptionText = Text.withProps({ size: "sm", c: "dimmed", }); -const TitleRow = Group.withProps({ - gap: "sm", - align: "center", - wrap: "nowrap", -}); - -const PanelIcon = Image.withProps({ - w: 24, - h: 24, - fit: "contain", -}); - function hasMissingRequiredFields( schema: Tool["inputSchema"], values: Record, @@ -53,19 +35,13 @@ export function AppDetailPanel({ onFormChange, onOpenApp, }: AppDetailPanelProps) { - const { name, title, description, icons, inputSchema } = tool; - const iconSrc = icons?.[0]?.src; + const { description, inputSchema } = tool; const hasErrors = hasMissingRequiredFields(inputSchema, formValues); const disabled = isOpening || hasErrors; - const hasFields = Object.keys(inputSchema.properties ?? {}).length > 0; + const hasFields = hasInputFields(tool); return ( - - {iconSrc && } - {resolveDisplayLabel(name, title)} - - {description && {description}} {hasFields && } diff --git a/clients/web/src/components/groups/ResourceListItem/ResourceListItem.stories.tsx b/clients/web/src/components/groups/ResourceListItem/ResourceListItem.stories.tsx index e1541e402..f68dce0a0 100644 --- a/clients/web/src/components/groups/ResourceListItem/ResourceListItem.stories.tsx +++ b/clients/web/src/components/groups/ResourceListItem/ResourceListItem.stories.tsx @@ -53,48 +53,3 @@ export const Template: Story = { selected: false, }, }; - -export const WithAudience: Story = { - args: { - resource: { - name: "settings.json", - uri: "file:///settings.json", - annotations: { audience: ["user"] }, - }, - selected: false, - }, -}; - -export const WithPriority: Story = { - args: { - resource: { - name: "schema.sql", - uri: "file:///schema.sql", - annotations: { priority: 0.8 }, - }, - selected: false, - }, -}; - -export const WithAudienceAndPriority: Story = { - args: { - resource: { - name: "config.json", - title: "Configuration File", - uri: "file:///config.json", - annotations: { audience: ["user", "assistant"], priority: 0.5 }, - }, - selected: false, - }, -}; - -export const TemplateWithAnnotations: Story = { - args: { - resource: { - name: "User Profile", - uriTemplate: "file:///users/{userId}/profile", - annotations: { audience: ["assistant"], priority: 0.2 }, - }, - selected: false, - }, -}; diff --git a/clients/web/src/components/groups/ResourceListItem/ResourceListItem.test.tsx b/clients/web/src/components/groups/ResourceListItem/ResourceListItem.test.tsx index a05423eb7..e5b749397 100644 --- a/clients/web/src/components/groups/ResourceListItem/ResourceListItem.test.tsx +++ b/clients/web/src/components/groups/ResourceListItem/ResourceListItem.test.tsx @@ -29,7 +29,7 @@ describe("ResourceListItem", () => { expect(screen.getByText("Configuration")).toBeInTheDocument(); }); - it("renders annotation badges when annotations are present", () => { + it("does not render annotation badges even when annotations are present", () => { renderWithMantine( { onClick={() => {}} />, ); - expect(screen.getByText("audience: user")).toBeInTheDocument(); - expect(screen.getByText("priority: high")).toBeInTheDocument(); - }); - - it("hides badges when only an empty audience annotation exists", () => { - renderWithMantine( - {}} - />, - ); expect(screen.queryByText(/audience/)).not.toBeInTheDocument(); + expect(screen.queryByText(/priority/)).not.toBeInTheDocument(); }); it("invokes onClick when the row is clicked", async () => { diff --git a/clients/web/src/components/groups/ResourceListItem/ResourceListItem.tsx b/clients/web/src/components/groups/ResourceListItem/ResourceListItem.tsx index 7059b5b69..3dbdf99be 100644 --- a/clients/web/src/components/groups/ResourceListItem/ResourceListItem.tsx +++ b/clients/web/src/components/groups/ResourceListItem/ResourceListItem.tsx @@ -1,9 +1,8 @@ -import { Group, Text, UnstyledButton } from "@mantine/core"; +import { Text, UnstyledButton } from "@mantine/core"; import type { Resource, ResourceTemplate, } from "@modelcontextprotocol/sdk/types.js"; -import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge"; export interface ResourceListItemProps { resource: Resource | ResourceTemplate; @@ -11,28 +10,11 @@ export interface ResourceListItemProps { onClick: () => void; } -const RowGroup = Group.withProps({ - gap: "xs", - wrap: "wrap", - justify: "space-between", -}); - -const BadgeGroup = Group.withProps({ - gap: "xs", - wrap: "wrap", -}); - export function ResourceListItem({ resource, selected, onClick, }: ResourceListItemProps) { - const audience = resource.annotations?.audience; - const priority = resource.annotations?.priority; - const showBadges = - (audience !== undefined && audience.length > 0) || priority !== undefined; - const label = {resource.title ?? resource.name}; - return ( - {showBadges ? ( - - {label} - - {audience !== undefined && audience.length > 0 && ( - - )} - {priority !== undefined && ( - - )} - - - ) : ( - label - )} + {resource.title ?? resource.name} ); } diff --git a/clients/web/src/components/screens/AppsScreen/AppsScreen.stories.tsx b/clients/web/src/components/screens/AppsScreen/AppsScreen.stories.tsx new file mode 100644 index 000000000..e70575f78 --- /dev/null +++ b/clients/web/src/components/screens/AppsScreen/AppsScreen.stories.tsx @@ -0,0 +1,151 @@ +import { useRef } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; +import { fn, userEvent, within } from "storybook/test"; +import { AppsScreen, type AppsScreenProps } from "./AppsScreen"; +import type { + AppRendererHandle, + BridgeFactory, +} from "../../elements/AppRenderer/AppRenderer"; +import { SUN_ICON_SVG } from "../../../test/fixtures/storyIcons"; + +const PLACEHOLDER_SANDBOX = "data:text/html,Mock%20Sandbox"; + +function createMockBridge(): AppBridge { + return { + sendToolInput: async () => {}, + sendToolResult: async () => {}, + sendToolCancelled: async () => {}, + teardownResource: async () => ({}), + close: async () => {}, + } as unknown as AppBridge; +} + +const okBridgeFactory: BridgeFactory = () => createMockBridge(); + +const cohortApp: Tool = { + name: "get-cohort-data", + title: "Cohort Data", + description: "Returns cohort retention heatmap data.", + inputSchema: { + type: "object", + properties: { + metric: { type: "string", description: "retention | engagement" }, + periodType: { type: "string", description: "daily | weekly | monthly" }, + cohortCount: { type: "number", description: "Cohorts to render" }, + maxPeriods: { type: "number", description: "Periods per cohort" }, + }, + required: ["metric", "periodType"], + }, + _meta: { ui: { resourceUri: "ui://apps/cohort-heatmap" } }, +}; + +const weatherApp: Tool = { + 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", + properties: { + city: { type: "string", description: "City name" }, + }, + required: ["city"], + }, + _meta: { ui: { resourceUri: "ui://apps/weather" } }, +}; + +const dashboardApp: Tool = { + name: "ops-dashboard", + title: "Ops Dashboard", + description: "Current operational status across services.", + inputSchema: { type: "object" }, + _meta: { ui: { resourceUri: "ui://apps/ops" } }, +}; + +const sampleApps: Tool[] = [cohortApp, weatherApp, dashboardApp]; + +const meta: Meta = { + title: "Screens/AppsScreen", + component: AppsScreen, + parameters: { layout: "fullscreen" }, + args: { + sandboxPath: PLACEHOLDER_SANDBOX, + bridgeFactory: okBridgeFactory, + listChanged: false, + onRefreshList: fn(), + onSelectApp: fn(), + onOpenApp: fn(), + onCloseApp: fn(), + }, + // Each story uses its own ref so AppRenderer's imperative handle gets a + // fresh slot per render (Storybook may keep the canvas mounted across + // arg edits, but the ref itself is owned by the wrapping component). + render: function StoryRender(args: AppsScreenProps) { + const ref = useRef(null); + return ; + }, +}; + +export default meta; +type Story = StoryObj; + +async function selectByLabel(canvasElement: HTMLElement, label: string) { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByText(label)); +} + +async function clickByName(canvasElement: HTMLElement, name: RegExp) { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole("button", { name })); +} + +export const NoSelection: Story = { + args: { tools: sampleApps }, +}; + +export const AppSelected: Story = { + args: { tools: sampleApps }, + play: async ({ canvasElement }) => { + await selectByLabel(canvasElement, "Cohort Data"); + }, +}; + +export const AppRunning: Story = { + args: { tools: sampleApps }, + play: async ({ canvasElement }) => { + await selectByLabel(canvasElement, "Weather Widget"); + const canvas = within(canvasElement); + const cityField = await canvas.findByRole("textbox", { name: /city/i }); + await userEvent.type(cityField, "Reykjavik"); + await clickByName(canvasElement, /open app/i); + }, +}; + +export const AppRunningMaximized: Story = { + args: { tools: sampleApps }, + play: async ({ canvasElement }) => { + await selectByLabel(canvasElement, "Weather Widget"); + const canvas = within(canvasElement); + const cityField = await canvas.findByRole("textbox", { name: /city/i }); + await userEvent.type(cityField, "Reykjavik"); + await clickByName(canvasElement, /open app/i); + await userEvent.click(await canvas.findByLabelText("Maximize")); + }, +}; + +export const NoFieldsApp: Story = { + args: { tools: sampleApps }, + play: async ({ canvasElement }) => { + await selectByLabel(canvasElement, "Ops Dashboard"); + }, +}; + +export const WithListChanged: Story = { + args: { tools: sampleApps, listChanged: true }, +}; + +export const Empty: Story = { + args: { tools: [] }, +}; diff --git a/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx b/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx new file mode 100644 index 000000000..24bec8fe6 --- /dev/null +++ b/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx @@ -0,0 +1,282 @@ +import { createRef } from "react"; +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; +import { + renderWithMantine, + screen, + within, +} from "../../../test/renderWithMantine"; +import { AppsScreen } from "./AppsScreen"; +import type { + AppRendererHandle, + BridgeFactory, +} from "../../elements/AppRenderer/AppRenderer"; + +const noFieldsApp: Tool = { + name: "ops", + title: "Ops Dashboard", + inputSchema: { type: "object" }, + _meta: { ui: { resourceUri: "ui://apps/ops" } }, +}; + +const fieldedApp: Tool = { + name: "weather", + title: "Weather Widget", + inputSchema: { + type: "object", + properties: { + city: { type: "string", description: "City name" }, + }, + required: ["city"], + }, + _meta: { ui: { resourceUri: "ui://apps/weather" } }, +}; + +const cohortApp: Tool = { + name: "cohorts", + title: "Cohort Data", + description: "Cohort retention", + inputSchema: { + type: "object", + properties: { metric: { type: "string" } }, + }, + _meta: { ui: { resourceUri: "ui://apps/cohorts" } }, +}; + +const okBridgeFactory: BridgeFactory = () => + ({ + sendToolInput: async () => {}, + sendToolResult: async () => {}, + sendToolCancelled: async () => {}, + teardownResource: async () => ({}), + close: async () => {}, + }) as unknown as AppBridge; + +function buildProps(overrides: Partial[0]> = {}) { + return { + tools: [fieldedApp, noFieldsApp, cohortApp] as Tool[], + listChanged: false, + // happy-dom would otherwise try to fetch the iframe `src` over the + // network. A data URL keeps the AppRenderer mountable without leaving + // the test environment. + sandboxPath: "data:text/html,sandbox", + bridgeFactory: okBridgeFactory, + rendererRef: createRef(), + onRefreshList: vi.fn(), + onSelectApp: vi.fn(), + onOpenApp: vi.fn(), + onCloseApp: vi.fn(), + ...overrides, + }; +} + +describe("AppsScreen", () => { + it("renders the empty selection state", () => { + renderWithMantine(); + expect(screen.getByText("Select an app to view details")).toBeVisible(); + expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument(); + }); + + it("shows 'No apps available' when the tool list is empty", () => { + renderWithMantine(); + expect(screen.getByText("No apps available")).toBeInTheDocument(); + expect(screen.getByText("MCP Apps (0)")).toBeInTheDocument(); + }); + + it("filters the list via the search input", async () => { + const user = userEvent.setup(); + renderWithMantine(); + await user.type(screen.getByPlaceholderText("Search apps..."), "weather"); + expect(screen.getByText("Weather Widget")).toBeInTheDocument(); + expect(screen.queryByText("Ops Dashboard")).not.toBeInTheDocument(); + }); + + it("shows 'No matching apps' when search yields no results", async () => { + const user = userEvent.setup(); + renderWithMantine(); + await user.type(screen.getByPlaceholderText("Search apps..."), "zzz"); + expect(screen.getByText("No matching apps")).toBeInTheDocument(); + }); + + it("opens the detail panel when a fielded app is selected", async () => { + const user = userEvent.setup(); + const onSelectApp = vi.fn(); + const onOpenApp = vi.fn(); + renderWithMantine( + , + ); + await user.click(screen.getByText("Weather Widget")); + expect(onSelectApp).toHaveBeenCalledWith("weather"); + expect(onOpenApp).not.toHaveBeenCalled(); + expect( + screen.getByRole("button", { name: /Open App/ }), + ).toBeInTheDocument(); + }); + + it("auto-launches a no-fields app on selection", async () => { + const user = userEvent.setup(); + const onOpenApp = vi.fn(); + renderWithMantine(); + await user.click(screen.getByText("Ops Dashboard")); + expect(onOpenApp).toHaveBeenCalledWith("ops", {}); + // Renderer iframe replaces the form; Open App button is gone. + expect( + screen.queryByRole("button", { name: /Open App/ }), + ).not.toBeInTheDocument(); + expect(screen.getByTitle("Ops Dashboard")).toBeInTheDocument(); + }); + + it("invokes onOpenApp with form values when Open App is clicked", async () => { + const user = userEvent.setup(); + const onOpenApp = vi.fn(); + renderWithMantine(); + await user.click(screen.getByText("Weather Widget")); + const cityField = screen.getByRole("textbox", { name: /city/i }); + await user.type(cityField, "Reykjavik"); + await user.click(screen.getByRole("button", { name: /Open App/ })); + expect(onOpenApp).toHaveBeenCalledWith("weather", { city: "Reykjavik" }); + expect(screen.getByTitle("Weather Widget")).toBeInTheDocument(); + }); + + it("returns to the input form when 'Back to Input' is clicked", async () => { + const user = userEvent.setup(); + renderWithMantine(); + await user.click(screen.getByText("Weather Widget")); + await user.type( + screen.getByRole("textbox", { name: /city/i }), + "Reykjavik", + ); + await user.click(screen.getByRole("button", { name: /Open App/ })); + await user.click(screen.getByRole("button", { name: /Back to Input/ })); + expect( + screen.getByRole("button", { name: /Open App/ }), + ).toBeInTheDocument(); + }); + + it("does not show 'Back to Input' for a no-fields app", async () => { + const user = userEvent.setup(); + renderWithMantine(); + await user.click(screen.getByText("Ops Dashboard")); + expect( + screen.queryByRole("button", { name: /Back to Input/ }), + ).not.toBeInTheDocument(); + }); + + it("toggles maximize, hiding the sidebar", async () => { + const user = userEvent.setup(); + renderWithMantine(); + await user.click(screen.getByText("Ops Dashboard")); + expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument(); + await user.click(screen.getByLabelText("Maximize")); + expect(screen.queryByText("MCP Apps (3)")).not.toBeInTheDocument(); + await user.click(screen.getByLabelText("Restore")); + expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument(); + }); + + it("calls onCloseApp and clears selection on Close", async () => { + const user = userEvent.setup(); + const onCloseApp = vi.fn(); + renderWithMantine(); + await user.click(screen.getByText("Ops Dashboard")); + await user.click(screen.getByLabelText("Close")); + expect(onCloseApp).toHaveBeenCalledTimes(1); + expect(screen.getByText("Select an app to view details")).toBeVisible(); + }); + + it("ignores re-clicking the same app (no duplicate onSelectApp)", async () => { + const user = userEvent.setup(); + const onSelectApp = vi.fn(); + renderWithMantine(); + // After the first click "Weather Widget" appears both in the sidebar + // list item and the right-pane header, so target the sidebar entry + // explicitly via the list-item button role. + const listItem = screen.getByRole("button", { name: /Weather Widget/ }); + await user.click(listItem); + await user.click(listItem); + expect(onSelectApp).toHaveBeenCalledTimes(1); + }); + + it("resets form state when switching to a different app", async () => { + const user = userEvent.setup(); + renderWithMantine(); + await user.click(screen.getByText("Weather Widget")); + await user.type( + screen.getByRole("textbox", { name: /city/i }), + "Reykjavik", + ); + await user.click(screen.getByText("Cohort Data")); + // Cohort form is fresh; Reykjavik (the previous tool's input) is gone. + expect(screen.queryByDisplayValue("Reykjavik")).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /Open App/ }), + ).toBeInTheDocument(); + }); + + it("renders the ListChangedIndicator when listChanged is true", async () => { + const user = userEvent.setup(); + const onRefreshList = vi.fn(); + renderWithMantine( + , + ); + // The indicator and toolbar both render "Refresh" buttons; the indicator + // is a sibling of the "List updated" label, so target it via that label. + expect(screen.getByText("List updated")).toBeInTheDocument(); + const refreshButtons = screen.getAllByRole("button", { name: "Refresh" }); + expect(refreshButtons).toHaveLength(2); + await user.click(refreshButtons[0]); + expect(onRefreshList).toHaveBeenCalledTimes(1); + }); + + it("invokes onRefreshList when the toolbar Refresh button is clicked", async () => { + const user = userEvent.setup(); + const onRefreshList = vi.fn(); + renderWithMantine(); + await user.click(screen.getByRole("button", { name: "Refresh" })); + expect(onRefreshList).toHaveBeenCalledTimes(1); + }); + + it("renders the tool icon next to the header title when present", async () => { + const user = userEvent.setup(); + const iconSrc = "data:image/svg+xml,%3Csvg/%3E"; + const iconedApp: Tool = { + name: "weather-with-icon", + title: "Weather (Iconed)", + icons: [{ src: iconSrc }], + inputSchema: { type: "object" }, + _meta: { ui: { resourceUri: "ui://apps/weather-iconed" } }, + }; + renderWithMantine(); + await user.click(screen.getByText("Weather (Iconed)")); + const headerImg = screen + .getAllByRole("presentation") + .find((img) => img.getAttribute("src") === iconSrc); + expect(headerImg).toBeDefined(); + }); + + it("ignores selection of an unknown tool name (defensive)", async () => { + const user = userEvent.setup(); + const onSelectApp = vi.fn(); + const { rerender } = renderWithMantine( + , + ); + // Click an item, then re-render with a tools list that no longer + // contains it: the selection state stays put, but a follow-up click + // on the same name no-ops because the lookup fails. + await user.click(screen.getByText("Weather Widget")); + rerender( + , + ); + // Sidebar no longer shows Weather Widget; the right pane falls back + // to the empty selection state since the lookup misses. + expect( + within(screen.getByText("MCP Apps (2)").parentElement!).queryByText( + "Weather Widget", + ), + ).not.toBeInTheDocument(); + expect(screen.getByText("Select an app to view details")).toBeVisible(); + }); +}); diff --git a/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx b/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx new file mode 100644 index 000000000..3105dd9d7 --- /dev/null +++ b/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx @@ -0,0 +1,276 @@ +import { useState, type Ref } from "react"; +import { + ActionIcon, + Button, + Card, + Flex, + Group, + Image, + Stack, + Text, + Tooltip, +} from "@mantine/core"; +import { + MdArrowBack, + MdClose, + MdFullscreen, + MdFullscreenExit, +} from "react-icons/md"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { + AppRenderer, + type AppRendererHandle, + type BridgeFactory, +} from "../../elements/AppRenderer/AppRenderer"; +import { AppDetailPanel } from "../../groups/AppDetailPanel/AppDetailPanel"; +import { AppControls } from "../../groups/AppControls/AppControls"; +import { hasInputFields, resolveDisplayLabel } from "../../../utils/toolUtils"; + +export interface AppsScreenProps { + tools: Tool[]; + listChanged: boolean; + sandboxPath: string; + bridgeFactory: BridgeFactory; + rendererRef: Ref; + onRefreshList: () => void; + onSelectApp: (name: string) => void; + onOpenApp: (name: string, args: Record) => void; + onCloseApp: () => void; +} + +const ScreenLayout = Flex.withProps({ + variant: "screen", + h: "calc(100vh - var(--app-shell-header-height, 0px))", + gap: "md", + p: "xl", + align: "flex-start", +}); + +const Sidebar = Stack.withProps({ + w: 340, + flex: "0 0 auto", +}); + +const SidebarCard = Card.withProps({ + withBorder: true, + padding: "lg", +}); + +const ContentCard = Card.withProps({ + withBorder: true, + padding: "lg", +}); + +const EmptyState = Text.withProps({ + c: "dimmed", + ta: "center", + py: "xl", +}); + +const HeaderRow = Group.withProps({ + justify: "space-between", + wrap: "nowrap", + gap: "sm", +}); + +const HeaderLabel = Group.withProps({ + gap: "sm", + wrap: "nowrap", + align: "center", + flex: 1, + miw: 0, +}); + +const HeaderIcon = Image.withProps({ + w: 24, + h: 24, + fit: "contain", +}); + +const HeaderTitle = Text.withProps({ + fw: 600, + size: "lg", + truncate: true, + flex: 1, + miw: 0, +}); + +const HeaderActions = Group.withProps({ + gap: "xs", + wrap: "nowrap", +}); + +const RendererFrame = Stack.withProps({ + flex: 1, + miw: 0, + mih: 0, + gap: 0, +}); + +const ContentStack = Stack.withProps({ + gap: "md", + h: "100%", +}); + +export function AppsScreen({ + tools, + listChanged, + sandboxPath, + bridgeFactory, + rendererRef, + onRefreshList, + onSelectApp, + onOpenApp, + onCloseApp, +}: AppsScreenProps) { + const [selectedAppName, setSelectedAppName] = useState( + undefined, + ); + const [formValues, setFormValues] = useState>({}); + const [running, setRunning] = useState(false); + const [maximized, setMaximized] = useState(false); + + const selectedTool = selectedAppName + ? tools.find((t) => t.name === selectedAppName) + : undefined; + const selectedHasFields = selectedTool ? hasInputFields(selectedTool) : false; + + function handleSelect(name: string) { + if (name === selectedAppName) return; + const next = tools.find((t) => t.name === name); + if (!next) return; + setSelectedAppName(name); + setFormValues({}); + setMaximized(false); + onSelectApp(name); + // No-input apps auto-launch on selection so the user lands directly in + // the running view; apps with fields wait for the explicit Open App click. + if (!hasInputFields(next)) { + setRunning(true); + onOpenApp(name, {}); + } else { + setRunning(false); + } + } + + function handleOpen() { + if (!selectedTool) return; + setRunning(true); + onOpenApp(selectedTool.name, formValues); + } + + function handleClose() { + setRunning(false); + setSelectedAppName(undefined); + setFormValues({}); + setMaximized(false); + onCloseApp(); + } + + function handleBackToInput() { + setRunning(false); + setMaximized(false); + } + + return ( + + {!maximized && ( + + + + + + )} + + + {selectedTool ? ( + + + + {selectedTool.icons?.[0]?.src && ( + + )} + + {resolveDisplayLabel(selectedTool.name, selectedTool.title)} + + + + {running && selectedHasFields && ( + + )} + {running && ( + + setMaximized((m) => !m)} + aria-label={maximized ? "Restore" : "Maximize"} + > + {maximized ? ( + + ) : ( + + )} + + + )} + + + + + + + + {running ? ( + + {/* Keying by name forces the renderer to remount when the + selected app changes, ensuring a fresh bridge and iframe + rather than reusing the previous app's transport. */} + + + ) : ( + // `isOpening` is always false here because `handleOpen` + // synchronously flips `running` to true, swapping in the + // AppRenderer before the panel could render its loading + // state. The prop stays in `AppDetailPanel`'s API for + // standalone use (the `Opening` story) and for Phase 3 + // wiring, where a managed-state hook can hold the panel + // in a pending state across an awaited `tools/call`. + + )} + + ) : ( + Select an app to view details + )} + + + ); +} diff --git a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx index 4a26488a8..8b78abc1b 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx @@ -3,6 +3,7 @@ import type { Resource, ResourceTemplate, Task, + Tool, } from "@modelcontextprotocol/sdk/types.js"; import type { InspectorResourceSubscription, @@ -13,9 +14,54 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { fn } from "storybook/test"; import { InspectorView } from "./InspectorView"; import { mixedEntries as demoLogs } from "../../screens/LoggingScreen/LoggingScreen.fixtures"; -import { longToolList as demoTools } from "../../screens/ToolsScreen/ToolsScreen.fixtures"; +import { longToolList as demoRegularTools } from "../../screens/ToolsScreen/ToolsScreen.fixtures"; +import { SUN_ICON_SVG } from "../../../test/fixtures/storyIcons"; import type { TaskProgress } from "../../groups/TaskCard/TaskCard"; +// MCP App tools — `isAppTool` detects these via `_meta.ui.resourceUri`, +// so they get filtered into the Apps tab while still appearing on Tools. +const demoApps: Tool[] = [ + { + name: "get-cohort-data", + title: "Cohort Data", + description: "Cohort retention heatmap with adjustable period and metric.", + inputSchema: { + type: "object", + properties: { + metric: { type: "string", description: "retention | engagement" }, + periodType: { type: "string", description: "daily | weekly | monthly" }, + cohortCount: { type: "number", description: "Cohorts to render" }, + maxPeriods: { type: "number", description: "Periods per cohort" }, + }, + required: ["metric", "periodType"], + }, + _meta: { ui: { resourceUri: "ui://apps/cohort-heatmap" } }, + }, + { + 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", + properties: { + city: { type: "string", description: "City name" }, + }, + required: ["city"], + }, + _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" } }, + }, +]; + +const demoTools: Tool[] = [...demoApps, ...demoRegularTools]; + const demoServers: ServerEntry[] = [ { id: "5e8c3d1f-2a4b-4c6d-8e7f-1a2b3c4d5e6f", diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx index 8e69d50d6..65fcdf30a 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import userEvent from "@testing-library/user-event"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import type { ServerEntry } from "@inspector/core/mcp/types.js"; import { renderWithMantine, @@ -127,6 +128,53 @@ describe("InspectorView", () => { expect(screen.getAllByText("Alpha").length).toBeGreaterThan(0); }); + it("filters tools to apps and auto-launches a no-fields app on the Apps tab", async () => { + vi.spyOn(Math, "random").mockReturnValue(0.1); + const user = userEvent.setup(); + const opsApp: Tool = { + name: "ops", + title: "Ops Dashboard", + inputSchema: { type: "object" }, + _meta: { ui: { resourceUri: "ui://apps/ops" } }, + }; + // Plain (non-app) tool plus a tool with a malformed UI resource URI + // exercise both branches of the appTools filter: the non-app drop and + // the try/catch around `isAppTool` for malformed metadata. + const plainTool: Tool = { + name: "shell.exec", + title: "Run Shell", + inputSchema: { type: "object" }, + }; + const malformedAppTool: Tool = { + name: "broken", + title: "Broken App", + inputSchema: { type: "object" }, + _meta: { ui: { resourceUri: "not-a-ui-uri" } }, + }; + renderWithMantine( + , + ); + await user.click(screen.getByRole("switch")); + await waitFor( + () => { + expect(screen.getByRole("switch")).toBeChecked(); + }, + { timeout: 2000 }, + ); + const tabSelect = await screen.findByDisplayValue("Servers"); + await user.click(tabSelect); + await user.click(await screen.findByText("Apps")); + expect(screen.getByText("MCP Apps (1)")).toBeInTheDocument(); + // Auto-launch on selection mounts the AppRenderer, which invokes the + // stub bridge factory wired in InspectorView. + await user.click(screen.getByText("Ops Dashboard")); + expect(screen.getByTitle("Ops Dashboard")).toBeInTheDocument(); + }); + it("toggles autoScroll on the Logs screen after connecting", async () => { vi.spyOn(Math, "random").mockReturnValue(0.1); const user = userEvent.setup(); diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index 1ccdd5f2d..734a15f8e 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -9,15 +9,22 @@ import type { Task, Tool, } from "@modelcontextprotocol/sdk/types.js"; +import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; import type { ConnectionStatus, InspectorResourceSubscription, MessageEntry, ServerEntry, } from "@inspector/core/mcp/types.js"; +import { isAppTool } from "@inspector/core/mcp/apps.js"; import { ViewHeader } from "../../groups/ViewHeader/ViewHeader"; import { ServerListScreen } from "../../screens/ServerListScreen/ServerListScreen"; import { ToolsScreen } from "../../screens/ToolsScreen/ToolsScreen"; +import { AppsScreen } from "../../screens/AppsScreen/AppsScreen"; +import type { + AppRendererHandle, + BridgeFactory, +} from "../../elements/AppRenderer/AppRenderer"; import { PromptsScreen } from "../../screens/PromptsScreen/PromptsScreen"; import { ResourcesScreen } from "../../screens/ResourcesScreen/ResourcesScreen"; import { LoggingScreen } from "../../screens/LoggingScreen/LoggingScreen"; @@ -31,6 +38,7 @@ const SERVERS_TAB = "Servers"; const ALL_TABS: string[] = [ SERVERS_TAB, "Tools", + "Apps", "Prompts", "Resources", "Tasks", @@ -38,6 +46,20 @@ const ALL_TABS: string[] = [ "History", ]; +// Demo stub: Phase 3 wiring will replace this with a factory derived from +// the active MCP `Client`. Apps are detected from the tools list, so the +// "Apps" tab is exposed whenever the server advertises tools capability — +// the screen itself receives only the already-filtered subset. +const STUB_SANDBOX_PATH = "about:blank"; +const stubBridgeFactory: BridgeFactory = () => + ({ + sendToolInput: async () => {}, + sendToolResult: async () => {}, + sendToolCancelled: async () => {}, + teardownResource: async () => ({}), + close: async () => {}, + }) as unknown as AppBridge; + const SCREEN_ENTER_MS = 350; const SCREEN_EXIT_MS = 250; @@ -152,6 +174,19 @@ export function InspectorView({ // toggles can cancel the previous attempt before it resolves and // overwrites the new server's state. const handshakeTimer = useRef(undefined); + const appRendererRef = useRef(null); + const appTools = useMemo(() => { + return tools.filter((tool) => { + try { + return isAppTool(tool); + } catch { + // `isAppTool` throws on malformed `_meta.ui.resourceUri`; tolerate + // mixed-validity tool lists by skipping the bad tool rather than + // halting the filter (and hiding every following App). + return false; + } + }); + }, [tools]); // The view is the single source of truth for connection state. Any // `connection` field on incoming `serversInput` items is intentionally @@ -290,6 +325,19 @@ export function InspectorView({ onCallTool={noop} /> + + + { it("returns the title when provided", () => { @@ -18,3 +19,31 @@ describe("resolveDisplayLabel", () => { expect(resolveDisplayLabel("send_message", "")).toBe(""); }); }); + +describe("hasInputFields", () => { + const baseTool = (inputSchema: Tool["inputSchema"]): Tool => ({ + name: "t", + inputSchema, + }); + + it("returns false when properties is missing", () => { + expect(hasInputFields(baseTool({ type: "object" }))).toBe(false); + }); + + it("returns false when properties is empty", () => { + expect(hasInputFields(baseTool({ type: "object", properties: {} }))).toBe( + false, + ); + }); + + it("returns true when properties has at least one entry", () => { + expect( + hasInputFields( + baseTool({ + type: "object", + properties: { x: { type: "string" } }, + }), + ), + ).toBe(true); + }); +}); diff --git a/clients/web/src/utils/toolUtils.ts b/clients/web/src/utils/toolUtils.ts index 1b8820c46..00bb30aa1 100644 --- a/clients/web/src/utils/toolUtils.ts +++ b/clients/web/src/utils/toolUtils.ts @@ -1,3 +1,5 @@ +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; + /** * Returns the display label for an MCP entity that follows the BaseMetadata * shape (Tool, Prompt, Resource): the optional `title` if provided, else the @@ -7,3 +9,13 @@ export function resolveDisplayLabel(name: string, title?: string): string { return title ?? name; } + +/** + * True when the tool's input schema declares at least one property — used by + * App-flow callers to decide whether to render a form or auto-launch. Kept in + * one place so the definition of "has fields" stays consistent if it ever + * grows to consider `additionalProperties`, `anyOf`, etc. + */ +export function hasInputFields(tool: Tool): boolean { + return Object.keys(tool.inputSchema.properties ?? {}).length > 0; +}