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
3 changed files with 1210 additions and 164 deletions

View file

@ -0,0 +1,88 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import { fn, userEvent, within } from "storybook/test";
import { AppControls } from "./AppControls";
import { SUN_ICON_SVG } from "../../../test/fixtures/storyIcons";
const sampleApps: Tool[] = [
{
name: "get-cohort-data",
title: "Cohort Data",
description: "Returns cohort retention heatmap data.",
inputSchema: { type: "object" },
_meta: { ui: { resourceUri: "ui://apps/cohort" } },
},
{
name: "weather-widget",
title: "Weather Widget",
description: "Live weather and a five-day forecast for any city.",
icons: [{ src: SUN_ICON_SVG, mimeType: "image/svg+xml" }],
inputSchema: { type: "object" },
_meta: { ui: { resourceUri: "ui://apps/weather" } },
},
{
name: "ops-dashboard",
title: "Ops Dashboard",
description: "Current operational status across services.",
inputSchema: { type: "object" },
_meta: { ui: { resourceUri: "ui://apps/ops" } },
},
{
name: "git_log",
description: "Recent commits on the current branch.",
inputSchema: { type: "object" },
_meta: { ui: { resourceUri: "ui://apps/git-log" } },
},
];
const meta: Meta<typeof AppControls> = {
title: "Groups/AppControls",
component: AppControls,
args: {
onRefreshList: fn(),
onSelectApp: fn(),
listChanged: false,
},
};
export default meta;
type Story = StoryObj<typeof AppControls>;
export const Default: Story = {
args: {
tools: sampleApps,
},
};
export const WithSelection: Story = {
args: {
tools: sampleApps,
selectedName: "weather-widget",
},
};
export const WithSearch: Story = {
args: {
tools: sampleApps,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(
await canvas.findByPlaceholderText("Search apps..."),
"git",
);
},
};
export const ListChanged: Story = {
args: {
tools: sampleApps,
listChanged: true,
},
};
export const Empty: Story = {
args: {
tools: [],
},
};

View file

