/** * How the shell decides whether it is a document or an app. * * ## The two modes, and why the difference is structural * * An artifact is rendered in two very different places, and they want opposite * things from the same code: * * - **Embedded in a chat thread** (Claude and every other MCP-Apps client). The * artifact is one block in a conversation that scrolls. It must be exactly as * tall as its content, because anything else either clips it or leaves a hole * in the transcript. The app reports its content height, the host grows the * frame, the PAGE scrolls. This is `inline`, and it is correct there. * * - **On the console's artifact detail page.** The artifact IS the page. There * is nothing else on the surface to scroll past, so growing the frame to * content produces the failure this module exists to fix: a table of 143 rows * pushes its own "Projects" header, its search box and its filters off the * top of the screen, and the user scrolls a document when they expected an * app. What they want — a stable header over a scrolling table — is not * something the artifact's author can express, no matter what they write, * because `h-full` against an unbounded parent is just `h-auto`. The artifact * never had a viewport to lay out against. * * So the fix is not a prompt rule. The host must hand the app a BOUNDED height * and stop growing with it, and the app must turn that bound into a real * `height: 100%` chain the artifact's own `flex-1 min-h-0 overflow-auto` can * resolve against. * * ## Why `fullscreen` and not a mode of our own * * The MCP Apps spec's display mode is a CLOSED union — `"inline" | "fullscreen" * | "pip"` (`McpUiDisplayMode`). There is no "fill", and inventing one would * mean a value no other host could ever understand travelling on a spec field * whose whole purpose is that hosts agree on it. * * `fullscreen` is the spec's existing word for the situation the detail page is * actually in: the app owns the whole surface it is given rather than sitting in * a flow of other content. That is exactly what the detail page offers it — the * viewport below the title bar, and nothing else competing for it. So the * console advertises `displayMode: "fullscreen"`, and the shell reads the spec * field rather than a private flag. * * The bound itself rides `containerDimensions`, which the shell already honors * (it is how the old unconditional 800px cap was replaced). The distinction that * carries the meaning is `height` versus `maxHeight`: a FIXED `height` is the * spec's way of saying "this is the room you have, exactly", where `maxHeight` * only says "no more than this". Fill mode sends a fixed `height`, so the app * can size to it rather than merely staying under it. * * Reading BOTH — the mode and a fixed height — is deliberate. A host that says * `fullscreen` but never reports a height has told the shell nothing it can lay * out against, and the safe answer there is to keep behaving like a document. */import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps";/** * The display mode the console's artifact detail page advertises. * * Named rather than inlined so the host and the shell cannot drift apart on * which of the spec's three modes means "the artifact owns this surface". */export const FILL_DISPLAY_MODE = "fullscreen" as const;/** The mode every other host gets, and the default when none is advertised. */export const INLINE_DISPLAY_MODE = "inline" as const;/** * The exact height the host has given the app, or `null` when it has not given * it one. * * `containerDimensions` is a union: one arm carries a fixed `height`, the other * an optional `maxHeight`. Only the fixed arm is a viewport — `maxHeight` is a * ceiling on growth, which is the inline story, not this one. */const fixedContainerHeight = (ctx: McpUiHostContext | undefined): number | null => { const container = ctx?.containerDimensions; if (!container) return null; if (!("height" in container)) return null; const { height } = container; return typeof height === "number" && height > 0 ? height : null;};/** * Whether this host has given the app a bounded viewport to fill. * * Both halves are required, and neither is redundant: * * - the mode says the app owns its surface, which is a statement about * LAYOUT INTENT that only the host can make; * - the fixed height says how much surface that is, which is the number the * app cannot measure for itself (its own viewport IS the frame the host * sizes, so measuring it would be circular). * * A host advertising `fullscreen` with no fixed height gets inline behaviour, * which is the honest fallback: without a bound there is nothing for `height: * 100%` to resolve against, and growing to content at least shows everything. */export const isFillDisplayMode = (ctx: McpUiHostContext | undefined): boolean => ctx?.displayMode === FILL_DISPLAY_MODE && fixedContainerHeight(ctx) !== null;/** The bounded viewport height, or `null` when this host is not filling one. */export const fillViewportHeight = (ctx: McpUiHostContext | undefined): number | null => isFillDisplayMode(ctx) ? fixedContainerHeight(ctx) : null;
/**
* How the shell decides whether it is a document or an app.
*
* ## The two modes, and why the difference is structural
*
* An artifact is rendered in two very different places, and they want opposite
* things from the same code:
*
* - **Embedded in a chat thread** (Claude and every other MCP-Apps client). The
* artifact is one block in a conversation that scrolls. It must be exactly as
* tall as its content, because anything else either clips it or leaves a hole
* in the transcript. The app reports its content height, the host grows the
* frame, the PAGE scrolls. This is `inline`, and it is correct there.
*
* - **On the console's artifact detail page.** The artifact IS the page. There
* is nothing else on the surface to scroll past, so growing the frame to
* content produces the failure this module exists to fix: a table of 143 rows
* pushes its own "Projects" header, its search box and its filters off the
* top of the screen, and the user scrolls a document when they expected an
* app. What they want — a stable header over a scrolling table — is not
* something the artifact's author can express, no matter what they write,
* because `h-full` against an unbounded parent is just `h-auto`. The artifact
* never had a viewport to lay out against.
*
* So the fix is not a prompt rule. The host must hand the app a BOUNDED height
* and stop growing with it, and the app must turn that bound into a real
* `height: 100%` chain the artifact's own `flex-1 min-h-0 overflow-auto` can
* resolve against.
*
* ## Why `fullscreen` and not a mode of our own
*
* The MCP Apps spec's display mode is a CLOSED union — `"inline" | "fullscreen"
* | "pip"` (`McpUiDisplayMode`). There is no "fill", and inventing one would
* mean a value no other host could ever understand travelling on a spec field
* whose whole purpose is that hosts agree on it.
*
* `fullscreen` is the spec's existing word for the situation the detail page is
* actually in: the app owns the whole surface it is given rather than sitting in
* a flow of other content. That is exactly what the detail page offers it — the
* viewport below the title bar, and nothing else competing for it. So the
* console advertises `displayMode: "fullscreen"`, and the shell reads the spec
* field rather than a private flag.
*
* The bound itself rides `containerDimensions`, which the shell already honors
* (it is how the old unconditional 800px cap was replaced). The distinction that
* carries the meaning is `height` versus `maxHeight`: a FIXED `height` is the
* spec's way of saying "this is the room you have, exactly", where `maxHeight`
* only says "no more than this". Fill mode sends a fixed `height`, so the app
* can size to it rather than merely staying under it.
*
* Reading BOTH — the mode and a fixed height — is deliberate. A host that says
* `fullscreen` but never reports a height has told the shell nothing it can lay
* out against, and the safe answer there is to keep behaving like a document.
*/
import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps";
/**
* The display mode the console's artifact detail page advertises.
*
* Named rather than inlined so the host and the shell cannot drift apart on
* which of the spec's three modes means "the artifact owns this surface".
*/
export const FILL_DISPLAY_MODE = "fullscreen" as const;
/** The mode every other host gets, and the default when none is advertised. */
export const INLINE_DISPLAY_MODE = "inline" as const;
/**
* The exact height the host has given the app, or `null` when it has not given
* it one.
*
* `containerDimensions` is a union: one arm carries a fixed `height`, the other
* an optional `maxHeight`. Only the fixed arm is a viewport — `maxHeight` is a
* ceiling on growth, which is the inline story, not this one.
*/
const fixedContainerHeight = (ctx: McpUiHostContext | undefined): number | null => {
const container = ctx?.containerDimensions;
if (!container) return null;
if (!("height" in container)) return null;
const { height } = container;
return typeof height === "number" && height > 0 ? height : null;
};
/**
* Whether this host has given the app a bounded viewport to fill.
*
* Both halves are required, and neither is redundant:
*
* - the mode says the app owns its surface, which is a statement about
* LAYOUT INTENT that only the host can make;
* - the fixed height says how much surface that is, which is the number the
* app cannot measure for itself (its own viewport IS the frame the host
* sizes, so measuring it would be circular).
*
* A host advertising `fullscreen` with no fixed height gets inline behaviour,
* which is the honest fallback: without a bound there is nothing for `height:
* 100%` to resolve against, and growing to content at least shows everything.
*/
export const isFillDisplayMode = (ctx: McpUiHostContext | undefined): boolean =>
ctx?.displayMode === FILL_DISPLAY_MODE && fixedContainerHeight(ctx) !== null;
/** The bounded viewport height, or `null` when this host is not filling one. */
export const fillViewportHeight = (ctx: McpUiHostContext | undefined): number | null =>
isFillDisplayMode(ctx) ? fixedContainerHeight(ctx) : null;