diff --git a/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.stories.tsx b/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.stories.tsx new file mode 100644 index 00000000..9ce42995 --- /dev/null +++ b/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.stories.tsx @@ -0,0 +1,148 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { fn } from "storybook/test"; +import { SUN_ICON_SVG } from "../../../test/fixtures/storyIcons"; +import { AppDetailPanel } from "./AppDetailPanel"; + +const meta: Meta = { + title: "Groups/AppDetailPanel", + component: AppDetailPanel, + args: { + formValues: {}, + isOpening: false, + onFormChange: fn(), + onOpenApp: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +const noFieldsTool: Tool = { + name: "no_input_app", + title: "No Input App", + description: "An app that takes no parameters.", + inputSchema: { type: "object" }, +}; + +const simpleTool: Tool = { + name: "greeting_app", + title: "Greeting App", + description: "Renders a personalized greeting.", + inputSchema: { + type: "object", + properties: { + name: { type: "string", description: "The name to greet" }, + }, + required: ["name"], + }, +}; + +const multiParamTool: Tool = { + name: "report_builder", + title: "Report Builder", + description: "Builds a report from a query and date range.", + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "SQL query" }, + from: { type: "string", description: "Start date (YYYY-MM-DD)" }, + to: { type: "string", description: "End date (YYYY-MM-DD)" }, + includeChart: { type: "boolean", description: "Render an inline chart" }, + }, + required: ["query"], + }, +}; + +const iconTool: Tool = { + name: "weather_widget", + title: "Weather Widget", + description: "Displays the current weather for a given city.", + icons: [{ src: SUN_ICON_SVG, mimeType: "image/svg+xml" }], + inputSchema: { + type: "object", + properties: { + city: { type: "string", description: "City name" }, + }, + required: ["city"], + }, +}; + +const complexSchemaTool: Tool = { + name: "advanced_search", + title: "Advanced Search", + description: "Run a structured search across multiple sources.", + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Free-text query" }, + sources: { + type: "array", + items: { + anyOf: [ + { const: "web", title: "Web" }, + { const: "docs", title: "Docs" }, + { const: "code", title: "Code" }, + ], + }, + description: "Sources to include", + }, + maxResults: { + type: "number", + description: "Maximum number of results", + }, + filters: { + type: "object", + properties: { + language: { type: "string", description: "Language filter" }, + recency: { + type: "string", + enum: ["day", "week", "month", "year"], + description: "Recency", + }, + }, + }, + }, + required: ["query"], + }, +}; + +export const NoFields: Story = { + args: { tool: noFieldsTool }, +}; + +export const SimpleStringParam: Story = { + args: { + tool: simpleTool, + formValues: { name: "Ada" }, + }, +}; + +export const MultipleParams: Story = { + args: { + tool: multiParamTool, + formValues: { query: "SELECT 1" }, + }, +}; + +export const WithIcon: Story = { + args: { + tool: iconTool, + formValues: { city: "Reykjavik" }, + }, +}; + +export const Opening: Story = { + args: { + tool: simpleTool, + formValues: { name: "Ada" }, + isOpening: true, + }, +}; + +export const ComplexSchema: Story = { + args: { + tool: complexSchemaTool, + formValues: { query: "" }, + }, +}; diff --git a/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.test.tsx b/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.test.tsx new file mode 100644 index 00000000..a71c2972 --- /dev/null +++ b/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.test.tsx @@ -0,0 +1,194 @@ +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 { AppDetailPanel } from "./AppDetailPanel"; + +const ICON_SRC = "data:image/svg+xml,%3Csvg/%3E"; + +const noFieldsTool: Tool = { + name: "no_input_app", + title: "No Input App", + description: "Takes no parameters", + inputSchema: { type: "object" }, +}; + +const requiredFieldTool: Tool = { + name: "greet", + title: "Greet", + inputSchema: { + type: "object", + properties: { + name: { type: "string", description: "The name to greet" }, + }, + required: ["name"], + }, +}; + +const optionalFieldTool: Tool = { + name: "greet", + inputSchema: { + type: "object", + properties: { + name: { type: "string", description: "The name to greet" }, + }, + }, +}; + +const baseProps = { + formValues: {}, + isOpening: false, + onFormChange: vi.fn(), + onOpenApp: vi.fn(), +}; + +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(); + }); + + it("does not render the description when missing", () => { + renderWithMantine( + , + ); + 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( + , + ); + expect(screen.getByText("The name to greet")).toBeInTheDocument(); + }); + + it("invokes onFormChange when the user types in a form field", async () => { + const user = userEvent.setup(); + const onFormChange = vi.fn(); + renderWithMantine( + , + ); + await user.type(screen.getByRole("textbox"), "h"); + expect(onFormChange).toHaveBeenCalled(); + }); + + it("renders a divider above the form when the schema has properties", () => { + renderWithMantine( + , + ); + expect(screen.getByRole("separator")).toBeInTheDocument(); + }); + + it("omits the divider when the schema has no properties", () => { + renderWithMantine(); + expect(screen.queryByRole("separator")).not.toBeInTheDocument(); + }); + + it("disables the Open App button when a required field is empty", () => { + renderWithMantine( + , + ); + expect(screen.getByRole("button", { name: /open app/i })).toBeDisabled(); + }); + + it("enables the Open App button when required fields are populated", () => { + renderWithMantine( + , + ); + expect( + screen.getByRole("button", { name: /open app/i }), + ).not.toBeDisabled(); + }); + + it("enables the Open App button when there are no required fields", () => { + renderWithMantine(); + expect( + screen.getByRole("button", { name: /open app/i }), + ).not.toBeDisabled(); + }); + + it("treats null and empty-string values as missing required fields", () => { + const { rerender } = renderWithMantine( + , + ); + expect(screen.getByRole("button", { name: /open app/i })).toBeDisabled(); + + rerender( + , + ); + expect(screen.getByRole("button", { name: /open app/i })).toBeDisabled(); + }); + + it("disables the Open App button while opening", () => { + renderWithMantine( + , + ); + expect(screen.getByRole("button", { name: /open app/i })).toBeDisabled(); + }); + + it("invokes onOpenApp when the button is clicked", async () => { + const user = userEvent.setup(); + const onOpenApp = vi.fn(); + renderWithMantine( + , + ); + await user.click(screen.getByRole("button", { name: /open app/i })); + expect(onOpenApp).toHaveBeenCalledTimes(1); + }); +}); diff --git a/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx b/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx new file mode 100644 index 00000000..8aea7520 --- /dev/null +++ b/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx @@ -0,0 +1,95 @@ +import { Button, Divider, Group, Image, 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"; + +export interface AppDetailPanelProps { + tool: Tool; + formValues: Record; + isOpening: boolean; + onFormChange: (values: Record) => void; + 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, +): boolean { + const required = schema.required ?? []; + return required.some((field) => { + const value = values[field]; + return value === undefined || value === null || value === ""; + }); +} + +export function AppDetailPanel({ + tool, + formValues, + isOpening, + onFormChange, + onOpenApp, +}: AppDetailPanelProps) { + const { name, title, description, icons, inputSchema } = tool; + const iconSrc = icons?.[0]?.src; + const hasErrors = hasMissingRequiredFields(inputSchema, formValues); + const disabled = isOpening || hasErrors; + const hasFields = Object.keys(inputSchema.properties ?? {}).length > 0; + + return ( + + + {iconSrc && } + {resolveDisplayLabel(name, title)} + + + {description && {description}} + + {hasFields && } + + {/* Form stays editable while validation fails so users can finish + filling required fields. The disabled-when-incomplete gate is on + the Open App button below, not on the form itself. */} + + + + + ); +} diff --git a/clients/web/src/components/groups/AppListItem/AppListItem.stories.tsx b/clients/web/src/components/groups/AppListItem/AppListItem.stories.tsx new file mode 100644 index 00000000..121c1dae --- /dev/null +++ b/clients/web/src/components/groups/AppListItem/AppListItem.stories.tsx @@ -0,0 +1,80 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { fn } from "storybook/test"; +import { SUN_ICON_SVG } from "../../../test/fixtures/storyIcons"; +import { AppListItem } from "./AppListItem"; + +const meta: Meta = { + title: "Groups/AppListItem", + component: AppListItem, + args: { + onClick: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +const calculatorTool: Tool = { + name: "calculator", + title: "Calculator", + description: "An interactive calculator widget for arithmetic operations.", + inputSchema: { type: "object" }, +}; + +const noDescriptionTool: Tool = { + name: "no_description", + title: "No Description", + inputSchema: { type: "object" }, +}; + +const withIconTool: Tool = { + name: "weather_widget", + title: "Weather Widget", + description: + "Displays the current weather and a five-day forecast for any city.", + icons: [{ src: SUN_ICON_SVG, mimeType: "image/svg+xml" }], + inputSchema: { type: "object" }, +}; + +const longNameTool: Tool = { + name: "this_is_a_very_long_app_tool_name_that_should_truncate_in_the_list_view_when_rendered", + description: + "A description that itself runs to a couple of lines so we can confirm the line clamp behavior on the description renders correctly without pushing the chevron down or wrapping the row in unexpected ways.", + inputSchema: { type: "object" }, +}; + +export const Default: Story = { + args: { + tool: calculatorTool, + selected: false, + }, +}; + +export const Selected: Story = { + args: { + tool: calculatorTool, + selected: true, + }, +}; + +export const WithIcon: Story = { + args: { + tool: withIconTool, + selected: false, + }, +}; + +export const NoDescription: Story = { + args: { + tool: noDescriptionTool, + selected: false, + }, +}; + +export const LongName: Story = { + args: { + tool: longNameTool, + selected: false, + }, +}; diff --git a/clients/web/src/components/groups/AppListItem/AppListItem.test.tsx b/clients/web/src/components/groups/AppListItem/AppListItem.test.tsx new file mode 100644 index 00000000..2323d476 --- /dev/null +++ b/clients/web/src/components/groups/AppListItem/AppListItem.test.tsx @@ -0,0 +1,88 @@ +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 { AppListItem } from "./AppListItem"; + +const baseTool: Tool = { + name: "calculator", + title: "Calculator", + description: "Arithmetic operations", + inputSchema: { type: "object" }, +}; + +const ICON_SRC = "data:image/svg+xml,%3Csvg/%3E"; + +describe("AppListItem", () => { + it("prefers the tool title over the name", () => { + renderWithMantine( + {}} />, + ); + expect(screen.getByText("Calculator")).toBeInTheDocument(); + }); + + it("falls back to the name when title is missing", () => { + renderWithMantine( + {}} + />, + ); + expect(screen.getByText("calculator")).toBeInTheDocument(); + }); + + it("renders the description when provided", () => { + renderWithMantine( + {}} />, + ); + expect(screen.getByText("Arithmetic operations")).toBeInTheDocument(); + }); + + it("does not render a description block when description is missing", () => { + renderWithMantine( + {}} + />, + ); + expect(screen.queryByText("Arithmetic operations")).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("invokes onClick when the row is clicked", async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + renderWithMantine( + , + ); + await user.click(screen.getByRole("button")); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("renders the selected state without crashing", () => { + renderWithMantine( + {}} />, + ); + expect(screen.getByRole("button")).toBeInTheDocument(); + }); +}); diff --git a/clients/web/src/components/groups/AppListItem/AppListItem.tsx b/clients/web/src/components/groups/AppListItem/AppListItem.tsx new file mode 100644 index 00000000..ef1d8980 --- /dev/null +++ b/clients/web/src/components/groups/AppListItem/AppListItem.tsx @@ -0,0 +1,67 @@ +import { Group, Image, Stack, Text, UnstyledButton } from "@mantine/core"; +import { MdChevronRight } from "react-icons/md"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { resolveDisplayLabel } from "../../../utils/toolUtils"; + +export interface AppListItemProps { + tool: Tool; + selected: boolean; + onClick: () => void; +} + +const ItemLabel = Text.withProps({ + fw: 500, + truncate: true, +}); + +const ItemDescription = Text.withProps({ + size: "xs", + c: "dimmed", + lineClamp: 2, +}); + +const ItemBody = Stack.withProps({ + gap: 2, + flex: 1, + miw: 0, +}); + +const Row = Group.withProps({ + gap: "sm", + wrap: "nowrap", + align: "flex-start", +}); + +const AppIcon = Image.withProps({ + w: 20, + h: 20, + fit: "contain", +}); + +export function AppListItem({ tool, selected, onClick }: AppListItemProps) { + const { name, title, description, icons } = tool; + const iconSrc = icons?.[0]?.src; + + return ( + + + {iconSrc && } + + {resolveDisplayLabel(name, title)} + {description && {description}} + + + + + ); +} diff --git a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx index 3cd88f7c..18780ee3 100644 --- a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx +++ b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx @@ -4,6 +4,7 @@ import type { Tool, ToolAnnotations, } from "@modelcontextprotocol/sdk/types.js"; +import { resolveDisplayLabel } from "../../../utils/toolUtils"; import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge"; import { ProgressDisplay } from "../../elements/ProgressDisplay/ProgressDisplay"; import { SchemaForm } from "../SchemaForm/SchemaForm"; @@ -39,10 +40,6 @@ const CancelButton = Button.withProps({ color: "red", }); -function resolveTitle(name: string, title?: string): string { - return title ?? name; -} - function hasAnyAnnotation(annotations?: ToolAnnotations): boolean { return !!( annotations && @@ -66,7 +63,7 @@ export function ToolDetailPanel({ return ( - {resolveTitle(name, title)} + {resolveDisplayLabel(name, title)} {hasAnyAnnotation(annotations) && annotations && ( {annotations.readOnlyHint && ( diff --git a/clients/web/src/components/groups/ToolListItem/ToolListItem.tsx b/clients/web/src/components/groups/ToolListItem/ToolListItem.tsx index fa4c86f9..03ce97e9 100644 --- a/clients/web/src/components/groups/ToolListItem/ToolListItem.tsx +++ b/clients/web/src/components/groups/ToolListItem/ToolListItem.tsx @@ -1,5 +1,6 @@ import { Stack, Text, UnstyledButton } from "@mantine/core"; import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { resolveDisplayLabel } from "../../../utils/toolUtils"; export interface ToolListItemProps { tool: Tool; @@ -18,10 +19,6 @@ const ItemSubLabel = Text.withProps({ truncate: true, }); -function resolveLabel(name: string, title?: string): string { - return title ?? name; -} - export function ToolListItem({ tool, selected, onClick }: ToolListItemProps) { const { name, title } = tool; return ( @@ -33,7 +30,7 @@ export function ToolListItem({ tool, selected, onClick }: ToolListItemProps) { onClick={onClick} > - {resolveLabel(name, title)} + {resolveDisplayLabel(name, title)} {title && {name}} diff --git a/clients/web/src/test/fixtures/storyIcons.ts b/clients/web/src/test/fixtures/storyIcons.ts new file mode 100644 index 00000000..69ce5d28 --- /dev/null +++ b/clients/web/src/test/fixtures/storyIcons.ts @@ -0,0 +1,7 @@ +/** + * Inline SVG data URIs used by Storybook stories that need a visible icon + * without depending on external network fetches (Chromatic, offline runs, + * play tests). Keep payloads small and self-contained. + */ +export const SUN_ICON_SVG = + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='gold'%3E%3Ccircle cx='12' cy='12' r='5'/%3E%3Cg stroke='gold' stroke-width='2' stroke-linecap='round'%3E%3Cline x1='12' y1='2' x2='12' y2='5'/%3E%3Cline x1='12' y1='19' x2='12' y2='22'/%3E%3Cline x1='2' y1='12' x2='5' y2='12'/%3E%3Cline x1='19' y1='12' x2='22' y2='12'/%3E%3Cline x1='4.93' y1='4.93' x2='7.05' y2='7.05'/%3E%3Cline x1='16.95' y1='16.95' x2='19.07' y2='19.07'/%3E%3Cline x1='4.93' y1='19.07' x2='7.05' y2='16.95'/%3E%3Cline x1='16.95' y1='7.05' x2='19.07' y2='4.93'/%3E%3C/g%3E%3C/svg%3E"; diff --git a/clients/web/src/utils/toolUtils.test.ts b/clients/web/src/utils/toolUtils.test.ts new file mode 100644 index 00000000..77cb7d89 --- /dev/null +++ b/clients/web/src/utils/toolUtils.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { resolveDisplayLabel } from "./toolUtils"; + +describe("resolveDisplayLabel", () => { + it("returns the title when provided", () => { + expect(resolveDisplayLabel("send_message", "Send Message")).toBe( + "Send Message", + ); + }); + + it("falls back to the name when title is undefined", () => { + expect(resolveDisplayLabel("send_message")).toBe("send_message"); + }); + + it("preserves an empty-string title rather than falling back to the name", () => { + // Empty string is a valid (if unusual) title — title ?? name only falls + // back on undefined / null, not empty string. Document that here. + expect(resolveDisplayLabel("send_message", "")).toBe(""); + }); +}); diff --git a/clients/web/src/utils/toolUtils.ts b/clients/web/src/utils/toolUtils.ts new file mode 100644 index 00000000..1b8820c4 --- /dev/null +++ b/clients/web/src/utils/toolUtils.ts @@ -0,0 +1,9 @@ +/** + * Returns the display label for an MCP entity that follows the BaseMetadata + * shape (Tool, Prompt, Resource): the optional `title` if provided, else the + * machine `name`. Centralized so list items, detail panels, and screens stay + * consistent. + */ +export function resolveDisplayLabel(name: string, title?: string): string { + return title ?? name; +}