* 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:
parent
323f42d11c
commit
5f296589ea
3 changed files with 1210 additions and 164 deletions
|
|
@ -0,0 +1,151 @@
|
|||
import { useRef } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import { fn, userEvent, within } from "storybook/test";
|
||||
import { AppsScreen, type AppsScreenProps } from "./AppsScreen";
|
||||
import type {
|
||||
AppRendererHandle,
|
||||
BridgeFactory,
|
||||
} from "../../elements/AppRenderer/AppRenderer";
|
||||
import { SUN_ICON_SVG } from "../../../test/fixtures/storyIcons";
|
||||
|
||||
const PLACEHOLDER_SANDBOX = "data:text/html,<title>Mock%20Sandbox</title>";
|
||||
|
||||
function createMockBridge(): AppBridge {
|
||||
return {
|
||||
sendToolInput: async () => {},
|
||||
sendToolResult: async () => {},
|
||||
sendToolCancelled: async () => {},
|
||||
teardownResource: async () => ({}),
|
||||
close: async () => {},
|
||||
} as unknown as AppBridge;
|
||||
}
|
||||
|
||||
const okBridgeFactory: BridgeFactory = () => createMockBridge();
|
||||
|
||||
const cohortApp: Tool = {
|
||||
name: "get-cohort-data",
|
||||
title: "Cohort Data",
|
||||
description: "Returns cohort retention heatmap data.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
metric: { type: "string", description: "retention | engagement" },
|
||||
periodType: { type: "string", description: "daily | weekly | monthly" },
|
||||
cohortCount: { type: "number", description: "Cohorts to render" },
|
||||
maxPeriods: { type: "number", description: "Periods per cohort" },
|
||||
},
|
||||
required: ["metric", "periodType"],
|
||||
},
|
||||
_meta: { ui: { resourceUri: "ui://apps/cohort-heatmap" } },
|
||||
};
|
||||
|
||||
const weatherApp: Tool = {
|
||||
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",
|
||||
properties: {
|
||||
city: { type: "string", description: "City name" },
|
||||
},
|
||||
required: ["city"],
|
||||
},
|
||||
_meta: { ui: { resourceUri: "ui://apps/weather" } },
|
||||
};
|
||||
|
||||
const dashboardApp: Tool = {
|
||||
name: "ops-dashboard",
|
||||
title: "Ops Dashboard",
|
||||
description: "Current operational status across services.",
|
||||
inputSchema: { type: "object" },
|
||||
_meta: { ui: { resourceUri: "ui://apps/ops" } },
|
||||
};
|
||||
|
||||
const sampleApps: Tool[] = [cohortApp, weatherApp, dashboardApp];
|
||||
|
||||
const meta: Meta<typeof AppsScreen> = {
|
||||
title: "Screens/AppsScreen",
|
||||
component: AppsScreen,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: {
|
||||
sandboxPath: PLACEHOLDER_SANDBOX,
|
||||
bridgeFactory: okBridgeFactory,
|
||||
listChanged: false,
|
||||
onRefreshList: fn(),
|
||||
onSelectApp: fn(),
|
||||
onOpenApp: fn(),
|
||||
onCloseApp: fn(),
|
||||
},
|
||||
// Each story uses its own ref so AppRenderer's imperative handle gets a
|
||||
// fresh slot per render (Storybook may keep the canvas mounted across
|
||||
// arg edits, but the ref itself is owned by the wrapping component).
|
||||
render: function StoryRender(args: AppsScreenProps) {
|
||||
const ref = useRef<AppRendererHandle>(null);
|
||||
return <AppsScreen {...args} rendererRef={ref} />;
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AppsScreen>;
|
||||
|
||||
async function selectByLabel(canvasElement: HTMLElement, label: string) {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(await canvas.findByText(label));
|
||||
}
|
||||
|
||||
async function clickByName(canvasElement: HTMLElement, name: RegExp) {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(await canvas.findByRole("button", { name }));
|
||||
}
|
||||
|
||||
export const NoSelection: Story = {
|
||||
args: { tools: sampleApps },
|
||||
};
|
||||
|
||||
export const AppSelected: Story = {
|
||||
args: { tools: sampleApps },
|
||||
play: async ({ canvasElement }) => {
|
||||
await selectByLabel(canvasElement, "Cohort Data");
|
||||
},
|
||||
};
|
||||
|
||||
export const AppRunning: Story = {
|
||||
args: { tools: sampleApps },
|
||||
play: async ({ canvasElement }) => {
|
||||
await selectByLabel(canvasElement, "Weather Widget");
|
||||
const canvas = within(canvasElement);
|
||||
const cityField = await canvas.findByRole("textbox", { name: /city/i });
|
||||
await userEvent.type(cityField, "Reykjavik");
|
||||
await clickByName(canvasElement, /open app/i);
|
||||
},
|
||||
};
|
||||
|
||||
export const AppRunningMaximized: Story = {
|
||||
args: { tools: sampleApps },
|
||||
play: async ({ canvasElement }) => {
|
||||
await selectByLabel(canvasElement, "Weather Widget");
|
||||
const canvas = within(canvasElement);
|
||||
const cityField = await canvas.findByRole("textbox", { name: /city/i });
|
||||
await userEvent.type(cityField, "Reykjavik");
|
||||
await clickByName(canvasElement, /open app/i);
|
||||
await userEvent.click(await canvas.findByLabelText("Maximize"));
|
||||
},
|
||||
};
|
||||
|
||||
export const NoFieldsApp: Story = {
|
||||
args: { tools: sampleApps },
|
||||
play: async ({ canvasElement }) => {
|
||||
await selectByLabel(canvasElement, "Ops Dashboard");
|
||||
},
|
||||
};
|
||||
|
||||
export const WithListChanged: Story = {
|
||||
args: { tools: sampleApps, listChanged: true },
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: { tools: [] },
|
||||
};
|
||||
|
|
@ -0,0 +1,282 @@
|
|||
import { createRef } 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 } from "./AppsScreen";
|
||||
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" } },
|
||||
},
|
||||
_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<Parameters<typeof AppsScreen>[0]> = {}) {
|
||||
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>(),
|
||||
onRefreshList: vi.fn(),
|
||||
onSelectApp: vi.fn(),
|
||||
onOpenApp: vi.fn(),
|
||||
onCloseApp: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
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("filters the list via the search input", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<AppsScreen {...buildProps()} />);
|
||||
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(<AppsScreen {...buildProps()} />);
|
||||
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(
|
||||
<AppsScreen {...buildProps({ onSelectApp, 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(<AppsScreen {...buildProps({ 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(<AppsScreen {...buildProps({ 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("returns to the input form when 'Back to Input' is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<AppsScreen {...buildProps()} />);
|
||||
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(<AppsScreen {...buildProps()} />);
|
||||
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(<AppsScreen {...buildProps()} />);
|
||||
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(<AppsScreen {...buildProps({ 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(<AppsScreen {...buildProps({ 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(<AppsScreen {...buildProps()} />);
|
||||
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 the ListChangedIndicator when listChanged is true", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRefreshList = vi.fn();
|
||||
renderWithMantine(
|
||||
<AppsScreen {...buildProps({ listChanged: true, onRefreshList })} />,
|
||||
);
|
||||
// The indicator and toolbar both render "Refresh" buttons; the indicator
|
||||
// is a sibling of the "List updated" label, so target it via that label.
|
||||
expect(screen.getByText("List updated")).toBeInTheDocument();
|
||||
const refreshButtons = screen.getAllByRole("button", { name: "Refresh" });
|
||||
expect(refreshButtons).toHaveLength(2);
|
||||
await user.click(refreshButtons[0]);
|
||||
expect(onRefreshList).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("invokes onRefreshList when the toolbar Refresh button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRefreshList = vi.fn();
|
||||
renderWithMantine(<AppsScreen {...buildProps({ onRefreshList })} />);
|
||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
expect(onRefreshList).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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(<AppsScreen {...buildProps({ 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(
|
||||
<AppsScreen {...buildProps({ 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(
|
||||
<AppsScreen
|
||||
{...buildProps({ 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();
|
||||
});
|
||||
});
|
||||
276
clients/web/src/components/screens/AppsScreen/AppsScreen.tsx
Normal file
276
clients/web/src/components/screens/AppsScreen/AppsScreen.tsx
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
import { useState, type Ref } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Card,
|
||||
Flex,
|
||||
Group,
|
||||
Image,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
MdArrowBack,
|
||||
MdClose,
|
||||
MdFullscreen,
|
||||
MdFullscreenExit,
|
||||
} from "react-icons/md";
|
||||
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import {
|
||||
AppRenderer,
|
||||
type AppRendererHandle,
|
||||
type BridgeFactory,
|
||||
} from "../../elements/AppRenderer/AppRenderer";
|
||||
import { AppDetailPanel } from "../../groups/AppDetailPanel/AppDetailPanel";
|
||||
import { AppControls } from "../../groups/AppControls/AppControls";
|
||||
import { hasInputFields, resolveDisplayLabel } from "../../../utils/toolUtils";
|
||||
|
||||
export interface AppsScreenProps {
|
||||
tools: Tool[];
|
||||
listChanged: boolean;
|
||||
sandboxPath: string;
|
||||
bridgeFactory: BridgeFactory;
|
||||
rendererRef: Ref<AppRendererHandle>;
|
||||
onRefreshList: () => void;
|
||||
onSelectApp: (name: string) => void;
|
||||
onOpenApp: (name: string, args: Record<string, unknown>) => void;
|
||||
onCloseApp: () => void;
|
||||
}
|
||||
|
||||
const ScreenLayout = Flex.withProps({
|
||||
variant: "screen",
|
||||
h: "calc(100vh - var(--app-shell-header-height, 0px))",
|
||||
gap: "md",
|
||||
p: "xl",
|
||||
align: "flex-start",
|
||||
});
|
||||
|
||||
const Sidebar = Stack.withProps({
|
||||
w: 340,
|
||||
flex: "0 0 auto",
|
||||
});
|
||||
|
||||
const SidebarCard = Card.withProps({
|
||||
withBorder: true,
|
||||
padding: "lg",
|
||||
});
|
||||
|
||||
const ContentCard = Card.withProps({
|
||||
withBorder: true,
|
||||
padding: "lg",
|
||||
});
|
||||
|
||||
const EmptyState = Text.withProps({
|
||||
c: "dimmed",
|
||||
ta: "center",
|
||||
py: "xl",
|
||||
});
|
||||
|
||||
const HeaderRow = Group.withProps({
|
||||
justify: "space-between",
|
||||
wrap: "nowrap",
|
||||
gap: "sm",
|
||||
});
|
||||
|
||||
const HeaderLabel = Group.withProps({
|
||||
gap: "sm",
|
||||
wrap: "nowrap",
|
||||
align: "center",
|
||||
flex: 1,
|
||||
miw: 0,
|
||||
});
|
||||
|
||||
const HeaderIcon = Image.withProps({
|
||||
w: 24,
|
||||
h: 24,
|
||||
fit: "contain",
|
||||
});
|
||||
|
||||
const HeaderTitle = Text.withProps({
|
||||
fw: 600,
|
||||
size: "lg",
|
||||
truncate: true,
|
||||
flex: 1,
|
||||
miw: 0,
|
||||
});
|
||||
|
||||
const HeaderActions = Group.withProps({
|
||||
gap: "xs",
|
||||
wrap: "nowrap",
|
||||
});
|
||||
|
||||
const RendererFrame = Stack.withProps({
|
||||
flex: 1,
|
||||
miw: 0,
|
||||
mih: 0,
|
||||
gap: 0,
|
||||
});
|
||||
|
||||
const ContentStack = Stack.withProps({
|
||||
gap: "md",
|
||||
h: "100%",
|
||||
});
|
||||
|
||||
export function AppsScreen({
|
||||
tools,
|
||||
listChanged,
|
||||
sandboxPath,
|
||||
bridgeFactory,
|
||||
rendererRef,
|
||||
onRefreshList,
|
||||
onSelectApp,
|
||||
onOpenApp,
|
||||
onCloseApp,
|
||||
}: AppsScreenProps) {
|
||||
const [selectedAppName, setSelectedAppName] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [formValues, setFormValues] = useState<Record<string, unknown>>({});
|
||||
const [running, setRunning] = useState(false);
|
||||
const [maximized, setMaximized] = useState(false);
|
||||
|
||||
const selectedTool = selectedAppName
|
||||
? tools.find((t) => t.name === selectedAppName)
|
||||
: undefined;
|
||||
const selectedHasFields = selectedTool ? hasInputFields(selectedTool) : false;
|
||||
|
||||
function handleSelect(name: string) {
|
||||
if (name === selectedAppName) return;
|
||||
const next = tools.find((t) => t.name === name);
|
||||
if (!next) return;
|
||||
setSelectedAppName(name);
|
||||
setFormValues({});
|
||||
setMaximized(false);
|
||||
onSelectApp(name);
|
||||
// No-input apps auto-launch on selection so the user lands directly in
|
||||
// the running view; apps with fields wait for the explicit Open App click.
|
||||
if (!hasInputFields(next)) {
|
||||
setRunning(true);
|
||||
onOpenApp(name, {});
|
||||
} else {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpen() {
|
||||
if (!selectedTool) return;
|
||||
setRunning(true);
|
||||
onOpenApp(selectedTool.name, formValues);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
setRunning(false);
|
||||
setSelectedAppName(undefined);
|
||||
setFormValues({});
|
||||
setMaximized(false);
|
||||
onCloseApp();
|
||||
}
|
||||
|
||||
function handleBackToInput() {
|
||||
setRunning(false);
|
||||
setMaximized(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScreenLayout>
|
||||
{!maximized && (
|
||||
<Sidebar>
|
||||
<SidebarCard>
|
||||
<AppControls
|
||||
tools={tools}
|
||||
selectedName={selectedAppName}
|
||||
listChanged={listChanged}
|
||||
onRefreshList={onRefreshList}
|
||||
onSelectApp={handleSelect}
|
||||
/>
|
||||
</SidebarCard>
|
||||
</Sidebar>
|
||||
)}
|
||||
|
||||
<ContentCard flex={1} h="100%">
|
||||
{selectedTool ? (
|
||||
<ContentStack>
|
||||
<HeaderRow>
|
||||
<HeaderLabel>
|
||||
{selectedTool.icons?.[0]?.src && (
|
||||
<HeaderIcon src={selectedTool.icons[0].src} alt="" />
|
||||
)}
|
||||
<HeaderTitle>
|
||||
{resolveDisplayLabel(selectedTool.name, selectedTool.title)}
|
||||
</HeaderTitle>
|
||||
</HeaderLabel>
|
||||
<HeaderActions>
|
||||
{running && selectedHasFields && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
leftSection={<MdArrowBack aria-hidden size={16} />}
|
||||
onClick={handleBackToInput}
|
||||
>
|
||||
Back to Input
|
||||
</Button>
|
||||
)}
|
||||
{running && (
|
||||
<Tooltip label={maximized ? "Restore" : "Maximize"}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
onClick={() => setMaximized((m) => !m)}
|
||||
aria-label={maximized ? "Restore" : "Maximize"}
|
||||
>
|
||||
{maximized ? (
|
||||
<MdFullscreenExit aria-hidden size={20} />
|
||||
) : (
|
||||
<MdFullscreen aria-hidden size={20} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="Close">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
onClick={handleClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<MdClose aria-hidden size={20} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</HeaderActions>
|
||||
</HeaderRow>
|
||||
{running ? (
|
||||
<RendererFrame>
|
||||
{/* Keying by name forces the renderer to remount when the
|
||||
selected app changes, ensuring a fresh bridge and iframe
|
||||
rather than reusing the previous app's transport. */}
|
||||
<AppRenderer
|
||||
key={selectedTool.name}
|
||||
sandboxPath={sandboxPath}
|
||||
tool={selectedTool}
|
||||
bridgeFactory={bridgeFactory}
|
||||
ref={rendererRef}
|
||||
/>
|
||||
</RendererFrame>
|
||||
) : (
|
||||
// `isOpening` is always false here because `handleOpen`
|
||||
// synchronously flips `running` to true, swapping in the
|
||||
// AppRenderer before the panel could render its loading
|
||||
// state. The prop stays in `AppDetailPanel`'s API for
|
||||
// standalone use (the `Opening` story) and for Phase 3
|
||||
// wiring, where a managed-state hook can hold the panel
|
||||
// in a pending state across an awaited `tools/call`.
|
||||
<AppDetailPanel
|
||||
tool={selectedTool}
|
||||
formValues={formValues}
|
||||
isOpening={false}
|
||||
onFormChange={setFormValues}
|
||||
onOpenApp={handleOpen}
|
||||
/>
|
||||
)}
|
||||
</ContentStack>
|
||||
) : (
|
||||
<EmptyState>Select an app to view details</EmptyState>
|
||||
)}
|
||||
</ContentCard>
|
||||
</ScreenLayout>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue