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:
@@ -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,18 +759,29 @@ export default function McpAppRenderer({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const showControls = !isStandalone && !isError && (appSupportsFullscreen || appSupportsPip);
|
const showControls =
|
||||||
|
!isStandalone && !isError && (appSupportsFullscreen || appSupportsPip || isFullscreen || isPip);
|
||||||
|
|
||||||
const renderDisplayModeControls = () => {
|
const fullscreenTitle = useMemo(() => {
|
||||||
if (!showControls) return null;
|
if (appTitle) return appTitle;
|
||||||
|
if (extensionName) return formatExtensionName(extensionName);
|
||||||
|
return 'App';
|
||||||
|
}, [appTitle, extensionName]);
|
||||||
|
|
||||||
if (activeDisplayMode === 'fullscreen') {
|
const renderFullscreenHeader = () => (
|
||||||
return (
|
<div
|
||||||
<div className="no-drag absolute top-3 right-3 z-[60] flex gap-1">
|
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 && (
|
{appSupportsPip && (
|
||||||
<button
|
<button
|
||||||
onClick={() => changeDisplayMode('pip')}
|
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"
|
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"
|
title="Picture-in-Picture"
|
||||||
aria-label="Picture-in-Picture"
|
aria-label="Picture-in-Picture"
|
||||||
>
|
>
|
||||||
@@ -775,15 +791,21 @@ export default function McpAppRenderer({
|
|||||||
<button
|
<button
|
||||||
ref={fullscreenCloseRef}
|
ref={fullscreenCloseRef}
|
||||||
onClick={() => changeDisplayMode('inline')}
|
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"
|
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)"
|
title="Exit fullscreen (Esc)"
|
||||||
aria-label="Exit fullscreen"
|
aria-label="Exit fullscreen"
|
||||||
>
|
>
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
const renderDisplayModeControls = () => {
|
||||||
|
if (!showControls) return null;
|
||||||
|
|
||||||
|
// Fullscreen controls are rendered by renderFullscreenHeader instead.
|
||||||
|
if (activeDisplayMode === 'fullscreen') return null;
|
||||||
|
|
||||||
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,
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user