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:
@@ -24,6 +24,7 @@ import type {
|
|||||||
McpUiSizeChangedNotification,
|
McpUiSizeChangedNotification,
|
||||||
} from '@modelcontextprotocol/ext-apps/app-bridge';
|
} from '@modelcontextprotocol/ext-apps/app-bridge';
|
||||||
import type { CallToolResult, JSONRPCRequest } from '@modelcontextprotocol/sdk/types.js';
|
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 { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
|
||||||
import { callTool, readResource } from '../../api';
|
import { callTool, readResource } from '../../api';
|
||||||
import { AppEvents } from '../../constants/events';
|
import { AppEvents } from '../../constants/events';
|
||||||
@@ -40,14 +41,21 @@ import {
|
|||||||
McpAppToolInputPartial,
|
McpAppToolInputPartial,
|
||||||
McpAppToolResult,
|
McpAppToolResult,
|
||||||
DimensionLayout,
|
DimensionLayout,
|
||||||
|
OnDisplayModeChange,
|
||||||
SamplingCreateMessageParams,
|
SamplingCreateMessageParams,
|
||||||
SamplingCreateMessageResponse,
|
SamplingCreateMessageResponse,
|
||||||
} from './types';
|
} 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 DEFAULT_IFRAME_HEIGHT = 200;
|
||||||
|
|
||||||
const AVAILABLE_DISPLAY_MODES: McpUiDisplayMode[] = ['inline'];
|
|
||||||
|
|
||||||
const DISPLAY_MODE_LAYOUTS: Record<GooseDisplayMode, DimensionLayout> = {
|
const DISPLAY_MODE_LAYOUTS: Record<GooseDisplayMode, DimensionLayout> = {
|
||||||
inline: { width: 'fixed', height: 'unbounded' },
|
inline: { width: 'fixed', height: 'unbounded' },
|
||||||
fullscreen: { width: 'fixed', height: 'fixed' },
|
fullscreen: { width: 'fixed', height: 'fixed' },
|
||||||
@@ -139,6 +147,7 @@ interface McpAppRendererProps {
|
|||||||
append?: (text: string) => void;
|
append?: (text: string) => void;
|
||||||
displayMode?: GooseDisplayMode;
|
displayMode?: GooseDisplayMode;
|
||||||
cachedHtml?: string;
|
cachedHtml?: string;
|
||||||
|
onDisplayModeChange?: OnDisplayModeChange;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ResourceMeta {
|
interface ResourceMeta {
|
||||||
@@ -235,8 +244,27 @@ export default function McpAppRenderer({
|
|||||||
append,
|
append,
|
||||||
displayMode = 'inline',
|
displayMode = 'inline',
|
||||||
cachedHtml,
|
cachedHtml,
|
||||||
|
onDisplayModeChange,
|
||||||
}: McpAppRendererProps) {
|
}: 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();
|
const { resolvedTheme, mcpHostStyles } = useTheme();
|
||||||
|
|
||||||
@@ -264,7 +292,18 @@ export default function McpAppRenderer({
|
|||||||
});
|
});
|
||||||
const [iframeHeight, setIframeHeight] = useState(DEFAULT_IFRAME_HEIGHT);
|
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 [containerWidth, setContainerWidth] = useState<number>(0);
|
||||||
const [containerHeight, setContainerHeight] = useState<number>(0);
|
const [containerHeight, setContainerHeight] = useState<number>(0);
|
||||||
const [apiHost, setApiHost] = useState<string | null>(null);
|
const [apiHost, setApiHost] = useState<string | null>(null);
|
||||||
@@ -512,11 +551,14 @@ export default function McpAppRenderer({
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSizeChanged = useCallback(({ height }: McpUiSizeChangedNotification['params']) => {
|
const handleSizeChanged = useCallback(
|
||||||
if (height !== undefined && height > 0) {
|
({ height }: McpUiSizeChangedNotification['params']) => {
|
||||||
setIframeHeight(height);
|
if (height !== undefined && height > 0 && isInline) {
|
||||||
}
|
setIframeHeight(height);
|
||||||
}, []);
|
}
|
||||||
|
},
|
||||||
|
[isInline]
|
||||||
|
);
|
||||||
|
|
||||||
// Track the container's pixel dimensions so we can report them to apps via containerDimensions.
|
// Track the container's pixel dimensions so we can report them to apps via containerDimensions.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -605,12 +647,17 @@ export default function McpAppRenderer({
|
|||||||
// todo: toolInfo: {}
|
// todo: toolInfo: {}
|
||||||
theme: resolvedTheme,
|
theme: resolvedTheme,
|
||||||
styles: mcpHostStyles,
|
styles: mcpHostStyles,
|
||||||
// 'standalone' is a Goose-specific display mode (dedicated Electron window)
|
displayMode: activeDisplayMode as McpUiDisplayMode,
|
||||||
// that maps to the spec's inline | fullscreen | pip modes.
|
availableDisplayModes: isStandalone
|
||||||
displayMode: displayMode as McpUiDisplayMode,
|
? [activeDisplayMode as McpUiDisplayMode]
|
||||||
availableDisplayModes:
|
: effectiveDisplayModes.length > 0
|
||||||
displayMode === 'standalone' ? [displayMode as McpUiDisplayMode] : AVAILABLE_DISPLAY_MODES,
|
? effectiveDisplayModes
|
||||||
containerDimensions: getContainerDimensions(displayMode, containerWidth, containerHeight),
|
: AVAILABLE_DISPLAY_MODES,
|
||||||
|
containerDimensions: getContainerDimensions(
|
||||||
|
activeDisplayMode,
|
||||||
|
containerWidth,
|
||||||
|
containerHeight
|
||||||
|
),
|
||||||
locale: navigator.language,
|
locale: navigator.language,
|
||||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
userAgent: navigator.userAgent,
|
userAgent: navigator.userAgent,
|
||||||
@@ -628,7 +675,15 @@ export default function McpAppRenderer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return context;
|
return context;
|
||||||
}, [resolvedTheme, mcpHostStyles, displayMode, containerWidth, containerHeight]);
|
}, [
|
||||||
|
resolvedTheme,
|
||||||
|
mcpHostStyles,
|
||||||
|
activeDisplayMode,
|
||||||
|
isStandalone,
|
||||||
|
containerWidth,
|
||||||
|
containerHeight,
|
||||||
|
effectiveDisplayModes,
|
||||||
|
]);
|
||||||
|
|
||||||
const appToolResult = useMemo((): CallToolResult | undefined => {
|
const appToolResult = useMemo((): CallToolResult | undefined => {
|
||||||
if (!toolResult) return 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(
|
const containerClasses = cn(
|
||||||
'bg-background-primary overflow-hidden [&_iframe]:!w-full',
|
'mcp-app-container bg-background-primary [&_iframe]:!w-full',
|
||||||
isError && 'border border-red-500 rounded-lg bg-red-50 dark:bg-red-900/20',
|
isFillsViewport && 'fixed inset-0 z-[1000] overflow-hidden [&_iframe]:!h-full',
|
||||||
!isError && !isExpandedView && 'mt-6 mb-2',
|
isPip &&
|
||||||
!isError && !isExpandedView && meta.prefersBorder && 'border border-border-primary rounded-lg'
|
'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
|
const containerStyle: React.CSSProperties = {
|
||||||
? { width: '100%', height: '100%' }
|
...(isFillsViewport
|
||||||
: {
|
? {}
|
||||||
width: '100%',
|
: isPip
|
||||||
height: `${iframeHeight || DEFAULT_IFRAME_HEIGHT}px`,
|
? {
|
||||||
};
|
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 (
|
return (
|
||||||
<div ref={containerRef} className={containerClasses} style={containerStyle}>
|
<>
|
||||||
{renderContent()}
|
{/* Placeholder in chat flow when app is detached (fullscreen or pip) */}
|
||||||
</div>
|
{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 };
|
_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 = {
|
export type SamplingMessage = {
|
||||||
role: 'user' | 'assistant';
|
role: 'user' | 'assistant';
|
||||||
content: { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string };
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -878,3 +878,52 @@ p > code.bg-inline-code {
|
|||||||
max-height: var(--search-bar-height);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user