mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
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,
|
||||
} 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user