| 1 | import type { EnvironmentId, ThreadId, TurnItemId } from "@t3tools/contracts"; |
| 2 | import { |
| 3 | makeMcpAppHost, |
| 4 | McpAppHostRefusal, |
| 5 | mcpAppStyleVariables, |
| 6 | mcpResourceBytes, |
| 7 | type McpAppCallToolResult, |
| 8 | type McpAppDisplayMode, |
| 9 | type McpAppHost, |
| 10 | type McpAppHostContext, |
| 11 | } from "@t3tools/client-runtime/mcp-apps"; |
| 12 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 13 | import { |
| 14 | clampMcpAppHeight, |
| 15 | MCP_APP_DEFAULT_HEIGHT, |
| 16 | MCP_APP_MAX_HEIGHT, |
| 17 | mcpAppAllowAttribute, |
| 18 | mcpAppFileName, |
| 19 | type McpAppReference, |
| 20 | } from "@t3tools/shared/mcpApp"; |
| 21 | import { Minimize2Icon } from "lucide-react"; |
| 22 | import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 23 | import { flushSync } from "react-dom"; |
| 24 | |
| 25 | import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls"; |
| 26 | import { APP_VERSION } from "~/branding"; |
| 27 | import { isConfirmDialogActive, requestConfirmDialog } from "~/confirmDialog"; |
| 28 | import { useHtmlRenderTheme } from "~/hooks/useHtmlRenderTheme"; |
| 29 | import { Button } from "~/components/ui/button"; |
| 30 | import { isElectron } from "~/env"; |
| 31 | import { cn } from "~/lib/utils"; |
| 32 | import { useTurnItemDetail } from "~/state/queries"; |
| 33 | import { mcpAppEnvironment } from "~/state/mcpApps"; |
| 34 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 35 | import { observeResize } from "~/lib/observeResize"; |
| 36 | |
| 37 | const commandFailure = (result: { |
| 38 | readonly cause: Parameters<typeof squashAtomCommandFailure>[0]["cause"]; |
| 39 | }) => { |
| 40 | const error = squashAtomCommandFailure(result); |
| 41 | return new McpAppHostRefusal( |
| 42 | error instanceof Error && error.message.trim() !== "" ? error.message : "Request failed.", |
| 43 | ); |
| 44 | }; |
| 45 | |
| 46 | /** Full screen shows the box in the top layer, which needs the Popover API. */ |
| 47 | /** A cached asset URL with less life than this is minted afresh first. */ |
| 48 | const MIN_URL_LIFE_MS = 5 * 60_000; |
| 49 | const fullscreenSupported = |
| 50 | typeof HTMLElement !== "undefined" && "popover" in HTMLElement.prototype; |
| 51 | |
| 52 | /** Largest file an app may hand the user through `ui/download-file`. */ |
| 53 | const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024; |
| 54 | |
| 55 | /** Saves a file through the browser's own download, which asks where when it is set to. */ |
| 56 | function saveBlob(blob: Blob, name: string) { |
| 57 | const url = URL.createObjectURL(blob); |
| 58 | const link = document.createElement("a"); |
| 59 | link.href = url; |
| 60 | link.download = name; |
| 61 | link.click(); |
| 62 | // Long after the browser has read it: some start the download a task or |
| 63 | // more after the click, and a revoked URL saves nothing. |
| 64 | setTimeout(() => URL.revokeObjectURL(url), 60_000); |
| 65 | } |
| 66 | |
| 67 | /** |
| 68 | * An MCP App inline in the thread: the captured document in an opaque-origin |
| 69 | * frame, speaking the MCP Apps bridge. Its tool calls and resource reads reach |
| 70 | * its own MCP server through the environment; calls to tools the server does |
| 71 | * not mark read-only, chat messages, and downloads ask first. Full screen keeps |
| 72 | * the same frame (and the app's state) and only restyles its box. |
| 73 | */ |
| 74 | export function McpAppFrame(props: { |
| 75 | readonly environmentId: EnvironmentId; |
| 76 | /** The thread that produced the app, which its requests run against. */ |
| 77 | readonly threadId: ThreadId; |
| 78 | /** The thread on screen, whose next turn the app's model context informs. */ |
| 79 | readonly conversationThreadId: ThreadId; |
| 80 | readonly itemId: TurnItemId; |
| 81 | /** The item's revision, so its stored call is fetched once per version. */ |
| 82 | readonly revision: string; |
| 83 | readonly app: McpAppReference; |
| 84 | readonly onSendMessage: ((text: string) => Promise<void>) | undefined; |
| 85 | /** The agent waits on the user, in a panel a full-screen app would cover. */ |
| 86 | readonly awaitingUser?: boolean; |
| 87 | /** Told when the app enters or leaves full screen. */ |
| 88 | readonly onFullscreenChange?: (fullscreen: boolean) => void; |
| 89 | }) { |
| 90 | const { app } = props; |
| 91 | const theme = useHtmlRenderTheme(); |
| 92 | const frameRef = useRef<HTMLIFrameElement>(null); |
| 93 | const boxRef = useRef<HTMLDivElement>(null); |
| 94 | const [width, setWidth] = useState(0); |
| 95 | const [height, setHeight] = useState(MCP_APP_DEFAULT_HEIGHT); |
| 96 | const [navigatedAway, setNavigatedAway] = useState(false); |
| 97 | const [displayMode, setDisplayMode] = useState<McpAppDisplayMode>("inline"); |
| 98 | // The app asked to be closed; the row falls back to its plain tool call. |
| 99 | const [closed, setClosed] = useState(false); |
| 100 | // Counts the app documents this row has shown. Reopening a closed app loads |
| 101 | // a new one, which needs its own host and its own load tracking. |
| 102 | const [documentGeneration, setDocumentGeneration] = useState(0); |
| 103 | useLayoutEffect(() => { |
| 104 | const box = boxRef.current; |
| 105 | if (!box) return; |
| 106 | setWidth(box.clientWidth); |
| 107 | return observeResize(box, ([entry]) => { |
| 108 | if (entry) setWidth(Math.round(entry.contentRect.width)); |
| 109 | }); |
| 110 | // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Closing and reopening replaces the box. |
| 111 | }, [closed]); |
| 112 | |
| 113 | const resource = useMemo( |
| 114 | () => ({ |
| 115 | _tag: "attachment" as const, |
| 116 | attachmentId: app.attachmentId, |
| 117 | fileName: mcpAppFileName(app), |
| 118 | mimeType: "text/html", |
| 119 | disposition: "inline" as const, |
| 120 | renderIntent: "mcp-app" as const, |
| 121 | }), |
| 122 | [app], |
| 123 | ); |
| 124 | const asset = useAssetUrlState(props.environmentId, resource); |
| 125 | const refreshAsset = useAssetUrlRefresh(props.environmentId, resource); |
| 126 | // The frame keeps its first URL: a re-minted one would reload the app. A |
| 127 | // cached URL near expiry is minted afresh first, since a frame cannot |
| 128 | // report a failed load. At most one mint per document, so a skewed clock |
| 129 | // cannot mint on every update. |
| 130 | const [src, setSrc] = useState<string | null>(null); |
| 131 | const [mintFailed, setMintFailed] = useState(false); |
| 132 | const minting = useRef(false); |
| 133 | const cachedUrl = asset._tag === "Success" ? asset.url : null; |
| 134 | const cachedExpiresAt = asset._tag === "Success" ? asset.expiresAt : 0; |
| 135 | useEffect(() => { |
| 136 | if (src !== null || cachedUrl === null || minting.current) return; |
| 137 | if (cachedExpiresAt - Date.now() > MIN_URL_LIFE_MS) { |
| 138 | // oxlint-disable-next-line react/set-state-in-effect -- Adopts the cached URL once it is known to last. |
| 139 | setSrc(cachedUrl); |
| 140 | return; |
| 141 | } |
| 142 | minting.current = true; |
| 143 | void refreshAsset().then( |
| 144 | (url) => (url === null ? setMintFailed(true) : setSrc(url)), |
| 145 | () => setMintFailed(true), |
| 146 | ); |
| 147 | }, [src, cachedUrl, cachedExpiresAt, refreshAsset]); |
| 148 | |
| 149 | // The wire timeline omits tool input and output; the app needs both. |
| 150 | const detail = useTurnItemDetail({ |
| 151 | environmentId: props.environmentId, |
| 152 | threadId: props.threadId, |
| 153 | itemId: props.itemId, |
| 154 | revision: props.revision, |
| 155 | }); |
| 156 | const storedItem = detail.data?.item; |
| 157 | const toolCall = useMemo(() => { |
| 158 | if (storedItem?.type !== "dynamic_tool") return undefined; |
| 159 | const output = storedItem.output as { readonly result?: unknown } | undefined; |
| 160 | const result = output?.result as McpAppCallToolResult | undefined; |
| 161 | return { arguments: storedItem.input, result }; |
| 162 | }, [storedItem]); |
| 163 | |
| 164 | const callTool = useAtomCommand(mcpAppEnvironment.callTool, { reportFailure: false }); |
| 165 | const toolInfo = useAtomCommand(mcpAppEnvironment.toolInfo, { reportFailure: false }); |
| 166 | const readResource = useAtomCommand(mcpAppEnvironment.readResource, { reportFailure: false }); |
| 167 | const updateModelContext = useAtomCommand(mcpAppEnvironment.updateModelContext, { |
| 168 | reportFailure: false, |
| 169 | }); |
| 170 | // The tool's definition, which the app receives as `toolInfo` at initialize. |
| 171 | const [toolDefinition, setToolDefinition] = useState<unknown>(undefined); |
| 172 | |
| 173 | // Read by the host on every message, so it always sees current values |
| 174 | // without being rebuilt (which would drop the app's session). |
| 175 | const live = { |
| 176 | theme, |
| 177 | width, |
| 178 | props, |
| 179 | callTool, |
| 180 | toolInfo, |
| 181 | readResource, |
| 182 | updateModelContext, |
| 183 | displayMode, |
| 184 | toolDefinition, |
| 185 | }; |
| 186 | const hostRef = useRef<McpAppHost | null>(null); |
| 187 | const latest = useRef(live); |
| 188 | useEffect(() => { |
| 189 | latest.current = live; |
| 190 | }); |
| 191 | |
| 192 | useEffect(() => { |
| 193 | const box = boxRef.current; |
| 194 | if (box === null || !fullscreenSupported) return; |
| 195 | const shown = box.matches(":popover-open"); |
| 196 | if (displayMode === "fullscreen" && !shown) box.showPopover(); |
| 197 | if (displayMode !== "fullscreen" && shown) box.hidePopover(); |
| 198 | // Reported only on a change, so an inline row never touches another |
| 199 | // row's pin. |
| 200 | if (displayMode === "fullscreen") { |
| 201 | latest.current.props.onFullscreenChange?.(true); |
| 202 | return () => latest.current.props.onFullscreenChange?.(false); |
| 203 | } |
| 204 | }, [displayMode]); |
| 205 | // The approval or question the agent waits on renders in the page, so a |
| 206 | // full-screen app steps aside for it. |
| 207 | const [stepAsideFor, setStepAsideFor] = useState(props.awaitingUser); |
| 208 | if (stepAsideFor !== props.awaitingUser) { |
| 209 | setStepAsideFor(props.awaitingUser); |
| 210 | if (props.awaitingUser === true) setDisplayMode("inline"); |
| 211 | } |
| 212 | // Full screen sits above everything else in the page. Anything that takes |
| 213 | // focus outside it (an approval, the command palette, a dialog) returns the |
| 214 | // app inline so it can be seen; so does Escape pressed outside the app. |
| 215 | // Keys pressed inside the app stay with the app. |
| 216 | useEffect(() => { |
| 217 | if (displayMode !== "fullscreen") return; |
| 218 | const leave = () => setDisplayMode("inline"); |
| 219 | const onFocus = (event: FocusEvent) => { |
| 220 | if (event.target instanceof Node && !boxRef.current?.contains(event.target)) leave(); |
| 221 | }; |
| 222 | const onKey = (event: KeyboardEvent) => { |
| 223 | if (event.key === "Escape") leave(); |
| 224 | }; |
| 225 | const onResize = () => hostRef.current?.updateHostContext(); |
| 226 | document.addEventListener("focusin", onFocus); |
| 227 | window.addEventListener("keydown", onKey); |
| 228 | window.addEventListener("resize", onResize); |
| 229 | return () => { |
| 230 | document.removeEventListener("focusin", onFocus); |
| 231 | window.removeEventListener("keydown", onKey); |
| 232 | window.removeEventListener("resize", onResize); |
| 233 | }; |
| 234 | }, [displayMode]); |
| 235 | |
| 236 | // The definition is read once per app, before the host is built, so the |
| 237 | // initialize response can carry it; a failure only leaves it out. |
| 238 | useEffect(() => { |
| 239 | let cancelled = false; |
| 240 | void latest.current |
| 241 | .toolInfo({ |
| 242 | environmentId: props.environmentId, |
| 243 | input: { threadId: props.threadId, itemId: props.itemId, name: app.tool }, |
| 244 | }) |
| 245 | .then((info) => { |
| 246 | if (!cancelled && info._tag === "Success" && info.value.tool !== undefined) { |
| 247 | setToolDefinition(info.value.tool); |
| 248 | } |
| 249 | }); |
| 250 | return () => { |
| 251 | cancelled = true; |
| 252 | }; |
| 253 | }, [props.environmentId, props.threadId, props.itemId, app.tool]); |
| 254 | // The bridge belongs to the captured document. A frame that navigates keeps |
| 255 | // its window, so a second load stops the app rather than letting a page T3 |
| 256 | // never served pose as it. This is not a confidentiality boundary: a frame |
| 257 | // can always navigate itself, so the app could carry anything it read out |
| 258 | // in a URL either way. |
| 259 | // Counted per document: a reopened app is a new document, not a navigation. |
| 260 | const loads = useRef({ generation: 0, count: 0 }); |
| 261 | const onFrameLoad = () => { |
| 262 | if (loads.current.generation !== documentGeneration) { |
| 263 | loads.current = { generation: documentGeneration, count: 0 }; |
| 264 | } |
| 265 | loads.current.count += 1; |
| 266 | if (loads.current.count > 1) { |
| 267 | hostRef.current?.dispose(); |
| 268 | setNavigatedAway(true); |
| 269 | } |
| 270 | }; |
| 271 | |
| 272 | useEffect(() => { |
| 273 | if (src === null) return; |
| 274 | const hostContext = (): McpAppHostContext => { |
| 275 | const current = latest.current; |
| 276 | return { |
| 277 | theme: current.theme.appearance, |
| 278 | styles: { variables: mcpAppStyleVariables(current.theme.variables) }, |
| 279 | displayMode: current.displayMode, |
| 280 | availableDisplayModes: fullscreenSupported ? ["inline", "fullscreen"] : ["inline"], |
| 281 | containerDimensions: |
| 282 | current.displayMode === "fullscreen" |
| 283 | ? { |
| 284 | width: frameRef.current?.clientWidth || window.innerWidth, |
| 285 | height: frameRef.current?.clientHeight || window.innerHeight, |
| 286 | } |
| 287 | : { width: current.width, maxHeight: MCP_APP_MAX_HEIGHT }, |
| 288 | platform: isElectron ? "desktop" : "web", |
| 289 | locale: navigator.language, |
| 290 | timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, |
| 291 | userAgent: `t3-code/${APP_VERSION}`, |
| 292 | deviceCapabilities: { |
| 293 | touch: window.matchMedia("(pointer: coarse)").matches, |
| 294 | hover: window.matchMedia("(hover: hover)").matches, |
| 295 | }, |
| 296 | safeAreaInsets: { top: 0, right: 0, bottom: 0, left: 0 }, |
| 297 | ...(current.toolDefinition === undefined |
| 298 | ? {} |
| 299 | : { toolInfo: { tool: current.toolDefinition } }), |
| 300 | }; |
| 301 | }; |
| 302 | // Approvals render in the page, beneath the top layer a full-screen app |
| 303 | // occupies, so the app returns inline before T3 asks. |
| 304 | const ask = async (message: string) => { |
| 305 | flushSync(() => setDisplayMode("inline")); |
| 306 | const approved = await requestConfirmDialog(message); |
| 307 | // No dialog host is mounted, so nobody was asked; say so rather than |
| 308 | // reporting that the user declined. |
| 309 | if (approved === undefined) { |
| 310 | throw new McpAppHostRefusal("T3 could not ask for approval here."); |
| 311 | } |
| 312 | return approved; |
| 313 | }; |
| 314 | const target = () => frameRef.current?.contentWindow ?? null; |
| 315 | const scope = () => { |
| 316 | const { environmentId, threadId, itemId } = latest.current.props; |
| 317 | return { environmentId, input: { threadId, itemId } }; |
| 318 | }; |
| 319 | const host = makeMcpAppHost({ |
| 320 | app, |
| 321 | hostVersion: APP_VERSION, |
| 322 | // The document's origin is opaque, so "*" is the only target that reaches it; |
| 323 | // the window reference itself is what scopes delivery to this frame. |
| 324 | post: (message) => target()?.postMessage(message, "*"), |
| 325 | hostContext, |
| 326 | callTool: async ({ name, arguments: args }) => { |
| 327 | const { environmentId, input } = scope(); |
| 328 | const info = await latest.current.toolInfo({ environmentId, input: { ...input, name } }); |
| 329 | if (info._tag !== "Success") throw commandFailure(info); |
| 330 | if (!info.value.callable) throw new McpAppHostRefusal("This app cannot call that tool."); |
| 331 | if (!info.value.readOnly) { |
| 332 | const approved = await ask( |
| 333 | `Allow ${app.server} to run ${info.value.title ?? name}?\n${JSON.stringify(args, null, 2)}`, |
| 334 | ); |
| 335 | if (approved !== true) throw new McpAppHostRefusal("Declined by the user."); |
| 336 | } |
| 337 | const result = await latest.current.callTool({ |
| 338 | environmentId, |
| 339 | input: { ...input, name, arguments: args }, |
| 340 | }); |
| 341 | if (result._tag !== "Success") throw commandFailure(result); |
| 342 | return result.value; |
| 343 | }, |
| 344 | readResource: async ({ uri }) => { |
| 345 | const { environmentId, input } = scope(); |
| 346 | const result = await latest.current.readResource({ |
| 347 | environmentId, |
| 348 | input: { ...input, uri }, |
| 349 | }); |
| 350 | if (result._tag !== "Success") throw commandFailure(result); |
| 351 | return result.value; |
| 352 | }, |
| 353 | openLink: async (url) => { |
| 354 | // Only right after the reader used this frame, as with HTML renders. |
| 355 | if (document.activeElement !== frameRef.current || !navigator.userActivation?.isActive) { |
| 356 | throw new McpAppHostRefusal("Links open only from a click in the app."); |
| 357 | } |
| 358 | window.open(url, "_blank", "noopener,noreferrer"); |
| 359 | }, |
| 360 | sendMessage: async (text) => { |
| 361 | const send = latest.current.props.onSendMessage; |
| 362 | if (send === undefined) throw new McpAppHostRefusal("Messages are not available here."); |
| 363 | const approved = await ask(`Send this message from ${app.server}?\n${text}`); |
| 364 | if (approved !== true) throw new McpAppHostRefusal("Declined by the user."); |
| 365 | await send(text); |
| 366 | }, |
| 367 | updateModelContext: async (context) => { |
| 368 | const { environmentId, input } = scope(); |
| 369 | const result = await latest.current.updateModelContext({ |
| 370 | environmentId, |
| 371 | input: { |
| 372 | ...input, |
| 373 | ...context, |
| 374 | conversationThreadId: latest.current.props.conversationThreadId, |
| 375 | }, |
| 376 | }); |
| 377 | if (result._tag !== "Success") throw commandFailure(result); |
| 378 | }, |
| 379 | requestDisplayMode: async (mode) => { |
| 380 | // Full screen would cover the approval or question the agent waits |
| 381 | // on, or a confirmation any app is waiting on. Another app already |
| 382 | // full screen keeps the page; two would stack. |
| 383 | const otherFullscreen = document.querySelector("[data-mcp-app-fullscreen]"); |
| 384 | if ( |
| 385 | mode === "fullscreen" && |
| 386 | (latest.current.props.awaitingUser === true || |
| 387 | isConfirmDialogActive() || |
| 388 | (otherFullscreen !== null && otherFullscreen !== boxRef.current)) |
| 389 | ) { |
| 390 | return latest.current.displayMode; |
| 391 | } |
| 392 | setDisplayMode(mode); |
| 393 | return mode; |
| 394 | }, |
| 395 | downloadFile: async (files) => { |
| 396 | const names = files.map((file) => file.name).join(", "); |
| 397 | const approved = await ask(`Save ${names} from ${app.server}?`); |
| 398 | if (approved !== true) throw new McpAppHostRefusal("Declined by the user."); |
| 399 | for (const file of files) { |
| 400 | // A linked file is read from the app's own server, like its other reads. |
| 401 | let bytes: Uint8Array | undefined; |
| 402 | let mimeType = file.mimeType ?? "application/octet-stream"; |
| 403 | if (file._tag === "embedded") { |
| 404 | bytes = file.bytes; |
| 405 | } else { |
| 406 | const { environmentId, input } = scope(); |
| 407 | const read = await latest.current.readResource({ |
| 408 | environmentId, |
| 409 | input: { ...input, uri: file.uri }, |
| 410 | }); |
| 411 | if (read._tag !== "Success") throw commandFailure(read); |
| 412 | const content = read.value.contents[0]; |
| 413 | bytes = mcpResourceBytes(content); |
| 414 | const declared = (content as { readonly mimeType?: unknown } | undefined)?.mimeType; |
| 415 | if (typeof declared === "string") mimeType = declared; |
| 416 | } |
| 417 | if (bytes === undefined) throw new McpAppHostRefusal(`${file.name} has no contents.`); |
| 418 | if (bytes.byteLength > MAX_DOWNLOAD_BYTES) { |
| 419 | throw new McpAppHostRefusal(`${file.name} is too large to save.`); |
| 420 | } |
| 421 | // A copy backed by a plain ArrayBuffer, which Blob requires. |
| 422 | saveBlob(new Blob([bytes.slice()], { type: mimeType }), file.name); |
| 423 | } |
| 424 | }, |
| 425 | onRequestTeardown: () => { |
| 426 | if (latest.current.displayMode === "fullscreen") { |
| 427 | setDisplayMode("inline"); |
| 428 | return; |
| 429 | } |
| 430 | // The app asked to go, so it gets its teardown before the frame does. |
| 431 | void host.teardown().then(() => setClosed(true)); |
| 432 | }, |
| 433 | onSizeChanged: (size) => { |
| 434 | // Full screen is a fixed box; what the app reports there would leave |
| 435 | // the inline row its full-screen height on the way back. |
| 436 | if (latest.current.displayMode === "fullscreen") return; |
| 437 | if (size.height !== undefined) setHeight(clampMcpAppHeight(size.height)); |
| 438 | }, |
| 439 | }); |
| 440 | hostRef.current = host; |
| 441 | const receive = (event: MessageEvent) => { |
| 442 | // Only messages from this frame's own window reach its host. |
| 443 | if (event.source !== null && event.source === target()) host.receive(event.data); |
| 444 | }; |
| 445 | window.addEventListener("message", receive); |
| 446 | return () => { |
| 447 | window.removeEventListener("message", receive); |
| 448 | // An unmount cannot wait: the request goes out before the frame does, |
| 449 | // which is all a list that recycles rows can offer the app. |
| 450 | void host.teardown(); |
| 451 | hostRef.current = null; |
| 452 | }; |
| 453 | // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A reopened document needs a new host. |
| 454 | }, [src, app, documentGeneration]); |
| 455 | |
| 456 | // The host reads the context through `latest`; these only say when to resend. |
| 457 | useEffect(() => { |
| 458 | hostRef.current?.updateHostContext(); |
| 459 | // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Context changes trigger a resend. |
| 460 | }, [theme, width, displayMode, toolDefinition]); |
| 461 | |
| 462 | // A new document gets a new host, which needs the call again. |
| 463 | useEffect(() => { |
| 464 | if (toolCall !== undefined) hostRef.current?.setToolCall(toolCall); |
| 465 | // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Each new document needs the call. |
| 466 | }, [toolCall, src, documentGeneration]); |
| 467 | |
| 468 | // An app that asked to close keeps the box an inline row had, so the |
| 469 | // timeline does not jump, and says how to bring it back. |
| 470 | if (closed) { |
| 471 | return ( |
| 472 | <div className="flex items-center justify-between gap-2 rounded-lg border border-border px-3 py-2 text-muted-foreground text-xs"> |
| 473 | <span>The {app.server} app was closed</span> |
| 474 | <Button |
| 475 | size="xs" |
| 476 | variant="ghost" |
| 477 | onClick={() => { |
| 478 | // The first URL's token may have expired: the new document |
| 479 | // takes a fresh one, minted again if it is near expiry. |
| 480 | minting.current = false; |
| 481 | setSrc(null); |
| 482 | setDocumentGeneration((value) => value + 1); |
| 483 | setClosed(false); |
| 484 | }} |
| 485 | > |
| 486 | Show app |
| 487 | </Button> |
| 488 | </div> |
| 489 | ); |
| 490 | } |
| 491 | |
| 492 | const fullscreen = displayMode === "fullscreen"; |
| 493 | return ( |
| 494 | // The row keeps its inline height while the app is full screen, so the |
| 495 | // timeline does not jump; the box itself is what goes full screen. |
| 496 | <div style={{ height }}> |
| 497 | <div |
| 498 | ref={boxRef} |
| 499 | // Full screen is the same box shown in the browser's top layer: moving |
| 500 | // the frame to another parent would reload the app, and the timeline's |
| 501 | // rows contain fixed positioning, which the top layer escapes. |
| 502 | {...(fullscreenSupported ? { popover: "manual" as const } : {})} |
| 503 | data-mcp-app-fullscreen={fullscreen ? "" : undefined} |
| 504 | className={cn( |
| 505 | "relative size-full overflow-hidden", |
| 506 | app.prefersBorder === true && !fullscreen && "rounded-lg border border-border", |
| 507 | fullscreen |
| 508 | ? "fixed inset-0 m-0 flex h-dvh max-h-none w-dvw max-w-none flex-col border-0 bg-background p-0" |
| 509 | : // A closed popover is hidden; inline, the box is a plain block. |
| 510 | "block! static! m-0 border-0 bg-transparent p-0 text-inherit", |
| 511 | )} |
| 512 | > |
| 513 | {fullscreen ? ( |
| 514 | <div className="flex h-10 shrink-0 items-center justify-between border-border border-b px-3 text-sm"> |
| 515 | <span className="truncate">{app.server}</span> |
| 516 | <Button |
| 517 | aria-label="Exit full screen" |
| 518 | size="icon-sm" |
| 519 | variant="ghost" |
| 520 | onClick={() => setDisplayMode("inline")} |
| 521 | > |
| 522 | <Minimize2Icon className="size-4" /> |
| 523 | </Button> |
| 524 | </div> |
| 525 | ) : null} |
| 526 | {navigatedAway ? ( |
| 527 | <p className="flex size-full items-center justify-center text-muted-foreground text-xs"> |
| 528 | The {app.server} app left its page and was stopped |
| 529 | </p> |
| 530 | ) : src !== null ? ( |
| 531 | <iframe |
| 532 | ref={frameRef} |
| 533 | src={src} |
| 534 | title={`${app.server} app`} |
| 535 | // Never allow-same-origin: the opaque origin keeps the app out of the session. |
| 536 | sandbox="allow-scripts allow-forms" |
| 537 | allow={mcpAppAllowAttribute(app.permissions)} |
| 538 | onLoad={onFrameLoad} |
| 539 | className={cn("block w-full border-0", fullscreen ? "min-h-0 flex-1" : "h-full")} |
| 540 | style={{ colorScheme: theme.appearance }} |
| 541 | /> |
| 542 | ) : asset._tag === "Failure" || mintFailed ? ( |
| 543 | <p className="flex size-full items-center justify-center text-muted-foreground text-xs"> |
| 544 | Unable to load the {app.server} app |
| 545 | </p> |
| 546 | ) : null} |
| 547 | </div> |
| 548 | </div> |
| 549 | ); |
| 550 | } |