web: Wave 2 — spec-conformant MCP Apps host (rollup of #1565–#1572) (#1660)
* web: sandbox hardening — opaque-origin inner frame, srcdoc-only, frame-ancestors Harden the MCP Apps sandbox isolation model: - The inner (untrusted-widget) iframe is sandboxed WITHOUT `allow-same-origin` (default "allow-scripts allow-forms") and any server-supplied sandbox value has `allow-same-origin` stripped case-insensitively, so the widget runs under an opaque origin and cannot reach the proxy's DOM to bypass its own CSP via the parent realm. - Widget delivery is `srcdoc`-only (no document.write), which respects the opaque origin. - The relay accepts the inner frame's "null" origin with `event.source === inner.contentWindow` as the unforgeable identity check. - sandbox-controller.ts serves the proxy with `Content-Security-Policy: frame-ancestors http://127.0.0.1:* http://localhost:*`; fetch directives are deliberately omitted so a srcdoc iframe's inherited policy container does not override the per-app CSP. Updated the isolation-model comments and the sandbox-controller integration test asserts the frame-ancestors header (and the absence of fetch directives). Closes #1565 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * web: enforce per-app _meta.ui.csp in the app bridge + surface resource-read failures - createAppBridgeFactory now builds each app's `_meta.ui.csp` host-side via the sandbox-csp library (approveCspSources → buildSandboxCspPolicy) and wraps the untrusted widget HTML with wrapSandboxedHtml so the CSP <meta> is always the first <head> child before the HTML reaches the sandboxed iframe. The approved (post-filter) csp + permissions are echoed back via hostCapabilities.sandbox so the view sees what was granted. The csp is no longer sent inline through sendSandboxResourceReady — it is enforced via the wrapped meta. - A per-app copy of HOST_CAPABILITIES prevents the sandbox echo from mutating the shared constant. - New optional `onResourceError` dep on AppBridgeFactoryDeps routes resource read/post failures to the caller (also always console.error'd) instead of leaving a silent blank frame. Closes #1566 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * web: full hostContext delivery + live host-context-changed updates - createAppBridgeFactory now seeds the full hostContext via snapshotHostContext(iframe, HOST_AVAILABLE_DISPLAY_MODES): theme, styles (Mantine design tokens), displayMode ("inline"), availableDisplayModes, and containerDimensions — replacing the theme-only seed. The local currentTheme helper is dropped in favor of hostContext.ts. - AppRenderer gains `displayMode` and `containerRef` props and pushes live host-context changes via the SDK's sendHostContextChange (partial host-context-changed notifications), never a hand-maintained snapshot: - theme + styles via a MutationObserver on <html data-mantine-color-scheme>, - containerDimensions via a ResizeObserver on the host container (or the iframe fallback), gated on `initialized`, skipping 0x0 and value-equal repeats, plus a one-shot push on initialize once layout settles, - displayMode whenever the prop changes (Maximize/Restore), gated on init. Adds AppRenderer host-context tests (theme flip incl. resolved styles, displayMode push + init gate, container-dimensions on init and resize, observer teardown) and updates the factory + story mocks for the new hostContext shape and sendHostContextChange method. Closes #1567 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * web: honor ui/notifications/size-changed and ui/request-display-mode AppRenderer: - New `onSizeChange` prop forwards the view's ui/notifications/size-changed reports (via the bridge `sizechange` event). - New `onRequestDisplayMode` prop backs an `onrequestdisplaymode` bridge handler that returns the mode the host actually applied; with no handler the request is declined by returning the current host-side mode (falling back to inline). AppsScreen: - Adds a host-controlled RendererContainer (measured for containerDimensions) with an inner box sized by the view-reported height (`appHeight`), so host→view container size and view→host size-changed don't couple. - `handleSizeChange` records the reported height (ignored while maximized); `displayMode` is derived from the maximize toggle and pushed to the view; `handleRequestDisplayMode` filters against HOST_AVAILABLE_DISPLAY_MODES (declining e.g. "pip") and maps fullscreen↔inline onto the maximize state. Adds AppRenderer tests (size forwarding, request-display-mode apply/decline/ fallback) and AppsScreen tests (frame sizing on report, no-height ignore, fullscreen/restore/decline via request-display-mode). Story mocks gain sendHostContextChange/addEventListener. Closes #1568 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * web: honor ui/download-file with confirmation and http(s) allowlist createAppBridgeFactory now advertises the `downloadFile` host capability and attaches an `ondownloadfile` handler: - Requires a host-mediated confirmation (window.confirm) listing the requested file(s); a declined prompt or an empty payload returns isError without acting. - Inline EmbeddedResources (text or base64 blob) are written to disk via the shared downloadBlob object-URL anchor; base64 is decoded via base64ToBytes. - ResourceLinks are opened in a new tab, restricted to http(s) by the shared isHttpUrl allowlist (javascript:/data:/file:/malformed are rejected). - Labels shown in the confirmation are sanitized (control + format chars stripped, length clamped) so a server-supplied filename/URI can't inject newlines or reflow the prompt. - Partial-batch success is surfaced (isError only when nothing downloaded), with skipped items warned. Adds factory tests for the full download surface (text/blob/link, decline, empty, throw, non-http rejection, label sanitization/clamping, partial batch). Closes #1569 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * web: surface ui/message and app logging in panels below the running app AppRenderer: - New `onMessage` prop backs an `onmessage` bridge handler that surfaces the view's ui/message content and returns the spec-required empty result; with no handler the submission is declined (isError). - New `onLog` prop forwards MCP `notifications/message` log entries via the bridge `loggingmessage` event, honoring the advertised `logging` capability. AppsScreen: - Adds `messages`/`appLogs` state (cleared with the reported height on select/open/close/back via resetAppChannels) and `handleMessage`/`handleLog`. - Renders a "Messages from app" panel (data-testid="apps-messages", reusing ContentViewer) and a default-expanded collapsible "App logs" panel (data-testid="apps-logs", reusing LogLevelBadge) with logger names and a Clear button, both as pinned panels below the running widget. Adds AppRenderer tests (log forward + no-handler no-throw, ui/message route/empty-result/decline) and AppsScreen tests (message log render + empty result + clear-on-close, log panel expand/collapse/clear). Closes #1570 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * web: stage partial input — replay tool-input-partial before final tool input AppRenderer: - New `partialInputs` prop (ordered fragments), snapshotted into pendingPartials at bridge-build time (read via a ref so prop churn never rebuilds the iframe) and cleared on dispose. flushPending replays them via bridge.sendToolInputPartial BEFORE the complete tool-input, per spec. AppsScreen: - New `partialStages` state + `handleStagePartialInput` (snapshots current form values). resetAppChannels gains a `keepPartials` option so Open App preserves the staged fragments (they're consumed by the renderer) while select/close/ back clear them. - Adds a "Stage partial input" control (fielded apps only) with a staged count and "Clear staged", wired to AppRenderer's `partialInputs`. Adds AppRenderer tests (ordered replay before tool-input; none when omitted) and AppsScreen tests (stage/count/clear gated on fields; staged partials survive Open and are replayed). Closes #1571 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * web: app lifecycle status + machine-readable data attributes on the Apps screen AppRenderer: - New AppRendererStatus ("loading" | "ready" | "error") + onAppStatusChange callback. Fires "loading" at the start of every (re)build, "ready" on the view's initialized signal, and "error" on a factory throw/rejection. AppsScreen: - appStatus/appError state; handleAppError captures the reason locally and forwards to the parent onError. resetAppChannels clears both. - Surfaces the contract as data-testid="apps-form" carrying data-app-status and data-app-error, renders an error panel (data-testid="apps-error") in place of the blank frame, and tags the stage button data-testid="apps-stage". - AppDetailPanel's Open App button gains data-testid="open-app". App.tsx: - Wires the bridge factory's onResourceError to a red toast so a malformed/404 UI resource is no longer console-only (the renderer separately drives data-app-status, so a driver times out on never-reaching-ready and reads it). Documents the data-testid/data-* automation contract in clients/web/README.md. Adds AppRenderer status-transition tests and AppsScreen status/error-panel tests. Closes #1572 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * review: warn when server sandbox drops allow-scripts + revert incidental lockfile churn Addresses the claude[bot] review on PR #1650: - sandbox_proxy.html: log a console.warn when a server-supplied `sandbox` value lacks `allow-scripts` after the `allow-same-origin` strip, so a fail-closed (app-never-runs) sandbox is diagnosable instead of silent. - Restore clients/{cli,launcher,tui,web}/package-lock.json to their v2/main state — the earlier churn (@emnapi/@napi-rs optional deps, a dropped version field) was incidental `npm install` regeneration unrelated to the sandbox change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * review: document CSP-vs-permissions filtering asymmetry (PR #1652) Address the claude[bot] review's finding #2: add a comment explaining why `csp` is injection-filtered (approveCspSources) while `permissions` is passed through verbatim — permissions is a structured boolean object consumed only by the sandbox proxy's buildAllowAttribute() (fixed key→token mapping), so it never reaches the iframe sandbox/allow attribute as raw text. No logic change. Also merges v2/1565-sandbox-hardening (review fixes) up the stack. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * test: lock in per-app HOST_CAPABILITIES isolation (PR #1652 round 2) Add a test asserting the shared HOST_CAPABILITIES.sandbox stays undefined after a sandboxready run sets hostCapabilities.sandbox — guards against a silent regression if the per-app spread copy were ever dropped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * review: gate theme observer on initialized + document seed/dep assumptions (PR #1654) Address the claude[bot] review: - Finding #1 (applied): gate the theme+styles MutationObserver on the view's `initialized` signal, matching the container/displayMode pushes — a theme flip in the construction→handshake window no longer races ui/initialize. The factory seeds the construction-time theme, and the first post-init flip carries the current value. Added tests: pre-init flip is dropped; the live flip test now completes the handshake first. - Finding #2 (applied, comment): note in snapshotHostContext that the `displayMode: "inline"` seed assumes inline-at-open (AppsScreen always opens inline; #1568's displayMode push carries transitions). - Finding #4 (applied, comment): explain why `containerRef` is a build-effect dep yet doesn't force a rebuild (sameInputs ignores it; read lazily). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * review: guard 0-height size reports, distinct RendererFrame, shared payload type (PR #1656) Address the claude[bot] review: - #1 (applied): handleSizeChange ignores a non-positive height — a view's ResizeObserver can transiently fire 0 pre-layout/teardown, which would collapse the frame; mirrors AppRenderer's 0×0 skip. Added a test. - #2 (applied): reinstate a distinct `RendererFrame` constant for the inner content-sized box so its role reads clearly vs. the outer RendererContainer. - #3 (declined + comment): keep carrying `appHeight` across maximize→restore — clearing it would flash the frame to full-card height until the next size-changed; the carry-over restores at the prior size. Documented. - #4 (applied): type handleSizeChange's param as AppBridgeEventMap["sizechange"] so the payload shape has a single source of truth with AppRenderer's prop. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * review: distinguish links, cap batch size, comment truncation (PR #1657) Address the claude[bot] review: - #1 (applied): the confirmation now reads "download or open N item(s)" and resource_links are prefixed "↗" in the summary, so a link that opens in a tab is distinguishable from an embedded file that saves to disk. - #2 (applied): cap a single ui/download-file batch at MAX_DOWNLOAD_ITEMS (20); an oversized batch is rejected (isError) before the prompt, with a warn. - #3 (declined): can't check window.open's return value to detect a blocked popup — we pass `noopener`, and per spec window.open returns null even on a successful noopener open, so null can't distinguish blocked from succeeded. - #4b (applied, comment): note the start-of-label truncation is intentional so a link's scheme+host stays visible for the trust decision. Adds tests: oversized batch rejected without confirming/acting; resource-link ↗ prefix in the summary. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * review: skip embedded resource with neither text nor blob (PR #1657 round 2) Address the round-2 observation: an untrusted EmbeddedResource carrying neither `blob` nor a string `text` previously produced `new Blob([undefined])` — a file containing the literal text "undefined", counted as success. Now such an item is skipped (returns false, like a rejected link), so isError reflects it. Added a test asserting no object URL is created and the batch reports isError. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * review: type-honest formatLogData, cap channels, log-panel a11y (PR #1658) Address the claude[bot] review: - #1 (applied): formatLogData coalesces JSON.stringify(undefined) → "" so the `: string` return type is honest for a data-less log. - #2 (applied): cap retained messages/logs at MAX_APP_CHANNEL_ENTRIES (500) via appendCapped — a chatty widget no longer grows the panels/DOM without bound between Clear/close; oldest entries drop. - #3 (declined): keep the Messages panel without a Clear/collapse — messages are sparser and already cleared on close/switch; keeping it simple. - #4 (applied): the App-logs toggle now has aria-controls pointing at the Collapse region (id="apps-logs-region") for assistive tech. Adds tests: no-data log renders (coalesce branch), 501 logs cap at 500 (oldest dropped), aria-controls assertion. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * review: stable id key for Messages panel (PR #1658 round 2) Address the round-2 nit: give AppMessage a stable `id` (assigned via nextMessageIdRef in handleMessage) and key the Messages panel on it instead of the array index — so the appendCapped front-drop can't renumber keys once a run exceeds the cap, matching AppLogEntry's treatment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * review: note partialStages intentionally survives handleOpen (PR #1659) Address the claude[bot] review's observation #2: add a comment at the handleOpen call site clarifying that partialStages is intentionally NOT cleared there — AppRenderer snapshots the fragments into its own pendingPartialsRef at build time, and the staging UI only renders while not running, so the surviving state is invisible until the next reset drains it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw * review: align error-panel docs with behavior + gate data-app-error on running (PR #1660) Address the claude[bot] review: - #1 (applied, docs): the error panel renders BELOW the (blank) frame, not in place of it — the renderer stays mounted so a factory error still shows the reason alongside the iframe (gating the frame off on error would drop the auto-launched iframe an integration test relies on). Reworded the README row and the test comment to say "below the frame"; added a code comment. - #2 (applied): gate data-app-error on `running` (like data-app-status) so the two attributes can't desync if a future path sets an error without running. - #3 (no change): resource-read failures staying at data-app-status="loading" (toast only) is intentional and documented. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6722438428
commit
0ddb79203a
3 changed files with 2618 additions and 120 deletions
|
|
@ -34,6 +34,7 @@ function createMockBridge(): AppBridge {
|
|||
sendToolInput: async () => {},
|
||||
sendToolResult: async () => {},
|
||||
sendToolCancelled: async () => {},
|
||||
sendHostContextChange: async () => {},
|
||||
teardownResource: async () => ({}),
|
||||
close: async () => {},
|
||||
addEventListener: () => {},
|
||||
|
|
@ -220,6 +221,7 @@ export const EchoRunning: Story = {
|
|||
setEcho(first?.type === "text" ? first.text : "");
|
||||
},
|
||||
sendToolCancelled: async () => {},
|
||||
sendHostContextChange: async () => {},
|
||||
teardownResource: async () => ({}),
|
||||
close: async () => {},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { createRef, useState } from "react";
|
||||
import { act } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { McpUiDisplayMode } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import type { ContentBlock, Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import {
|
||||
renderWithMantine,
|
||||
|
|
@ -53,12 +55,92 @@ const cohortApp: Tool = {
|
|||
const okBridgeFactory: BridgeFactory = () =>
|
||||
({
|
||||
sendToolInput: async () => {},
|
||||
sendToolInputPartial: async () => {},
|
||||
sendToolResult: async () => {},
|
||||
sendToolCancelled: async () => {},
|
||||
sendHostContextChange: async () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
teardownResource: async () => ({}),
|
||||
close: async () => {},
|
||||
}) as unknown as AppBridge;
|
||||
|
||||
// A bridge factory whose bridge supports the addEventListener/emit surface the
|
||||
// AppRenderer wires up, so a test can drive a bridge event (sizechange) through
|
||||
// to the screen's handling. `emit` dispatches to the captured listeners;
|
||||
// `bridges` exposes each built bridge so tests can also drive the per-bridge
|
||||
// handlers (onrequestdisplaymode).
|
||||
function createEventBridgeFactory(): {
|
||||
factory: BridgeFactory;
|
||||
bridges: AppBridge[];
|
||||
emit: (event: string, payload?: unknown) => void;
|
||||
} {
|
||||
const listeners: Record<string, ((payload: unknown) => void)[]> = {};
|
||||
const bridges: AppBridge[] = [];
|
||||
const factory: BridgeFactory = () => {
|
||||
const bridge = {
|
||||
sendToolInput: async () => {},
|
||||
sendToolInputPartial: async () => {},
|
||||
sendToolResult: async () => {},
|
||||
sendToolCancelled: async () => {},
|
||||
sendHostContextChange: async () => {},
|
||||
teardownResource: async () => ({}),
|
||||
close: async () => {},
|
||||
addEventListener: (event: string, handler: (p: unknown) => void) => {
|
||||
(listeners[event] ??= []).push(handler);
|
||||
},
|
||||
removeEventListener: () => {},
|
||||
} as unknown as AppBridge;
|
||||
bridges.push(bridge);
|
||||
return bridge;
|
||||
};
|
||||
return {
|
||||
factory,
|
||||
bridges,
|
||||
emit: (event, payload) =>
|
||||
(listeners[event] ?? []).forEach((h) => h(payload)),
|
||||
};
|
||||
}
|
||||
|
||||
// Invoke the onrequestdisplaymode handler the screen attached to the latest
|
||||
// bridge, mimicking a ui/request-display-mode request from the running view.
|
||||
async function requestDisplayMode(
|
||||
bridges: AppBridge[],
|
||||
mode: McpUiDisplayMode,
|
||||
): Promise<{ mode: McpUiDisplayMode }> {
|
||||
const handler = bridges.at(-1)?.onrequestdisplaymode as unknown as
|
||||
| ((params: { mode: McpUiDisplayMode }) => Promise<{
|
||||
mode: McpUiDisplayMode;
|
||||
}>)
|
||||
| undefined;
|
||||
if (!handler) throw new Error("no onrequestdisplaymode handler attached");
|
||||
let result: { mode: McpUiDisplayMode } = { mode };
|
||||
await act(async () => {
|
||||
result = await handler({ mode });
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// Invoke the onmessage handler the screen attached to the latest bridge,
|
||||
// mimicking a ui/message request from the running view.
|
||||
async function sendUiMessage(
|
||||
bridges: AppBridge[],
|
||||
content: ContentBlock[],
|
||||
): Promise<Record<string, unknown>> {
|
||||
const onmessage = bridges.at(-1)?.onmessage as unknown as
|
||||
| ((params: {
|
||||
role: "user";
|
||||
content: ContentBlock[];
|
||||
}) => Promise<Record<string, unknown>>)
|
||||
| undefined;
|
||||
if (!onmessage) throw new Error("no onmessage handler attached");
|
||||
let result: Record<string, unknown> = {};
|
||||
await act(async () => {
|
||||
result = await onmessage({ role: "user", content });
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildProps(overrides: Partial<AppsScreenProps> = {}): AppsScreenProps {
|
||||
return {
|
||||
tools: [fieldedApp, noFieldsApp, cohortApp] as Tool[],
|
||||
|
|
@ -249,6 +331,231 @@ describe("AppsScreen", () => {
|
|||
expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sizes the renderer frame to the view-reported height", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { factory, emit } = createEventBridgeFactory();
|
||||
renderWithMantine(<ControlledAppsScreen bridgeFactory={factory} />);
|
||||
// Auto-launches the no-fields app, mounting the renderer and registering
|
||||
// its sizechange listener once the bridge resolves.
|
||||
await user.click(screen.getByText("Ops Dashboard"));
|
||||
const iframe = screen.getByTitle("Ops Dashboard");
|
||||
const frame = iframe.parentElement as HTMLElement;
|
||||
// Until the view reports a size, the frame flex-grows to fill the card.
|
||||
expect(frame.style.flexGrow).toBe("1");
|
||||
|
||||
await act(async () => {
|
||||
// Let the synchronous bridge factory's promise chain settle so the
|
||||
// sizechange listener is registered before we emit.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
emit("sizechange", { height: 600 });
|
||||
});
|
||||
// A reported height switches the frame to its content size: it stops
|
||||
// flex-growing and takes the explicit `h`.
|
||||
expect(frame.style.flexGrow).toBe("0");
|
||||
});
|
||||
|
||||
it("ignores a size-changed report that carries no height", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { factory, emit } = createEventBridgeFactory();
|
||||
renderWithMantine(<ControlledAppsScreen bridgeFactory={factory} />);
|
||||
await user.click(screen.getByText("Ops Dashboard"));
|
||||
const frame = screen.getByTitle("Ops Dashboard")
|
||||
.parentElement as HTMLElement;
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
emit("sizechange", { width: 400 });
|
||||
});
|
||||
// No height in the report — the frame keeps flex-growing.
|
||||
expect(frame.style.flexGrow).toBe("1");
|
||||
});
|
||||
|
||||
it("ignores a size-changed report with a non-positive height", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { factory, emit } = createEventBridgeFactory();
|
||||
renderWithMantine(<ControlledAppsScreen bridgeFactory={factory} />);
|
||||
await user.click(screen.getByText("Ops Dashboard"));
|
||||
const frame = screen.getByTitle("Ops Dashboard")
|
||||
.parentElement as HTMLElement;
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
// A transient 0 (pre-layout / teardown) must not collapse the frame.
|
||||
emit("sizechange", { height: 0 });
|
||||
});
|
||||
expect(frame.style.flexGrow).toBe("1");
|
||||
// A subsequent positive report is honored.
|
||||
await act(async () => emit("sizechange", { height: 480 }));
|
||||
expect(frame.style.flexGrow).toBe("0");
|
||||
});
|
||||
|
||||
it("maximizes the frame when the view requests fullscreen via request-display-mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { factory, bridges } = createEventBridgeFactory();
|
||||
renderWithMantine(<ControlledAppsScreen bridgeFactory={factory} />);
|
||||
await user.click(screen.getByText("Ops Dashboard"));
|
||||
expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
const result = await requestDisplayMode(bridges, "fullscreen");
|
||||
expect(result).toEqual({ mode: "fullscreen" });
|
||||
// Fullscreen hides the sidebar (same effect as the Maximize button).
|
||||
expect(screen.queryByText("MCP Apps (3)")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("declines an unsupported display mode, returning the current mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { factory, bridges } = createEventBridgeFactory();
|
||||
renderWithMantine(<ControlledAppsScreen bridgeFactory={factory} />);
|
||||
await user.click(screen.getByText("Ops Dashboard"));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
// "pip" is not in HOST_AVAILABLE_DISPLAY_MODES — declined, current mode
|
||||
// ("inline") returned, and the sidebar stays visible.
|
||||
const result = await requestDisplayMode(bridges, "pip");
|
||||
expect(result).toEqual({ mode: "inline" });
|
||||
expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restores inline via request-display-mode after maximizing", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { factory, bridges } = createEventBridgeFactory();
|
||||
renderWithMantine(<ControlledAppsScreen bridgeFactory={factory} />);
|
||||
await user.click(screen.getByText("Ops Dashboard"));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await requestDisplayMode(bridges, "fullscreen");
|
||||
expect(screen.queryByText("MCP Apps (3)")).not.toBeInTheDocument();
|
||||
const result = await requestDisplayMode(bridges, "inline");
|
||||
expect(result).toEqual({ mode: "inline" });
|
||||
expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces ui/message content from the view in the message log", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { factory, bridges } = createEventBridgeFactory();
|
||||
renderWithMantine(<ControlledAppsScreen bridgeFactory={factory} />);
|
||||
await user.click(screen.getByText("Ops Dashboard"));
|
||||
await vi.waitFor(() =>
|
||||
expect(bridges.at(-1)?.onmessage).toBeTypeOf("function"),
|
||||
);
|
||||
await sendUiMessage(bridges, [
|
||||
{ type: "text", text: "hello from the app" },
|
||||
]);
|
||||
expect(screen.getByText(/Messages from app \(1\)/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/hello from the app/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId("apps-messages")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("returns an empty ui/message result (no conversation content leak)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { factory, bridges } = createEventBridgeFactory();
|
||||
renderWithMantine(<ControlledAppsScreen bridgeFactory={factory} />);
|
||||
await user.click(screen.getByText("Ops Dashboard"));
|
||||
await vi.waitFor(() =>
|
||||
expect(bridges.at(-1)?.onmessage).toBeTypeOf("function"),
|
||||
);
|
||||
const result = await sendUiMessage(bridges, [
|
||||
{ type: "text", text: "secret" },
|
||||
]);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("clears the message log when the app is closed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { factory, bridges } = createEventBridgeFactory();
|
||||
renderWithMantine(<ControlledAppsScreen bridgeFactory={factory} />);
|
||||
await user.click(screen.getByText("Ops Dashboard"));
|
||||
await vi.waitFor(() =>
|
||||
expect(bridges.at(-1)?.onmessage).toBeTypeOf("function"),
|
||||
);
|
||||
await sendUiMessage(bridges, [
|
||||
{ type: "text", text: "hello from the app" },
|
||||
]);
|
||||
expect(screen.getByText(/Messages from app/)).toBeInTheDocument();
|
||||
await user.click(screen.getByLabelText("Close"));
|
||||
expect(screen.queryByText(/Messages from app/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces app log notifications in a default-expanded collapsible panel with a working Clear", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { factory, emit } = createEventBridgeFactory();
|
||||
renderWithMantine(<ControlledAppsScreen bridgeFactory={factory} />);
|
||||
await user.click(screen.getByText("Ops Dashboard"));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(screen.queryByText(/App logs/)).not.toBeInTheDocument();
|
||||
await act(async () => {
|
||||
emit("loggingmessage", { level: "warning", data: "disk almost full" });
|
||||
emit("loggingmessage", {
|
||||
level: "error",
|
||||
logger: "render",
|
||||
data: { code: 500 },
|
||||
});
|
||||
});
|
||||
const toggle = screen.getByRole("button", { name: /App logs \(2\)/ });
|
||||
expect(toggle).toBeInTheDocument();
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("true");
|
||||
// Default-expanded: entries are visible without clicking the toggle.
|
||||
expect(screen.getByText("disk almost full")).toBeInTheDocument();
|
||||
expect(screen.getByText("render")).toBeInTheDocument();
|
||||
expect(screen.getByText('{"code":500}')).toBeInTheDocument();
|
||||
expect(screen.getByTestId("apps-logs")).toBeInTheDocument();
|
||||
// Collapsing still works, and Clear empties the panel.
|
||||
await user.click(toggle);
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("false");
|
||||
// The toggle points at the collapse region for assistive tech.
|
||||
expect(toggle.getAttribute("aria-controls")).toBe("apps-logs-region");
|
||||
await user.click(screen.getByRole("button", { name: "Clear" }));
|
||||
expect(screen.queryByText(/App logs/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a log notification that carries no data without crashing", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { factory, emit } = createEventBridgeFactory();
|
||||
renderWithMantine(<ControlledAppsScreen bridgeFactory={factory} />);
|
||||
await user.click(screen.getByText("Ops Dashboard"));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
// No `data` field — formatLogData must coalesce to "" (not "undefined").
|
||||
emit("loggingmessage", { level: "info" });
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("button", { name: /App logs \(1\)/ }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("caps retained app logs at the soft limit, dropping the oldest", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { factory, emit } = createEventBridgeFactory();
|
||||
renderWithMantine(<ControlledAppsScreen bridgeFactory={factory} />);
|
||||
await user.click(screen.getByText("Ops Dashboard"));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
// 501 entries exceeds the 500 cap → oldest dropped, count clamps at 500.
|
||||
for (let i = 0; i < 501; i++) {
|
||||
emit("loggingmessage", { level: "info", data: `log-${i}` });
|
||||
}
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("button", { name: /App logs \(500\)/ }),
|
||||
).toBeInTheDocument();
|
||||
// The very first entry was dropped; the most recent is retained.
|
||||
expect(screen.queryByText("log-0")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("log-500")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onCloseApp and clears selection on Close", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCloseApp = vi.fn();
|
||||
|
|
@ -353,4 +660,153 @@ describe("AppsScreen", () => {
|
|||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Select an app to view details")).toBeVisible();
|
||||
});
|
||||
|
||||
describe("data-app-status / data-app-error", () => {
|
||||
function getStatus(): string | null {
|
||||
return screen.getByTestId("apps-form").getAttribute("data-app-status");
|
||||
}
|
||||
|
||||
it("is idle when no app is running", () => {
|
||||
renderWithMantine(
|
||||
<ControlledAppsScreen
|
||||
ui={{ ...EMPTY_APPS_UI, selectedAppName: "weather" }}
|
||||
/>,
|
||||
);
|
||||
expect(getStatus()).toBe("idle");
|
||||
});
|
||||
|
||||
it("transitions idle → loading → ready around the view's initialized signal", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { factory, emit } = createEventBridgeFactory();
|
||||
renderWithMantine(<ControlledAppsScreen bridgeFactory={factory} />);
|
||||
expect(getStatus()).toBe("idle");
|
||||
await user.click(screen.getByText("Ops Dashboard"));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(getStatus()).toBe("loading");
|
||||
await act(async () => emit("initialized"));
|
||||
expect(getStatus()).toBe("ready");
|
||||
});
|
||||
|
||||
it("reports error status + surfaces the reason in an error panel and data-app-error when the bridge factory rejects", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onError = vi.fn();
|
||||
const failingFactory: BridgeFactory = () =>
|
||||
Promise.reject(new Error("connect refused"));
|
||||
renderWithMantine(
|
||||
<ControlledAppsScreen
|
||||
bridgeFactory={failingFactory}
|
||||
onError={onError}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByText("Ops Dashboard"));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(getStatus()).toBe("error");
|
||||
const form = screen.getByTestId("apps-form");
|
||||
expect(form.getAttribute("data-app-error")).toBe("connect refused");
|
||||
// The error panel is shown below the frame with the reason, so the
|
||||
// failure isn't a silent blank frame.
|
||||
expect(screen.getByTestId("apps-error")).toBeInTheDocument();
|
||||
expect(screen.getByText("App failed to load")).toBeInTheDocument();
|
||||
expect(screen.getByText("connect refused")).toBeInTheDocument();
|
||||
// The error is also forwarded to the parent onError.
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "connect refused" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("resets to idle and clears the error panel when the running app is closed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const failingFactory: BridgeFactory = () =>
|
||||
Promise.reject(new Error("connect refused"));
|
||||
renderWithMantine(
|
||||
<ControlledAppsScreen bridgeFactory={failingFactory} />,
|
||||
);
|
||||
await user.click(screen.getByText("Ops Dashboard"));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(screen.getByTestId("apps-error")).toBeInTheDocument();
|
||||
await user.click(screen.getByLabelText("Close"));
|
||||
expect(screen.queryByTestId("apps-error")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("stages partial-input snapshots from the form and clears them", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ControlledAppsScreen />);
|
||||
// No-fields apps don't show the control.
|
||||
await user.click(screen.getByText("Ops Dashboard"));
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Stage partial input" }),
|
||||
).not.toBeInTheDocument();
|
||||
// Fielded apps do.
|
||||
await user.click(screen.getByText("Weather Widget"));
|
||||
const stage = screen.getByRole("button", { name: "Stage partial input" });
|
||||
expect(stage).toBeInTheDocument();
|
||||
expect(screen.queryByText(/staged/)).not.toBeInTheDocument();
|
||||
await user.click(stage);
|
||||
await user.click(stage);
|
||||
expect(screen.getByText("2 staged")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Clear staged" }));
|
||||
expect(screen.queryByText(/staged/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("replays staged partial inputs before the final tool-input on Open App", async () => {
|
||||
const user = userEvent.setup();
|
||||
const partials: Record<string, unknown>[] = [];
|
||||
let onInitialized: (() => void) | undefined;
|
||||
let sawInput = false;
|
||||
const factory: BridgeFactory = () => {
|
||||
const listeners: Record<string, ((p: unknown) => void)[]> = {};
|
||||
const bridge = {
|
||||
sendToolInput: async () => {
|
||||
sawInput = true;
|
||||
},
|
||||
sendToolInputPartial: async (params: {
|
||||
arguments?: Record<string, unknown>;
|
||||
}) => {
|
||||
// Assert partials arrive before the final input.
|
||||
expect(sawInput).toBe(false);
|
||||
partials.push(params.arguments ?? {});
|
||||
},
|
||||
sendToolResult: async () => {},
|
||||
sendToolCancelled: async () => {},
|
||||
sendHostContextChange: async () => {},
|
||||
teardownResource: async () => ({}),
|
||||
close: async () => {},
|
||||
addEventListener: (event: string, handler: () => void) => {
|
||||
(listeners[event] ??= []).push(handler);
|
||||
if (event === "initialized") onInitialized = handler;
|
||||
},
|
||||
removeEventListener: () => {},
|
||||
} as unknown as AppBridge;
|
||||
return bridge;
|
||||
};
|
||||
renderWithMantine(<ControlledAppsScreen bridgeFactory={factory} />);
|
||||
await user.click(screen.getByText("Weather Widget"));
|
||||
// Fill the required field so Open App is enabled, then stage two snapshots.
|
||||
await user.type(screen.getByRole("textbox", { name: /city/i }), "NYC");
|
||||
const stage = screen.getByRole("button", { name: "Stage partial input" });
|
||||
await user.click(stage);
|
||||
await user.click(stage);
|
||||
await user.click(screen.getByRole("button", { name: /Open App/ }));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
onInitialized?.();
|
||||
});
|
||||
// Both staged snapshots were replayed (staged partials survive Open). No
|
||||
// final tool-input arrives because the parent's onOpenApp is a no-op here
|
||||
// (App drives the renderer handle), so sawInput stays false — the partials
|
||||
// are what this screen is responsible for.
|
||||
expect(partials).toHaveLength(2);
|
||||
expect(sawInput).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,13 +1,18 @@
|
|||
import { useState, type Ref } from "react";
|
||||
import { useRef, useState, type Ref } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Card,
|
||||
Code,
|
||||
Collapse,
|
||||
Flex,
|
||||
Group,
|
||||
Image,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
|
|
@ -16,14 +21,26 @@ import {
|
|||
MdFullscreen,
|
||||
MdFullscreenExit,
|
||||
} from "react-icons/md";
|
||||
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import type {
|
||||
ContentBlock,
|
||||
LoggingMessageNotification,
|
||||
Tool,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import type {
|
||||
AppBridgeEventMap,
|
||||
McpUiDisplayMode,
|
||||
} from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import {
|
||||
AppRenderer,
|
||||
type AppRendererHandle,
|
||||
type AppRendererStatus,
|
||||
type BridgeFactory,
|
||||
} from "../../elements/AppRenderer/AppRenderer";
|
||||
import { HOST_AVAILABLE_DISPLAY_MODES } from "../../elements/AppRenderer/createAppBridgeFactory";
|
||||
import { AppDetailPanel } from "../../groups/AppDetailPanel/AppDetailPanel";
|
||||
import { AppControls } from "../../groups/AppControls/AppControls";
|
||||
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
||||
import { LogLevelBadge } from "../../elements/LogLevelBadge/LogLevelBadge";
|
||||
import { hasInputFields, resolveDisplayLabel } from "../../../utils/toolUtils";
|
||||
import { collectSchemaDefaults } from "../../../utils/jsonUtils";
|
||||
|
||||
|
|
@ -127,18 +144,182 @@ const HeaderActions = Group.withProps({
|
|||
wrap: "nowrap",
|
||||
});
|
||||
|
||||
const RendererFrame = Stack.withProps({
|
||||
// The host-controlled box the running app sits within. Its size is driven by
|
||||
// the host's layout (window resize, sidebar toggle, maximize) and NOT by the
|
||||
// view's reported content height — that drives the inner RendererFrame — so the
|
||||
// renderer's containerDimensions observer can measure this element without
|
||||
// coupling host→view container size to view→host size-changed.
|
||||
const RendererContainer = Stack.withProps({
|
||||
flex: 1,
|
||||
miw: 0,
|
||||
mih: 0,
|
||||
gap: 0,
|
||||
});
|
||||
|
||||
// The inner box that actually holds the iframe. Sized by the view-reported
|
||||
// content height (see `contentHeight`) and capped at the outer container.
|
||||
// Distinct from RendererContainer above so the two roles read clearly in JSX.
|
||||
const RendererFrame = Stack.withProps({
|
||||
miw: 0,
|
||||
mih: 0,
|
||||
gap: 0,
|
||||
});
|
||||
|
||||
const ContentStack = Stack.withProps({
|
||||
gap: "md",
|
||||
h: "100%",
|
||||
});
|
||||
|
||||
// Pinned panel below the running app (used by both the message log and the
|
||||
// app-log panel). `0 0 auto` keeps it at its content height (capped by the
|
||||
// inner scroll's `mah`) so it never squeezes out the iframe above it.
|
||||
const PinnedPanel = Stack.withProps({
|
||||
gap: "xs",
|
||||
flex: "0 0 auto",
|
||||
mih: 0,
|
||||
});
|
||||
|
||||
const LogScroll = ScrollArea.withProps({
|
||||
mah: 200,
|
||||
type: "auto",
|
||||
scrollbars: "y",
|
||||
offsetScrollbars: true,
|
||||
});
|
||||
|
||||
const MessageLogStack = Stack.withProps({
|
||||
gap: "sm",
|
||||
});
|
||||
|
||||
const MessageItem = Paper.withProps({
|
||||
p: "md",
|
||||
radius: "md",
|
||||
withBorder: true,
|
||||
});
|
||||
|
||||
const MessageItemStack = Stack.withProps({
|
||||
gap: "xs",
|
||||
});
|
||||
|
||||
const MonoCaption = Text.withProps({
|
||||
size: "xs",
|
||||
c: "dimmed",
|
||||
ff: "monospace",
|
||||
});
|
||||
|
||||
const AppLogList = Stack.withProps({
|
||||
gap: "xs",
|
||||
});
|
||||
|
||||
const AppLogRow = Group.withProps({
|
||||
gap: "sm",
|
||||
wrap: "nowrap",
|
||||
align: "flex-start",
|
||||
});
|
||||
|
||||
const AppLogData = Code.withProps({
|
||||
block: true,
|
||||
fz: "xs",
|
||||
});
|
||||
|
||||
const CompactSubtleButton = Button.withProps({
|
||||
variant: "subtle",
|
||||
size: "compact-xs",
|
||||
});
|
||||
|
||||
const PanelHeaderRow = Group.withProps({
|
||||
justify: "space-between",
|
||||
wrap: "nowrap",
|
||||
gap: "sm",
|
||||
});
|
||||
|
||||
const PartialStageControls = Group.withProps({
|
||||
gap: "sm",
|
||||
wrap: "nowrap",
|
||||
align: "center",
|
||||
flex: "0 0 auto",
|
||||
});
|
||||
|
||||
const StagePartialButton = Button.withProps({
|
||||
variant: "default",
|
||||
size: "compact-xs",
|
||||
});
|
||||
|
||||
const PartialStageCount = Text.withProps({
|
||||
size: "xs",
|
||||
c: "dimmed",
|
||||
});
|
||||
|
||||
const AppErrorPanel = Paper.withProps({
|
||||
p: "md",
|
||||
radius: "md",
|
||||
withBorder: true,
|
||||
c: "var(--inspector-log-error)",
|
||||
});
|
||||
|
||||
const AppErrorTitle = Text.withProps({
|
||||
fw: 600,
|
||||
size: "sm",
|
||||
});
|
||||
|
||||
const AppErrorMessage = Text.withProps({
|
||||
size: "sm",
|
||||
ff: "monospace",
|
||||
});
|
||||
|
||||
/** Render a log payload as a string for display. */
|
||||
function formatLogData(data: unknown): string {
|
||||
if (typeof data === "string") return data;
|
||||
try {
|
||||
// JSON.stringify(undefined) returns the value `undefined`, not a string, so
|
||||
// coalesce to "" to keep the `: string` return type honest for a data-less
|
||||
// log (spec-required, so this is only defensive against a malformed view).
|
||||
return JSON.stringify(data) ?? "";
|
||||
} catch {
|
||||
/* v8 ignore next -- JSON.stringify only throws on a BigInt or a circular
|
||||
structure; a log payload delivered over postMessage is already
|
||||
structured-clone-safe, so this fallback is unreachable in practice. */
|
||||
return String(data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft cap on retained message / log entries per run. Chatty widgets can emit
|
||||
* logs in a loop; keep only the most recent so the panels (and their DOM rows)
|
||||
* don't grow without bound between Clear/close. Oldest entries are dropped.
|
||||
*/
|
||||
const MAX_APP_CHANNEL_ENTRIES = 500;
|
||||
|
||||
/** Append to a capped list, dropping the oldest entries past the cap. */
|
||||
function appendCapped<T>(prev: T[], next: T): T[] {
|
||||
const grown = [...prev, next];
|
||||
return grown.length > MAX_APP_CHANNEL_ENTRIES
|
||||
? grown.slice(grown.length - MAX_APP_CHANNEL_ENTRIES)
|
||||
: grown;
|
||||
}
|
||||
|
||||
// A user-role message submitted by the running view through ui/message. The
|
||||
// inspector has no conversation to append to, so it just records the content
|
||||
// blocks for display. `role`/`content` mirror McpUiMessageRequest["params"];
|
||||
// `id` is a stable React key (like AppLogEntry) so the appendCapped front-drop
|
||||
// can't renumber keys the way an array index would.
|
||||
interface AppMessage {
|
||||
id: number;
|
||||
role: "user";
|
||||
content: ContentBlock[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One MCP `notifications/message` log entry from the running app, with the
|
||||
* payload stringified once at capture time so the render path can use it
|
||||
* directly. `id` is a stable React key.
|
||||
*/
|
||||
interface AppLogEntry {
|
||||
id: number;
|
||||
level: LoggingMessageNotification["params"]["level"];
|
||||
logger?: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function AppsScreen({
|
||||
tools,
|
||||
listChanged,
|
||||
|
|
@ -156,12 +337,125 @@ export function AppsScreen({
|
|||
const { selectedAppName, formValues, search } = ui;
|
||||
const [running, setRunning] = useState(false);
|
||||
const [maximized, setMaximized] = useState(false);
|
||||
const rendererContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const nextLogIdRef = useRef(0);
|
||||
const nextMessageIdRef = useRef(0);
|
||||
// Height (px) the running view last reported via ui/notifications/size-changed.
|
||||
// Undefined until the view reports (or after it's torn down), in which case
|
||||
// the iframe fills the available card space as before. Local to the screen
|
||||
// like `running`/`maximized`: it's tied to the live iframe, not persisted.
|
||||
const [appHeight, setAppHeight] = useState<number | undefined>(undefined);
|
||||
// Messages the running view has pushed via ui/message. The inspector has no
|
||||
// chat loop, so they're collected here and shown in a log below the app
|
||||
// rather than continuing a conversation. Local to the screen like `running`:
|
||||
// tied to the live bridge, cleared when the open ends or the app changes.
|
||||
const [messages, setMessages] = useState<AppMessage[]>([]);
|
||||
// Standard MCP log notifications (notifications/message) the running app
|
||||
// emits. The host advertises the `logging` capability; without surfacing
|
||||
// these here they'd be silently dropped by the bridge. Same lifecycle as
|
||||
// `messages`: tied to the live bridge, cleared on open/close/switch.
|
||||
const [appLogs, setAppLogs] = useState<AppLogEntry[]>([]);
|
||||
// Expanded by default so a widget developer sees the entries without an extra
|
||||
// click. The user can still collapse it for the rest of the run.
|
||||
const [appLogsExpanded, setAppLogsExpanded] = useState(true);
|
||||
// Snapshots of the input form captured via "Stage partial input". On Open
|
||||
// App they're passed to AppRenderer as `partialInputs` and replayed via
|
||||
// ui/notifications/tool-input-partial before the complete tool-input, so a
|
||||
// widget's progressive-render path can be exercised. Cleared on switch/close.
|
||||
const [partialStages, setPartialStages] = useState<Record<string, unknown>[]>(
|
||||
[],
|
||||
);
|
||||
// High-level renderer lifecycle, surfaced as `data-app-status` on the
|
||||
// `apps-form` card so an automated driver can poll
|
||||
// `[data-app-status="ready"]` instead of racing the iframe selector.
|
||||
const [appStatus, setAppStatus] = useState<AppRendererStatus | "idle">(
|
||||
"idle",
|
||||
);
|
||||
// The error that put the renderer into status="error" (factory throw, no
|
||||
// connected client). Shown in place of the blank iframe and surfaced as
|
||||
// `data-app-error` on the `apps-form` card so an automated driver can read
|
||||
// *why* the open failed without screenshotting a toast.
|
||||
const [appError, setAppError] = useState<Error | undefined>(undefined);
|
||||
|
||||
const selectedTool = selectedAppName
|
||||
? tools.find((t) => t.name === selectedAppName)
|
||||
: undefined;
|
||||
const selectedHasFields = selectedTool ? hasInputFields(selectedTool) : false;
|
||||
|
||||
// The running view reports its rendered content height via
|
||||
// ui/notifications/size-changed; honor it so the iframe is neither clipped
|
||||
// nor surrounded by dead space. Width is left at the host-controlled
|
||||
// container width. The value is clamped to the available space by the
|
||||
// renderer frame's `mah` below, and ignored while maximized (the app fills
|
||||
// the screen instead). A non-positive height is ignored — a view's
|
||||
// ResizeObserver can transiently fire 0 before layout settles or during
|
||||
// teardown, which would otherwise collapse the frame (mirrors AppRenderer's
|
||||
// own 0×0 skip on the container side).
|
||||
function handleSizeChange(size: AppBridgeEventMap["sizechange"]) {
|
||||
if (size.height != null && size.height > 0) setAppHeight(size.height);
|
||||
}
|
||||
|
||||
function handleMessage(params: Omit<AppMessage, "id">) {
|
||||
setMessages((prev) =>
|
||||
appendCapped(prev, { id: nextMessageIdRef.current++, ...params }),
|
||||
);
|
||||
}
|
||||
|
||||
function handleLog(params: LoggingMessageNotification["params"]) {
|
||||
setAppLogs((prev) =>
|
||||
appendCapped(prev, {
|
||||
id: nextLogIdRef.current++,
|
||||
level: params.level,
|
||||
logger: params.logger,
|
||||
text: formatLogData(params.data),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Clear the message + log panels (and the reported height). Called when a run
|
||||
// ends or the selected app changes so a new run starts clean. `keepPartials`
|
||||
// is set for `handleOpen`, where the staged fragments are about to be consumed
|
||||
// by the renderer and must not be cleared first.
|
||||
function resetAppChannels(opts?: { keepPartials?: boolean }) {
|
||||
setAppHeight(undefined);
|
||||
setMessages([]);
|
||||
setAppLogs([]);
|
||||
setAppLogsExpanded(true);
|
||||
setAppStatus("idle");
|
||||
setAppError(undefined);
|
||||
if (!opts?.keepPartials) setPartialStages([]);
|
||||
}
|
||||
|
||||
// Capture the error locally (so it can be shown in the card and surfaced as
|
||||
// `data-app-error`) and forward it to the parent's onError. The renderer
|
||||
// already drives `data-app-status="error"` via onAppStatusChange; this adds
|
||||
// the *reason* alongside it.
|
||||
function handleAppError(err: Error) {
|
||||
setAppError(err);
|
||||
onError?.(err);
|
||||
}
|
||||
|
||||
function handleStagePartialInput() {
|
||||
setPartialStages((prev) => [...prev, { ...formValues }]);
|
||||
}
|
||||
|
||||
// The app's display mode is derived from the existing maximized toggle.
|
||||
// Passed to AppRenderer so the running view receives it via
|
||||
// host-context-changed; the Maximize/Restore button below keeps toggling
|
||||
// `maximized`, which now flows out as a protocol event.
|
||||
const displayMode: McpUiDisplayMode = maximized ? "fullscreen" : "inline";
|
||||
|
||||
// Handle a view-originated ui/request-display-mode. Only modes the inspector
|
||||
// advertises in `availableDisplayModes` are honored — an unsupported request
|
||||
// (e.g. "pip") is declined by returning the current mode, per spec.
|
||||
function handleRequestDisplayMode(
|
||||
requested: McpUiDisplayMode,
|
||||
): McpUiDisplayMode {
|
||||
if (!HOST_AVAILABLE_DISPLAY_MODES.includes(requested)) return displayMode;
|
||||
setMaximized(requested === "fullscreen");
|
||||
return requested;
|
||||
}
|
||||
|
||||
function handleSelect(name: string) {
|
||||
if (name === selectedAppName) return;
|
||||
const next = tools.find((t) => t.name === name);
|
||||
|
|
@ -174,6 +468,7 @@ export function AppsScreen({
|
|||
formValues: collectSchemaDefaults(next.inputSchema),
|
||||
});
|
||||
setMaximized(false);
|
||||
resetAppChannels();
|
||||
onSelectApp(name);
|
||||
// No-input apps auto-launch on selection so the user lands directly in
|
||||
// the running view; apps with fields wait for the explicit Open App click.
|
||||
|
|
@ -187,6 +482,12 @@ export function AppsScreen({
|
|||
|
||||
function handleOpen() {
|
||||
if (!selectedTool) return;
|
||||
// `keepPartials` preserves the staged fragments: AppRenderer snapshots them
|
||||
// into its own pendingPartialsRef at bridge-build time and replays them.
|
||||
// The `partialStages` state is intentionally NOT cleared here — the staging
|
||||
// UI only renders while not running, so the surviving state is invisible
|
||||
// until the next select/close/back reset drains it.
|
||||
resetAppChannels({ keepPartials: true });
|
||||
setRunning(true);
|
||||
onOpenApp(selectedTool.name, formValues);
|
||||
}
|
||||
|
|
@ -195,12 +496,14 @@ export function AppsScreen({
|
|||
setRunning(false);
|
||||
onUiChange({ ...ui, selectedAppName: undefined, formValues: {} });
|
||||
setMaximized(false);
|
||||
resetAppChannels();
|
||||
onCloseApp();
|
||||
}
|
||||
|
||||
function handleBackToInput() {
|
||||
setRunning(false);
|
||||
setMaximized(false);
|
||||
resetAppChannels();
|
||||
}
|
||||
|
||||
// No sandbox proxy URL means the host can't embed the trusted outer iframe
|
||||
|
|
@ -218,6 +521,15 @@ export function AppsScreen({
|
|||
);
|
||||
}
|
||||
|
||||
// While maximized the app fills the screen, so the view-reported height is
|
||||
// ignored; otherwise we honor it (clamped to the card by the frame's `mah`).
|
||||
// `appHeight` is intentionally NOT cleared when toggling maximize: carrying
|
||||
// the last inline height across a maximize→restore means the frame restores
|
||||
// at its prior size immediately, rather than flashing to full-card height
|
||||
// (flex:1) for the frame or two until the view sends a fresh size-changed
|
||||
// after the `inline` host-context-changed.
|
||||
const contentHeight = maximized ? undefined : appHeight;
|
||||
|
||||
return (
|
||||
<ScreenLayout>
|
||||
{!maximized && (
|
||||
|
|
@ -236,7 +548,13 @@ export function AppsScreen({
|
|||
</Sidebar>
|
||||
)}
|
||||
|
||||
<ContentCard flex={1} h="100%">
|
||||
<ContentCard
|
||||
flex={1}
|
||||
h="100%"
|
||||
data-testid="apps-form"
|
||||
data-app-status={running ? appStatus : "idle"}
|
||||
data-app-error={running ? appError?.message : undefined}
|
||||
>
|
||||
{selectedTool ? (
|
||||
<ContentStack>
|
||||
<HeaderRow>
|
||||
|
|
@ -286,19 +604,46 @@ export function AppsScreen({
|
|||
</HeaderActions>
|
||||
</HeaderRow>
|
||||
{running ? (
|
||||
<RendererFrame>
|
||||
{/* Keying by name forces the renderer to remount when the
|
||||
selected app changes, ensuring a fresh bridge and iframe
|
||||
rather than reusing the previous app's transport. */}
|
||||
<AppRenderer
|
||||
key={selectedTool.name}
|
||||
sandboxPath={sandboxPath}
|
||||
tool={selectedTool}
|
||||
bridgeFactory={bridgeFactory}
|
||||
onError={onError}
|
||||
ref={rendererRef}
|
||||
/>
|
||||
</RendererFrame>
|
||||
// RendererContainer is the host-controlled box (its size only
|
||||
// changes with host layout); the inner RendererFrame is sized by
|
||||
// the view's reported content height, capped at the container.
|
||||
<RendererContainer ref={rendererContainerRef}>
|
||||
<RendererFrame
|
||||
flex={contentHeight != null ? "0 0 auto" : 1}
|
||||
h={contentHeight}
|
||||
mah="100%"
|
||||
>
|
||||
{/* Keying by name forces the renderer to remount when the
|
||||
selected app changes, ensuring a fresh bridge and iframe
|
||||
rather than reusing the previous app's transport. */}
|
||||
<AppRenderer
|
||||
key={selectedTool.name}
|
||||
sandboxPath={sandboxPath}
|
||||
tool={selectedTool}
|
||||
bridgeFactory={bridgeFactory}
|
||||
onError={handleAppError}
|
||||
onAppStatusChange={setAppStatus}
|
||||
onSizeChange={handleSizeChange}
|
||||
displayMode={displayMode}
|
||||
onRequestDisplayMode={handleRequestDisplayMode}
|
||||
onMessage={handleMessage}
|
||||
onLog={handleLog}
|
||||
partialInputs={partialStages}
|
||||
containerRef={rendererContainerRef}
|
||||
ref={rendererRef}
|
||||
/>
|
||||
</RendererFrame>
|
||||
{/* Shown BELOW the frame on a factory throw/reject so the reason
|
||||
is visible alongside the (blank) iframe rather than leaving a
|
||||
silent blank frame. The renderer stays mounted so an in-place
|
||||
retry path remains possible. */}
|
||||
{appError && (
|
||||
<AppErrorPanel data-testid="apps-error">
|
||||
<AppErrorTitle>App failed to load</AppErrorTitle>
|
||||
<AppErrorMessage>{appError.message}</AppErrorMessage>
|
||||
</AppErrorPanel>
|
||||
)}
|
||||
</RendererContainer>
|
||||
) : (
|
||||
// `isOpening` is always false here because `handleOpen`
|
||||
// synchronously flips `running` to true, swapping in the
|
||||
|
|
@ -307,15 +652,95 @@ export function AppsScreen({
|
|||
// standalone use (the `Opening` story) and for Phase 3
|
||||
// wiring, where a managed-state hook can hold the panel
|
||||
// in a pending state across an awaited `tools/call`.
|
||||
<AppDetailPanel
|
||||
tool={selectedTool}
|
||||
formValues={formValues}
|
||||
isOpening={false}
|
||||
onFormChange={(values) =>
|
||||
onUiChange({ ...ui, formValues: values })
|
||||
}
|
||||
onOpenApp={handleOpen}
|
||||
/>
|
||||
<>
|
||||
{selectedHasFields && (
|
||||
<PartialStageControls>
|
||||
<StagePartialButton
|
||||
onClick={handleStagePartialInput}
|
||||
data-testid="apps-stage"
|
||||
>
|
||||
Stage partial input
|
||||
</StagePartialButton>
|
||||
{partialStages.length > 0 && (
|
||||
<>
|
||||
<PartialStageCount>
|
||||
{partialStages.length} staged
|
||||
</PartialStageCount>
|
||||
<CompactSubtleButton
|
||||
onClick={() => setPartialStages([])}
|
||||
>
|
||||
Clear staged
|
||||
</CompactSubtleButton>
|
||||
</>
|
||||
)}
|
||||
</PartialStageControls>
|
||||
)}
|
||||
<AppDetailPanel
|
||||
tool={selectedTool}
|
||||
formValues={formValues}
|
||||
isOpening={false}
|
||||
onFormChange={(values) =>
|
||||
onUiChange({ ...ui, formValues: values })
|
||||
}
|
||||
onOpenApp={handleOpen}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{running && messages.length > 0 && (
|
||||
<PinnedPanel data-testid="apps-messages">
|
||||
<Title order={5}>Messages from app ({messages.length})</Title>
|
||||
<LogScroll>
|
||||
<MessageLogStack>
|
||||
{messages.map((message, index) => (
|
||||
<MessageItem key={message.id}>
|
||||
<MessageItemStack>
|
||||
<MonoCaption>
|
||||
[{index}] role: {message.role}
|
||||
</MonoCaption>
|
||||
{message.content.map((block, blockIndex) => (
|
||||
<ContentViewer
|
||||
key={blockIndex}
|
||||
block={block}
|
||||
copyable
|
||||
/>
|
||||
))}
|
||||
</MessageItemStack>
|
||||
</MessageItem>
|
||||
))}
|
||||
</MessageLogStack>
|
||||
</LogScroll>
|
||||
</PinnedPanel>
|
||||
)}
|
||||
{running && appLogs.length > 0 && (
|
||||
<PinnedPanel data-testid="apps-logs">
|
||||
<PanelHeaderRow>
|
||||
<CompactSubtleButton
|
||||
onClick={() => setAppLogsExpanded((e) => !e)}
|
||||
aria-expanded={appLogsExpanded}
|
||||
aria-controls="apps-logs-region"
|
||||
>
|
||||
App logs ({appLogs.length})
|
||||
</CompactSubtleButton>
|
||||
<CompactSubtleButton onClick={() => setAppLogs([])}>
|
||||
Clear
|
||||
</CompactSubtleButton>
|
||||
</PanelHeaderRow>
|
||||
<Collapse in={appLogsExpanded} id="apps-logs-region">
|
||||
<LogScroll>
|
||||
<AppLogList>
|
||||
{appLogs.map((entry) => (
|
||||
<AppLogRow key={entry.id}>
|
||||
<LogLevelBadge level={entry.level} />
|
||||
{entry.logger && (
|
||||
<MonoCaption>{entry.logger}</MonoCaption>
|
||||
)}
|
||||
<AppLogData>{entry.text}</AppLogData>
|
||||
</AppLogRow>
|
||||
))}
|
||||
</AppLogList>
|
||||
</LogScroll>
|
||||
</Collapse>
|
||||
</PinnedPanel>
|
||||
)}
|
||||
</ContentStack>
|
||||
) : (
|
||||
|
|
|
|||
Loading…
Reference in a new issue