"use client"; import { useEffect, useMemo, useRef, useState, type MutableRefObject, type ReactNode, } from "react"; import type { McpAppMetadata } from "@assistant-ui/core"; import type { ToolCallMessagePartComponent, ToolCallMessagePartProps, } from "@assistant-ui/core/react"; import { useAui } from "@assistant-ui/store"; import { resource, tapConst, tapRef, tapResource, type ResourceElement, } from "@assistant-ui/tap"; import { McpAppFrame } from "./app-frame"; import type { McpAppBridgeHandlers, McpAppHostContext, McpAppHostInfo, McpAppResource, McpAppSandboxConfig, McpAppsHost, } from "./types"; import { getMcpAppFromToolPart } from "./utils"; export type McpAppRendererOptions = { /** * Provides the data-plane operations the widget can request * (`loadResource`, `callTool`, `readResource`, `listResources`). Use * `McpAppsRemoteHost({ url })` for the default HTTP-route convention. */ host: ResourceElement; /** Sandbox + container styling. Passes through to SafeContentFrame. */ sandbox?: McpAppSandboxConfig; /** * Upper bound (in pixels) applied to the widget-driven auto-resize height. * Defaults to 800. */ maxHeight?: number; /** Identifies the host to the widget in the `ui/initialize` response. */ hostInfo?: McpAppHostInfo; /** Delivered to the widget on initialize and pushed via `notifications/host_context/changed` on change. */ hostContext?: McpAppHostContext; /** Rendered when no MCP app is on the part, or while load is in flight / failed (unless overridden). */ fallback?: ReactNode; /** Rendered while the resource is loading. Defaults to `fallback`. */ loadingFallback?: ReactNode; /** Rendered when the resource load rejects. Defaults to `fallback`. */ errorFallback?: ReactNode | ((error: Error) => ReactNode); }; type LoadedResourceState = { resourceUri: string; resource?: McpAppResource; error?: Error; }; function getInput(part: { status: { type: string }; argsText: string; args: unknown; }): unknown { if ( part.status.type === "running" && (part.argsText === "" || part.argsText === "{}") ) { return undefined; } return part.args; } const defaultOpenLink = ({ url }: { url: string }) => { window.open(url, "_blank", "noopener,noreferrer"); }; function extractSendMessageText(params: unknown): string | undefined { if (typeof params === "string") return params; if (!params || typeof params !== "object") return undefined; const obj = params as Record; if (typeof obj["prompt"] === "string") return obj["prompt"]; if (typeof obj["text"] === "string") return obj["text"]; if (typeof obj["message"] === "string") return obj["message"]; return undefined; } function InlineRenderer({ part, internalsRef, optionsRef, }: { part: ToolCallMessagePartProps; internalsRef: MutableRefObject<{ host: McpAppsHost }>; optionsRef: MutableRefObject; }) { const opts = optionsRef.current; const aui = useAui(); const app = getMcpAppFromToolPart(part); const cachedAppRef = useRef(undefined); if (app != null && cachedAppRef.current?.resourceUri !== app.resourceUri) { cachedAppRef.current = app; } const appForRender = app ?? cachedAppRef.current; const [loadedResource, setLoadedResource] = useState(); const resourceUri = appForRender?.resourceUri; // biome-ignore lint/correctness/useExhaustiveDependencies: re-fetches only when URI changes; mcp.app object identity is unstable across renders useEffect(() => { if (appForRender == null || resourceUri == null) return; let cancelled = false; const targetUri = resourceUri; internalsRef.current.host .loadResource({ uri: targetUri }) .then((res) => { if (!cancelled) setLoadedResource({ resourceUri: targetUri, resource: res }); }) .catch((error: unknown) => { if (!cancelled) { setLoadedResource({ resourceUri: targetUri, error: error instanceof Error ? error : new Error(String(error)), }); } }); return () => { cancelled = true; }; }, [resourceUri]); const bridgeHandlers = useMemo( () => ({ openLink: defaultOpenLink, sendMessage: (params) => { const text = extractSendMessageText(params); if (!text) return { ok: false, reason: "unrecognised params shape" }; aui.thread().append({ content: [{ type: "text", text }] }); return { ok: true }; }, callTool: (params) => internalsRef.current.host.callTool(params), readResource: (params) => internalsRef.current.host.readResource(params), listResources: (params) => internalsRef.current.host.listResources(params), }), [aui, internalsRef], ); const loadedResourceForApp = loadedResource?.resourceUri === appForRender?.resourceUri ? loadedResource : undefined; const appResource = loadedResourceForApp?.resource; const error = loadedResourceForApp?.error; const fallback = opts.fallback ?? null; if (appForRender == null) { return <>{fallback}; } if (error != null) { const errorFallback = opts.errorFallback; if (errorFallback === undefined) return <>{fallback}; if (typeof errorFallback === "function") return <>{errorFallback(error)}; return <>{errorFallback}; } if (appResource == null) { return <>{opts.loadingFallback ?? fallback}; } return ( ); } export const McpAppRenderer = resource( ( options: McpAppRendererOptions, ): { readonly render: ToolCallMessagePartComponent } => { const host = tapResource(options.host); const optionsRef = tapRef(options); optionsRef.current = options; const internalsRef = tapRef<{ host: McpAppsHost }>({ host }); internalsRef.current = { host }; const render = tapConst((): ToolCallMessagePartComponent => { const Render: ToolCallMessagePartComponent = (props) => ( ); Render.displayName = "McpAppRenderer"; return Render; }, []); return { render }; }, );