@assistant-ui/react 0.15.17 → 0.15.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +1 -1
  2. package/dist/index.d.ts +6 -3
  3. package/dist/index.js +4 -2
  4. package/dist/mcp-apps/McpAppRenderer.d.ts +30 -8
  5. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  6. package/dist/mcp-apps/McpAppRenderer.js +91 -47
  7. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  8. package/dist/mcp-apps/app-frame.d.ts.map +1 -1
  9. package/dist/mcp-apps/app-frame.js +41 -22
  10. package/dist/mcp-apps/app-frame.js.map +1 -1
  11. package/dist/mcp-apps/bridge.js +2 -3
  12. package/dist/mcp-apps/bridge.js.map +1 -1
  13. package/dist/mcp-apps/index.d.ts +2 -2
  14. package/dist/primitives/composer/ComposerInputPluginContext.d.ts +1 -2
  15. package/dist/primitives/composer/ComposerInputPluginContext.d.ts.map +1 -1
  16. package/dist/primitives/composer/ComposerInputPluginContext.js +1 -6
  17. package/dist/primitives/composer/ComposerInputPluginContext.js.map +1 -1
  18. package/dist/primitives/composer/trigger/TriggerPopoverAction.js +46 -15
  19. package/dist/primitives/composer/trigger/TriggerPopoverAction.js.map +1 -1
  20. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +40 -10
  21. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js.map +1 -1
  22. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.d.ts +6 -0
  23. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.d.ts.map +1 -0
  24. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.js +9 -0
  25. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.js.map +1 -0
  26. package/dist/primitives/composer/trigger/triggerNavigationResource.d.ts.map +1 -1
  27. package/dist/primitives/composer/trigger/triggerNavigationResource.js +3 -5
  28. package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
  29. package/dist/primitives/messagePart/MessagePartText.d.ts +1 -1
  30. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  31. package/dist/primitives/thread/useThreadViewportAutoScroll.js +3 -3
  32. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  33. package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
  34. package/dist/sandbox-host/SandboxHost.js +14 -5
  35. package/dist/sandbox-host/SandboxHost.js.map +1 -1
  36. package/dist/unstable/useMentionAdapter.d.ts +9 -3
  37. package/dist/unstable/useMentionAdapter.d.ts.map +1 -1
  38. package/dist/unstable/useMentionAdapter.js +50 -43
  39. package/dist/unstable/useMentionAdapter.js.map +1 -1
  40. package/dist/unstable/useModelContextSnapshot.d.ts +34 -0
  41. package/dist/unstable/useModelContextSnapshot.d.ts.map +1 -0
  42. package/dist/unstable/useModelContextSnapshot.js +61 -0
  43. package/dist/unstable/useModelContextSnapshot.js.map +1 -0
  44. package/dist/unstable/useSlashCommandAdapter.d.ts.map +1 -1
  45. package/dist/unstable/useSlashCommandAdapter.js +2 -8
  46. package/dist/unstable/useSlashCommandAdapter.js.map +1 -1
  47. package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts +14 -0
  48. package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts.map +1 -0
  49. package/dist/unstable/webmcp/WebMcpRegistrationResource.js +85 -0
  50. package/dist/unstable/webmcp/WebMcpRegistrationResource.js.map +1 -0
  51. package/dist/unstable/webmcp/convertTools.d.ts +21 -0
  52. package/dist/unstable/webmcp/convertTools.d.ts.map +1 -0
  53. package/dist/unstable/webmcp/convertTools.js +110 -0
  54. package/dist/unstable/webmcp/convertTools.js.map +1 -0
  55. package/dist/unstable/webmcp/useWebMcpProvider.d.ts +21 -0
  56. package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -0
  57. package/dist/unstable/webmcp/useWebMcpProvider.js +105 -0
  58. package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -0
  59. package/dist/unstable/webmcp/webmcp-host.d.ts +39 -0
  60. package/dist/unstable/webmcp/webmcp-host.d.ts.map +1 -0
  61. package/dist/unstable/webmcp/webmcp-host.js +29 -0
  62. package/dist/unstable/webmcp/webmcp-host.js.map +1 -0
  63. package/dist/utils/Primitive.d.ts +4 -4
  64. package/package.json +12 -12
  65. package/src/index.ts +13 -0
  66. package/src/mcp-apps/McpAppRenderer.test.tsx +253 -5
  67. package/src/mcp-apps/McpAppRenderer.tsx +147 -52
  68. package/src/mcp-apps/app-frame.test.tsx +248 -1
  69. package/src/mcp-apps/app-frame.tsx +51 -5
  70. package/src/mcp-apps/bridge.ts +7 -2
  71. package/src/mcp-apps/index.ts +5 -1
  72. package/src/primitives/composer/ComposerInputPluginContext.tsx +0 -10
  73. package/src/primitives/composer/trigger/TriggerPopoverAction.tsx +4 -2
  74. package/src/primitives/composer/trigger/TriggerPopoverBehavior.test.tsx +109 -0
  75. package/src/primitives/composer/trigger/TriggerPopoverDirective.tsx +4 -2
  76. package/src/primitives/composer/trigger/matchesTriggerItemQuery.test.ts +52 -0
  77. package/src/primitives/composer/trigger/matchesTriggerItemQuery.ts +13 -0
  78. package/src/primitives/composer/trigger/triggerNavigationResource.ts +3 -10
  79. package/src/primitives/thread/useThreadViewportAutoScroll.ts +7 -6
  80. package/src/sandbox-host/SandboxHost.test.tsx +120 -2
  81. package/src/sandbox-host/SandboxHost.tsx +26 -6
  82. package/src/unstable/useComposerInputHistory.test.tsx +2 -1
  83. package/src/unstable/useMentionAdapter.test.tsx +337 -0
  84. package/src/unstable/useMentionAdapter.ts +94 -57
  85. package/src/unstable/useModelContextSnapshot.test.tsx +132 -0
  86. package/src/unstable/useModelContextSnapshot.ts +67 -0
  87. package/src/unstable/useSlashCommandAdapter.ts +2 -9
  88. package/src/unstable/webmcp/WebMcpRegistrationResource.ts +92 -0
  89. package/src/unstable/webmcp/__tests__/webmcp.fake.tsx +168 -0
  90. package/src/unstable/webmcp/convertTools.test.ts +393 -0
  91. package/src/unstable/webmcp/convertTools.ts +180 -0
  92. package/src/unstable/webmcp/useWebMcpProvider.test.tsx +466 -0
  93. package/src/unstable/webmcp/useWebMcpProvider.ts +150 -0
  94. package/src/unstable/webmcp/webmcp-host.test.ts +142 -0
  95. package/src/unstable/webmcp/webmcp-host.ts +83 -0
  96. package/dist/utils/invokeCallbackSafely.d.ts +0 -5
  97. package/dist/utils/invokeCallbackSafely.d.ts.map +0 -1
  98. package/dist/utils/invokeCallbackSafely.js +0 -15
  99. package/dist/utils/invokeCallbackSafely.js.map +0 -1
  100. package/dist/utils/useToolArgsFieldStatus.d.ts +0 -19
  101. package/dist/utils/useToolArgsFieldStatus.d.ts.map +0 -1
  102. package/dist/utils/useToolArgsFieldStatus.js +0 -23
  103. package/dist/utils/useToolArgsFieldStatus.js.map +0 -1
  104. package/src/utils/invokeCallbackSafely.ts +0 -11
  105. package/src/utils/useToolArgsFieldStatus.ts +0 -18
