Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
94d2753
feat(ui): implement fullscreen and pip display modes for MCP Apps
aharvard Feb 18, 2026
e4511bc
feat(ui): add view transitions for display mode changes
aharvard Feb 18, 2026
9aabe0a
fix(ui): improve display mode transitions and button UX
aharvard Feb 18, 2026
301d5c6
fix(ui): preserve chat scroll position during fullscreen mode
aharvard Feb 18, 2026
a78c611
fix(ui): force iframe to fill container in fullscreen and pip modes
aharvard Feb 18, 2026
d8e4369
fix(ui): enable scrolling in PiP mode
aharvard Feb 18, 2026
e7bb22f
fix(ui): fix PiP scrolling by removing h-full constraints from inner …
aharvard Feb 18, 2026
676ee06
fix(ui): preserve app iframe across display mode changes
aharvard Feb 18, 2026
f98b9d3
fix(ui): restore PiP scrolling after stable container refactor
aharvard Feb 18, 2026
cc93801
feat(ui): add view transition CSS for smooth display mode animations
aharvard Feb 18, 2026
8d5fd42
fix(ui): replace view transitions with CSS transitions for display mo…
aharvard Feb 18, 2026
e475f2a
feat(ui): add FLIP animation for display mode transitions
aharvard Feb 18, 2026
c1148e9
fix(ui): stabilize FLIP animation and placeholder sizing
aharvard Feb 18, 2026
ebe9abc
feat(ui): only show display mode controls for modes the app declares
aharvard Feb 18, 2026
4fcdd5d
fix(ui): make PiP control buttons sticky so they don't scroll away
aharvard Feb 18, 2026
86a8955
fix(ui): make PiP drag handle sticky during scroll
aharvard Feb 18, 2026
e70e2bb
fix(ui): combine PiP drag handle and controls into single compact bar
aharvard Feb 18, 2026
833d003
fix(ui): hide PiP controls until hover, remove top padding
aharvard Feb 18, 2026
9f8a48d
fix(ui): remove pt-1 padding from PiP hover controls overlay
aharvard Feb 18, 2026
9bfb895
fix(ui): unify PiP drag handle style with control buttons
aharvard Feb 18, 2026
b3fc7b9
fix(ui): left-align drag handle, remove pt, justify-between layout
aharvard Feb 18, 2026
a029ba4
fix(ui): equal inset spacing for PiP drag handle and controls
aharvard Feb 18, 2026
bb2c114
fix(ui): fix PiP toolbar spacing - m-1 on each element, no container …
aharvard Feb 18, 2026
4c515c5
fix(ui): align PiP drag handle and controls at same vertical position
aharvard Feb 18, 2026
1be6a13
fix(ui): fix PiP controls alignment - render as fragment, not wrapper…
aharvard Feb 18, 2026
05fdb42
fix(ui): remove pt-1 from PiP toolbar container
aharvard Feb 18, 2026
e793d5b
fix(ui): nudge PiP toolbar down with sticky top-1 instead of padding
aharvard Feb 18, 2026
296f450
Update ui/desktop/src/components/McpApps/types.ts
aharvard Feb 18, 2026
1ab5ebe
Update ui/desktop/src/components/McpApps/McpAppRenderer.tsx
aharvard Feb 18, 2026
d27dcf2
fix(ui): address Copilot review - FLIP animation race + O(1) iframe m…
aharvard Feb 18, 2026
64fc7a4
Merge remote-tracking branch 'origin/main' into aharvard/display-modes
aharvard Feb 19, 2026
fd6d864
fix(ui): improve PiP accessibility, positioning, and drag stability
aharvard Feb 19, 2026
4da2e59
fix(ui): clamp PiP position to viewport bounds
aharvard Feb 19, 2026
bc6a8f6
feat(ui): replace FLIP with entrance animations, respect app display …
aharvard Feb 19, 2026
ef10572
fix(ui): revert fullscreen to overflow-hidden (scrolling handled by app)
aharvard Feb 19, 2026
0499a5d
fix(ui): address Copilot review round 3
aharvard Feb 19, 2026
79f43ce
fix: suppress no-undef lint errors for Window type in McpAppRenderer
aharvard Feb 19, 2026
15f9097
Merge remote-tracking branch 'origin/main' into aharvard/display-modes
aharvard Feb 24, 2026
9fffea4
revert formatting-only changes to out-of-scope files
aharvard Feb 24, 2026
ed2f1e1
fix: validate display mode requests against effective modes
aharvard Feb 25, 2026
663825d
docs: explain PIP_MARGIN_BOTTOM value
aharvard Feb 25, 2026
853b070
chore: add TODO to extract useDisplayMode hook
aharvard Feb 25, 2026
7ec29ea
fix: prevent stuck PiP drag state on lost pointer capture
aharvard Feb 25, 2026
d317fcb
fix: clean up entrance animation class on animationend
aharvard Feb 25, 2026
2c4ed84
refactor: extract useDisplayMode hook from McpAppRenderer
aharvard Feb 25, 2026
6b1f6ca
fix: stabilize chat body when returning from PiP to inline
aharvard Feb 25, 2026
64673a5
fix: restore inline height when returning from fullscreen/pip
aharvard Feb 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
261 changes: 232 additions & 29 deletions ui/desktop/src/components/McpApps/McpAppRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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' },
Expand Down Expand Up @@ -139,6 +147,7 @@ interface McpAppRendererProps {
append?: (text: string) => void;
displayMode?: GooseDisplayMode;
cachedHtml?: string;
onDisplayModeChange?: OnDisplayModeChange;
}

interface ResourceMeta {
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -693,23 +748,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>
</>
);
}
6 changes: 6 additions & 0 deletions ui/desktop/src/components/McpApps/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ export type McpAppToolResult = {
structuredContent?: 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 };
Expand Down
Loading
Loading