import { useCallback, useEffect, useRef, useState, type Ref } from "react"; import { ActionIcon, Button, Card, Code, Collapse, Flex, Group, Image, Paper, ScrollArea, Stack, Text, Title, Tooltip, } from "@mantine/core"; import { MdArrowBack, MdClose, MdFullscreen, MdFullscreenExit, } from "react-icons/md"; import type { ContentBlock, LoggingMessageNotification, Tool, } from "@modelcontextprotocol/client"; import type { AppBridgeEventMap, McpUiDisplayMode, } from "@modelcontextprotocol/ext-apps/app-bridge"; import { AppRenderer, type AppRendererHandle, type AppRendererStatus, 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 { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; import { LogLevelBadge } from "../../elements/LogLevelBadge/LogLevelBadge"; import { hasInputFields, resolveDisplayLabel } from "../../../utils/toolUtils"; import { collectSchemaDefaults, toFormSchema } from "../../../utils/jsonUtils"; export interface AppsScreenProps { tools: Tool[]; listChanged: boolean; /** * URL of the inspector's sandbox proxy page (the trusted outer iframe). When * undefined, MCP Apps cannot run (legacy backend, or a build without the * sandbox controller) and the screen renders an unavailable state instead of * a silently blank iframe. */ sandboxPath?: string; bridgeFactory: BridgeFactory; rendererRef: Ref; ui: AppsUiState; onUiChange: (next: AppsUiState) => void; onRefreshList: () => void; onSelectApp: (name: string) => void; onOpenApp: (name: string, args: Record) => void; onCloseApp: () => void; /** Surfaces bridge/runtime failures from the renderer (e.g. no client). */ onError?: (err: Error) => void; /** * Deep-link auto-open (#1577): when true and an app is already pre-selected * (the parent seeds `ui.selectedAppName` + `ui.formValues` from the URL), * the screen fires "Open App" automatically — no explicit click. Token-gated * upstream in `parseDeepLink` (the URL value must equal the session token), * so a third-party link cannot auto-invoke a tool. Fires exactly once. */ autoOpen?: boolean; } // Selected app, its form values, and the sidebar search — controlled by the // parent (App) as one object so they persist across tab navigation within a // live session (#1417). `running`/`maximized` stay local to the screen: they're // tied to the live iframe and bridge, which are torn down on unmount, so // persisting them would restore a flag without its runtime. On return the // selected app's input form (with its values) is shown, ready to re-open. export interface AppsUiState { selectedAppName?: string; formValues: Record; search: string; } const ScreenLayout = Flex.withProps({ variant: "screen", h: "calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-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", }); // `variant="preview"` (overflow: hidden) keeps the full-height card from // bleeding past the viewport: the running app's iframe fills it, and the // app-input form scrolls internally (see AppDetailPanel's PanelScroll). // `flex: 1` + `h: "100%"` make it fill the screen column (both call sites do). const ContentCard = Card.withProps({ withBorder: true, padding: "lg", variant: "preview", flex: 1, h: "100%", }); 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 BackToInputButton = Button.withProps({ variant: "subtle", size: "sm", leftSection: , }); const CloseIconButton = ActionIcon.withProps({ variant: "subtle", "aria-label": "Close", }); // 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%", }); // Pinned panel below the running app (used by both the message log and the // app-log panel). `0 0 auto` keeps it at its content height (capped by the // inner scroll's `mah`) so it never squeezes out the iframe above it. const PinnedPanel = Stack.withProps({ gap: "xs", flex: "0 0 auto", mih: 0, }); const LogScroll = ScrollArea.withProps({ mah: 200, type: "auto", scrollbars: "y", offsetScrollbars: true, }); const MessageLogStack = Stack.withProps({ gap: "sm", }); const MessageItem = Paper.withProps({ p: "md", radius: "md", withBorder: true, }); const MessageItemStack = Stack.withProps({ gap: "xs", }); const MonoCaption = Text.withProps({ size: "xs", c: "dimmed", ff: "monospace", }); const AppLogList = Stack.withProps({ gap: "xs", }); const AppLogRow = Group.withProps({ gap: "sm", wrap: "nowrap", align: "flex-start", }); const AppLogData = Code.withProps({ block: true, fz: "xs", }); const CompactSubtleButton = Button.withProps({ variant: "subtle", size: "compact-xs", }); const PanelHeaderRow = Group.withProps({ justify: "space-between", wrap: "nowrap", gap: "sm", }); const PartialStageControls = Group.withProps({ gap: "sm", wrap: "nowrap", align: "center", flex: "0 0 auto", }); const StagePartialButton = Button.withProps({ variant: "default", size: "compact-xs", }); const PartialStageCount = Text.withProps({ size: "xs", c: "dimmed", }); const AppErrorPanel = Paper.withProps({ p: "md", radius: "md", withBorder: true, c: "var(--inspector-log-error)", }); const AppErrorTitle = Text.withProps({ fw: 600, size: "sm", }); const AppErrorMessage = Text.withProps({ size: "sm", ff: "monospace", }); /** Render a log payload as a string for display. */ function formatLogData(data: unknown): string { if (typeof data === "string") return data; try { // JSON.stringify(undefined) returns the value `undefined`, not a string, so // coalesce to "" to keep the `: string` return type honest for a data-less // log (spec-required, so this is only defensive against a malformed view). return JSON.stringify(data) ?? ""; } catch { /* v8 ignore next -- JSON.stringify only throws on a BigInt or a circular structure; a log payload delivered over postMessage is already structured-clone-safe, so this fallback is unreachable in practice. */ return String(data); } } /** * Soft cap on retained message / log entries per run. Chatty widgets can emit * logs in a loop; keep only the most recent so the panels (and their DOM rows) * don't grow without bound between Clear/close. Oldest entries are dropped. */ const MAX_APP_CHANNEL_ENTRIES = 500; /** Append to a capped list, dropping the oldest entries past the cap. */ function appendCapped(prev: T[], next: T): T[] { const grown = [...prev, next]; return grown.length > MAX_APP_CHANNEL_ENTRIES ? grown.slice(grown.length - MAX_APP_CHANNEL_ENTRIES) : grown; } // A user-role message submitted by the running view through ui/message. The // inspector has no conversation to append to, so it just records the content // blocks for display. `role`/`content` mirror McpUiMessageRequest["params"]; // `id` is a stable React key (like AppLogEntry) so the appendCapped front-drop // can't renumber keys the way an array index would. interface AppMessage { id: number; role: "user"; content: ContentBlock[]; } /** * One MCP `notifications/message` log entry from the running app, with the * payload stringified once at capture time so the render path can use it * directly. `id` is a stable React key. */ interface AppLogEntry { id: number; level: LoggingMessageNotification["params"]["level"]; logger?: string; text: string; } export function AppsScreen({ tools, listChanged, sandboxPath, bridgeFactory, rendererRef, ui, onUiChange, onRefreshList, onSelectApp, onOpenApp, onCloseApp, onError, autoOpen = false, }: AppsScreenProps) { const { selectedAppName, formValues, search } = ui; const [running, setRunning] = useState(false); const [maximized, setMaximized] = useState(false); const rendererContainerRef = useRef(null); const nextLogIdRef = useRef(0); const nextMessageIdRef = useRef(0); // 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); // Messages the running view has pushed via ui/message. The inspector has no // chat loop, so they're collected here and shown in a log below the app // rather than continuing a conversation. Local to the screen like `running`: // tied to the live bridge, cleared when the open ends or the app changes. const [messages, setMessages] = useState([]); // Standard MCP log notifications (notifications/message) the running app // emits. The host advertises the `logging` capability; without surfacing // these here they'd be silently dropped by the bridge. Same lifecycle as // `messages`: tied to the live bridge, cleared on open/close/switch. const [appLogs, setAppLogs] = useState([]); // Expanded by default so a widget developer sees the entries without an extra // click. The user can still collapse it for the rest of the run. const [appLogsExpanded, setAppLogsExpanded] = useState(true); // Snapshots of the input form captured via "Stage partial input". On Open // App they're passed to AppRenderer as `partialInputs` and replayed via // ui/notifications/tool-input-partial before the complete tool-input, so a // widget's progressive-render path can be exercised. Cleared on switch/close. const [partialStages, setPartialStages] = useState[]>( [], ); // High-level renderer lifecycle, surfaced as `data-app-status` on the // `apps-form` card so an automated driver can poll // `[data-app-status="ready"]` instead of racing the iframe selector. const [appStatus, setAppStatus] = useState( "idle", ); // The error that put the renderer into status="error" (factory throw, no // connected client). Shown in place of the blank iframe and surfaced as // `data-app-error` on the `apps-form` card so an automated driver can read // *why* the open failed without screenshotting a toast. const [appError, setAppError] = 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); } function handleMessage(params: Omit) { setMessages((prev) => appendCapped(prev, { id: nextMessageIdRef.current++, ...params }), ); } function handleLog(params: LoggingMessageNotification["params"]) { setAppLogs((prev) => appendCapped(prev, { id: nextLogIdRef.current++, level: params.level, logger: params.logger, text: formatLogData(params.data), }), ); } // Clear the message + log panels (and the reported height). Called when a run // ends or the selected app changes so a new run starts clean. `keepPartials` // is set for `handleOpen`, where the staged fragments are about to be consumed // by the renderer and must not be cleared first. Memoized (stable setState // calls only) so the deep-link auto-open effect's deps don't churn. const resetAppChannels = useCallback((opts?: { keepPartials?: boolean }) => { setAppHeight(undefined); setMessages([]); setAppLogs([]); setAppLogsExpanded(true); setAppStatus("idle"); setAppError(undefined); if (!opts?.keepPartials) setPartialStages([]); }, []); // Capture the error locally (so it can be shown in the card and surfaced as // `data-app-error`) and forward it to the parent's onError. The renderer // already drives `data-app-status="error"` via onAppStatusChange; this adds // the *reason* alongside it. function handleAppError(err: Error) { setAppError(err); onError?.(err); } function handleStagePartialInput() { setPartialStages((prev) => [...prev, { ...formValues }]); } // 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); if (!next) return; // Seed schema defaults so default-only fields are sent on Open App (parity // with the form's resolveValue display, which onChange doesn't capture). onUiChange({ ...ui, selectedAppName: name, formValues: collectSchemaDefaults(toFormSchema(next.inputSchema) ?? {}), }); setMaximized(false); resetAppChannels(); 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; // `keepPartials` preserves the staged fragments: AppRenderer snapshots them // into its own pendingPartialsRef at bridge-build time and replays them. // The `partialStages` state is intentionally NOT cleared here — the staging // UI only renders while not running, so the surviving state is invisible // until the next select/close/back reset drains it. resetAppChannels({ keepPartials: true }); setRunning(true); onOpenApp(selectedTool.name, formValues); } function handleClose() { setRunning(false); onUiChange({ ...ui, selectedAppName: undefined, formValues: {} }); setMaximized(false); resetAppChannels(); onCloseApp(); } // Deep-link auto-open (#1577): the parent seeds `ui.selectedAppName` + // `ui.formValues` from the URL and sets `autoOpen`; fire "Open App" here so // the driver lands on a rendered widget with zero clicks. Ref-guarded to fire // exactly once — a later manual close leaves `running` false without // re-triggering. The open (running + channel resets + `onOpenApp`) is // deferred one microtask past the synchronous effect body: it calls setState, // which the set-state-in-effect lint rightly flags in general, but this is a // ref-guarded run-once effect so the cascading-render concern doesn't apply // (same pattern as App.tsx's deep-link connect effect). `resetAppChannels` // changes identity each render, so the effect re-runs, but the ref guard // makes every run after the first a no-op. const autoOpenFiredRef = useRef(false); useEffect(() => { if (!autoOpen || autoOpenFiredRef.current) return; if (!selectedTool || running) return; autoOpenFiredRef.current = true; void Promise.resolve().then(() => { resetAppChannels({ keepPartials: true }); setRunning(true); onOpenApp(selectedTool.name, formValues); }); }, [ autoOpen, selectedTool, running, formValues, onOpenApp, resetAppChannels, ]); function handleBackToInput() { setRunning(false); setMaximized(false); resetAppChannels(); } // No sandbox proxy URL means the host can't embed the trusted outer iframe // the double-iframe sandbox depends on — surface that plainly instead of // mounting an iframe that would render blank. if (!sandboxPath) { return ( MCP Apps are unavailable — the sandbox could not be reached. ); } // 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 && ( onUiChange({ ...ui, search: value })} onSelectApp={handleSelect} /> )} {selectedTool ? ( {selectedTool.icons?.[0]?.src && ( )} {resolveDisplayLabel(selectedTool.name, selectedTool.title)} {running && selectedHasFields && ( Back to Input )} {running && ( setMaximized((m) => !m)} aria-label={maximized ? "Restore" : "Maximize"} > {maximized ? ( ) : ( )} )} {running ? ( // 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. */} {/* Shown BELOW the frame on a factory throw/reject so the reason is visible alongside the (blank) iframe rather than leaving a silent blank frame. The renderer stays mounted so an in-place retry path remains possible. */} {appError && ( App failed to load {appError.message} )} ) : ( // `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`. <> {selectedHasFields && ( Stage partial input {partialStages.length > 0 && ( <> {partialStages.length} staged setPartialStages([])} > Clear staged )} )} onUiChange({ ...ui, formValues: values }) } onOpenApp={handleOpen} /> )} {running && messages.length > 0 && ( Messages from app ({messages.length}) {messages.map((message, index) => ( [{index}] role: {message.role} {message.content.map((block, blockIndex) => ( ))} ))} )} {running && appLogs.length > 0 && ( setAppLogsExpanded((e) => !e)} aria-expanded={appLogsExpanded} aria-controls="apps-logs-region" > App logs ({appLogs.length}) setAppLogs([])}> Clear {appLogs.map((entry) => ( {entry.logger && ( {entry.logger} )} {entry.text} ))} )} ) : ( Select an app to view details )} ); }