authorgravatar for me@pixp.ccAlex <me@pixp.cc> 2026-10-11 11:30:43-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 11:30:43-07:00
log74f9df3abfb62b3a977390f01321b93a63c29cc5
tree2774ebf728555a11fd87ddee0c16bd98d7b899a9
parenta1f81a44e5ba18f099a7c43b310ddb7f7fa7fb37
signature Signed by PGP key B5690EEEBB952194

fix(mobile): size inline MCP apps to their content (#17040)


4 files changed, 57 insertions(+), 26 deletions(-)

apps/mobile/src/features/threads/McpAppWebView.tsx+24-24
...@@ -38,15 +38,10 @@ import { useEnvironmentQuery } from "../../state/query";...@@ -38,15 +38,10 @@ import { useEnvironmentQuery } from "../../state/query";
38import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";38import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";
39import { useAtomCommand } from "../../state/use-atom-command";39import { useAtomCommand } from "../../state/use-atom-command";
40import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";40import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
41import { MCP_APP_INLINE_MAX_HEIGHT, mcpAppInlineHeight } from "./mcpAppSizing";
4142
42/** The feed reserves a fixed box for an app; a taller app scrolls inside it. */
43const MCP_APP_ROW_HEIGHT = 420;
44const ROW_BOTTOM_MARGIN = 8;43const ROW_BOTTOM_MARGIN = 8;
4544
46export function mcpAppRowHeight() {
47 return MCP_APP_ROW_HEIGHT + ROW_BOTTOM_MARGIN;
48}
49
50// The WebView loads a tiny outer page that hosts the app in a real45// The WebView loads a tiny outer page that hosts the app in a real
51// opaque-origin iframe, as web does, so the app's `window.parent` and the46// opaque-origin iframe, as web does, so the app's `window.parent` and the
52// `event.source` of host replies are a real window, which the MCP Apps SDK47// `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;...@@ -104,7 +99,7 @@ const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024;
10499
105/**100/**
106 * A captured MCP App, hosted in a WebView over the MCP Apps bridge: inline as101 * A captured MCP App, hosted in a WebView over the MCP Apps bridge: inline as
107 * a fixed row of the thread feed, or full screen in its own modal screen. A102 * a content-sized row of the thread feed, or full screen in its own modal screen. A
108 * WebView cannot move between the two without reloading, so each is its own103 * WebView cannot move between the two without reloading, so each is its own
109 * view of the app: the inline one is torn down before full screen opens, and104 * view of the app: the inline one is torn down before full screen opens, and
110 * comes back when it closes.105 * comes back when it closes.
...@@ -119,7 +114,7 @@ export function ThreadMcpApp(props: {...@@ -119,7 +114,7 @@ export function ThreadMcpApp(props: {
119 readonly revision: string;114 readonly revision: string;
120 readonly app: McpAppReference;115 readonly app: McpAppReference;
121 readonly width: number;116 readonly width: number;
122 /** Full screen fills its screen; inline is the feed's fixed row. */117 /** Full screen fills its screen; inline follows the app's reported height. */
123 readonly displayMode?: "inline" | "fullscreen";118 readonly displayMode?: "inline" | "fullscreen";
124 /** Full screen only: leaves it, back to the inline row. */119 /** Full screen only: leaves it, back to the inline row. */
125 readonly onExitFullscreen?: () => void;120 readonly onExitFullscreen?: () => void;
...@@ -128,8 +123,12 @@ export function ThreadMcpApp(props: {...@@ -128,8 +123,12 @@ export function ThreadMcpApp(props: {
128}) {123}) {
129 // One reference per app: a new object with the same content (a refetch, a124 // One reference per app: a new object with the same content (a refetch, a
130 // rerender) must not rebuild the host of a document that is already live.125 // rerender) must not rebuild the host of a document that is already live.
126 const [height, setHeight] = useState(MCP_APP_INLINE_MAX_HEIGHT);
131 const [app, setApp] = useState(props.app);127 const [app, setApp] = useState(props.app);
132 if (!mcpAppReferencesEqual(app, props.app)) setApp(props.app);128 if (!mcpAppReferencesEqual(app, props.app)) {
129 setApp(props.app);
130 setHeight(MCP_APP_INLINE_MAX_HEIGHT);
131 }
133 const fullscreen = props.displayMode === "fullscreen";132 const fullscreen = props.displayMode === "fullscreen";
134 const navigation = useNavigation();133 const navigation = useNavigation();
135 const insets = useSafeAreaInsets();134 const insets = useSafeAreaInsets();
...@@ -183,6 +182,7 @@ export function ThreadMcpApp(props: {...@@ -183,6 +182,7 @@ export function ThreadMcpApp(props: {
183 return;182 return;
184 }183 }
185 setLoaded(false);184 setLoaded(false);
185 setHeight(MCP_APP_INLINE_MAX_HEIGHT);
186 // The first URL's token may have expired by now; the asset query keeps186 // The first URL's token may have expired by now; the asset query keeps
187 // a refreshed one, which this new view can load.187 // a refreshed one, which this new view can load.
188 if (asset._tag === "Success") setUri(asset.url);188 if (asset._tag === "Success") setUri(asset.url);
...@@ -194,6 +194,7 @@ export function ThreadMcpApp(props: {...@@ -194,6 +194,7 @@ export function ThreadMcpApp(props: {
194 // loads the asset query's current URL: the first one's token may be gone.194 // loads the asset query's current URL: the first one's token may be gone.
195 const setDocumentKey = (next: (value: number) => number) => {195 const setDocumentKey = (next: (value: number) => number) => {
196 setLoaded(false);196 setLoaded(false);
197 setHeight(MCP_APP_INLINE_MAX_HEIGHT);
197 setNavigatedAway(false);198 setNavigatedAway(false);
198 if (asset._tag === "Success") setUri(asset.url);199 if (asset._tag === "Success") setUri(asset.url);
199 setDocumentKeyState(next);200 setDocumentKeyState(next);
...@@ -296,11 +297,12 @@ export function ThreadMcpApp(props: {...@@ -296,11 +297,12 @@ export function ThreadMcpApp(props: {
296 styles: { variables: mcpAppStyleVariables(current.theme.variables) },297 styles: { variables: mcpAppStyleVariables(current.theme.variables) },
297 displayMode: isFullscreen ? "fullscreen" : "inline",298 displayMode: isFullscreen ? "fullscreen" : "inline",
298 availableDisplayModes: ["inline", "fullscreen"],299 availableDisplayModes: ["inline", "fullscreen"],
299 // Both are fixed boxes, so the app is told its exact size.300 containerDimensions: isFullscreen
300 containerDimensions: {301 ? {
301 width: current.props.width,302 width: current.props.width,
302 height: isFullscreen ? (current.props.height ?? MCP_APP_ROW_HEIGHT) : MCP_APP_ROW_HEIGHT,303 height: current.props.height ?? MCP_APP_INLINE_MAX_HEIGHT,
303 },304 }
305 : { width: current.props.width, maxHeight: MCP_APP_INLINE_MAX_HEIGHT },
304 platform: "mobile",306 platform: "mobile",
305 locale: Intl.DateTimeFormat().resolvedOptions().locale,307 locale: Intl.DateTimeFormat().resolvedOptions().locale,
306 timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,308 timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
...@@ -475,9 +477,11 @@ export function ThreadMcpApp(props: {...@@ -475,9 +477,11 @@ export function ThreadMcpApp(props: {
475 void hostRef.current?.teardown().then(() => setClosed(true));477 void hostRef.current?.teardown().then(() => setClosed(true));
476 }478 }
477 },479 },
478 // Both modes are fixed boxes, so the app's own height only decides480 onSizeChanged: (size) => {
479 // whether it scrolls inside one.481 if (latest.current.props.displayMode === "fullscreen") return;
480 onSizeChanged: () => undefined,482 const nextHeight = mcpAppInlineHeight(size.height);
483 if (nextHeight !== undefined) setHeight(nextHeight);
484 },
481 });485 });
482 hostRef.current = next;486 hostRef.current = next;
483 return () => {487 return () => {
...@@ -510,7 +514,7 @@ export function ThreadMcpApp(props: {...@@ -510,7 +514,7 @@ export function ThreadMcpApp(props: {
510 if (presentedFullscreen) {514 if (presentedFullscreen) {
511 return (515 return (
512 <View516 <View
513 style={{ height: MCP_APP_ROW_HEIGHT, marginBottom: ROW_BOTTOM_MARGIN }}517 style={{ height, marginBottom: ROW_BOTTOM_MARGIN }}
514 className="items-center justify-center rounded-lg border border-border"518 className="items-center justify-center rounded-lg border border-border"
515 >519 >
516 <Text className="text-sm text-foreground-muted">520 <Text className="text-sm text-foreground-muted">
...@@ -523,7 +527,7 @@ export function ThreadMcpApp(props: {...@@ -523,7 +527,7 @@ export function ThreadMcpApp(props: {
523 if (closed) {527 if (closed) {
524 return (528 return (
525 <View529 <View
526 style={{ height: MCP_APP_ROW_HEIGHT, marginBottom: ROW_BOTTOM_MARGIN }}530 style={{ height, marginBottom: ROW_BOTTOM_MARGIN }}
527 className="items-center justify-center gap-2 rounded-lg border border-border"531 className="items-center justify-center gap-2 rounded-lg border border-border"
528 >532 >
529 <Text className="text-sm text-foreground-muted">The {app.server} app was closed</Text>533 <Text className="text-sm text-foreground-muted">The {app.server} app was closed</Text>
...@@ -541,11 +545,7 @@ export function ThreadMcpApp(props: {...@@ -541,11 +545,7 @@ export function ThreadMcpApp(props: {
541 }545 }
542546
543 return (547 return (
544 <View548 <View style={fullscreen ? { flex: 1 } : { height, marginBottom: ROW_BOTTOM_MARGIN }}>
545 style={
546 fullscreen ? { flex: 1 } : { height: MCP_APP_ROW_HEIGHT, marginBottom: ROW_BOTTOM_MARGIN }
547 }
548 >
549 {navigatedAway ? (549 {navigatedAway ? (
550 <View className="flex-1 items-center justify-center">550 <View className="flex-1 items-center justify-center">
551 <Text className="text-sm text-foreground-muted">551 <Text className="text-sm text-foreground-muted">
apps/mobile/src/features/threads/ThreadFeed.tsx+2-2
...@@ -189,7 +189,7 @@ import type { QueuedThreadMessage } from "../../state/thread-outbox-model";...@@ -189,7 +189,7 @@ import type { QueuedThreadMessage } from "../../state/thread-outbox-model";
189import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size";189import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size";
190import { htmlRenderFrameHeight } from "@t3tools/shared/htmlRender";190import { htmlRenderFrameHeight } from "@t3tools/shared/htmlRender";
191import { htmlRenderRowHeight, ThreadHtmlRender } from "./HtmlRenderWebView";191import { htmlRenderRowHeight, ThreadHtmlRender } from "./HtmlRenderWebView";
192import { mcpAppRowHeight, ThreadMcpApp } from "./McpAppWebView";192import { ThreadMcpApp } from "./McpAppWebView";
193import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";193import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
194import {194import {
195 assetEnvironment,195 assetEnvironment,
...@@ -1604,6 +1604,7 @@ function renderFeedEntry(...@@ -1604,6 +1604,7 @@ function renderFeedEntry(
1604 if (entry.type === "mcp-app") {1604 if (entry.type === "mcp-app") {
1605 return (1605 return (
1606 <ThreadMcpApp1606 <ThreadMcpApp
1607 key={entry.app.attachmentId}
1607 environmentId={props.environmentId}1608 environmentId={props.environmentId}
1608 threadId={entry.sourceThreadId}1609 threadId={entry.sourceThreadId}
1609 conversationThreadId={props.threadId}1610 conversationThreadId={props.threadId}
...@@ -2959,7 +2960,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2959,7 +2960,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2959 if (entry.type === "html-render") {2960 if (entry.type === "html-render") {
2960 return htmlRenderRowHeight(htmlRenderFrameHeight(entry.render, contentWidth));2961 return htmlRenderRowHeight(htmlRenderFrameHeight(entry.render, contentWidth));
2961 }2962 }
2962 if (entry.type === "mcp-app") return mcpAppRowHeight();
2963 if (workRowSizing.fixedRowHeight === undefined) {2963 if (workRowSizing.fixedRowHeight === undefined) {
2964 return undefined;2964 return undefined;
2965 }2965 }
apps/mobile/src/features/threads/mcpAppSizing.test.ts created+22
...@@ -0,0 +1,22 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { mcpAppInlineHeight } from "./mcpAppSizing";
4
5describe("inline MCP app height", () => {
6 it.each([undefined, NaN, Infinity, -Infinity])(
7 "ignores invalid or absent height %s",
8 (height) => {
9 expect(mcpAppInlineHeight(height)).toBeUndefined();
10 },
11 );
12
13 it.each([
14 [-10, 80],
15 [0, 80],
16 [149.6, 150],
17 [420, 420],
18 [2000, 420],
19 ])("sizes content height %s within the inline viewport", (height, expected) => {
20 expect(mcpAppInlineHeight(height)).toBe(expected);
21 });
22});
apps/mobile/src/features/threads/mcpAppSizing.ts created+9
...@@ -0,0 +1,9 @@
1import { clampMcpAppHeight } from "@t3tools/shared/mcpApp";
2
3/** Taller apps scroll inside the inline view; full screen fills its own screen. */
4export const MCP_APP_INLINE_MAX_HEIGHT = 420;
5
6export function mcpAppInlineHeight(height: number | undefined): number | undefined {
7 if (height === undefined || !Number.isFinite(height)) return undefined;
8 return Math.min(MCP_APP_INLINE_MAX_HEIGHT, clampMcpAppHeight(height));
9}