feat(apps): AppsScreen and InspectorView wiring (#1263) (#1286)

* feat(apps): AppsScreen and InspectorView wiring (#1263)

Adds the Apps tab as a top-level screen composed of `AppListItem`,
`AppDetailPanel`, and `AppRenderer`. The screen is presentational —
selection, form values, running, and maximize state are local; data and
side effects come in via props and callbacks.

InspectorView filters `tools` through `isAppTool` (wrapped in a try/catch
so a malformed `_meta.ui.resourceUri` skips one tool instead of dropping
every following App), exposes the Apps tab in `ALL_TABS` after Tools, and
wires Phase-3 stub callbacks plus a stub bridge factory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(apps): extract AppControls and rename Refresh button

Pulls the sidebar list, search input, "List updated" indicator, and
Refresh button out of `AppsScreen` into a dedicated `AppControls`
group, mirroring `ToolControls` and `TaskControls`. The Refresh button
moves from a full-width row beneath the list to the top-right of the
title row (single label "Refresh", matching `TaskControls`).

`AppsScreen` keeps the right-pane orchestration (selection, form,
running, maximize, header actions) and now delegates the entire
sidebar to `AppControls`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(stories): seed InspectorView demo tools with MCP Apps

Adds three apps (Cohort Data, Weather Widget, Ops Dashboard) carrying
real `_meta.ui.resourceUri` to the demo tools list so the Apps tab has
content in the Default story alongside the existing tools.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(apps): drop AppDetailPanel title row to avoid duplicate name

The parent AppsScreen header already shows the app name, so the panel's
own title/icon row rendered the name twice. Remove the title row (and
its now-unused imports, styled subcomponents, and the four tests that
asserted on title/icon rendering).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(resources): drop annotation badges from ResourceListItem

The list row now renders just the resource title/name. Audience and
priority badges remain visible in the preview/template panels, so the
data is still surfaced — just not duplicated in the picker. Drops the
now-unused AnnotationBadge import, the badge-only stories, and the
badge-rendering test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(apps): address review — share hasInputFields, show header icon

- Extracts the duplicated `Object.keys(inputSchema.properties ?? {}).length > 0`
  check from `AppsScreen` (and the equivalent in `AppDetailPanel`'s
  `hasFields`) into a shared `hasInputFields` helper in `toolUtils`,
  with tests.
- Renders the selected tool's first icon next to the right-pane header
  title in `AppsScreen`, restoring the icon visibility that was lost
  when `AppDetailPanel`'s title row was removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(apps): finish hasInputFields extraction; document isOpening

- AppDetailPanel now actually calls the shared `hasInputFields` helper
  instead of inlining the same `Object.keys(...).length > 0` check.
- AppsScreen comments why `isOpening={false}` is hardcoded: the screen
  flips to AppRenderer synchronously, so the loading state is unreachable
  here — the prop remains for standalone use and Phase 3 wiring.

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-07 14:18:27 -04:00 • committed by GitHub
commit 5f296589ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 1210 additions and 164 deletions

View file

@ -4,8 +4,6 @@ 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",
@ -25,16 +23,6 @@ const requiredFieldTool: Tool = {
},
};
const optionalFieldTool: Tool = {
name: "greet",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "The name to greet" },
},
},
};
const baseProps = {
formValues: {},
isOpening: false,
@ -43,20 +31,6 @@ const baseProps = {
};
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();
@ -69,22 +43,6 @@ describe("AppDetailPanel", () => {
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} />,

View file

@ -1,8 +1,8 @@
import { Button, Divider, Group, Image, Stack, Text } from "@mantine/core";
import { Button, Divider, 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";
import { hasInputFields } from "../../../utils/toolUtils";
export interface AppDetailPanelProps {
tool: Tool;
@ -12,29 +12,11 @@ export interface AppDetailPanelProps {
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>,
@ -53,19 +35,13 @@ export function AppDetailPanel({
onFormChange,
onOpenApp,
}: AppDetailPanelProps) {
const { name, title, description, icons, inputSchema } = tool;
const iconSrc = icons?.[0]?.src;
const { description, inputSchema } = tool;
const hasErrors = hasMissingRequiredFields(inputSchema, formValues);
const disabled = isOpening || hasErrors;
const hasFields = Object.keys(inputSchema.properties ?? {}).length > 0;
const hasFields = hasInputFields(tool);
return (
<Stack gap="md" miw={0}>
<TitleRow>
{iconSrc && <PanelIcon src={iconSrc} alt="" />}
<PanelTitle>{resolveDisplayLabel(name, title)}</PanelTitle>
</TitleRow>
{description && <DescriptionText>{description}</DescriptionText>}
{hasFields && <Divider />}