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

@ -53,48 +53,3 @@ export const Template: Story = {
selected: false,
},
};
export const WithAudience: Story = {
args: {
resource: {
name: "settings.json",
uri: "file:///settings.json",
annotations: { audience: ["user"] },
},
selected: false,
},
};
export const WithPriority: Story = {
args: {
resource: {
name: "schema.sql",
uri: "file:///schema.sql",
annotations: { priority: 0.8 },
},
selected: false,
},
};
export const WithAudienceAndPriority: Story = {
args: {
resource: {
name: "config.json",
title: "Configuration File",
uri: "file:///config.json",
annotations: { audience: ["user", "assistant"], priority: 0.5 },
},
selected: false,
},
};
export const TemplateWithAnnotations: Story = {
args: {
resource: {
name: "User Profile",
uriTemplate: "file:///users/{userId}/profile",
annotations: { audience: ["assistant"], priority: 0.2 },
},
selected: false,
},
};

View file

@ -29,7 +29,7 @@ describe("ResourceListItem", () => {
expect(screen.getByText("Configuration")).toBeInTheDocument();
});
it("renders annotation badges when annotations are present", () => {
it("does not render annotation badges even when annotations are present", () => {
renderWithMantine(
<ResourceListItem
resource={{
@ -40,19 +40,8 @@ describe("ResourceListItem", () => {
onClick={() => {}}
/>,
);
expect(screen.getByText("audience: user")).toBeInTheDocument();
expect(screen.getByText("priority: high")).toBeInTheDocument();
});
it("hides badges when only an empty audience annotation exists", () => {
renderWithMantine(
<ResourceListItem
resource={{ ...baseResource, annotations: { audience: [] } }}
selected={false}
onClick={() => {}}
/>,
);
expect(screen.queryByText(/audience/)).not.toBeInTheDocument();
expect(screen.queryByText(/priority/)).not.toBeInTheDocument();
});
it("invokes onClick when the row is clicked", async () => {

View file

@ -1,9 +1,8 @@
import { Group, Text, UnstyledButton } from "@mantine/core";
import { Text, UnstyledButton } from "@mantine/core";
import type {
Resource,
ResourceTemplate,
} from "@modelcontextprotocol/sdk/types.js";
import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge";
export interface ResourceListItemProps {
resource: Resource | ResourceTemplate;
@ -11,28 +10,11 @@ export interface ResourceListItemProps {
onClick: () => void;
}
const RowGroup = Group.withProps({
gap: "xs",
wrap: "wrap",
justify: "space-between",
});
const BadgeGroup = Group.withProps({
gap: "xs",
wrap: "wrap",
});
export function ResourceListItem({
resource,
selected,
onClick,
}: ResourceListItemProps) {
const audience = resource.annotations?.audience;
const priority = resource.annotations?.priority;
const showBadges =
(audience !== undefined && audience.length > 0) || priority !== undefined;
const label = <Text fw={500}>{resource.title ?? resource.name}</Text>;
return (
<UnstyledButton
w="100%"
@ -41,21 +23,7 @@ export function ResourceListItem({
bg={selected ? "var(--mantine-primary-color-light)" : undefined}
onClick={onClick}
>
{showBadges ? (
<RowGroup>
{label}
<BadgeGroup>
{audience !== undefined && audience.length > 0 && (
<AnnotationBadge facet="audience" value={audience} />
)}
{priority !== undefined && (
<AnnotationBadge facet="priority" value={priority} />
)}
</BadgeGroup>
</RowGroup>
) : (
label
)}
<Text fw={500}>{resource.title ?? resource.name}</Text>
</UnstyledButton>
);
}