@@ -6,10 +6,13 @@ import {
6
6
  useMemo,
7
7
  useRef,
8
8
  useState,
9
- type MutableRefObject,
10
9
  type ReactNode,
11
10
  } from "react";
12
- import type { McpAppMetadata } from "@assistant-ui/core";
11
+ import type {
12
+ McpAppMetadata,
13
+ TextMessagePart,
14
+ ToolCallMessagePart,
15
+ } from "@assistant-ui/core";
13
16
  import type {
14
17
  ToolCallMessagePartComponent,
15
18
  ToolCallMessagePartProps,
@@ -30,13 +33,11 @@ import type {
30
33
  import { getMcpAppFromToolPart } from "./utils";
31
34
  import { isRecord } from "@assistant-ui/core/internal";
32
35
 
33
- export type McpAppRendererOptions = {
34
- /**
35
- * Provides the data-plane operations the widget can request
36
- * (`loadResource`, `callTool`, `readResource`, `listResources`). Use
37
- * `McpAppsRemoteHost({ url })` for the default HTTP-route convention.
38
- */
39
- host: ResourceElement<McpAppsHost>;
36
+ /**
37
+ * Options that apply to a single MCP app. `McpAppRenderer` takes them as
38
+ * thread-wide defaults, and its `forPart` resolver overrides them for one part.
39
+ */
40
+ export type McpAppPartOptions = {
40
41
  /** Sandbox + container styling. Passes through to SafeContentFrame. */
41
42
  sandbox?: McpAppSandboxConfig;
42
43
  /**
@@ -51,6 +52,10 @@ export type McpAppRendererOptions = {
51
52
  /**
52
53
  * Optional widget interaction and lifecycle handlers. Data-plane handlers
53
54
  * (`callTool`, `readResource`, `listResources`) always use `host`.
55
+ *
56
+ * Which handlers a widget may call is captured when its frame mounts, so a
57
+ * `forPart` resolver returns the same handler keys for a part on every
58
+ * render even when the implementations change.
54
59
  */
55
60
  handlers?: Omit<
56
61
  McpAppBridgeHandlers,
@@ -64,6 +69,26 @@ export type McpAppRendererOptions = {
64
69
  errorFallback?: ReactNode | ((error: Error) => ReactNode);
65
70
  };
66
71
 
72
+ export type McpAppRendererOptions = McpAppPartOptions & {
73
+ /**
74
+ * Provides the data-plane operations the widget can request
75
+ * (`loadResource`, `callTool`, `readResource`, `listResources`). Use
76
+ * `McpAppsRemoteHost({ url })` for the default HTTP-route convention.
77
+ */
78
+ host: ResourceElement<McpAppsHost>;
79
+ /**
80
+ * Resolves the options for one part. Each returned value replaces the
81
+ * thread-wide option of the same name, so a host can give every MCP app its
82
+ * own display mode and answer `requestDisplayMode` for the part that asked.
83
+ *
84
+ * `handlers` is the exception and merges per key. Its keys are capabilities
85
+ * negotiated with the widget rather than data, so replacing the whole bag to
86
+ * add one handler would withdraw the others from that part's widget with
87
+ * nothing to observe it by.
88
+ */
89
+ forPart?: (part: ToolCallMessagePart) => McpAppPartOptions;
90
+ };
91
+
67
92
  type LoadedResourceState = {
68
93
  host: McpAppsHost;
69
94
  resourceUri: string;
@@ -72,7 +97,31 @@ type LoadedResourceState = {
72
97
  error?: Error;
73
98
  };
74
99
 
75
- type UseHostStore = UseBoundStore<StoreApi<{ host: McpAppsHost }>>;
100
+ type RendererState = {
101
+ host: McpAppsHost;
102
+ options: McpAppRendererOptions;
103
+ };
104
+
105
+ type UseRendererStore = UseBoundStore<StoreApi<RendererState>>;
106
+
107
+ // Callers pass options as an object literal, so the resource hands this a fresh
108
+ // identity on every run; comparing the values keeps an unchanged run from
109
+ // re-rendering every mounted part. `host` is excluded because it is an element
110
+ // rebuilt on every run, and the store carries the resolved host separately.
111
+ const isSameOptions = (
112
+ a: McpAppRendererOptions,
113
+ b: McpAppRendererOptions,
114
+ ): boolean => {
115
+ const aKeys = Object.keys(a).filter((key) => key !== "host");
116
+ const bKeys = Object.keys(b).filter((key) => key !== "host");
117
+ if (aKeys.length !== bKeys.length) return false;
118
+ const aValues = a as Record<string, unknown>;
119
+ const bValues = b as Record<string, unknown>;
120
+ return aKeys.every(
121
+ (key) =>
122
+ Object.hasOwn(bValues, key) && Object.is(aValues[key], bValues[key]),
123
+ );
124
+ };
76
125
 
77
126
  function getInput(part: {
78
127
  status: { type: string };
@@ -92,27 +141,67 @@ const defaultOpenLink = ({ url }: { url: string }) => {
92
141
  window.open(url, "_blank", "noopener,noreferrer");
93
142
  };
94
143
 
95
- function extractSendMessageText(params: unknown): string | undefined {
96
- if (typeof params === "string") return params;
144
+ function extractSendMessageTexts(params: unknown): string[] | undefined {
145
+ if (typeof params === "string") return [params];
97
146
  if (!params || typeof params !== "object") return undefined;
98
147
  const obj = params as Record<string, unknown>;
99
- if (typeof obj["prompt"] === "string") return obj["prompt"];
100
- if (typeof obj["text"] === "string") return obj["text"];
101
- if (typeof obj["message"] === "string") return obj["message"];
148
+ if (typeof obj["prompt"] === "string") return [obj["prompt"]];
149
+ if (typeof obj["text"] === "string") return [obj["text"]];
150
+ if (typeof obj["message"] === "string") return [obj["message"]];
151
+ if (Array.isArray(obj["content"])) {
152
+ return obj["content"]
153
+ .filter(
154
+ (block): block is { type: "text"; text: string } =>
155
+ isRecord(block) &&
156
+ block["type"] === "text" &&
157
+ typeof block["text"] === "string",
158
+ )
159
+ .map((block) => block.text);
160
+ }
102
161
  return undefined;
103
162
  }
104
163
 
164
+ function extractSendMessageContent(
165
+ params: unknown,
166
+ ): { content: TextMessagePart[] } | { reason: string } {
167
+ const texts = extractSendMessageTexts(params);
168
+ if (!texts) return { reason: "unrecognised params shape" };
169
+ const content = texts
170
+ .filter((text) => text !== "")
171
+ .map((text): TextMessagePart => ({ type: "text", text }));
172
+ if (content.length === 0) {
173
+ return { reason: "no text content to append" };
174
+ }
175
+ return { content };
176
+ }
177
+
178
+ function resolvePartOptions(
179
+ options: McpAppRendererOptions,
180
+ part: ToolCallMessagePartProps,
181
+ ): McpAppRendererOptions {
182
+ const overrides = options.forPart?.(part);
183
+ if (!overrides) return options;
184
+ const handlers =
185
+ options.handlers && overrides.handlers
186
+ ? { ...options.handlers, ...overrides.handlers }
187
+ : (overrides.handlers ?? options.handlers);
188
+ return {
189
+ ...options,
190
+ ...overrides,
191
+ ...(handlers === undefined ? {} : { handlers }),
192
+ };
193
+ }
194
+
105
195
  function InlineRenderer({
106
196
  part,
107
- useHostStore,
108
- optionsRef,
197
+ useRendererStore,
109
198
  }: {
110
199
  part: ToolCallMessagePartProps;
111
- useHostStore: UseHostStore;
112
- optionsRef: MutableRefObject<McpAppRendererOptions>;
200
+ useRendererStore: UseRendererStore;
113
201
  }) {
114
- const opts = optionsRef.current;
115
202
  const aui = useAui();
203
+ const rendererOptions = useRendererStore((state) => state.options);
204
+ const opts = resolvePartOptions(rendererOptions, part);
116
205
  const app = getMcpAppFromToolPart(part);
117
206
  const cachedAppRef = useRef<McpAppMetadata | undefined>(undefined);
118
207
  useLayoutEffect(() => {
@@ -122,7 +211,7 @@ function InlineRenderer({
122
211
 
123
212
  const [loadedResource, setLoadedResource] = useState<LoadedResourceState>();
124
213
 
125
- const host = useHostStore((state) => state.host);
214
+ const host = useRendererStore((state) => state.host);
126
215
  const resourceUri = appForRender?.resourceUri;
127
216
  const serverId = appForRender?.serverId;
128
217
  const callerHandlers = opts.handlers;
@@ -133,12 +222,16 @@ function InlineRenderer({
133
222
  const targetUri = resourceUri;
134
223
  const targetServerId = serverId;
135
224
 
136
- targetHost
137
- .loadResource({
138
- uri: targetUri,
139
- ...(targetServerId ? { serverId: targetServerId } : {}),
140
- })
141
- .then((res) => {
225
+ // Host changes are published later in this passive flush. Defer until that
226
+ // publication lands, then verify this effect still owns the current host.
227
+ const loadResource = async () => {
228
+ await Promise.resolve();
229
+ if (cancelled || useRendererStore.getState().host !== targetHost) return;
230
+ try {
231
+ const res = await targetHost.loadResource({
232
+ uri: targetUri,
233
+ ...(targetServerId ? { serverId: targetServerId } : {}),
234
+ });
142
235
  if (!cancelled)
143
236
  setLoadedResource({
144
237
  host: targetHost,
@@ -148,8 +241,7 @@ function InlineRenderer({
148
241
  : {}),
149
242
  resource: res,
150
243
  });
151
- })
152
- .catch((error: unknown) => {
244
+ } catch (error: unknown) {
153
245
  if (!cancelled) {
154
246
  setLoadedResource({
155
247
  host: targetHost,
@@ -160,12 +252,14 @@ function InlineRenderer({
160
252
  error: error instanceof Error ? error : new Error(String(error)),
161
253
  });
162
254
  }
163
- });
255
+ }
256
+ };
257
+ void loadResource();
164
258
 
165
259
  return () => {
166
260
  cancelled = true;
167
261
  };
168
- }, [host, resourceUri, serverId]);
262
+ }, [host, resourceUri, serverId, useRendererStore]);
169
263
 
170
264
  const bridgeHandlers = useMemo<McpAppBridgeHandlers>(
171
265
  () => ({
@@ -174,32 +268,34 @@ function InlineRenderer({
174
268
  sendMessage:
175
269
  callerHandlers?.sendMessage ??
176
270
  ((params) => {
177
- const text = extractSendMessageText(params);
178
- if (!text) return { ok: false, reason: "unrecognised params shape" };
179
- aui.thread.append({ content: [{ type: "text", text }] });
271
+ const result = extractSendMessageContent(params);
272
+ if ("reason" in result) {
273
+ return { isError: true, ok: false, reason: result.reason };
274
+ }
275
+ aui.thread.append({ content: result.content });
180
276
  return { ok: true };
181
277
  }),
182
278
  callTool: (params) =>
183
- useHostStore.getState().host.callTool({
279
+ useRendererStore.getState().host.callTool({
184
280
  ...params,
185
281
  ...(serverId ? { serverId } : {}),
186
282
  }),
187
283
  readResource: (params) =>
188
- useHostStore.getState().host.readResource({
284
+ useRendererStore.getState().host.readResource({
189
285
  ...params,
190
286
  ...(serverId ? { serverId } : {}),
191
287
  }),
192
288
  listResources: (params) => {
193
289
  if (!serverId) {
194
- return useHostStore.getState().host.listResources(params);
290
+ return useRendererStore.getState().host.listResources(params);
195
291
  }
196
- return useHostStore.getState().host.listResources({
292
+ return useRendererStore.getState().host.listResources({
197
293
  ...(isRecord(params) ? params : {}),
198
294
  serverId,
199
295
  });
200
296
  },
201
297
  }),
202
- [aui, callerHandlers, serverId, useHostStore],
298
+ [aui, callerHandlers, serverId, useRendererStore],
203
299
  );
204
300
 
205
301
  const loadedResourceForApp =
@@ -253,27 +349,26 @@ const useMcpAppRenderer = (
253
349
  ): { readonly render: ToolCallMessagePartComponent } => {
254
350
  const host = useResource(options.host);
255
351
 
256
- const optionsRef = useRef<McpAppRendererOptions>(options);
257
- optionsRef.current = options;
258
-
259
- const [useHostStore] = useState(() =>
260
- create<{ host: McpAppsHost }>(() => ({ host })),
352
+ // The rendered component identity has to stay stable, or every part remounts
353
+ // its frame; options reach the parts through the store instead of props.
354
+ const [useRendererStore] = useState(() =>
355
+ create<RendererState>(() => ({ host, options })),
261
356
  );
262
357
  useEffect(() => {
263
- useHostStore.setState({ host });
264
- }, [host, useHostStore]);
358
+ useRendererStore.setState((prev) =>
359
+ prev.host === host && isSameOptions(prev.options, options)
360
+ ? prev
361
+ : { host, options },
362
+ );
363
+ }, [host, options, useRendererStore]);
265
364
 
266
365
  const render = useMemo((): ToolCallMessagePartComponent => {
267
366
  const Render: ToolCallMessagePartComponent = (props) => (
268
- <InlineRenderer
269
- part={props}
270
- useHostStore={useHostStore}
271
- optionsRef={optionsRef}
272
- />
367
+ <InlineRenderer part={props} useRendererStore={useRendererStore} />
273
368
  );
274
369
  Render.displayName = "McpAppRenderer";
275
370
  return Render;
276
- }, [useHostStore]);
371
+ }, [useRendererStore]);
277
372
 
278
373
  return { render };
279
374
  };
@@ -7,7 +7,7 @@ import type {
7
7
  SandboxHostProps,
8
8
  } from "../sandbox-host/SandboxHost";
9
9
  import type { CreateMcpAppBridgeOptions, McpAppBridge } from "./bridge";
10
- import { MCP_APP_MIME_TYPE } from "./types";
10
+ import { MCP_APP_MIME_TYPE, type McpAppHostContext } from "./types";
11
11
 
12
12
  const { sandboxHostMock, createMcpAppBridgeMock } = vi.hoisted(() => ({
13
13
  sandboxHostMock: vi.fn(),
@@ -99,4 +99,251 @@ describe("McpAppFrame", () => {
99
99
 
100
100
  sandboxBridge.dispose();
101
101
  });
102
+
103
+ it("only notifies the widget when host context actually changes", () => {
104
+ let createBridge: SandboxHostProps["createBridge"] | null = null;
105
+ sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
106
+ createBridge ??= props.createBridge;
107
+ return null;
108
+ });
109
+ const bridge: McpAppBridge = {
110
+ onMessage: vi.fn(),
111
+ dispose: vi.fn(),
112
+ notifyToolInput: vi.fn(),
113
+ notifyToolResult: vi.fn(),
114
+ notifyHostContextChanged: vi.fn(),
115
+ };
116
+ createMcpAppBridgeMock.mockReturnValue(bridge);
117
+
118
+ const view = (hostContext: McpAppHostContext) => (
119
+ <McpAppFrame
120
+ app={{ resourceUri: "ui://example/widget" }}
121
+ resource={{
122
+ uri: "ui://example/widget",
123
+ mimeType: MCP_APP_MIME_TYPE,
124
+ html: "",
125
+ }}
126
+ hostContext={hostContext}
127
+ />
128
+ );
129
+ const rendered = render(
130
+ view({ displayMode: "inline", availableDisplayModes: ["inline", "pip"] }),
131
+ );
132
+
133
+ const sandboxBridge = createBridge!(
134
+ {
135
+ iframe: document.createElement("iframe"),
136
+ origin: "https://widget.example",
137
+ sendMessage: vi.fn(),
138
+ },
139
+ { setHeight: vi.fn() },
140
+ );
141
+ const options = createMcpAppBridgeMock.mock
142
+ .calls[0]![0] as CreateMcpAppBridgeOptions;
143
+ options.handlers?.onInitialized?.();
144
+
145
+ rendered.rerender(
146
+ view({ displayMode: "inline", availableDisplayModes: ["inline", "pip"] }),
147
+ );
148
+ expect(bridge.notifyHostContextChanged).not.toHaveBeenCalled();
149
+
150
+ rendered.rerender(
151
+ view({
152
+ displayMode: "fullscreen",
153
+ availableDisplayModes: ["inline", "pip"],
154
+ }),
155
+ );
156
+ expect(bridge.notifyHostContextChanged).toHaveBeenCalledTimes(1);
157
+ expect(bridge.notifyHostContextChanged).toHaveBeenCalledWith({
158
+ displayMode: "fullscreen",
159
+ availableDisplayModes: ["inline", "pip"],
160
+ });
161
+
162
+ sandboxBridge.dispose();
163
+ });
164
+ it("cancels a queued host context that returns to the delivered value", () => {
165
+ let createBridge: SandboxHostProps["createBridge"] | null = null;
166
+ sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
167
+ createBridge ??= props.createBridge;
168
+ return null;
169
+ });
170
+ const bridge: McpAppBridge = {
171
+ onMessage: vi.fn(),
172
+ dispose: vi.fn(),
173
+ notifyToolInput: vi.fn(),
174
+ notifyToolResult: vi.fn(),
175
+ notifyHostContextChanged: vi.fn(),
176
+ };
177
+ createMcpAppBridgeMock.mockReturnValue(bridge);
178
+
179
+ const view = (hostContext: McpAppHostContext) => (
180
+ <McpAppFrame
181
+ app={{ resourceUri: "ui://example/widget" }}
182
+ resource={{
183
+ uri: "ui://example/widget",
184
+ mimeType: MCP_APP_MIME_TYPE,
185
+ html: "",
186
+ }}
187
+ hostContext={hostContext}
188
+ />
189
+ );
190
+ const rendered = render(view({ displayMode: "inline" }));
191
+
192
+ const sandboxBridge = createBridge!(
193
+ {
194
+ iframe: document.createElement("iframe"),
195
+ origin: "https://widget.example",
196
+ sendMessage: vi.fn(),
197
+ },
198
+ { setHeight: vi.fn() },
199
+ );
200
+
201
+ rendered.rerender(view({ displayMode: "fullscreen" }));
202
+ rendered.rerender(view({ displayMode: "inline" }));
203
+
204
+ const options = createMcpAppBridgeMock.mock
205
+ .calls[0]![0] as CreateMcpAppBridgeOptions;
206
+ options.handlers?.onInitialized?.();
207
+
208
+ expect(bridge.notifyHostContextChanged).not.toHaveBeenCalled();
209
+
210
+ sandboxBridge.dispose();
211
+ });
212
+
213
+ it("treats an equal host context as unchanged when it is not strict JSON", () => {
214
+ let createBridge: SandboxHostProps["createBridge"] | null = null;
215
+ sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
216
+ createBridge ??= props.createBridge;
217
+ return null;
218
+ });
219
+ const bridge: McpAppBridge = {
220
+ onMessage: vi.fn(),
221
+ dispose: vi.fn(),
222
+ notifyToolInput: vi.fn(),
223
+ notifyToolResult: vi.fn(),
224
+ notifyHostContextChanged: vi.fn(),
225
+ };
226
+ createMcpAppBridgeMock.mockReturnValue(bridge);
227
+
228
+ const view = () => (
229
+ <McpAppFrame
230
+ app={{ resourceUri: "ui://example/widget" }}
231
+ resource={{
232
+ uri: "ui://example/widget",
233
+ mimeType: MCP_APP_MIME_TYPE,
234
+ html: "",
235
+ }}
236
+ hostContext={{ displayMode: "inline", workspace: undefined }}
237
+ />
238
+ );
239
+ const rendered = render(view());
240
+
241
+ const sandboxBridge = createBridge!(
242
+ {
243
+ iframe: document.createElement("iframe"),
244
+ origin: "https://widget.example",
245
+ sendMessage: vi.fn(),
246
+ },
247
+ { setHeight: vi.fn() },
248
+ );
249
+ const options = createMcpAppBridgeMock.mock
250
+ .calls[0]![0] as CreateMcpAppBridgeOptions;
251
+ options.handlers?.onInitialized?.();
252
+
253
+ rendered.rerender(view());
254
+ expect(bridge.notifyHostContextChanged).not.toHaveBeenCalled();
255
+
256
+ sandboxBridge.dispose();
257
+ });
258
+ it("notifies when a context value changes behind an unchanged key set", () => {
259
+ let createBridge: SandboxHostProps["createBridge"] | null = null;
260
+ sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
261
+ createBridge ??= props.createBridge;
262
+ return null;
263
+ });
264
+ const bridge: McpAppBridge = {
265
+ onMessage: vi.fn(),
266
+ dispose: vi.fn(),
267
+ notifyToolInput: vi.fn(),
268
+ notifyToolResult: vi.fn(),
269
+ notifyHostContextChanged: vi.fn(),
270
+ };
271
+ createMcpAppBridgeMock.mockReturnValue(bridge);
272
+
273
+ const view = (updatedAt: Date) => (
274
+ <McpAppFrame
275
+ app={{ resourceUri: "ui://example/widget" }}
276
+ resource={{
277
+ uri: "ui://example/widget",
278
+ mimeType: MCP_APP_MIME_TYPE,
279
+ html: "",
280
+ }}
281
+ hostContext={{ updatedAt }}
282
+ />
283
+ );
284
+ const rendered = render(view(new Date(1)));
285
+
286
+ const sandboxBridge = createBridge!(
287
+ {
288
+ iframe: document.createElement("iframe"),
289
+ origin: "https://widget.example",
290
+ sendMessage: vi.fn(),
291
+ },
292
+ { setHeight: vi.fn() },
293
+ );
294
+ const options = createMcpAppBridgeMock.mock
295
+ .calls[0]![0] as CreateMcpAppBridgeOptions;
296
+ options.handlers?.onInitialized?.();
297
+
298
+ rendered.rerender(view(new Date(2)));
299
+ expect(bridge.notifyHostContextChanged).toHaveBeenCalledTimes(1);
300
+
301
+ sandboxBridge.dispose();
302
+ });
303
+
304
+ it("does not repeat the empty context the bridge already delivered", () => {
305
+ let createBridge: SandboxHostProps["createBridge"] | null = null;
306
+ sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
307
+ createBridge ??= props.createBridge;
308
+ return null;
309
+ });
310
+ const bridge: McpAppBridge = {
311
+ onMessage: vi.fn(),
312
+ dispose: vi.fn(),
313
+ notifyToolInput: vi.fn(),
314
+ notifyToolResult: vi.fn(),
315
+ notifyHostContextChanged: vi.fn(),
316
+ };
317
+ createMcpAppBridgeMock.mockReturnValue(bridge);
318
+
319
+ const view = (hostContext?: McpAppHostContext) => (
320
+ <McpAppFrame
321
+ app={{ resourceUri: "ui://example/widget" }}
322
+ resource={{
323
+ uri: "ui://example/widget",
324
+ mimeType: MCP_APP_MIME_TYPE,
325
+ html: "",
326
+ }}
327
+ {...(hostContext === undefined ? {} : { hostContext })}
328
+ />
329
+ );
330
+ const rendered = render(view());
331
+
332
+ const sandboxBridge = createBridge!(
333
+ {
334
+ iframe: document.createElement("iframe"),
335
+ origin: "https://widget.example",
336
+ sendMessage: vi.fn(),
337
+ },
338
+ { setHeight: vi.fn() },
339
+ );
340
+ const options = createMcpAppBridgeMock.mock
341
+ .calls[0]![0] as CreateMcpAppBridgeOptions;
342
+ options.handlers?.onInitialized?.();
343
+
344
+ rendered.rerender(view({}));
345
+ expect(bridge.notifyHostContextChanged).not.toHaveBeenCalled();
346
+
347
+ sandboxBridge.dispose();
348
+ });
102
349
  });
@@ -18,11 +18,48 @@ import type {
18
18
  McpAppFrameProps,
19
19
  McpAppHostContext,
20
20
  } from "./types";
21
+ import { isRecord } from "@assistant-ui/core/internal";
21
22
 
22
23
  const DEFAULT_PRODUCT = "assistant-ui-mcp-app";
23
24
  const INIT_TIMEOUT_MS = 5000;
24
25
  const DEFAULT_MAX_HEIGHT = 800;
25
26
 
27
+ // Only a plain object can be compared by its own keys. Structured clone carries
28
+ // a Date, Map, or class instance to the widget intact, and those expose no
29
+ // enumerable keys, so walking them would report two different values as equal.
30
+ const isPlainObject = (value: unknown): value is Record<string, unknown> => {
31
+ if (!isRecord(value)) return false;
32
+ const prototype = Object.getPrototypeOf(value);
33
+ return prototype === null || prototype === Object.prototype;
34
+ };
35
+
36
+ // Hosts commonly rebuild an equal context object on every render, and the
37
+ // widget cannot tell a repeated notification apart from a real change. The
38
+ // open index signature on McpAppHostContext admits values JSON rejects (an
39
+ // undefined property value is enough), so leaves the walk cannot compare fall
40
+ // back to identity rather than failing the whole comparison.
41
+ const isSameHostContext = (a: unknown, b: unknown, depth = 0): boolean => {
42
+ if (Object.is(a, b)) return true;
43
+ if (depth > 100) return false;
44
+ if (Array.isArray(a) || Array.isArray(b)) {
45
+ return (
46
+ Array.isArray(a) &&
47
+ Array.isArray(b) &&
48
+ a.length === b.length &&
49
+ a.every((item, index) => isSameHostContext(item, b[index], depth + 1))
50
+ );
51
+ }
52
+ if (!isPlainObject(a) || !isPlainObject(b)) return false;
53
+ const aKeys = Object.keys(a);
54
+ return (
55
+ aKeys.length === Object.keys(b).length &&
56
+ aKeys.every(
57
+ (key) =>
58
+ Object.hasOwn(b, key) && isSameHostContext(a[key], b[key], depth + 1),
59
+ )
60
+ );
61
+ };
62
+
26
63
  function useBridgeNotify<T>(
27
64
  value: T | undefined,
28
65
  bridgeRef: MutableRefObject<McpAppBridge | null>,
@@ -30,18 +67,24 @@ function useBridgeNotify<T>(
30
67
  pendingRef: MutableRefObject<T | undefined>,
31
68
  lastSentRef: MutableRefObject<T | undefined>,
32
69
  notify: (bridge: McpAppBridge, v: T) => void,
70
+ isEqual: (sent: T | undefined, next: T) => boolean = Object.is,
33
71
  ) {
34
72
  useEffect(() => {
35
73
  if (!bridgeRef.current) return;
36
- if (value === undefined) return;
37
- if (lastSentRef.current === value) return;
74
+ // Nothing to deliver, either because there is no value or because the
75
+ // widget already holds it; a queued value here is superseded and would
76
+ // otherwise flush after initialization as a stale update.
77
+ if (value === undefined || isEqual(lastSentRef.current, value)) {
78
+ pendingRef.current = undefined;
79
+ return;
80
+ }
38
81
  if (!widgetReadyRef.current) {
39
82
  pendingRef.current = value;
40
83
  return;
41
84
  }
42
85
  notify(bridgeRef.current, value);
43
86
  lastSentRef.current = value;
44
- // oxlint-disable-next-line react/exhaustive-deps -- refs are stable; notify is assumed stable; re-run only when value changes
87
+ // oxlint-disable-next-line react/exhaustive-deps -- refs are stable; notify and isEqual are assumed stable; re-run only when value changes
45
88
  }, [value]);
46
89
  }
47
90
 
@@ -208,8 +251,10 @@ export function McpAppFrame({
208
251
 
209
252
  if (current.input !== undefined) pendingInputRef.current = current.input;
210
253
  if (current.output !== undefined) pendingOutputRef.current = current.output;
211
- // hostContext is delivered inside the ui/initialize response; subsequent
212
- // changes flow through useBridgeNotify's pending path.
254
+ // hostContext is delivered inside the ui/initialize response, where the
255
+ // bridge defaults it to {}; recording that same normalized value keeps the
256
+ // first later change from repeating what the widget already holds.
257
+ lastSentHostContextRef.current = current.hostContext ?? {};
213
258
 
214
259
  return {
215
260
  onMessage: bridge.onMessage,
@@ -254,6 +299,7 @@ export function McpAppFrame({
254
299
  pendingHostContextRef,
255
300
  lastSentHostContextRef,
256
301
  (b, v) => b.notifyHostContextChanged(v),
302
+ isSameHostContext,
257
303
  );
258
304
 
259
305
  return (