diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4af5aeefb..c7a83d8a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,7 +145,9 @@ jobs: - run: npm run test:dependency-isolation e2e: - runs-on: ubuntu-latest + # Pinned: the Playwright version in package.json cannot install its + # browser on ubuntu-26.04, which ubuntu-latest now resolves to. + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/update-snapshots.yml b/.github/workflows/update-snapshots.yml index 182f01a90..775fb4a67 100644 --- a/.github/workflows/update-snapshots.yml +++ b/.github/workflows/update-snapshots.yml @@ -24,7 +24,8 @@ jobs: (github.event.issue.pull_request && contains(github.event.comment.body, '/update-snapshots') && contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)) - runs-on: ubuntu-latest + # Pinned to the same image as the e2e job in ci.yml + runs-on: ubuntu-24.04 steps: - name: Get PR branch if: github.event_name == 'issue_comment' diff --git a/docs/migrate_from_openai_apps.md b/docs/migrate_from_openai_apps.md index 22d5d9f0c..fb64e73be 100644 --- a/docs/migrate_from_openai_apps.md +++ b/docs/migrate_from_openai_apps.md @@ -265,6 +265,9 @@ Client-side migration involves replacing the implicit `window.openai` global wit | -------------------------------------------------- | --------------------------------------------------------- | ----------------------------------- | | `await window.openai.requestDisplayMode({ mode })` | `await app.requestDisplayMode({ mode })` | Same API | | — | Check `app.getHostContext()?.availableDisplayModes` first | MCP lets you check what's available | +| `_meta["openai/ui"].preferredDisplayMode` | `preferredDisplayMode` in the `App` capabilities | A hint; see below | + +`_meta["openai/ui"].preferredDisplayMode` comes from OpenAI's [MCP extensions](https://github.com/openai/mcp-extensions/blob/main/docs/spec.md#display-modes), where it is set on the UI resource. In MCP Apps the View declares the preference itself when it connects (`new App(appInfo, { availableDisplayModes, preferredDisplayMode })`), and the host may ignore it. ### Size Reporting diff --git a/docs/patterns.md b/docs/patterns.md index 884de2651..39f723138 100644 --- a/docs/patterns.md +++ b/docs/patterns.md @@ -447,6 +447,25 @@ In fullscreen mode, remove the container's border radius so content extends to t } ``` +If your View is mainly useful on a large surface, declare `fullscreen` as its preferred display mode. This is a hint for how the host first shows the View. The host may ignore it, or apply it just after the View is first shown, so keep the `onhostcontextchanged` handler above: + + +```ts source="../src/app.examples.ts#App_constructor_preferredDisplayMode" +const app = new App( + { name: "MyApp", version: "1.0.0" }, + { + availableDisplayModes: ["inline", "fullscreen"], + preferredDisplayMode: "fullscreen", + }, +); +await app.connect(); + +// The host may ignore the preference: read the actual mode. +const container = document.getElementById("main")!; +const isFullscreen = app.getHostContext()?.displayMode === "fullscreen"; +container.classList.toggle("fullscreen", isFullscreen); +``` + > [!NOTE] > For full examples that implement this pattern, see: [`examples/shadertoy-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/shadertoy-server), [`examples/pdf-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/pdf-server), and [`examples/map-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/map-server). diff --git a/examples/basic-host/src/implementation.ts b/examples/basic-host/src/implementation.ts index 5f680ffdc..9998ae5f0 100644 --- a/examples/basic-host/src/implementation.ts +++ b/examples/basic-host/src/implementation.ts @@ -409,5 +409,25 @@ export function newAppBridge( return { mode: newMode }; }; + // Honor `appCapabilities.preferredDisplayMode`. It is a hint for how the + // app is first shown, so apply it once, as soon as the app has initialized + // (a repeated `ui/initialize` does not renew it). This host starts every + // app inline and offers only inline and fullscreen, so fullscreen is the + // one preference to act on, and only if the app also lists it as available. + const applyPreferredDisplayMode = () => { + appBridge.removeEventListener("initialized", applyPreferredDisplayMode); + const caps = appBridge.getAppCapabilities(); + if ( + caps?.preferredDisplayMode === "fullscreen" && + caps.availableDisplayModes?.includes("fullscreen") && + (options?.displayMode ?? "inline") === "inline" + ) { + log.info("Applying the app's preferred display mode: fullscreen"); + appBridge.sendHostContextChange({ displayMode: "fullscreen" }); + callbacks?.onDisplayModeChange?.("fullscreen"); + } + }; + appBridge.addEventListener("initialized", applyPreferredDisplayMode); + return appBridge; } diff --git a/examples/basic-host/src/index.module.css b/examples/basic-host/src/index.module.css index 0bd09dd05..0b1461bea 100644 --- a/examples/basic-host/src/index.module.css +++ b/examples/basic-host/src/index.module.css @@ -171,6 +171,16 @@ display: flex; flex-direction: column; + /* Host controls stay visible above the app: its name and a way out */ + .fullscreenBar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.5rem; + font-family: monospace; + } + /* Hide collapsible panels in fullscreen */ .collapsiblePanel { display: none; diff --git a/examples/basic-host/src/index.tsx b/examples/basic-host/src/index.tsx index 00c0d52b2..9afa9fc35 100644 --- a/examples/basic-host/src/index.tsx +++ b/examples/basic-host/src/index.tsx @@ -514,8 +514,21 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI ? `${styles.appIframePanel} ${styles.fullscreen}` : styles.appIframePanel; + // Host-owned way out of fullscreen: the app may have been started there by + // its preferred display mode, before the user has seen it. + const exitFullscreen = () => { + appBridgeRef.current?.sendHostContextChange({ displayMode: "inline" }); + setDisplayMode("inline"); + }; + return (
+ {displayMode === "fullscreen" && ( +
+ {toolCallInfo.serverInfo.name}:{toolCallInfo.tool.name} + +
+ )}