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
3 changed files with 712 additions and 10 deletions

View file

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

View file

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

View file

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