* feat(apps): add AppListItem and AppDetailPanel groups (#1261) Two presentational groups for the upcoming AppsScreen. AppListItem (clients/web/src/components/groups/AppListItem/): - Props { tool, selected, onClick } - Renders tool.icons[0] (sized sm) when present, label (title ?? name), two-line clamped description, and a trailing chevron affordance. - Reuses ToolListItem's selected-state highlight (UnstyledButton bg via --mantine-primary-color-light). AppDetailPanel (clients/web/src/components/groups/AppDetailPanel/): - Props { tool, formValues, isOpening, onFormChange, onOpenApp } - Layout: optional icon + title row, optional description, divider, SchemaForm bound to tool.inputSchema, full-width "Open App" button. - "Open App" is disabled while isOpening or while any required input field is empty (validation derived from tool.inputSchema.required). - No annotations, progress, or cancel — execution is delegated to the embedded app, not run as an MCP tool call from the panel. Convention notes: - Issue text references @tabler IconChevronRight / IconPlayerPlay; the v2 web client already uses react-icons (md/ri namespaces), so this PR uses MdChevronRight and MdPlayArrow for consistency rather than introducing a second icon library. Stories: AppListItem (Default, Selected, WithIcon, NoDescription, LongName); AppDetailPanel (NoFields, SimpleStringParam, MultipleParams, WithIcon, Opening, ComplexSchema). Tests: 22 cases covering rendering branches, callback wiring, and the disabled-button derivation. Per-file coverage 100% across lines, statements, functions, and branches for both new components. Closes #1261 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(stories): inline SVG for WithIcon stories so they render reliably The Wikipedia-hosted PNG used in the AppListItem and AppDetailPanel WithIcon stories returned a broken image in Storybook (rate-limited / hot-link blocked). Replace it with an inline SVG sun icon as a data URI so the stories render offline, in Chromatic, and on any network. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(apps): address PR #1273 review feedback - Extract resolveDisplayLabel(name, title?) to src/utils/toolUtils.ts and migrate all four call sites (AppListItem, AppDetailPanel, ToolListItem, ToolDetailPanel) to consume it. Avoids the fourth copy when the next list/detail screen arrives. Includes a small unit suite. - AppDetailPanel: only render the <Divider/> when the schema has at least one property — previously a no-input app showed an orphaned separator between the description and the Open App button. - AppDetailPanel: align PanelTitle truncate to `true` (matches the ItemLabel form used in AppListItem / ToolListItem); behaviorally identical, just less surprising for future readers. - AppDetailPanel: add an inline comment explaining that SchemaForm is disabled only while isOpening, not while validation fails — users should be able to keep filling required fields. The validation gate lives on the Open App button. - Extract the inline SUN_ICON_SVG data URI used by the WithIcon stories to src/test/fixtures/storyIcons.ts so the next story that needs it doesn't grow a third copy. - Tests: add the AppDetailPanel divider-conditional cases (rendered with fields, omitted without). Per-file coverage stays at 100% for AppListItem, AppDetailPanel, ToolListItem, ToolDetailPanel, and the new toolUtils. Suite: 70 files / 618 tests passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(toolUtils): rename empty-string test for readability Drops the doubled "is ... is preserved" phrase in the test name. Behavior unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8fa1a0c074
commit
1ffcdc38ca
11 changed files with 712 additions and 10 deletions
|
|
@ -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<typeof AppDetailPanel> = {
|
||||||
|
title: "Groups/AppDetailPanel",
|
||||||
|
component: AppDetailPanel,
|
||||||
|
args: {
|
||||||
|
formValues: {},
|
||||||
|
isOpening: false,
|
||||||
|
onFormChange: fn(),
|
||||||
|
onOpenApp: fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof AppDetailPanel>;
|
||||||
|
|
||||||
|
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: "" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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(
|
||||||
|
<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();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render the description when missing", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<AppDetailPanel {...baseProps} tool={requiredFieldTool} />,
|
||||||
|
);
|
||||||
|
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} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<AppDetailPanel
|
||||||
|
{...baseProps}
|
||||||
|
tool={requiredFieldTool}
|
||||||
|
onFormChange={onFormChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await user.type(screen.getByRole("textbox"), "h");
|
||||||
|
expect(onFormChange).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a divider above the form when the schema has properties", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<AppDetailPanel {...baseProps} tool={requiredFieldTool} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("separator")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the divider when the schema has no properties", () => {
|
||||||
|
renderWithMantine(<AppDetailPanel {...baseProps} tool={noFieldsTool} />);
|
||||||
|
expect(screen.queryByRole("separator")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables the Open App button when a required field is empty", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<AppDetailPanel {...baseProps} tool={requiredFieldTool} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("button", { name: /open app/i })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enables the Open App button when required fields are populated", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<AppDetailPanel
|
||||||
|
{...baseProps}
|
||||||
|
tool={requiredFieldTool}
|
||||||
|
formValues={{ name: "Ada" }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /open app/i }),
|
||||||
|
).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enables the Open App button when there are no required fields", () => {
|
||||||
|
renderWithMantine(<AppDetailPanel {...baseProps} tool={noFieldsTool} />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /open app/i }),
|
||||||
|
).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats null and empty-string values as missing required fields", () => {
|
||||||
|
const { rerender } = renderWithMantine(
|
||||||
|
<AppDetailPanel
|
||||||
|
{...baseProps}
|
||||||
|
tool={requiredFieldTool}
|
||||||
|
formValues={{ name: null }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("button", { name: /open app/i })).toBeDisabled();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<AppDetailPanel
|
||||||
|
{...baseProps}
|
||||||
|
tool={requiredFieldTool}
|
||||||
|
formValues={{ name: "" }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("button", { name: /open app/i })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables the Open App button while opening", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<AppDetailPanel
|
||||||
|
{...baseProps}
|
||||||
|
tool={requiredFieldTool}
|
||||||
|
formValues={{ name: "Ada" }}
|
||||||
|
isOpening={true}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<AppDetailPanel
|
||||||
|
{...baseProps}
|
||||||
|
tool={requiredFieldTool}
|
||||||
|
formValues={{ name: "Ada" }}
|
||||||
|
onOpenApp={onOpenApp}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: /open app/i }));
|
||||||
|
expect(onOpenApp).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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<string, unknown>;
|
||||||
|
isOpening: boolean;
|
||||||
|
onFormChange: (values: Record<string, unknown>) => 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<string, unknown>,
|
||||||
|
): 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 (
|
||||||
|
<Stack gap="md" miw={0}>
|
||||||
|
<TitleRow>
|
||||||
|
{iconSrc && <PanelIcon src={iconSrc} alt="" />}
|
||||||
|
<PanelTitle>{resolveDisplayLabel(name, title)}</PanelTitle>
|
||||||
|
</TitleRow>
|
||||||
|
|
||||||
|
{description && <DescriptionText>{description}</DescriptionText>}
|
||||||
|
|
||||||
|
{hasFields && <Divider />}
|
||||||
|
|
||||||
|
{/* 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. */}
|
||||||
|
<SchemaForm
|
||||||
|
schema={inputSchema}
|
||||||
|
values={formValues}
|
||||||
|
onChange={onFormChange}
|
||||||
|
disabled={isOpening}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
fullWidth
|
||||||
|
leftSection={<MdPlayArrow aria-hidden size={18} />}
|
||||||
|
onClick={onOpenApp}
|
||||||
|
disabled={disabled}
|
||||||
|
loading={isOpening}
|
||||||
|
>
|
||||||
|
Open App
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<typeof AppListItem> = {
|
||||||
|
title: "Groups/AppListItem",
|
||||||
|
component: AppListItem,
|
||||||
|
args: {
|
||||||
|
onClick: fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof AppListItem>;
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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(
|
||||||
|
<AppListItem tool={baseTool} selected={false} onClick={() => {}} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Calculator")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the name when title is missing", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<AppListItem
|
||||||
|
tool={{ ...baseTool, title: undefined }}
|
||||||
|
selected={false}
|
||||||
|
onClick={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("calculator")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the description when provided", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<AppListItem tool={baseTool} selected={false} onClick={() => {}} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Arithmetic operations")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render a description block when description is missing", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<AppListItem
|
||||||
|
tool={{ ...baseTool, description: undefined }}
|
||||||
|
selected={false}
|
||||||
|
onClick={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("Arithmetic operations")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the first icon when tool.icons is present", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<AppListItem
|
||||||
|
tool={{ ...baseTool, icons: [{ src: ICON_SRC }] }}
|
||||||
|
selected={false}
|
||||||
|
onClick={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const img = screen.getByRole("presentation");
|
||||||
|
expect(img).toHaveAttribute("src", ICON_SRC);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render an icon when tool.icons is missing", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<AppListItem tool={baseTool} selected={false} onClick={() => {}} />,
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole("presentation")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invokes onClick when the row is clicked", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onClick = vi.fn();
|
||||||
|
renderWithMantine(
|
||||||
|
<AppListItem tool={baseTool} selected={false} onClick={onClick} />,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button"));
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the selected state without crashing", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<AppListItem tool={baseTool} selected onClick={() => {}} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("button")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 (
|
||||||
|
<UnstyledButton
|
||||||
|
w="100%"
|
||||||
|
p="sm"
|
||||||
|
variant="listItem"
|
||||||
|
bg={selected ? "var(--mantine-primary-color-light)" : undefined}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<Row>
|
||||||
|
{iconSrc && <AppIcon src={iconSrc} alt="" />}
|
||||||
|
<ItemBody>
|
||||||
|
<ItemLabel>{resolveDisplayLabel(name, title)}</ItemLabel>
|
||||||
|
{description && <ItemDescription>{description}</ItemDescription>}
|
||||||
|
</ItemBody>
|
||||||
|
<MdChevronRight
|
||||||
|
aria-hidden
|
||||||
|
color="var(--inspector-text-secondary)"
|
||||||
|
size={18}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import type {
|
||||||
Tool,
|
Tool,
|
||||||
ToolAnnotations,
|
ToolAnnotations,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { resolveDisplayLabel } from "../../../utils/toolUtils";
|
||||||
import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge";
|
import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge";
|
||||||
import { ProgressDisplay } from "../../elements/ProgressDisplay/ProgressDisplay";
|
import { ProgressDisplay } from "../../elements/ProgressDisplay/ProgressDisplay";
|
||||||
import { SchemaForm } from "../SchemaForm/SchemaForm";
|
import { SchemaForm } from "../SchemaForm/SchemaForm";
|
||||||
|
|
@ -39,10 +40,6 @@ const CancelButton = Button.withProps({
|
||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
|
|
||||||
function resolveTitle(name: string, title?: string): string {
|
|
||||||
return title ?? name;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasAnyAnnotation(annotations?: ToolAnnotations): boolean {
|
function hasAnyAnnotation(annotations?: ToolAnnotations): boolean {
|
||||||
return !!(
|
return !!(
|
||||||
annotations &&
|
annotations &&
|
||||||
|
|
@ -66,7 +63,7 @@ export function ToolDetailPanel({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="md" miw={0}>
|
<Stack gap="md" miw={0}>
|
||||||
<ToolTitle>{resolveTitle(name, title)}</ToolTitle>
|
<ToolTitle>{resolveDisplayLabel(name, title)}</ToolTitle>
|
||||||
{hasAnyAnnotation(annotations) && annotations && (
|
{hasAnyAnnotation(annotations) && annotations && (
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
{annotations.readOnlyHint && (
|
{annotations.readOnlyHint && (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Stack, Text, UnstyledButton } from "@mantine/core";
|
import { Stack, Text, UnstyledButton } from "@mantine/core";
|
||||||
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { resolveDisplayLabel } from "../../../utils/toolUtils";
|
||||||
|
|
||||||
export interface ToolListItemProps {
|
export interface ToolListItemProps {
|
||||||
tool: Tool;
|
tool: Tool;
|
||||||
|
|
@ -18,10 +19,6 @@ const ItemSubLabel = Text.withProps({
|
||||||
truncate: true,
|
truncate: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
function resolveLabel(name: string, title?: string): string {
|
|
||||||
return title ?? name;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ToolListItem({ tool, selected, onClick }: ToolListItemProps) {
|
export function ToolListItem({ tool, selected, onClick }: ToolListItemProps) {
|
||||||
const { name, title } = tool;
|
const { name, title } = tool;
|
||||||
return (
|
return (
|
||||||
|
|
@ -33,7 +30,7 @@ export function ToolListItem({ tool, selected, onClick }: ToolListItemProps) {
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
<ItemLabel>{resolveLabel(name, title)}</ItemLabel>
|
<ItemLabel>{resolveDisplayLabel(name, title)}</ItemLabel>
|
||||||
{title && <ItemSubLabel>{name}</ItemSubLabel>}
|
{title && <ItemSubLabel>{name}</ItemSubLabel>}
|
||||||
</Stack>
|
</Stack>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
|
|
|
||||||
7
clients/web/src/test/fixtures/storyIcons.ts
vendored
Normal file
7
clients/web/src/test/fixtures/storyIcons.ts
vendored
Normal file
|
|
@ -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";
|
||||||
20
clients/web/src/utils/toolUtils.test.ts
Normal file
20
clients/web/src/utils/toolUtils.test.ts
Normal file
|
|
@ -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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
9
clients/web/src/utils/toolUtils.ts
Normal file
9
clients/web/src/utils/toolUtils.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue