Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 24 additions & 24 deletions apps/mobile/src/features/threads/McpAppWebView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -510,7 +514,7 @@ export function ThreadMcpApp(props: {
if (presentedFullscreen) {
return (
<View
style={{ height: MCP_APP_ROW_HEIGHT, marginBottom: ROW_BOTTOM_MARGIN }}
style={{ height, marginBottom: ROW_BOTTOM_MARGIN }}
className="items-center justify-center rounded-lg border border-border"
>
<Text className="text-sm text-foreground-muted">
Expand All @@ -523,7 +527,7 @@ export function ThreadMcpApp(props: {
if (closed) {
return (
<View
style={{ height: MCP_APP_ROW_HEIGHT, marginBottom: ROW_BOTTOM_MARGIN }}
style={{ height, marginBottom: ROW_BOTTOM_MARGIN }}
className="items-center justify-center gap-2 rounded-lg border border-border"
>
<Text className="text-sm text-foreground-muted">The {app.server} app was closed</Text>
Expand All @@ -541,11 +545,7 @@ export function ThreadMcpApp(props: {
}

return (
<View
style={
fullscreen ? { flex: 1 } : { height: MCP_APP_ROW_HEIGHT, marginBottom: ROW_BOTTOM_MARGIN }
}
>
<View style={fullscreen ? { flex: 1 } : { height, marginBottom: ROW_BOTTOM_MARGIN }}>
{navigatedAway ? (
<View className="flex-1 items-center justify-center">
<Text className="text-sm text-foreground-muted">
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1604,6 +1604,7 @@ function renderFeedEntry(
if (entry.type === "mcp-app") {
return (
<ThreadMcpApp
key={entry.app.attachmentId}
environmentId={props.environmentId}
threadId={entry.sourceThreadId}
conversationThreadId={props.threadId}
Expand Down Expand Up @@ -2959,7 +2960,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
if (entry.type === "html-render") {
return htmlRenderRowHeight(htmlRenderFrameHeight(entry.render, contentWidth));
}
if (entry.type === "mcp-app") return mcpAppRowHeight();
if (workRowSizing.fixedRowHeight === undefined) {
return undefined;
}
Expand Down
22 changes: 22 additions & 0 deletions apps/mobile/src/features/threads/mcpAppSizing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vite-plus/test";

import { mcpAppInlineHeight } from "./mcpAppSizing";

describe("inline MCP app height", () => {
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);
});
});
9 changes: 9 additions & 0 deletions apps/mobile/src/features/threads/mcpAppSizing.ts
Original file line number Diff line number Diff line change
@@ -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));
}
Loading