fix(desktop): fullscreen header bar + always-visible close controls (#8033)

Signed-off-by: Andrew Harvard <aharvard@squareup.com>
Signed-off-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Goose <opensource@block.xyz>
Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Andrew Harvard
2026-03-20 12:15:17 -04:00
committed by GitHub
parent 8df8642660
commit 80f3e50d0d
3 changed files with 61 additions and 31 deletions
@@ -192,12 +192,10 @@ var McpAppBridge = (function () {
}; };
sendRequest("ui/initialize", { sendRequest("ui/initialize", {
protocolVersion: "2026-01-26", protocolVersion: "2026-01-26",
appInfo: appIdentity,
clientInfo: appIdentity, clientInfo: appIdentity,
appCapabilities: { appCapabilities: {
availableDisplayModes: ["inline", "fullscreen"], availableDisplayModes: ["inline", "fullscreen"],
}, },
capabilities: {},
}) })
.then(function (result) { .then(function (result) {
applyTheme(result.hostContext || result); applyTheme(result.hostContext || result);
@@ -33,6 +33,7 @@ import { cn } from '../../utils';
import { errorMessage } from '../../utils/conversionUtils'; import { errorMessage } from '../../utils/conversionUtils';
import { getProtocol, isProtocolSafe } from '../../utils/urlSecurity'; import { getProtocol, isProtocolSafe } from '../../utils/urlSecurity';
import FlyingBird from '../FlyingBird'; import FlyingBird from '../FlyingBird';
import { formatExtensionName } from '../settings/extensions/subcomponents/ExtensionList';
import { import {
GooseDisplayMode, GooseDisplayMode,
SandboxPermissions, SandboxPermissions,
@@ -54,6 +55,7 @@ import {
} from './useDisplayMode'; } from './useDisplayMode';
const DEFAULT_IFRAME_HEIGHT = 200; const DEFAULT_IFRAME_HEIGHT = 200;
const FULLSCREEN_HEADER_HEIGHT = 48;
const DISPLAY_MODE_LAYOUTS: Record<GooseDisplayMode, DimensionLayout> = { const DISPLAY_MODE_LAYOUTS: Record<GooseDisplayMode, DimensionLayout> = {
inline: { width: 'fixed', height: 'unbounded' }, inline: { width: 'fixed', height: 'unbounded' },
@@ -246,6 +248,7 @@ export default function McpAppRenderer({
onDisplayModeChange, onDisplayModeChange,
}: McpAppRendererProps) { }: McpAppRendererProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const dm = useDisplayMode({ displayMode, onDisplayModeChange, containerRef }); const dm = useDisplayMode({ displayMode, onDisplayModeChange, containerRef });
const { const {
@@ -258,6 +261,7 @@ export default function McpAppRenderer({
isInline, isInline,
appSupportsFullscreen, appSupportsFullscreen,
appSupportsPip, appSupportsPip,
appTitle,
changeDisplayMode, changeDisplayMode,
inlineHeight, inlineHeight,
pipPosition, pipPosition,
@@ -672,7 +676,7 @@ export default function McpAppRenderer({
containerDimensions: getContainerDimensions( containerDimensions: getContainerDimensions(
activeDisplayMode, activeDisplayMode,
containerWidth, containerWidth,
containerHeight isFullscreen ? containerHeight - FULLSCREEN_HEADER_HEIGHT : containerHeight
), ),
locale: navigator.language, locale: navigator.language,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
@@ -695,6 +699,7 @@ export default function McpAppRenderer({
resolvedTheme, resolvedTheme,
mcpHostStyles, mcpHostStyles,
activeDisplayMode, activeDisplayMode,
isFullscreen,
isStandalone, isStandalone,
containerWidth, containerWidth,
containerHeight, containerHeight,
@@ -754,36 +759,53 @@ export default function McpAppRenderer({
); );
}; };
const showControls = !isStandalone && !isError && (appSupportsFullscreen || appSupportsPip); const showControls =
!isStandalone && !isError && (appSupportsFullscreen || appSupportsPip || isFullscreen || isPip);
const fullscreenTitle = useMemo(() => {
if (appTitle) return appTitle;
if (extensionName) return formatExtensionName(extensionName);
return 'App';
}, [appTitle, extensionName]);
const renderFullscreenHeader = () => (
<div
className="flex shrink-0 items-center border-b border-border-primary bg-background-primary px-3"
style={{ height: `${FULLSCREEN_HEADER_HEIGHT}px` }}
>
<div className="min-w-0 flex-1" />
<span className="truncate px-4 text-sm font-medium text-text-secondary">
{fullscreenTitle}
</span>
<div className="flex flex-1 items-center justify-end gap-1">
{appSupportsPip && (
<button
onClick={() => changeDisplayMode('pip')}
className="no-drag cursor-pointer rounded-md p-1.5 text-text-secondary transition-colors hover:bg-black/10 hover:text-text-primary dark:hover:bg-white/10"
title="Picture-in-Picture"
aria-label="Picture-in-Picture"
>
<PictureInPicture2 size={16} />
</button>
)}
<button
ref={fullscreenCloseRef}
onClick={() => changeDisplayMode('inline')}
className="no-drag cursor-pointer rounded-md p-1.5 text-text-secondary transition-colors hover:bg-black/10 hover:text-text-primary dark:hover:bg-white/10"
title="Exit fullscreen (Esc)"
aria-label="Exit fullscreen"
>
<X size={16} />
</button>
</div>
</div>
);
const renderDisplayModeControls = () => { const renderDisplayModeControls = () => {
if (!showControls) return null; if (!showControls) return null;
if (activeDisplayMode === 'fullscreen') { // Fullscreen controls are rendered by renderFullscreenHeader instead.
return ( if (activeDisplayMode === 'fullscreen') return null;
<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') { if (activeDisplayMode === 'pip') {
return ( return (
@@ -893,9 +915,10 @@ export default function McpAppRenderer({
{/* Stable app container — never unmounted, only repositioned via CSS */} {/* Stable app container — never unmounted, only repositioned via CSS */}
<div <div
ref={containerRef} ref={containerRef}
className={cn(containerClasses, isPip && 'group/pip')} className={cn(containerClasses, isFillsViewport && 'flex flex-col', isPip && 'group/pip')}
style={containerStyle} style={containerStyle}
> >
{isFullscreen && renderFullscreenHeader()}
{isPip && ( {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 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 <div
@@ -914,7 +937,7 @@ export default function McpAppRenderer({
<div className="flex gap-1">{renderDisplayModeControls()}</div> <div className="flex gap-1">{renderDisplayModeControls()}</div>
</div> </div>
)} )}
<div className={cn('relative w-full', !isPip && 'h-full')}> <div ref={contentRef} className={cn('relative w-full', !isPip && 'flex-1 min-h-0')}>
{!isPip && renderDisplayModeControls()} {!isPip && renderDisplayModeControls()}
{renderContent()} {renderContent()}
</div> </div>
@@ -36,6 +36,7 @@ export interface DisplayModeState {
isInline: boolean; isInline: boolean;
appSupportsFullscreen: boolean; appSupportsFullscreen: boolean;
appSupportsPip: boolean; appSupportsPip: boolean;
appTitle: string | null;
changeDisplayMode: (mode: GooseDisplayMode) => void; changeDisplayMode: (mode: GooseDisplayMode) => void;
@@ -77,6 +78,9 @@ export function useDisplayMode({
// null = not yet known (controls stay hidden until initialize), empty = app didn't declare any. // null = not yet known (controls stay hidden until initialize), empty = app didn't declare any.
const [appDeclaredModes, setAppDeclaredModes] = useState<string[] | null>(null); const [appDeclaredModes, setAppDeclaredModes] = useState<string[] | null>(null);
// App-declared title from ui/initialize (highest priority in the title fallback chain).
const [appTitle, setAppTitle] = useState<string | null>(null);
const effectiveDisplayModes = useMemo((): McpUiDisplayMode[] => { const effectiveDisplayModes = useMemo((): McpUiDisplayMode[] => {
if (!appDeclaredModes) return []; if (!appDeclaredModes) return [];
return AVAILABLE_DISPLAY_MODES.filter((m) => appDeclaredModes.includes(m)); return AVAILABLE_DISPLAY_MODES.filter((m) => appDeclaredModes.includes(m));
@@ -263,6 +267,10 @@ export function useDisplayMode({
if (caps?.availableDisplayModes && Array.isArray(caps.availableDisplayModes)) { if (caps?.availableDisplayModes && Array.isArray(caps.availableDisplayModes)) {
setAppDeclaredModes(caps.availableDisplayModes); setAppDeclaredModes(caps.availableDisplayModes);
} }
const title = data.params.clientInfo?.name;
if (typeof title === 'string' && title.trim()) {
setAppTitle(title.trim());
}
} }
// After initialize, only allow modes both host and app agree on. // After initialize, only allow modes both host and app agree on.
@@ -319,6 +327,7 @@ export function useDisplayMode({
isInline, isInline,
appSupportsFullscreen, appSupportsFullscreen,
appSupportsPip, appSupportsPip,
appTitle,
changeDisplayMode, changeDisplayMode,