@ -0,0 +1,131 @@
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 { AppControls } from "./AppControls";
const sampleApps: Tool[] = [
{
name: "weather",
title: "Weather Widget",
inputSchema: { type: "object" },
_meta: { ui: { resourceUri: "ui://apps/weather" } },
},
{
name: "ops",
title: "Ops Dashboard",
inputSchema: { type: "object" },
_meta: { ui: { resourceUri: "ui://apps/ops" } },
},
{
name: "git_status",
inputSchema: { type: "object" },
_meta: { ui: { resourceUri: "ui://apps/git-status" } },
},
];
const baseProps = {
tools: sampleApps,
listChanged: false,
onRefreshList: vi.fn(),
onSelectApp: vi.fn(),
};
describe("AppControls", () => {
it("renders the title with the app count and a search input", () => {
renderWithMantine(<AppControls {...baseProps} />);
expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Search apps...")).toBeInTheDocument();
});
it("renders all apps by default", () => {
renderWithMantine(<AppControls {...baseProps} />);
expect(screen.getByText("Weather Widget")).toBeInTheDocument();
expect(screen.getByText("Ops Dashboard")).toBeInTheDocument();
expect(screen.getByText("git_status")).toBeInTheDocument();
});
it("filters apps by name when typing in the search input", async () => {
const user = userEvent.setup();
renderWithMantine(<AppControls {...baseProps} />);
await user.type(screen.getByPlaceholderText("Search apps..."), "git");
expect(screen.getByText("git_status")).toBeInTheDocument();
expect(screen.queryByText("Weather Widget")).not.toBeInTheDocument();
});
it("filters apps by title when typing in the search input", async () => {
const user = userEvent.setup();
renderWithMantine(<AppControls {...baseProps} />);
await user.type(
screen.getByPlaceholderText("Search apps..."),
"weather widget",
);
expect(screen.getByText("Weather Widget")).toBeInTheDocument();
expect(screen.queryByText("Ops Dashboard")).not.toBeInTheDocument();
});
it("shows 'No apps available' when the tool list is empty", () => {
renderWithMantine(<AppControls {...baseProps} tools={[]} />);
expect(screen.getByText("No apps available")).toBeInTheDocument();
expect(screen.getByText("MCP Apps (0)")).toBeInTheDocument();
});
it("shows 'No matching apps' when search yields no results", async () => {
const user = userEvent.setup();
renderWithMantine(<AppControls {...baseProps} />);
await user.type(screen.getByPlaceholderText("Search apps..."), "zzz");
expect(screen.getByText("No matching apps")).toBeInTheDocument();
});
it("invokes onSelectApp when an unselected app is clicked", async () => {
const user = userEvent.setup();
const onSelectApp = vi.fn();
renderWithMantine(<AppControls {...baseProps} onSelectApp={onSelectApp} />);
await user.click(screen.getByText("git_status"));
expect(onSelectApp).toHaveBeenCalledWith("git_status");
});
it("does not invoke onSelectApp when the already-selected app is clicked", async () => {
const user = userEvent.setup();
const onSelectApp = vi.fn();
renderWithMantine(
<AppControls
{...baseProps}
selectedName="git_status"
onSelectApp={onSelectApp}
/>,
);
await user.click(screen.getByText("git_status"));
expect(onSelectApp).not.toHaveBeenCalled();
});
it("invokes onRefreshList when the toolbar Refresh button is clicked", async () => {
const user = userEvent.setup();
const onRefreshList = vi.fn();
renderWithMantine(
<AppControls {...baseProps} onRefreshList={onRefreshList} />,
);
await user.click(screen.getByRole("button", { name: "Refresh" }));
expect(onRefreshList).toHaveBeenCalledTimes(1);
});
it("does not show the list-changed indicator when listChanged is false", () => {
renderWithMantine(<AppControls {...baseProps} />);
expect(screen.queryByText("List updated")).not.toBeInTheDocument();
});
it("shows the list-changed indicator when listChanged is true", async () => {
const user = userEvent.setup();
const onRefreshList = vi.fn();
renderWithMantine(
<AppControls {...baseProps} listChanged onRefreshList={onRefreshList} />,
);
expect(screen.getByText("List updated")).toBeInTheDocument();
// Both the toolbar button and the list-changed indicator's button render
// as "Refresh"; either one should drive onRefreshList.
const refreshButtons = screen.getAllByRole("button", { name: "Refresh" });
expect(refreshButtons).toHaveLength(2);
await user.click(refreshButtons[1]);
expect(onRefreshList).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,88 @@
import { useState } from "react";
import {
Button,
Group,
ScrollArea,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator";
import { AppListItem } from "../AppListItem/AppListItem";
export interface AppControlsProps {
tools: Tool[];
selectedName?: string;
listChanged: boolean;
onRefreshList: () => void;
onSelectApp: (name: string) => void;
}
const LIST_MAX_HEIGHT =
"calc(100vh - var(--app-shell-header-height, 0px) - var(--mantine-spacing-xl) * 2 - 220px)";
const ToolbarButton = Button.withProps({
variant: "subtle",
size: "sm",
});
const EmptyState = Text.withProps({
c: "dimmed",
ta: "center",
py: "xl",
});
export function AppControls({
tools,
selectedName,
listChanged,
onRefreshList,
onSelectApp,
}: AppControlsProps) {
const [searchText, setSearchText] = useState("");
const query = searchText.toLowerCase();
const filteredTools = searchText
? tools.filter(
(tool) =>
tool.name.toLowerCase().includes(query) ||
(tool.title?.toLowerCase().includes(query) ?? false),
)
: tools;
return (
<Stack gap="sm">
<Group justify="space-between">
<Title order={4}>MCP Apps ({tools.length})</Title>
<ToolbarButton onClick={onRefreshList}>Refresh</ToolbarButton>
</Group>
<ListChangedIndicator visible={listChanged} onRefresh={onRefreshList} />
<TextInput
placeholder="Search apps..."
value={searchText}
onChange={(e) => setSearchText(e.currentTarget.value)}
/>
<ScrollArea.Autosize mah={LIST_MAX_HEIGHT}>
<Stack gap="xs">
{filteredTools.length === 0 ? (
<EmptyState>
{tools.length === 0 ? "No apps available" : "No matching apps"}
</EmptyState>
) : (
filteredTools.map((tool) => (
<AppListItem
key={tool.name}
tool={tool}
selected={tool.name === selectedName}
onClick={() => {
if (tool.name !== selectedName) onSelectApp(tool.name);
}}
/>
))
)}
</Stack>
</ScrollArea.Autosize>
</Stack>
);
}