* feat(web): wire the listChanged indicator across all screens (#1402) The per-screen list-changed indicator was hardcoded false in InspectorView for Tools, Apps, Prompts, and Resources, so it could never light up. Source it from the managed-state layer instead. Each ManagedXState now tracks a listChanged flag: set true when its */list_changed notification arrives (the existing auto-refresh leaves it set), exposed via getListChanged()/a listChangedChange event, and cleared by clearListChanged() or on disconnect. The useManagedX hooks surface the flag and clear it on a user-initiated refresh (the indicator's Refresh button = acknowledge). App threads the three flags to InspectorView, which maps them to the screens — Apps shares the tools flag since apps are a filtered view of tools. Tests cover set/clear/disconnect at the state-manager layer, the hook subscription + refresh-clears behavior, and the InspectorView routing (including Apps sharing the tools flag and no cross-wiring between screens). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(core): close clear-after-await race in managed-list refresh (#1402 review) The useManagedX refresh() callbacks cleared the listChanged flag AFTER awaiting the fetch. If a */list_changed arrived mid-fetch, the state re-set the flag (and auto-refreshed), then the trailing clear wiped that genuinely-new signal. Clear BEFORE the await instead: the user's refresh acknowledges only the change in hand, and a mid-fetch change re-sets the flag and survives. Adds a race regression test on the tools hook. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): drop duplicate Refresh on the Apps screen (#1402) AppControls uniquely had a standalone toolbar Refresh button AND the ListChangedIndicator on a separate row, so once the indicator could actually appear (this PR), the Apps screen showed two Refresh buttons. Match the other three controls (Tools/Prompts/Resources): the indicator is the single header-right refresh affordance. Updates AppControls and AppsScreen tests to assert exactly one Refresh (and none when the list hasn't changed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(core): stop auto-invoking */list on list_changed; user pulls via Refresh (#1402) With auto-refresh on, the list was already current by the time the list-changed indicator appeared, making its Refresh button moot. Now the managed Tools/Prompts/Resources state managers only flag the change on */list_changed (they no longer auto-refresh); the list stays as-is until the user clicks Refresh, which fetches the new list and clears the indicator. The connect-time initial load is unchanged. Resource templates (no own indicator, part of the Resources screen) also stop auto-refreshing; onRefreshResources now refreshes both resources and templates, since a single resources/list_changed covers both. Tasks are left auto-refreshing — they have no list-changed indicator and need live status updates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(core): correct ManagedResourceTemplatesState header comments (#1402 review) The class no longer subscribes to resourceTemplatesListChanged; update the stale file/class docstrings that still claimed it syncs on that notification to reflect connect + statusChange only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): add per-server 'Auto Refresh on List Changed Notifications' option (#1402) New Options section in ServerSettingsForm with a single checkbox. When checked, the managed Tools/Prompts/Resources (and resource templates) state auto-refreshes on */list_changed notifications; when unchecked (default) the notification only lights the list-changed indicator and the user pulls via Refresh. Plumbing: new optional autoRefreshOnListChanged on InspectorServerSettings and StoredMCPServer, round-tripped through serverList (lifted with a false default, persisted only when true, added to the exhaustive field-key map); surfaced from InspectorClient via a new getServerSettings() on the protocol; the state managers read it live at notification time to decide auto-refresh vs flag-only. Resource templates re-subscribe to their list_changed but only auto-refresh when the option is on (they have no indicator of their own). Tests cover the round-trip (true persists, false omitted), the form section (render/checked/toggle), and each state manager's on/off branch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(server): persist autoRefreshOnListChanged through validateSettings (#1402) The PUT /api/servers write path runs settings through validateSettings, which rebuilds the value from explicitly-named fields and silently drops unknown keys — so autoRefreshOnListChanged never reached disk (the serverList round-trip test passed because it calls inspectorSettingsToStoredFields directly, bypassing this path). Validate it as an optional boolean and include it in the built value; false is omitted on disk by inspectorSettingsToStoredFields as before. Adds integration tests over the real PUT path: true persists, false is omitted, and a non-boolean is rejected (400). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): default the Server Settings modal to the Options section (#1402) Open 'Options' instead of 'Custom Headers' when the Server Settings modal first opens, so the new Auto Refresh toggle is the first thing surfaced. Updates the modal default-expanded section, the modal test's initial-state assertions, the three header tests (now expand 'Custom Headers' first since it's no longer open by default), and the story defaults. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: drop accidentally-committed Inspector history dump; gitignore the class 83076441 swept an exported Inspector message-history JSON (manual-testing artifact at the repo root) into the tree via git add -A. Untrack it and extend the existing repo-root debug-artifact .gitignore block (alongside /*.png) to cover /inspector-history-*.json so these can't be re-added. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
356 lines
13 KiB
TypeScript
356 lines
13 KiB
TypeScript
import { createRef, useState } from "react";
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import userEvent from "@testing-library/user-event";
|
|
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
|
|
import {
|
|
renderWithMantine,
|
|
screen,
|
|
within,
|
|
} from "../../../test/renderWithMantine";
|
|
import {
|
|
AppsScreen,
|
|
type AppsScreenProps,
|
|
type AppsUiState,
|
|
} from "./AppsScreen";
|
|
import { EMPTY_APPS_UI } from "../screenUiState";
|
|
import type {
|
|
AppRendererHandle,
|
|
BridgeFactory,
|
|
} from "../../elements/AppRenderer/AppRenderer";
|
|
|
|
const noFieldsApp: Tool = {
|
|
name: "ops",
|
|
title: "Ops Dashboard",
|
|
inputSchema: { type: "object" },
|
|
_meta: { ui: { resourceUri: "ui://apps/ops" } },
|
|
};
|
|
|
|
const fieldedApp: Tool = {
|
|
name: "weather",
|
|
title: "Weather Widget",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
city: { type: "string", description: "City name" },
|
|
},
|
|
required: ["city"],
|
|
},
|
|
_meta: { ui: { resourceUri: "ui://apps/weather" } },
|
|
};
|
|
|
|
const cohortApp: Tool = {
|
|
name: "cohorts",
|
|
title: "Cohort Data",
|
|
description: "Cohort retention",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { metric: { type: "string", default: "retention" } },
|
|
},
|
|
_meta: { ui: { resourceUri: "ui://apps/cohorts" } },
|
|
};
|
|
|
|
const okBridgeFactory: BridgeFactory = () =>
|
|
({
|
|
sendToolInput: async () => {},
|
|
sendToolResult: async () => {},
|
|
sendToolCancelled: async () => {},
|
|
teardownResource: async () => ({}),
|
|
close: async () => {},
|
|
}) as unknown as AppBridge;
|
|
|
|
function buildProps(overrides: Partial<AppsScreenProps> = {}): AppsScreenProps {
|
|
return {
|
|
tools: [fieldedApp, noFieldsApp, cohortApp] as Tool[],
|
|
listChanged: false,
|
|
// happy-dom would otherwise try to fetch the iframe `src` over the
|
|
// network. A data URL keeps the AppRenderer mountable without leaving
|
|
// the test environment.
|
|
sandboxPath: "data:text/html,<title>sandbox</title>",
|
|
bridgeFactory: okBridgeFactory,
|
|
rendererRef: createRef<AppRendererHandle>(),
|
|
ui: EMPTY_APPS_UI,
|
|
onUiChange: vi.fn(),
|
|
onRefreshList: vi.fn(),
|
|
onSelectApp: vi.fn(),
|
|
onOpenApp: vi.fn(),
|
|
onCloseApp: vi.fn(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// AppsScreen lifts selection, form values, and the sidebar search to the
|
|
// parent (App) as one `ui` object so they persist across tab navigation
|
|
// (#1417), while `running`/`maximized` stay local to the screen. This host
|
|
// holds the lifted state so clicking an app, typing into its form/search, and
|
|
// closing drive the panel exactly as App owns it. The internal running/maximized
|
|
// state handles auto-launch, open, maximize, and back-to-input on its own. Props
|
|
// passed in override defaults; the stateful `ui` wiring is applied last so
|
|
// callers can still observe activity via the rendered state.
|
|
function ControlledAppsScreen(overrides: Partial<AppsScreenProps> = {}) {
|
|
const props = buildProps(overrides);
|
|
const [ui, setUi] = useState<AppsUiState>({
|
|
...EMPTY_APPS_UI,
|
|
...props.ui,
|
|
});
|
|
return (
|
|
<AppsScreen
|
|
{...props}
|
|
ui={ui}
|
|
onUiChange={(next) => {
|
|
setUi(next);
|
|
props.onUiChange(next);
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
describe("AppsScreen", () => {
|
|
it("renders the empty selection state", () => {
|
|
renderWithMantine(<AppsScreen {...buildProps()} />);
|
|
expect(screen.getByText("Select an app to view details")).toBeVisible();
|
|
expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows 'No apps available' when the tool list is empty", () => {
|
|
renderWithMantine(<AppsScreen {...buildProps({ tools: [] })} />);
|
|
expect(screen.getByText("No apps available")).toBeInTheDocument();
|
|
expect(screen.getByText("MCP Apps (0)")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders the unavailable state when no sandbox path is provided", () => {
|
|
renderWithMantine(
|
|
<AppsScreen {...buildProps({ sandboxPath: undefined })} />,
|
|
);
|
|
expect(screen.getByText(/MCP Apps are unavailable/i)).toBeInTheDocument();
|
|
// The sidebar / app list is not rendered in the unavailable state.
|
|
expect(screen.queryByText("MCP Apps (3)")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("surfaces a bridge factory failure via onError", async () => {
|
|
const user = userEvent.setup();
|
|
const onError = vi.fn();
|
|
const throwingFactory: BridgeFactory = () => {
|
|
throw new Error("no connected MCP client");
|
|
};
|
|
renderWithMantine(
|
|
<ControlledAppsScreen
|
|
bridgeFactory={throwingFactory}
|
|
onError={onError}
|
|
/>,
|
|
);
|
|
// The no-fields app auto-launches on selection, mounting the renderer,
|
|
// whose effect invokes the factory (which throws → routes to onError).
|
|
await user.click(screen.getByText("Ops Dashboard"));
|
|
await vi.waitFor(() => expect(onError).toHaveBeenCalledTimes(1));
|
|
expect(onError.mock.calls[0][0]).toBeInstanceOf(Error);
|
|
expect((onError.mock.calls[0][0] as Error).message).toContain(
|
|
"no connected MCP client",
|
|
);
|
|
});
|
|
|
|
it("filters the list via the search input", async () => {
|
|
const user = userEvent.setup();
|
|
renderWithMantine(<ControlledAppsScreen />);
|
|
await user.type(screen.getByPlaceholderText("Search apps..."), "weather");
|
|
expect(screen.getByText("Weather Widget")).toBeInTheDocument();
|
|
expect(screen.queryByText("Ops Dashboard")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("shows 'No matching apps' when search yields no results", async () => {
|
|
const user = userEvent.setup();
|
|
renderWithMantine(<ControlledAppsScreen />);
|
|
await user.type(screen.getByPlaceholderText("Search apps..."), "zzz");
|
|
expect(screen.getByText("No matching apps")).toBeInTheDocument();
|
|
});
|
|
|
|
it("opens the detail panel when a fielded app is selected", async () => {
|
|
const user = userEvent.setup();
|
|
const onSelectApp = vi.fn();
|
|
const onOpenApp = vi.fn();
|
|
renderWithMantine(
|
|
<ControlledAppsScreen onSelectApp={onSelectApp} onOpenApp={onOpenApp} />,
|
|
);
|
|
await user.click(screen.getByText("Weather Widget"));
|
|
expect(onSelectApp).toHaveBeenCalledWith("weather");
|
|
expect(onOpenApp).not.toHaveBeenCalled();
|
|
expect(
|
|
screen.getByRole("button", { name: /Open App/ }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("auto-launches a no-fields app on selection", async () => {
|
|
const user = userEvent.setup();
|
|
const onOpenApp = vi.fn();
|
|
renderWithMantine(<ControlledAppsScreen onOpenApp={onOpenApp} />);
|
|
await user.click(screen.getByText("Ops Dashboard"));
|
|
expect(onOpenApp).toHaveBeenCalledWith("ops", {});
|
|
// Renderer iframe replaces the form; Open App button is gone.
|
|
expect(
|
|
screen.queryByRole("button", { name: /Open App/ }),
|
|
).not.toBeInTheDocument();
|
|
expect(screen.getByTitle("Ops Dashboard")).toBeInTheDocument();
|
|
});
|
|
|
|
it("invokes onOpenApp with form values when Open App is clicked", async () => {
|
|
const user = userEvent.setup();
|
|
const onOpenApp = vi.fn();
|
|
renderWithMantine(<ControlledAppsScreen onOpenApp={onOpenApp} />);
|
|
await user.click(screen.getByText("Weather Widget"));
|
|
const cityField = screen.getByRole("textbox", { name: /city/i });
|
|
await user.type(cityField, "Reykjavik");
|
|
await user.click(screen.getByRole("button", { name: /Open App/ }));
|
|
expect(onOpenApp).toHaveBeenCalledWith("weather", { city: "Reykjavik" });
|
|
expect(screen.getByTitle("Weather Widget")).toBeInTheDocument();
|
|
});
|
|
|
|
it("seeds schema defaults so untouched fields are sent on Open App", async () => {
|
|
const user = userEvent.setup();
|
|
const onOpenApp = vi.fn();
|
|
renderWithMantine(<ControlledAppsScreen onOpenApp={onOpenApp} />);
|
|
await user.click(screen.getByText("Cohort Data"));
|
|
// Open without editing the metric field: its default must still be sent.
|
|
await user.click(screen.getByRole("button", { name: /Open App/ }));
|
|
expect(onOpenApp).toHaveBeenCalledWith("cohorts", { metric: "retention" });
|
|
});
|
|
|
|
it("returns to the input form when 'Back to Input' is clicked", async () => {
|
|
const user = userEvent.setup();
|
|
renderWithMantine(<ControlledAppsScreen />);
|
|
await user.click(screen.getByText("Weather Widget"));
|
|
await user.type(
|
|
screen.getByRole("textbox", { name: /city/i }),
|
|
"Reykjavik",
|
|
);
|
|
await user.click(screen.getByRole("button", { name: /Open App/ }));
|
|
await user.click(screen.getByRole("button", { name: /Back to Input/ }));
|
|
expect(
|
|
screen.getByRole("button", { name: /Open App/ }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("does not show 'Back to Input' for a no-fields app", async () => {
|
|
const user = userEvent.setup();
|
|
renderWithMantine(<ControlledAppsScreen />);
|
|
await user.click(screen.getByText("Ops Dashboard"));
|
|
expect(
|
|
screen.queryByRole("button", { name: /Back to Input/ }),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("toggles maximize, hiding the sidebar", async () => {
|
|
const user = userEvent.setup();
|
|
renderWithMantine(<ControlledAppsScreen />);
|
|
await user.click(screen.getByText("Ops Dashboard"));
|
|
expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument();
|
|
await user.click(screen.getByLabelText("Maximize"));
|
|
expect(screen.queryByText("MCP Apps (3)")).not.toBeInTheDocument();
|
|
await user.click(screen.getByLabelText("Restore"));
|
|
expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument();
|
|
});
|
|
|
|
it("calls onCloseApp and clears selection on Close", async () => {
|
|
const user = userEvent.setup();
|
|
const onCloseApp = vi.fn();
|
|
renderWithMantine(<ControlledAppsScreen onCloseApp={onCloseApp} />);
|
|
await user.click(screen.getByText("Ops Dashboard"));
|
|
await user.click(screen.getByLabelText("Close"));
|
|
expect(onCloseApp).toHaveBeenCalledTimes(1);
|
|
expect(screen.getByText("Select an app to view details")).toBeVisible();
|
|
});
|
|
|
|
it("ignores re-clicking the same app (no duplicate onSelectApp)", async () => {
|
|
const user = userEvent.setup();
|
|
const onSelectApp = vi.fn();
|
|
renderWithMantine(<ControlledAppsScreen onSelectApp={onSelectApp} />);
|
|
// After the first click "Weather Widget" appears both in the sidebar
|
|
// list item and the right-pane header, so target the sidebar entry
|
|
// explicitly via the list-item button role.
|
|
const listItem = screen.getByRole("button", { name: /Weather Widget/ });
|
|
await user.click(listItem);
|
|
await user.click(listItem);
|
|
expect(onSelectApp).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("resets form state when switching to a different app", async () => {
|
|
const user = userEvent.setup();
|
|
renderWithMantine(<ControlledAppsScreen />);
|
|
await user.click(screen.getByText("Weather Widget"));
|
|
await user.type(
|
|
screen.getByRole("textbox", { name: /city/i }),
|
|
"Reykjavik",
|
|
);
|
|
await user.click(screen.getByText("Cohort Data"));
|
|
// Cohort form is fresh; Reykjavik (the previous tool's input) is gone.
|
|
expect(screen.queryByDisplayValue("Reykjavik")).not.toBeInTheDocument();
|
|
expect(
|
|
screen.getByRole("button", { name: /Open App/ }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders a single Refresh via the ListChangedIndicator when listChanged is true", async () => {
|
|
const user = userEvent.setup();
|
|
const onRefreshList = vi.fn();
|
|
renderWithMantine(
|
|
<AppsScreen {...buildProps({ listChanged: true, onRefreshList })} />,
|
|
);
|
|
// The indicator's Refresh is the only refresh affordance — no standalone
|
|
// toolbar button duplicating it.
|
|
expect(screen.getByText("List updated")).toBeInTheDocument();
|
|
const refreshButtons = screen.getAllByRole("button", { name: "Refresh" });
|
|
expect(refreshButtons).toHaveLength(1);
|
|
await user.click(refreshButtons[0]);
|
|
expect(onRefreshList).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("shows no Refresh button when listChanged is false", () => {
|
|
renderWithMantine(<AppsScreen {...buildProps()} />);
|
|
expect(
|
|
screen.queryByRole("button", { name: "Refresh" }),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("renders the tool icon next to the header title when present", async () => {
|
|
const user = userEvent.setup();
|
|
const iconSrc = "data:image/svg+xml,%3Csvg/%3E";
|
|
const iconedApp: Tool = {
|
|
name: "weather-with-icon",
|
|
title: "Weather (Iconed)",
|
|
icons: [{ src: iconSrc }],
|
|
inputSchema: { type: "object" },
|
|
_meta: { ui: { resourceUri: "ui://apps/weather-iconed" } },
|
|
};
|
|
renderWithMantine(<ControlledAppsScreen tools={[iconedApp]} />);
|
|
await user.click(screen.getByText("Weather (Iconed)"));
|
|
const headerImg = screen
|
|
.getAllByRole("presentation")
|
|
.find((img) => img.getAttribute("src") === iconSrc);
|
|
expect(headerImg).toBeDefined();
|
|
});
|
|
|
|
it("ignores selection of an unknown tool name (defensive)", async () => {
|
|
const user = userEvent.setup();
|
|
const onSelectApp = vi.fn();
|
|
const { rerender } = renderWithMantine(
|
|
<ControlledAppsScreen onSelectApp={onSelectApp} />,
|
|
);
|
|
// Click an item, then re-render with a tools list that no longer
|
|
// contains it: the selection state stays put, but a follow-up click
|
|
// on the same name no-ops because the lookup fails.
|
|
await user.click(screen.getByText("Weather Widget"));
|
|
rerender(
|
|
<ControlledAppsScreen
|
|
onSelectApp={onSelectApp}
|
|
tools={[noFieldsApp, cohortApp]}
|
|
/>,
|
|
);
|
|
// Sidebar no longer shows Weather Widget; the right pane falls back
|
|
// to the empty selection state since the lookup misses.
|
|
expect(
|
|
within(screen.getByText("MCP Apps (2)").parentElement!).queryByText(
|
|
"Weather Widget",
|
|
),
|
|
).not.toBeInTheDocument();
|
|
expect(screen.getByText("Select an app to view details")).toBeVisible();
|
|
});
|
|
});
|