fix(ui): revert app-driven iframe width and send containerDimensions per ext-apps spec (#7300)
This commit is contained in:
@@ -24,7 +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 { useCallback, useEffect, useMemo, useReducer, 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';
|
||||||
import { useTheme } from '../../contexts/ThemeContext';
|
import { useTheme } from '../../contexts/ThemeContext';
|
||||||
@@ -39,12 +39,60 @@ import {
|
|||||||
McpAppToolInput,
|
McpAppToolInput,
|
||||||
McpAppToolInputPartial,
|
McpAppToolInputPartial,
|
||||||
McpAppToolResult,
|
McpAppToolResult,
|
||||||
|
DimensionLayout,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
const DEFAULT_IFRAME_HEIGHT = 200;
|
const DEFAULT_IFRAME_HEIGHT = 200;
|
||||||
|
|
||||||
const AVAILABLE_DISPLAY_MODES: McpUiDisplayMode[] = ['inline'];
|
const AVAILABLE_DISPLAY_MODES: McpUiDisplayMode[] = ['inline'];
|
||||||
|
|
||||||
|
const DISPLAY_MODE_LAYOUTS: Record<GooseDisplayMode, DimensionLayout> = {
|
||||||
|
inline: { width: 'fixed', height: 'unbounded' },
|
||||||
|
fullscreen: { width: 'fixed', height: 'fixed' },
|
||||||
|
standalone: { width: 'fixed', height: 'fixed' },
|
||||||
|
pip: { width: 'fixed', height: 'fixed' },
|
||||||
|
// sidecar: { width: 'fixed', height: 'flexible' }, // example on how to use flexible layout
|
||||||
|
};
|
||||||
|
|
||||||
|
function getContainerDimensions(
|
||||||
|
displayMode: GooseDisplayMode,
|
||||||
|
measuredWidth: number,
|
||||||
|
measuredHeight: number
|
||||||
|
): McpUiHostContext['containerDimensions'] {
|
||||||
|
const layout = DISPLAY_MODE_LAYOUTS[displayMode] ?? DISPLAY_MODE_LAYOUTS.inline;
|
||||||
|
|
||||||
|
// Only require a measurement for axes that are fixed or flexible (unbounded axes are omitted).
|
||||||
|
if (
|
||||||
|
(layout.width !== 'unbounded' && measuredWidth <= 0) ||
|
||||||
|
(layout.height !== 'unbounded' && measuredHeight <= 0)
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
const widthDimension = (() => {
|
||||||
|
switch (layout.width) {
|
||||||
|
case 'fixed':
|
||||||
|
return { width: measuredWidth };
|
||||||
|
case 'flexible':
|
||||||
|
return { maxWidth: measuredWidth };
|
||||||
|
case 'unbounded':
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const heightDimension = (() => {
|
||||||
|
switch (layout.height) {
|
||||||
|
case 'fixed':
|
||||||
|
return { height: measuredHeight };
|
||||||
|
case 'flexible':
|
||||||
|
return { maxHeight: measuredHeight };
|
||||||
|
case 'unbounded':
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return { ...widthDimension, ...heightDimension };
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchMcpAppProxyUrl(csp: McpUiResourceCsp | null): Promise<string | null> {
|
async function fetchMcpAppProxyUrl(csp: McpUiResourceCsp | null): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const baseUrl = await window.electron.getGoosedHostPort();
|
const baseUrl = await window.electron.getGoosedHostPort();
|
||||||
@@ -195,8 +243,10 @@ export default function McpAppRenderer({
|
|||||||
|
|
||||||
const [state, dispatch] = useReducer(appReducer, initialState);
|
const [state, dispatch] = useReducer(appReducer, initialState);
|
||||||
const [iframeHeight, setIframeHeight] = useState(DEFAULT_IFRAME_HEIGHT);
|
const [iframeHeight, setIframeHeight] = useState(DEFAULT_IFRAME_HEIGHT);
|
||||||
// null = fluid (100% width), number = explicit width from app
|
|
||||||
const [iframeWidth, setIframeWidth] = useState<number | null>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [containerWidth, setContainerWidth] = useState<number>(0);
|
||||||
|
const [containerHeight, setContainerHeight] = useState<number>(0);
|
||||||
|
|
||||||
// Fetch the resource from the extension to get HTML and metadata (CSP, permissions, etc.).
|
// Fetch the resource from the extension to get HTML and metadata (CSP, permissions, etc.).
|
||||||
// If cachedHtml is provided we show it immediately; the fetch updates metadata and
|
// If cachedHtml is provided we show it immediately; the fetch updates metadata and
|
||||||
@@ -383,22 +433,28 @@ export default function McpAppRenderer({
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
const handleSizeChanged = useCallback(({ height }: McpUiSizeChangedNotification['params']) => {
|
||||||
* Height: non-positive values are ignored (keeps previous height).
|
if (height !== undefined && height > 0) {
|
||||||
* Width: if provided, container uses that width (capped at 100%);
|
setIframeHeight(height);
|
||||||
* if omitted or non-positive, container is fluid (100%).
|
}
|
||||||
*/
|
}, []);
|
||||||
const handleSizeChanged = useCallback(
|
|
||||||
({ height, width }: McpUiSizeChangedNotification['params']) => {
|
// Track the container's pixel dimensions so we can report them to apps via containerDimensions.
|
||||||
if (height !== undefined && height > 0) {
|
useEffect(() => {
|
||||||
setIframeHeight(height);
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const observer = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const { width, height } = entry.contentRect;
|
||||||
|
setContainerWidth((prev) => (prev !== Math.round(width) ? Math.round(width) : prev));
|
||||||
|
setContainerHeight((prev) => (prev !== Math.round(height) ? Math.round(height) : prev));
|
||||||
}
|
}
|
||||||
if (width !== undefined) {
|
});
|
||||||
setIframeWidth(width > 0 ? width : null);
|
|
||||||
}
|
observer.observe(el);
|
||||||
},
|
return () => observer.disconnect();
|
||||||
[]
|
}, []);
|
||||||
);
|
|
||||||
|
|
||||||
const handleFallbackRequest = useCallback(
|
const handleFallbackRequest = useCallback(
|
||||||
async (request: JSONRPCRequest, _extra: RequestHandlerExtra) => {
|
async (request: JSONRPCRequest, _extra: RequestHandlerExtra) => {
|
||||||
@@ -453,7 +509,7 @@ export default function McpAppRenderer({
|
|||||||
displayMode: displayMode as McpUiDisplayMode,
|
displayMode: displayMode as McpUiDisplayMode,
|
||||||
availableDisplayModes:
|
availableDisplayModes:
|
||||||
displayMode === 'standalone' ? [displayMode as McpUiDisplayMode] : AVAILABLE_DISPLAY_MODES,
|
displayMode === 'standalone' ? [displayMode as McpUiDisplayMode] : AVAILABLE_DISPLAY_MODES,
|
||||||
// todo: containerDimensions: {} (depends on displayMode)
|
containerDimensions: getContainerDimensions(displayMode, containerWidth, containerHeight),
|
||||||
locale: navigator.language,
|
locale: navigator.language,
|
||||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
userAgent: navigator.userAgent,
|
userAgent: navigator.userAgent,
|
||||||
@@ -471,7 +527,7 @@ export default function McpAppRenderer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return context;
|
return context;
|
||||||
}, [resolvedTheme, displayMode]);
|
}, [resolvedTheme, displayMode, containerWidth, containerHeight]);
|
||||||
|
|
||||||
const appToolResult = useMemo((): CallToolResult | undefined => {
|
const appToolResult = useMemo((): CallToolResult | undefined => {
|
||||||
if (!toolResult) return undefined;
|
if (!toolResult) return undefined;
|
||||||
@@ -537,8 +593,7 @@ export default function McpAppRenderer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const containerClasses = cn(
|
const containerClasses = cn(
|
||||||
'bg-background-default overflow-hidden',
|
'bg-background-default overflow-hidden [&_iframe]:!w-full',
|
||||||
iframeWidth === null && '[&_iframe]:!w-full',
|
|
||||||
isError && 'border border-red-500 rounded-lg bg-red-50 dark:bg-red-900/20',
|
isError && 'border border-red-500 rounded-lg bg-red-50 dark:bg-red-900/20',
|
||||||
!isError && !isExpandedView && 'mt-6 mb-2',
|
!isError && !isExpandedView && 'mt-6 mb-2',
|
||||||
!isError && !isExpandedView && meta.prefersBorder && 'border border-border-default rounded-lg'
|
!isError && !isExpandedView && meta.prefersBorder && 'border border-border-default rounded-lg'
|
||||||
@@ -547,13 +602,12 @@ export default function McpAppRenderer({
|
|||||||
const containerStyle = isExpandedView
|
const containerStyle = isExpandedView
|
||||||
? { width: '100%', height: '100%' }
|
? { width: '100%', height: '100%' }
|
||||||
: {
|
: {
|
||||||
width: iframeWidth !== null ? `${iframeWidth}px` : '100%',
|
width: '100%',
|
||||||
maxWidth: '100%',
|
|
||||||
height: `${iframeHeight || DEFAULT_IFRAME_HEIGHT}px`,
|
height: `${iframeHeight || DEFAULT_IFRAME_HEIGHT}px`,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={containerClasses} style={containerStyle}>
|
<div ref={containerRef} className={containerClasses} style={containerStyle}>
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,19 @@ export type SandboxPermissions = string;
|
|||||||
|
|
||||||
export type GooseDisplayMode = McpUiDisplayMode | 'standalone';
|
export type GooseDisplayMode = McpUiDisplayMode | 'standalone';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per the ext-apps spec, each axis is independently:
|
||||||
|
* fixed – sends width/height (host controls, view fills it)
|
||||||
|
* flexible – sends maxWidth/maxHeight (view controls, up to max; host resizes via size-changed)
|
||||||
|
* unbounded – field omitted (view controls with no limit; host resizes via size-changed)
|
||||||
|
*/
|
||||||
|
export type DimensionMode = 'fixed' | 'flexible' | 'unbounded';
|
||||||
|
|
||||||
|
export interface DimensionLayout {
|
||||||
|
width: DimensionMode;
|
||||||
|
height: DimensionMode;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tool input from the message stream.
|
* Tool input from the message stream.
|
||||||
* McpAppRenderer extracts `.arguments` when passing to the SDK's AppRenderer.
|
* McpAppRenderer extracts `.arguments` when passing to the SDK's AppRenderer.
|
||||||
|
|||||||
Reference in New Issue
Block a user