diff --git a/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx b/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx index f6bf43f0f..b71b78483 100644 --- a/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx +++ b/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx @@ -25,6 +25,9 @@ interface MockBridge { close: ReturnType; addEventListener: ReturnType; removeEventListener: ReturnType; + onrequestdisplaymode?: (params: { + mode: "inline" | "fullscreen" | "pip"; + }) => Promise<{ mode: "inline" | "fullscreen" | "pip" }>; /** Test helper: dispatch a bridge event (e.g. "initialized") to listeners. */ emit: (event: string, payload?: unknown) => void; } @@ -232,6 +235,92 @@ describe("AppRenderer", () => { }); }); + it("forwards view size-changed notifications to onSizeChange", async () => { + const bridge = createMockBridge(); + const onSizeChange = vi.fn(); + renderWithMantine( + asBridge(bridge)} + onSizeChange={onSizeChange} + />, + ); + await flushAsync(); + await act(async () => { + bridge.emit("sizechange", { width: 480, height: 600 }); + }); + expect(onSizeChange).toHaveBeenCalledWith({ width: 480, height: 600 }); + }); + + it("does not throw on size-changed when no onSizeChange is provided", async () => { + const bridge = createMockBridge(); + renderWithMantine( + asBridge(bridge)} + />, + ); + await flushAsync(); + await act(async () => { + bridge.emit("sizechange", { height: 320 }); + }); + expect(screen.getByTitle("Cohort App")).toBeInTheDocument(); + }); + + it("routes ui/request-display-mode to onRequestDisplayMode and returns the applied mode", async () => { + const bridge = createMockBridge(); + const onRequestDisplayMode = vi + .fn<(m: "inline" | "fullscreen" | "pip") => "inline" | "fullscreen">() + .mockReturnValue("fullscreen"); + renderWithMantine( + asBridge(bridge)} + displayMode="inline" + onRequestDisplayMode={onRequestDisplayMode} + />, + ); + await flushAsync(); + await expect( + bridge.onrequestdisplaymode?.({ mode: "fullscreen" }), + ).resolves.toEqual({ mode: "fullscreen" }); + expect(onRequestDisplayMode).toHaveBeenCalledWith("fullscreen"); + }); + + it("declines ui/request-display-mode by returning the current displayMode when no handler is provided", async () => { + const bridge = createMockBridge(); + renderWithMantine( + asBridge(bridge)} + displayMode="fullscreen" + />, + ); + await flushAsync(); + await expect( + bridge.onrequestdisplaymode?.({ mode: "pip" }), + ).resolves.toEqual({ mode: "fullscreen" }); + }); + + it("declines ui/request-display-mode with inline when neither a handler nor displayMode is set", async () => { + const bridge = createMockBridge(); + renderWithMantine( + asBridge(bridge)} + />, + ); + await flushAsync(); + await expect( + bridge.onrequestdisplaymode?.({ mode: "pip" }), + ).resolves.toEqual({ mode: "inline" }); + }); + it("pushes a displayMode change to the running view via host-context-changed", async () => { const bridge = createMockBridge(); // Stable factory identity so the rerender reuses the live bridge instead of diff --git a/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx b/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx index 7421d3620..0e6c4be6d 100644 --- a/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx +++ b/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx @@ -9,6 +9,7 @@ import { } from "react"; import type { AppBridge, + AppBridgeEventMap, McpUiDisplayMode, } from "@modelcontextprotocol/ext-apps/app-bridge"; import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js"; @@ -41,12 +42,26 @@ export interface AppRendererProps { tool: Tool; bridgeFactory: BridgeFactory; onError?: (err: Error) => void; + /** + * Called when the running view reports a new rendered content size via + * `ui/notifications/size-changed` (typically driven by its `ResizeObserver`). + * Width and height (px) are both optional. The host uses this to resize the + * iframe's container so the widget is neither clipped nor padded with dead + * space. + */ + onSizeChange?: (size: AppBridgeEventMap["sizechange"]) => void; /** * Current host display mode for the app frame. Pushed to the running view * via `host-context-changed` whenever it changes (e.g. Maximize/Restore), so * an app can adapt its layout to inline vs fullscreen. */ displayMode?: McpUiDisplayMode; + /** + * Handles a view-originated `ui/request-display-mode`. Return the mode the + * host actually applied — the spec lets the host decline an unsupported mode + * by returning its current one. + */ + onRequestDisplayMode?: (requested: McpUiDisplayMode) => McpUiDisplayMode; /** * The host-controlled box the app renders within, used to derive * `hostContext.containerDimensions`. This MUST be an element whose size is @@ -102,7 +117,9 @@ export function AppRenderer({ tool, bridgeFactory, onError, + onSizeChange, displayMode, + onRequestDisplayMode, containerRef, ref, }: AppRendererProps) { @@ -123,8 +140,14 @@ export function AppRenderer({ tool: Tool; } | null>(null); const onErrorRef = useRef(onError); + const onSizeChangeRef = useRef(onSizeChange); + const displayModeRef = useRef(displayMode); + const onRequestDisplayModeRef = useRef(onRequestDisplayMode); useEffect(() => { onErrorRef.current = onError; + onSizeChangeRef.current = onSizeChange; + displayModeRef.current = displayMode; + onRequestDisplayModeRef.current = onRequestDisplayMode; }); // Flush buffered tool input/result to the view, but only once the bridge @@ -249,6 +272,21 @@ export function AppRenderer({ } flushPending(); }); + // Forward the view's content-size reports (ui/notifications/size-changed) + // so the host can resize the iframe container to fit the rendered widget. + bridge.addEventListener("sizechange", (size) => { + onSizeChangeRef.current?.(size); + }); + // Handle ui/request-display-mode: let the host (AppsScreen) decide what + // mode to actually apply and return that. With no handler the request is + // declined by returning the current host-side mode. + bridge.onrequestdisplaymode = async ({ mode }) => { + const handler = onRequestDisplayModeRef.current; + const applied = handler + ? handler(mode) + : (displayModeRef.current ?? "inline"); + return { mode: applied }; + }; flushPending(); }) .catch((err) => { diff --git a/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx b/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx index 9157918b4..14f770862 100644 --- a/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx +++ b/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx @@ -1,6 +1,8 @@ import { createRef, useState } from "react"; +import { act } from "@testing-library/react"; import { describe, it, expect, vi } from "vitest"; import userEvent from "@testing-library/user-event"; +import type { McpUiDisplayMode } from "@modelcontextprotocol/ext-apps/app-bridge"; import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; import { @@ -55,10 +57,68 @@ const okBridgeFactory: BridgeFactory = () => sendToolInput: async () => {}, sendToolResult: async () => {}, sendToolCancelled: async () => {}, + sendHostContextChange: async () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, teardownResource: async () => ({}), close: async () => {}, }) as unknown as AppBridge; +// A bridge factory whose bridge supports the addEventListener/emit surface the +// AppRenderer wires up, so a test can drive a bridge event (sizechange) through +// to the screen's handling. `emit` dispatches to the captured listeners; +// `bridges` exposes each built bridge so tests can also drive the per-bridge +// handlers (onrequestdisplaymode). +function createEventBridgeFactory(): { + factory: BridgeFactory; + bridges: AppBridge[]; + emit: (event: string, payload?: unknown) => void; +} { + const listeners: Record void)[]> = {}; + const bridges: AppBridge[] = []; + const factory: BridgeFactory = () => { + const bridge = { + sendToolInput: async () => {}, + sendToolResult: async () => {}, + sendToolCancelled: async () => {}, + sendHostContextChange: async () => {}, + teardownResource: async () => ({}), + close: async () => {}, + addEventListener: (event: string, handler: (p: unknown) => void) => { + (listeners[event] ??= []).push(handler); + }, + removeEventListener: () => {}, + } as unknown as AppBridge; + bridges.push(bridge); + return bridge; + }; + return { + factory, + bridges, + emit: (event, payload) => + (listeners[event] ?? []).forEach((h) => h(payload)), + }; +} + +// Invoke the onrequestdisplaymode handler the screen attached to the latest +// bridge, mimicking a ui/request-display-mode request from the running view. +async function requestDisplayMode( + bridges: AppBridge[], + mode: McpUiDisplayMode, +): Promise<{ mode: McpUiDisplayMode }> { + const handler = bridges.at(-1)?.onrequestdisplaymode as unknown as + | ((params: { mode: McpUiDisplayMode }) => Promise<{ + mode: McpUiDisplayMode; + }>) + | undefined; + if (!handler) throw new Error("no onrequestdisplaymode handler attached"); + let result: { mode: McpUiDisplayMode } = { mode }; + await act(async () => { + result = await handler({ mode }); + }); + return result; +} + function buildProps(overrides: Partial = {}): AppsScreenProps { return { tools: [fieldedApp, noFieldsApp, cohortApp] as Tool[], @@ -249,6 +309,113 @@ describe("AppsScreen", () => { expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument(); }); + it("sizes the renderer frame to the view-reported height", async () => { + const user = userEvent.setup(); + const { factory, emit } = createEventBridgeFactory(); + renderWithMantine(); + // Auto-launches the no-fields app, mounting the renderer and registering + // its sizechange listener once the bridge resolves. + await user.click(screen.getByText("Ops Dashboard")); + const iframe = screen.getByTitle("Ops Dashboard"); + const frame = iframe.parentElement as HTMLElement; + // Until the view reports a size, the frame flex-grows to fill the card. + expect(frame.style.flexGrow).toBe("1"); + + await act(async () => { + // Let the synchronous bridge factory's promise chain settle so the + // sizechange listener is registered before we emit. + await Promise.resolve(); + await Promise.resolve(); + emit("sizechange", { height: 600 }); + }); + // A reported height switches the frame to its content size: it stops + // flex-growing and takes the explicit `h`. + expect(frame.style.flexGrow).toBe("0"); + }); + + it("ignores a size-changed report that carries no height", async () => { + const user = userEvent.setup(); + const { factory, emit } = createEventBridgeFactory(); + renderWithMantine(); + await user.click(screen.getByText("Ops Dashboard")); + const frame = screen.getByTitle("Ops Dashboard") + .parentElement as HTMLElement; + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + emit("sizechange", { width: 400 }); + }); + // No height in the report — the frame keeps flex-growing. + expect(frame.style.flexGrow).toBe("1"); + }); + + it("ignores a size-changed report with a non-positive height", async () => { + const user = userEvent.setup(); + const { factory, emit } = createEventBridgeFactory(); + renderWithMantine(); + await user.click(screen.getByText("Ops Dashboard")); + const frame = screen.getByTitle("Ops Dashboard") + .parentElement as HTMLElement; + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + // A transient 0 (pre-layout / teardown) must not collapse the frame. + emit("sizechange", { height: 0 }); + }); + expect(frame.style.flexGrow).toBe("1"); + // A subsequent positive report is honored. + await act(async () => emit("sizechange", { height: 480 })); + expect(frame.style.flexGrow).toBe("0"); + }); + + it("maximizes the frame when the view requests fullscreen via request-display-mode", async () => { + const user = userEvent.setup(); + const { factory, bridges } = createEventBridgeFactory(); + renderWithMantine(); + await user.click(screen.getByText("Ops Dashboard")); + expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + const result = await requestDisplayMode(bridges, "fullscreen"); + expect(result).toEqual({ mode: "fullscreen" }); + // Fullscreen hides the sidebar (same effect as the Maximize button). + expect(screen.queryByText("MCP Apps (3)")).not.toBeInTheDocument(); + }); + + it("declines an unsupported display mode, returning the current mode", async () => { + const user = userEvent.setup(); + const { factory, bridges } = createEventBridgeFactory(); + renderWithMantine(); + await user.click(screen.getByText("Ops Dashboard")); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + // "pip" is not in HOST_AVAILABLE_DISPLAY_MODES — declined, current mode + // ("inline") returned, and the sidebar stays visible. + const result = await requestDisplayMode(bridges, "pip"); + expect(result).toEqual({ mode: "inline" }); + expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument(); + }); + + it("restores inline via request-display-mode after maximizing", async () => { + const user = userEvent.setup(); + const { factory, bridges } = createEventBridgeFactory(); + renderWithMantine(); + await user.click(screen.getByText("Ops Dashboard")); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + await requestDisplayMode(bridges, "fullscreen"); + expect(screen.queryByText("MCP Apps (3)")).not.toBeInTheDocument(); + const result = await requestDisplayMode(bridges, "inline"); + expect(result).toEqual({ mode: "inline" }); + expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument(); + }); + it("calls onCloseApp and clears selection on Close", async () => { const user = userEvent.setup(); const onCloseApp = vi.fn(); diff --git a/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx b/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx index 127f43c67..0976e844e 100644 --- a/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx +++ b/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx @@ -1,4 +1,4 @@ -import { useState, type Ref } from "react"; +import { useRef, useState, type Ref } from "react"; import { ActionIcon, Button, @@ -17,11 +17,16 @@ import { MdFullscreenExit, } from "react-icons/md"; import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import type { + AppBridgeEventMap, + McpUiDisplayMode, +} from "@modelcontextprotocol/ext-apps/app-bridge"; import { AppRenderer, type AppRendererHandle, type BridgeFactory, } from "../../elements/AppRenderer/AppRenderer"; +import { HOST_AVAILABLE_DISPLAY_MODES } from "../../elements/AppRenderer/createAppBridgeFactory"; import { AppDetailPanel } from "../../groups/AppDetailPanel/AppDetailPanel"; import { AppControls } from "../../groups/AppControls/AppControls"; import { hasInputFields, resolveDisplayLabel } from "../../../utils/toolUtils"; @@ -127,13 +132,27 @@ const HeaderActions = Group.withProps({ wrap: "nowrap", }); -const RendererFrame = Stack.withProps({ +// The host-controlled box the running app sits within. Its size is driven by +// the host's layout (window resize, sidebar toggle, maximize) and NOT by the +// view's reported content height — that drives the inner RendererFrame — so the +// renderer's containerDimensions observer can measure this element without +// coupling host→view container size to view→host size-changed. +const RendererContainer = Stack.withProps({ flex: 1, miw: 0, mih: 0, gap: 0, }); +// The inner box that actually holds the iframe. Sized by the view-reported +// content height (see `contentHeight`) and capped at the outer container. +// Distinct from RendererContainer above so the two roles read clearly in JSX. +const RendererFrame = Stack.withProps({ + miw: 0, + mih: 0, + gap: 0, +}); + const ContentStack = Stack.withProps({ gap: "md", h: "100%", @@ -156,12 +175,48 @@ export function AppsScreen({ const { selectedAppName, formValues, search } = ui; const [running, setRunning] = useState(false); const [maximized, setMaximized] = useState(false); + const rendererContainerRef = useRef(null); + // Height (px) the running view last reported via ui/notifications/size-changed. + // Undefined until the view reports (or after it's torn down), in which case + // the iframe fills the available card space as before. Local to the screen + // like `running`/`maximized`: it's tied to the live iframe, not persisted. + const [appHeight, setAppHeight] = useState(undefined); const selectedTool = selectedAppName ? tools.find((t) => t.name === selectedAppName) : undefined; const selectedHasFields = selectedTool ? hasInputFields(selectedTool) : false; + // The running view reports its rendered content height via + // ui/notifications/size-changed; honor it so the iframe is neither clipped + // nor surrounded by dead space. Width is left at the host-controlled + // container width. The value is clamped to the available space by the + // renderer frame's `mah` below, and ignored while maximized (the app fills + // the screen instead). A non-positive height is ignored — a view's + // ResizeObserver can transiently fire 0 before layout settles or during + // teardown, which would otherwise collapse the frame (mirrors AppRenderer's + // own 0×0 skip on the container side). + function handleSizeChange(size: AppBridgeEventMap["sizechange"]) { + if (size.height != null && size.height > 0) setAppHeight(size.height); + } + + // The app's display mode is derived from the existing maximized toggle. + // Passed to AppRenderer so the running view receives it via + // host-context-changed; the Maximize/Restore button below keeps toggling + // `maximized`, which now flows out as a protocol event. + const displayMode: McpUiDisplayMode = maximized ? "fullscreen" : "inline"; + + // Handle a view-originated ui/request-display-mode. Only modes the inspector + // advertises in `availableDisplayModes` are honored — an unsupported request + // (e.g. "pip") is declined by returning the current mode, per spec. + function handleRequestDisplayMode( + requested: McpUiDisplayMode, + ): McpUiDisplayMode { + if (!HOST_AVAILABLE_DISPLAY_MODES.includes(requested)) return displayMode; + setMaximized(requested === "fullscreen"); + return requested; + } + function handleSelect(name: string) { if (name === selectedAppName) return; const next = tools.find((t) => t.name === name); @@ -174,6 +229,7 @@ export function AppsScreen({ formValues: collectSchemaDefaults(next.inputSchema), }); setMaximized(false); + setAppHeight(undefined); 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. @@ -187,6 +243,7 @@ export function AppsScreen({ function handleOpen() { if (!selectedTool) return; + setAppHeight(undefined); setRunning(true); onOpenApp(selectedTool.name, formValues); } @@ -195,12 +252,14 @@ export function AppsScreen({ setRunning(false); onUiChange({ ...ui, selectedAppName: undefined, formValues: {} }); setMaximized(false); + setAppHeight(undefined); onCloseApp(); } function handleBackToInput() { setRunning(false); setMaximized(false); + setAppHeight(undefined); } // No sandbox proxy URL means the host can't embed the trusted outer iframe @@ -218,6 +277,15 @@ export function AppsScreen({ ); } + // While maximized the app fills the screen, so the view-reported height is + // ignored; otherwise we honor it (clamped to the card by the frame's `mah`). + // `appHeight` is intentionally NOT cleared when toggling maximize: carrying + // the last inline height across a maximize→restore means the frame restores + // at its prior size immediately, rather than flashing to full-card height + // (flex:1) for the frame or two until the view sends a fresh size-changed + // after the `inline` host-context-changed. + const contentHeight = maximized ? undefined : appHeight; + return ( {!maximized && ( @@ -286,19 +354,32 @@ export function AppsScreen({ {running ? ( - - {/* 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. */} - - + // RendererContainer is the host-controlled box (its size only + // changes with host layout); the inner RendererFrame is sized by + // the view's reported content height, capped at the container. + + + {/* 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. */} + + + ) : ( // `isOpening` is always false here because `handleOpen` // synchronously flips `running` to true, swapping in the