Skip to content

Commit 2dd8730

Browse files
cliffhallclaude
andcommitted
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>
1 parent 8fa1a0c commit 2dd8730

6 files changed

Lines changed: 672 additions & 0 deletions

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import type { Meta, StoryObj } from "@storybook/react-vite";
2+
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
3+
import { fn } from "storybook/test";
4+
import { AppDetailPanel } from "./AppDetailPanel";
5+
6+
const meta: Meta<typeof AppDetailPanel> = {
7+
title: "Groups/AppDetailPanel",
8+
component: AppDetailPanel,
9+
args: {
10+
formValues: {},
11+
isOpening: false,
12+
onFormChange: fn(),
13+
onOpenApp: fn(),
14+
},
15+
};
16+
17+
export default meta;
18+
type Story = StoryObj<typeof AppDetailPanel>;
19+
20+
const noFieldsTool: Tool = {
21+
name: "no_input_app",
22+
title: "No Input App",
23+
description: "An app that takes no parameters.",
24+
inputSchema: { type: "object" },
25+
};
26+
27+
const simpleTool: Tool = {
28+
name: "greeting_app",
29+
title: "Greeting App",
30+
description: "Renders a personalized greeting.",
31+
inputSchema: {
32+
type: "object",
33+
properties: {
34+
name: { type: "string", description: "The name to greet" },
35+
},
36+
required: ["name"],
37+
},
38+
};
39+
40+
const multiParamTool: Tool = {
41+
name: "report_builder",
42+
title: "Report Builder",
43+
description: "Builds a report from a query and date range.",
44+
inputSchema: {
45+
type: "object",
46+
properties: {
47+
query: { type: "string", description: "SQL query" },
48+
from: { type: "string", description: "Start date (YYYY-MM-DD)" },
49+
to: { type: "string", description: "End date (YYYY-MM-DD)" },
50+
includeChart: { type: "boolean", description: "Render an inline chart" },
51+
},
52+
required: ["query"],
53+
},
54+
};
55+
56+
const iconTool: Tool = {
57+
name: "weather_widget",
58+
title: "Weather Widget",
59+
description: "Displays the current weather for a given city.",
60+
icons: [
61+
{
62+
src: "https://upload.wikimedia.org/wikipedia/commons/thumb/4/49/Sun_in_X-Ray.png/120px-Sun_in_X-Ray.png",
63+
mimeType: "image/png",
64+
sizes: ["120x120"],
65+
},
66+
],
67+
inputSchema: {
68+
type: "object",
69+
properties: {
70+
city: { type: "string", description: "City name" },
71+
},
72+
required: ["city"],
73+
},
74+
};
75+
76+
const complexSchemaTool: Tool = {
77+
name: "advanced_search",
78+
title: "Advanced Search",
79+
description: "Run a structured search across multiple sources.",
80+
inputSchema: {
81+
type: "object",
82+
properties: {
83+
query: { type: "string", description: "Free-text query" },
84+
sources: {
85+
type: "array",
86+
items: {
87+
anyOf: [
88+
{ const: "web", title: "Web" },
89+
{ const: "docs", title: "Docs" },
90+
{ const: "code", title: "Code" },
91+
],
92+
},
93+
description: "Sources to include",
94+
},
95+
maxResults: {
96+
type: "number",
97+
description: "Maximum number of results",
98+
},
99+
filters: {
100+
type: "object",
101+
properties: {
102+
language: { type: "string", description: "Language filter" },
103+
recency: {
104+
type: "string",
105+
enum: ["day", "week", "month", "year"],
106+
description: "Recency",
107+
},
108+
},
109+
},
110+
},
111+
required: ["query"],
112+
},
113+
};
114+
115+
export const NoFields: Story = {
116+
args: { tool: noFieldsTool },
117+
};
118+
119+
export const SimpleStringParam: Story = {
120+
args: {
121+
tool: simpleTool,
122+
formValues: { name: "Ada" },
123+
},
124+
};
125+
126+
export const MultipleParams: Story = {
127+
args: {
128+
tool: multiParamTool,
129+
formValues: { query: "SELECT 1" },
130+
},
131+
};
132+
133+
export const WithIcon: Story = {
134+
args: {
135+
tool: iconTool,
136+
formValues: { city: "Reykjavik" },
137+
},
138+
};
139+
140+
export const Opening: Story = {
141+
args: {
142+
tool: simpleTool,
143+
formValues: { name: "Ada" },
144+
isOpening: true,
145+
},
146+
};
147+
148+
export const ComplexSchema: Story = {
149+
args: {
150+
tool: complexSchemaTool,
151+
formValues: { query: "" },
152+
},
153+
};
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import userEvent from "@testing-library/user-event";
3+
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
4+
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
5+
import { AppDetailPanel } from "./AppDetailPanel";
6+
7+
const ICON_SRC = "data:image/svg+xml,%3Csvg/%3E";
8+
9+
const noFieldsTool: Tool = {
10+
name: "no_input_app",
11+
title: "No Input App",
12+
description: "Takes no parameters",
13+
inputSchema: { type: "object" },
14+
};
15+
16+
const requiredFieldTool: Tool = {
17+
name: "greet",
18+
title: "Greet",
19+
inputSchema: {
20+
type: "object",
21+
properties: {
22+
name: { type: "string", description: "The name to greet" },
23+
},
24+
required: ["name"],
25+
},
26+
};
27+
28+
const optionalFieldTool: Tool = {
29+
name: "greet",
30+
inputSchema: {
31+
type: "object",
32+
properties: {
33+
name: { type: "string", description: "The name to greet" },
34+
},
35+
},
36+
};
37+
38+
const baseProps = {
39+
formValues: {},
40+
isOpening: false,
41+
onFormChange: vi.fn(),
42+
onOpenApp: vi.fn(),
43+
};
44+
45+
describe("AppDetailPanel", () => {
46+
it("prefers the title over the name", () => {
47+
renderWithMantine(
48+
<AppDetailPanel {...baseProps} tool={requiredFieldTool} />,
49+
);
50+
expect(screen.getByText("Greet")).toBeInTheDocument();
51+
});
52+
53+
it("falls back to the name when title is missing", () => {
54+
renderWithMantine(
55+
<AppDetailPanel {...baseProps} tool={optionalFieldTool} />,
56+
);
57+
expect(screen.getByText("greet")).toBeInTheDocument();
58+
});
59+
60+
it("renders the description when provided", () => {
61+
renderWithMantine(<AppDetailPanel {...baseProps} tool={noFieldsTool} />);
62+
expect(screen.getByText("Takes no parameters")).toBeInTheDocument();
63+
});
64+
65+
it("does not render the description when missing", () => {
66+
renderWithMantine(
67+
<AppDetailPanel {...baseProps} tool={requiredFieldTool} />,
68+
);
69+
expect(screen.queryByText("Takes no parameters")).not.toBeInTheDocument();
70+
});
71+
72+
it("renders the first icon when tool.icons is present", () => {
73+
renderWithMantine(
74+
<AppDetailPanel
75+
{...baseProps}
76+
tool={{ ...noFieldsTool, icons: [{ src: ICON_SRC }] }}
77+
/>,
78+
);
79+
const img = screen.getByRole("presentation");
80+
expect(img).toHaveAttribute("src", ICON_SRC);
81+
});
82+
83+
it("does not render an icon when tool.icons is missing", () => {
84+
renderWithMantine(<AppDetailPanel {...baseProps} tool={noFieldsTool} />);
85+
expect(screen.queryByRole("presentation")).not.toBeInTheDocument();
86+
});
87+
88+
it("renders the schema form using the tool's inputSchema", () => {
89+
renderWithMantine(
90+
<AppDetailPanel {...baseProps} tool={requiredFieldTool} />,
91+
);
92+
expect(screen.getByText("The name to greet")).toBeInTheDocument();
93+
});
94+
95+
it("invokes onFormChange when the user types in a form field", async () => {
96+
const user = userEvent.setup();
97+
const onFormChange = vi.fn();
98+
renderWithMantine(
99+
<AppDetailPanel
100+
{...baseProps}
101+
tool={requiredFieldTool}
102+
onFormChange={onFormChange}
103+
/>,
104+
);
105+
await user.type(screen.getByRole("textbox"), "h");
106+
expect(onFormChange).toHaveBeenCalled();
107+
});
108+
109+
it("disables the Open App button when a required field is empty", () => {
110+
renderWithMantine(
111+
<AppDetailPanel {...baseProps} tool={requiredFieldTool} />,
112+
);
113+
expect(screen.getByRole("button", { name: /open app/i })).toBeDisabled();
114+
});
115+
116+
it("enables the Open App button when required fields are populated", () => {
117+
renderWithMantine(
118+
<AppDetailPanel
119+
{...baseProps}
120+
tool={requiredFieldTool}
121+
formValues={{ name: "Ada" }}
122+
/>,
123+
);
124+
expect(
125+
screen.getByRole("button", { name: /open app/i }),
126+
).not.toBeDisabled();
127+
});
128+
129+
it("enables the Open App button when there are no required fields", () => {
130+
renderWithMantine(<AppDetailPanel {...baseProps} tool={noFieldsTool} />);
131+
expect(
132+
screen.getByRole("button", { name: /open app/i }),
133+
).not.toBeDisabled();
134+
});
135+
136+
it("treats null and empty-string values as missing required fields", () => {
137+
const { rerender } = renderWithMantine(
138+
<AppDetailPanel
139+
{...baseProps}
140+
tool={requiredFieldTool}
141+
formValues={{ name: null }}
142+
/>,
143+
);
144+
expect(screen.getByRole("button", { name: /open app/i })).toBeDisabled();
145+
146+
rerender(
147+
<AppDetailPanel
148+
{...baseProps}
149+
tool={requiredFieldTool}
150+
formValues={{ name: "" }}
151+
/>,
152+
);
153+
expect(screen.getByRole("button", { name: /open app/i })).toBeDisabled();
154+
});
155+
156+
it("disables the Open App button while opening", () => {
157+
renderWithMantine(
158+
<AppDetailPanel
159+
{...baseProps}
160+
tool={requiredFieldTool}
161+
formValues={{ name: "Ada" }}
162+
isOpening={true}
163+
/>,
164+
);
165+
expect(screen.getByRole("button", { name: /open app/i })).toBeDisabled();
166+
});
167+
168+
it("invokes onOpenApp when the button is clicked", async () => {
169+
const user = userEvent.setup();
170+
const onOpenApp = vi.fn();
171+
renderWithMantine(
172+
<AppDetailPanel
173+
{...baseProps}
174+
tool={requiredFieldTool}
175+
formValues={{ name: "Ada" }}
176+
onOpenApp={onOpenApp}
177+
/>,
178+
);
179+
await user.click(screen.getByRole("button", { name: /open app/i }));
180+
expect(onOpenApp).toHaveBeenCalledTimes(1);
181+
});
182+
});

0 commit comments

Comments
 (0)