feat(apps): add AppListItem and AppDetailPanel groups (#1261) (#1273)

* 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:
Cliff Hall 2026-05-04 10:28:58 -04:00 • committed by GitHub
commit 1ffcdc38ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -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);
});
});