* 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>
131 lines
4.8 KiB
TypeScript
131 lines
4.8 KiB
TypeScript
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);
|
|
});
|
|
});
|