diff --git a/apps/mobile/src/features/threads/McpAppWebView.tsx b/apps/mobile/src/features/threads/McpAppWebView.tsx index 119f5f946817..6367a36f7bda 100644 --- a/apps/mobile/src/features/threads/McpAppWebView.tsx +++ b/apps/mobile/src/features/threads/McpAppWebView.tsx @@ -38,15 +38,10 @@ import { useEnvironmentQuery } from "../../state/query"; import { enqueueThreadOutboxMessage } from "../../state/thread-outbox"; import { useAtomCommand } from "../../state/use-atom-command"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; +import { MCP_APP_INLINE_MAX_HEIGHT, mcpAppInlineHeight } from "./mcpAppSizing"; -/** The feed reserves a fixed box for an app; a taller app scrolls inside it. */ -const MCP_APP_ROW_HEIGHT = 420; const ROW_BOTTOM_MARGIN = 8; -export function mcpAppRowHeight() { - return MCP_APP_ROW_HEIGHT + ROW_BOTTOM_MARGIN; -} - // The WebView loads a tiny outer page that hosts the app in a real // opaque-origin iframe, as web does, so the app's `window.parent` and the // `event.source` of host replies are a real window, which the MCP Apps SDK @@ -104,7 +99,7 @@ const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024; /** * A captured MCP App, hosted in a WebView over the MCP Apps bridge: inline as - * a fixed row of the thread feed, or full screen in its own modal screen. A + * a content-sized row of the thread feed, or full screen in its own modal screen. A * WebView cannot move between the two without reloading, so each is its own * view of the app: the inline one is torn down before full screen opens, and * comes back when it closes. @@ -119,7 +114,7 @@ export function ThreadMcpApp(props: { readonly revision: string; readonly app: McpAppReference; readonly width: number; - /** Full screen fills its screen; inline is the feed's fixed row. */ + /** Full screen fills its screen; inline follows the app's reported height. */ readonly displayMode?: "inline" | "fullscreen"; /** Full screen only: leaves it, back to the inline row. */ readonly onExitFullscreen?: () => void; @@ -128,8 +123,12 @@ export function ThreadMcpApp(props: { }) { // One reference per app: a new object with the same content (a refetch, a // rerender) must not rebuild the host of a document that is already live. + const [height, setHeight] = useState(MCP_APP_INLINE_MAX_HEIGHT); const [app, setApp] = useState(props.app); - if (!mcpAppReferencesEqual(app, props.app)) setApp(props.app); + if (!mcpAppReferencesEqual(app, props.app)) { + setApp(props.app); + setHeight(MCP_APP_INLINE_MAX_HEIGHT); + } const fullscreen = props.displayMode === "fullscreen"; const navigation = useNavigation(); const insets = useSafeAreaInsets(); @@ -183,6 +182,7 @@ export function ThreadMcpApp(props: { return; } setLoaded(false); + setHeight(MCP_APP_INLINE_MAX_HEIGHT); // The first URL's token may have expired by now; the asset query keeps // a refreshed one, which this new view can load. if (asset._tag === "Success") setUri(asset.url); @@ -194,6 +194,7 @@ export function ThreadMcpApp(props: { // loads the asset query's current URL: the first one's token may be gone. const setDocumentKey = (next: (value: number) => number) => { setLoaded(false); + setHeight(MCP_APP_INLINE_MAX_HEIGHT); setNavigatedAway(false); if (asset._tag === "Success") setUri(asset.url); setDocumentKeyState(next); @@ -296,11 +297,12 @@ export function ThreadMcpApp(props: { styles: { variables: mcpAppStyleVariables(current.theme.variables) }, displayMode: isFullscreen ? "fullscreen" : "inline", availableDisplayModes: ["inline", "fullscreen"], - // Both are fixed boxes, so the app is told its exact size. - containerDimensions: { - width: current.props.width, - height: isFullscreen ? (current.props.height ?? MCP_APP_ROW_HEIGHT) : MCP_APP_ROW_HEIGHT, - }, + containerDimensions: isFullscreen + ? { + width: current.props.width, + height: current.props.height ?? MCP_APP_INLINE_MAX_HEIGHT, + } + : { width: current.props.width, maxHeight: MCP_APP_INLINE_MAX_HEIGHT }, platform: "mobile", locale: Intl.DateTimeFormat().resolvedOptions().locale, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, @@ -475,9 +477,11 @@ export function ThreadMcpApp(props: { void hostRef.current?.teardown().then(() => setClosed(true)); } }, - // Both modes are fixed boxes, so the app's own height only decides - // whether it scrolls inside one. - onSizeChanged: () => undefined, + onSizeChanged: (size) => { + if (latest.current.props.displayMode === "fullscreen") return; + const nextHeight = mcpAppInlineHeight(size.height); + if (nextHeight !== undefined) setHeight(nextHeight); + }, }); hostRef.current = next; return () => { @@ -510,7 +514,7 @@ export function ThreadMcpApp(props: { if (presentedFullscreen) { return ( @@ -523,7 +527,7 @@ export function ThreadMcpApp(props: { if (closed) { return ( The {app.server} app was closed @@ -541,11 +545,7 @@ export function ThreadMcpApp(props: { } return ( - + {navigatedAway ? ( diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index ee0c4711b03b..469a10907c35 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -189,7 +189,7 @@ import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size"; import { htmlRenderFrameHeight } from "@t3tools/shared/htmlRender"; import { htmlRenderRowHeight, ThreadHtmlRender } from "./HtmlRenderWebView"; -import { mcpAppRowHeight, ThreadMcpApp } from "./McpAppWebView"; +import { ThreadMcpApp } from "./McpAppWebView"; import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; import { assetEnvironment, @@ -1604,6 +1604,7 @@ function renderFeedEntry( if (entry.type === "mcp-app") { return ( { + it.each([undefined, NaN, Infinity, -Infinity])( + "ignores invalid or absent height %s", + (height) => { + expect(mcpAppInlineHeight(height)).toBeUndefined(); + }, + ); + + it.each([ + [-10, 80], + [0, 80], + [149.6, 150], + [420, 420], + [2000, 420], + ])("sizes content height %s within the inline viewport", (height, expected) => { + expect(mcpAppInlineHeight(height)).toBe(expected); + }); +}); diff --git a/apps/mobile/src/features/threads/mcpAppSizing.ts b/apps/mobile/src/features/threads/mcpAppSizing.ts new file mode 100644 index 000000000000..33a711f3b116 --- /dev/null +++ b/apps/mobile/src/features/threads/mcpAppSizing.ts @@ -0,0 +1,9 @@ +import { clampMcpAppHeight } from "@t3tools/shared/mcpApp"; + +/** Taller apps scroll inside the inline view; full screen fills its own screen. */ +export const MCP_APP_INLINE_MAX_HEIGHT = 420; + +export function mcpAppInlineHeight(height: number | undefined): number | undefined { + if (height === undefined || !Number.isFinite(height)) return undefined; + return Math.min(MCP_APP_INLINE_MAX_HEIGHT, clampMcpAppHeight(height)); +}