feat(ui): implement fullscreen and pip display modes for MCP Apps (#7312)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Andrew Harvard
2026-02-26 13:30:16 -05:00
committed by GitHub
parent b542af5330
commit 8c2b6f9785
4 changed files with 625 additions and 29 deletions
@@ -24,6 +24,7 @@ import type {
McpUiSizeChangedNotification,
} from '@modelcontextprotocol/ext-apps/app-bridge';
import type { CallToolResult, JSONRPCRequest } from '@modelcontextprotocol/sdk/types.js';
import { GripHorizontal, Maximize2, PictureInPicture2, X } from 'lucide-react';
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import { callTool, readResource } from '../../api';
import { AppEvents } from '../../constants/events';
@@ -40,14 +41,21 @@ import {
McpAppToolInputPartial,
McpAppToolResult,
DimensionLayout,
OnDisplayModeChange,
SamplingCreateMessageParams,
SamplingCreateMessageResponse,
} from './types';
import {
useDisplayMode,
AVAILABLE_DISPLAY_MODES,
PIP_WIDTH,
PIP_HEIGHT,
PIP_MARGIN_RIGHT,
PIP_MARGIN_BOTTOM,
} from './useDisplayMode';
const DEFAULT_IFRAME_HEIGHT = 200;
const AVAILABLE_DISPLAY_MODES: McpUiDisplayMode[] = ['inline'];
const DISPLAY_MODE_LAYOUTS: Record<GooseDisplayMode, DimensionLayout> = {
inline: { width: 'fixed', height: 'unbounded' },
fullscreen: { width: 'fixed', height: 'fixed' },
@@ -139,6 +147,7 @@ interface McpAppRendererProps {
append?: (text: string) => void;
displayMode?: GooseDisplayMode;
cachedHtml?: string;
onDisplayModeChange?: OnDisplayModeChange;
}
interface ResourceMeta {
@@ -235,8 +244,27 @@ export default function McpAppRenderer({
append,
displayMode = 'inline',
cachedHtml,
onDisplayModeChange,
}: McpAppRendererProps) {
const isExpandedView = displayMode === 'fullscreen' || displayMode === 'standalone';
const containerRef = useRef<HTMLDivElement>(null);
const dm = useDisplayMode({ displayMode, onDisplayModeChange, containerRef });
const {
activeDisplayMode,
effectiveDisplayModes,
isStandalone,
isFullscreen,
isPip,
isFillsViewport,
isInline,
appSupportsFullscreen,
appSupportsPip,
changeDisplayMode,
inlineHeight,
pipPosition,
pipHandlers,
fullscreenCloseRef,
} = dm;
const { resolvedTheme, mcpHostStyles } = useTheme();
@@ -264,7 +292,18 @@ export default function McpAppRenderer({
});
const [iframeHeight, setIframeHeight] = useState(DEFAULT_IFRAME_HEIGHT);
const containerRef = useRef<HTMLDivElement>(null);
// Restore iframeHeight from the saved snapshot when returning to inline.
// While in fullscreen/pip, handleSizeChanged ignores size notifications, so
// iframeHeight may be stale. This ensures the container starts at the correct
// height the moment the mode flips back to inline.
useEffect(() => {
if (isInline) {
setIframeHeight(inlineHeight);
}
}, [isInline, inlineHeight]);
const effectiveInlineHeight = iframeHeight || DEFAULT_IFRAME_HEIGHT;
const [containerWidth, setContainerWidth] = useState<number>(0);
const [containerHeight, setContainerHeight] = useState<number>(0);
const [apiHost, setApiHost] = useState<string | null>(null);
@@ -512,11 +551,14 @@ export default function McpAppRenderer({
[]
);
const handleSizeChanged = useCallback(({ height }: McpUiSizeChangedNotification['params']) => {
if (height !== undefined && height > 0) {
setIframeHeight(height);
}
}, []);
const handleSizeChanged = useCallback(
({ height }: McpUiSizeChangedNotification['params']) => {
if (height !== undefined && height > 0 && isInline) {
setIframeHeight(height);
}
},
[isInline]
);
// Track the container's pixel dimensions so we can report them to apps via containerDimensions.
useEffect(() => {
@@ -605,12 +647,17 @@ export default function McpAppRenderer({
// todo: toolInfo: {}
theme: resolvedTheme,
styles: mcpHostStyles,
// 'standalone' is a Goose-specific display mode (dedicated Electron window)
// that maps to the spec's inline | fullscreen | pip modes.
displayMode: displayMode as McpUiDisplayMode,
availableDisplayModes:
displayMode === 'standalone' ? [displayMode as McpUiDisplayMode] : AVAILABLE_DISPLAY_MODES,
containerDimensions: getContainerDimensions(displayMode, containerWidth, containerHeight),
displayMode: activeDisplayMode as McpUiDisplayMode,
availableDisplayModes: isStandalone
? [activeDisplayMode as McpUiDisplayMode]
: effectiveDisplayModes.length > 0
? effectiveDisplayModes
: AVAILABLE_DISPLAY_MODES,
containerDimensions: getContainerDimensions(
activeDisplayMode,
containerWidth,
containerHeight
),
locale: navigator.language,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
userAgent: navigator.userAgent,
@@ -628,7 +675,15 @@ export default function McpAppRenderer({
};
return context;
}, [resolvedTheme, mcpHostStyles, displayMode, containerWidth, containerHeight]);
}, [
resolvedTheme,
mcpHostStyles,
activeDisplayMode,
isStandalone,
containerWidth,
containerHeight,
effectiveDisplayModes,
]);
const appToolResult = useMemo((): CallToolResult | undefined => {
if (!toolResult) return undefined;
@@ -694,23 +749,171 @@ export default function McpAppRenderer({
);
};
const showControls = !isStandalone && !isError && (appSupportsFullscreen || appSupportsPip);
const renderDisplayModeControls = () => {
if (!showControls) return null;
if (activeDisplayMode === 'fullscreen') {
return (
<div className="no-drag absolute top-3 right-3 z-[60] flex gap-1">
{appSupportsPip && (
<button
onClick={() => changeDisplayMode('pip')}
className="cursor-pointer rounded-md bg-black/50 p-1.5 text-white backdrop-blur-sm transition-opacity hover:bg-black/70"
title="Picture-in-Picture"
aria-label="Picture-in-Picture"
>
<PictureInPicture2 size={16} />
</button>
)}
<button
ref={fullscreenCloseRef}
onClick={() => changeDisplayMode('inline')}
className="cursor-pointer rounded-md bg-black/50 p-1.5 text-white backdrop-blur-sm transition-opacity hover:bg-black/70"
title="Exit fullscreen (Esc)"
aria-label="Exit fullscreen"
>
<X size={16} />
</button>
</div>
);
}
if (activeDisplayMode === 'pip') {
return (
<>
{appSupportsFullscreen && (
<button
onClick={() => changeDisplayMode('fullscreen')}
className="cursor-pointer rounded-md bg-black/50 p-1 text-white backdrop-blur-sm transition-opacity hover:bg-black/70"
title="Fullscreen"
aria-label="Fullscreen"
>
<Maximize2 size={14} />
</button>
)}
<button
onClick={() => changeDisplayMode('inline')}
className="cursor-pointer rounded-md bg-black/50 p-1 text-white backdrop-blur-sm transition-opacity hover:bg-black/70"
title="Close"
aria-label="Close"
>
<X size={14} />
</button>
</>
);
}
// Inline mode — show controls on hover or keyboard focus
return (
<div className="absolute top-2 right-2 z-10 flex gap-1 opacity-0 transition-opacity group-hover/mcp-app:opacity-100 focus-within:opacity-100">
{appSupportsFullscreen && (
<button
onClick={() => changeDisplayMode('fullscreen')}
className="cursor-pointer rounded-md bg-black/40 p-1.5 text-white backdrop-blur-sm transition-opacity hover:bg-black/60"
title="Fullscreen"
aria-label="Fullscreen"
>
<Maximize2 size={14} />
</button>
)}
{appSupportsPip && (
<button
onClick={() => changeDisplayMode('pip')}
className="cursor-pointer rounded-md bg-black/40 p-1.5 text-white backdrop-blur-sm transition-opacity hover:bg-black/60"
title="Picture-in-Picture"
aria-label="Picture-in-Picture"
>
<PictureInPicture2 size={14} />
</button>
)}
</div>
);
};
// Single stable container — CSS switches between inline/fullscreen/pip positioning.
// The AppRenderer and its iframe are never unmounted, preserving app state across mode changes.
const containerClasses = cn(
'bg-background-primary overflow-hidden [&_iframe]:!w-full',
isError && 'border border-red-500 rounded-lg bg-red-50 dark:bg-red-900/20',
!isError && !isExpandedView && 'mt-6 mb-2',
!isError && !isExpandedView && meta.prefersBorder && 'border border-border-primary rounded-lg'
'mcp-app-container bg-background-primary [&_iframe]:!w-full',
isFillsViewport && 'fixed inset-0 z-[1000] overflow-hidden [&_iframe]:!h-full',
isPip &&
'fixed z-[900] overflow-y-auto overflow-x-hidden rounded-xl border border-border-primary shadow-2xl',
isInline && 'group/mcp-app relative overflow-hidden',
isInline && !isError && 'mt-6 mb-2',
isInline && !isError && meta.prefersBorder && 'border border-border-primary rounded-lg',
isError && 'border border-red-500 rounded-lg bg-red-50 dark:bg-red-900/20'
);
const containerStyle = isExpandedView
? { width: '100%', height: '100%' }
: {
width: '100%',
height: `${iframeHeight || DEFAULT_IFRAME_HEIGHT}px`,
};
const containerStyle: React.CSSProperties = {
...(isFillsViewport
? {}
: isPip
? {
width: `${PIP_WIDTH}px`,
height: `${PIP_HEIGHT}px`,
right: `${PIP_MARGIN_RIGHT - pipPosition.x}px`,
bottom: `${PIP_MARGIN_BOTTOM - pipPosition.y}px`,
}
: {
width: '100%',
height: `${effectiveInlineHeight}px`,
}),
};
return (
<div ref={containerRef} className={containerClasses} style={containerStyle}>
{renderContent()}
</div>
<>
{/* Placeholder in chat flow when app is detached (fullscreen or pip) */}
{isFullscreen && (
<div
className="invisible mt-6 mb-2"
style={{ width: '100%', height: `${inlineHeight}px` }}
/>
)}
{isPip && (
<div
className="mt-6 mb-2 flex items-center justify-center rounded-lg border border-dashed border-border-primary bg-black/[0.02] dark:bg-white/[0.02]"
style={{ width: '100%', height: `${inlineHeight}px` }}
>
<button
onClick={() => changeDisplayMode('inline')}
className="cursor-pointer flex items-center gap-2 rounded-md px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-black/5 hover:text-text-primary dark:hover:bg-white/5"
>
<PictureInPicture2 size={14} />
<span>Playing in Picture-in-Picture</span>
</button>
</div>
)}
{/* Stable app container — never unmounted, only repositioned via CSS */}
<div
ref={containerRef}
className={cn(containerClasses, isPip && 'group/pip')}
style={containerStyle}
>
{isPip && (
<div className="pointer-events-none sticky top-1 z-20 flex h-0 items-start justify-between px-1 opacity-0 transition-opacity group-hover/pip:pointer-events-auto group-hover/pip:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100">
<div
role="button"
tabIndex={0}
aria-label="Move Picture-in-Picture window (use arrow keys)"
className="pointer-events-auto cursor-grab rounded-md bg-black/50 p-1 text-white backdrop-blur-sm hover:bg-black/70 active:cursor-grabbing"
onPointerDown={pipHandlers.onPointerDown}
onPointerMove={pipHandlers.onPointerMove}
onPointerUp={pipHandlers.onPointerUp}
onLostPointerCapture={pipHandlers.onLostPointerCapture}
onKeyDown={pipHandlers.onKeyDown}
>
<GripHorizontal size={14} />
</div>
<div className="flex gap-1">{renderDisplayModeControls()}</div>
</div>
)}
<div className={cn('relative w-full', !isPip && 'h-full')}>
{!isPip && renderDisplayModeControls()}
{renderContent()}
</div>
</div>
</>
);
}
@@ -43,6 +43,12 @@ export type McpAppToolResult = {
_meta?: { [key: string]: unknown };
};
/**
* Callback fired when the display mode changes, either via user-initiated
* host-side controls or app-initiated `ui/request-display-mode` changes.
*/
export type OnDisplayModeChange = (mode: GooseDisplayMode) => void;
export type SamplingMessage = {
role: 'user' | 'assistant';
content: { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string };
@@ -0,0 +1,338 @@
/**
* useDisplayMode — Manages display mode state for MCP App containers.
*
* Encapsulates the display mode state machine, capability negotiation,
* PiP drag handling, entrance animations, and postMessage interception
* for ui/initialize and ui/request-display-mode.
*/
import type { McpUiDisplayMode } from '@modelcontextprotocol/ext-apps/app-bridge';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { GooseDisplayMode, OnDisplayModeChange } from './types';
const DEFAULT_IFRAME_HEIGHT = 200;
const AVAILABLE_DISPLAY_MODES: McpUiDisplayMode[] = ['inline', 'fullscreen', 'pip'];
const PIP_WIDTH = 400;
const PIP_HEIGHT = 300;
const PIP_MARGIN_RIGHT = 16;
// Keeps the PiP window above the chat input area (~120px) plus padding.
const PIP_MARGIN_BOTTOM = 140;
interface UseDisplayModeOptions {
displayMode: GooseDisplayMode;
onDisplayModeChange?: OnDisplayModeChange;
containerRef: React.RefObject<HTMLDivElement | null>;
}
export interface DisplayModeState {
activeDisplayMode: GooseDisplayMode;
effectiveDisplayModes: McpUiDisplayMode[];
isStandalone: boolean;
isFullscreen: boolean;
isPip: boolean;
isFillsViewport: boolean;
isInline: boolean;
appSupportsFullscreen: boolean;
appSupportsPip: boolean;
changeDisplayMode: (mode: GooseDisplayMode) => void;
/** Remembered inline height for placeholders when detached. */
inlineHeight: number;
/** PiP position offset from the default bottom-right corner. */
pipPosition: { x: number; y: number };
/** PiP drag handle event handlers. */
pipHandlers: {
onPointerDown: (e: React.PointerEvent) => void;
onPointerMove: (e: React.PointerEvent) => void;
onPointerUp: (e: React.PointerEvent) => void;
onLostPointerCapture: () => void;
onKeyDown: (e: React.KeyboardEvent) => void;
};
/** Ref for the fullscreen close button (auto-focused on enter). */
fullscreenCloseRef: React.RefObject<HTMLButtonElement | null>;
}
export { AVAILABLE_DISPLAY_MODES, PIP_WIDTH, PIP_HEIGHT, PIP_MARGIN_RIGHT, PIP_MARGIN_BOTTOM };
export function useDisplayMode({
displayMode,
onDisplayModeChange,
containerRef,
}: UseDisplayModeOptions): DisplayModeState {
const [activeDisplayMode, setActiveDisplayMode] = useState<GooseDisplayMode>(displayMode);
useEffect(() => {
setActiveDisplayMode(displayMode);
}, [displayMode]);
const isStandalone = displayMode === 'standalone';
// Display modes the app declared support for during ui/initialize.
// null = not yet known (controls stay hidden until initialize), empty = app didn't declare any.
const [appDeclaredModes, setAppDeclaredModes] = useState<string[] | null>(null);
const effectiveDisplayModes = useMemo((): McpUiDisplayMode[] => {
if (!appDeclaredModes) return [];
return AVAILABLE_DISPLAY_MODES.filter((m) => appDeclaredModes.includes(m));
}, [appDeclaredModes]);
// Snapshot of the container height captured when leaving inline mode.
// Stored as state (not a ref) so consumers re-render with the correct value
// for placeholders and for restoring the inline container on return.
const [savedInlineHeight, setSavedInlineHeight] = useState(DEFAULT_IFRAME_HEIGHT);
// Cache iframe contentWindows for O(1) message source matching.
// eslint-disable-next-line no-undef
const iframeWindowsRef = useRef<Set<Window>>(new Set());
const enterAnimRef = useRef<string | null>(null);
const fullscreenCloseRef = useRef<HTMLButtonElement>(null);
// ── Mode transitions ──────────────────────────────────────────────────
const changeDisplayMode = useCallback(
(mode: GooseDisplayMode) => {
const el = containerRef.current;
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (activeDisplayMode === 'inline' && el) {
setSavedInlineHeight(el.getBoundingClientRect().height || DEFAULT_IFRAME_HEIGHT);
}
if (enterAnimRef.current && el) {
el.classList.remove(enterAnimRef.current);
enterAnimRef.current = null;
}
setActiveDisplayMode(mode);
onDisplayModeChange?.(mode);
if (el && !prefersReducedMotion && mode !== activeDisplayMode) {
const animClass =
mode === 'pip'
? 'mcp-enter-pip'
: mode === 'fullscreen'
? 'mcp-enter-fullscreen'
: 'mcp-enter-inline';
requestAnimationFrame(() => {
el.classList.add(animClass);
enterAnimRef.current = animClass;
el.addEventListener(
'animationend',
() => {
el.classList.remove(animClass);
if (enterAnimRef.current === animClass) {
enterAnimRef.current = null;
}
},
{ once: true }
);
});
}
},
[onDisplayModeChange, activeDisplayMode, containerRef]
);
// ── PiP drag ──────────────────────────────────────────────────────────
const [pipPosition, setPipPosition] = useState({ x: 0, y: 0 });
const pipPositionRef = useRef(pipPosition);
const pipDragRef = useRef<{
startX: number;
startY: number;
originX: number;
originY: number;
} | null>(null);
useEffect(() => {
pipPositionRef.current = pipPosition;
}, [pipPosition]);
const clampPipPosition = useCallback((pos: { x: number; y: number }) => {
const minX = PIP_WIDTH + PIP_MARGIN_RIGHT - window.innerWidth;
const maxX = PIP_MARGIN_RIGHT;
const minY = PIP_HEIGHT + PIP_MARGIN_BOTTOM - window.innerHeight;
const maxY = PIP_MARGIN_BOTTOM;
return {
x: minX > maxX ? 0 : Math.max(minX, Math.min(maxX, pos.x)),
y: minY > maxY ? 0 : Math.max(minY, Math.min(maxY, pos.y)),
};
}, []);
const handlePipPointerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
const { x, y } = pipPositionRef.current;
pipDragRef.current = { startX: e.clientX, startY: e.clientY, originX: x, originY: y };
}, []);
const handlePipPointerMove = useCallback(
(e: React.PointerEvent) => {
if (!pipDragRef.current) return;
const dx = e.clientX - pipDragRef.current.startX;
const dy = e.clientY - pipDragRef.current.startY;
setPipPosition(
clampPipPosition({
x: pipDragRef.current.originX + dx,
y: pipDragRef.current.originY + dy,
})
);
},
[clampPipPosition]
);
const handlePipPointerUp = useCallback((e: React.PointerEvent) => {
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
pipDragRef.current = null;
}, []);
const handlePipLostPointerCapture = useCallback(() => {
pipDragRef.current = null;
}, []);
const handlePipKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const step = e.shiftKey ? 32 : 8;
let dx = 0;
let dy = 0;
switch (e.key) {
case 'ArrowUp':
dy = -step;
break;
case 'ArrowDown':
dy = step;
break;
case 'ArrowLeft':
dx = -step;
break;
case 'ArrowRight':
dx = step;
break;
default:
return;
}
e.preventDefault();
setPipPosition((prev) => clampPipPosition({ x: prev.x + dx, y: prev.y + dy }));
},
[clampPipPosition]
);
// ── Effects ───────────────────────────────────────────────────────────
// Cache iframe contentWindows for O(1) source matching via MutationObserver.
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const refreshCache = () => {
const windows = iframeWindowsRef.current;
windows.clear();
container.querySelectorAll('iframe').forEach((iframe) => {
if (iframe.contentWindow) windows.add(iframe.contentWindow);
});
};
refreshCache();
const observer = new MutationObserver(refreshCache);
observer.observe(container, { childList: true, subtree: true });
return () => observer.disconnect();
}, [containerRef]);
// Intercept app postMessages for:
// 1. ui/initialize — extract appCapabilities.availableDisplayModes
// 2. ui/request-display-mode — change display mode on behalf of the app
useEffect(() => {
if (isStandalone) return;
const handleMessage = (e: MessageEvent) => {
const data = e.data;
if (!data || typeof data !== 'object') return;
// eslint-disable-next-line no-undef
if (!e.source || !iframeWindowsRef.current.has(e.source as Window)) return;
if (data.method === 'ui/initialize' && data.params) {
const caps = data.params.appCapabilities || data.params.capabilities;
if (caps?.availableDisplayModes && Array.isArray(caps.availableDisplayModes)) {
setAppDeclaredModes(caps.availableDisplayModes);
}
}
// After initialize, only allow modes both host and app agree on.
// Before initialize (effectiveDisplayModes empty), fall back to the full host list.
if (data.method === 'ui/request-display-mode' && data.params?.mode) {
const requested = data.params.mode as McpUiDisplayMode;
const allowed =
effectiveDisplayModes.length > 0 ? effectiveDisplayModes : AVAILABLE_DISPLAY_MODES;
if (allowed.includes(requested)) {
changeDisplayMode(requested);
}
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [isStandalone, changeDisplayMode, effectiveDisplayModes]);
// Escape key exits fullscreen.
useEffect(() => {
if (activeDisplayMode !== 'fullscreen') return;
fullscreenCloseRef.current?.focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') changeDisplayMode('inline');
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [activeDisplayMode, changeDisplayMode]);
// Reset PiP position when entering PiP mode.
useEffect(() => {
if (activeDisplayMode === 'pip') {
setPipPosition({ x: 0, y: 0 });
}
}, [activeDisplayMode]);
// ── Derived state ─────────────────────────────────────────────────────
const isFullscreen = activeDisplayMode === 'fullscreen';
const isPip = activeDisplayMode === 'pip';
const isFillsViewport = isFullscreen || isStandalone;
const isInline = !isFillsViewport && !isPip;
const appSupportsFullscreen = effectiveDisplayModes.includes('fullscreen');
const appSupportsPip = effectiveDisplayModes.includes('pip');
return {
activeDisplayMode,
effectiveDisplayModes,
isStandalone,
isFullscreen,
isPip,
isFillsViewport,
isInline,
appSupportsFullscreen,
appSupportsPip,
changeDisplayMode,
inlineHeight: savedInlineHeight,
pipPosition,
pipHandlers: {
onPointerDown: handlePipPointerDown,
onPointerMove: handlePipPointerMove,
onPointerUp: handlePipPointerUp,
onLostPointerCapture: handlePipLostPointerCapture,
onKeyDown: handlePipKeyDown,
},
fullscreenCloseRef,
};
}
+49
View File
@@ -878,3 +878,52 @@ p > code.bg-inline-code {
max-height: var(--search-bar-height);
}
}
/* ==========================================================================
MCP App Display Mode Entrance Animations
========================================================================== */
@keyframes mcp-enter-pip {
from {
opacity: 0;
transform: scale(0.85) translate(12px, -12px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes mcp-enter-fullscreen {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes mcp-enter-inline {
from {
opacity: 0;
transform: scale(1.03);
}
to {
opacity: 1;
transform: none;
}
}
.mcp-app-container.mcp-enter-pip {
animation: mcp-enter-pip 200ms cubic-bezier(0.2, 0, 0, 1) both;
}
.mcp-app-container.mcp-enter-fullscreen {
animation: mcp-enter-fullscreen 200ms cubic-bezier(0.2, 0, 0, 1) both;
}
.mcp-app-container.mcp-enter-inline {
animation: mcp-enter-inline 180ms cubic-bezier(0.2, 0, 0, 1) both;
}