@assistant-ui/react 0.15.17 → 0.15.19

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 (169) hide show
  1. package/README.md +8 -2
  2. package/dist/context/providers/ThreadViewportProvider.js +2 -2
  3. package/dist/context/providers/ThreadViewportProvider.js.map +1 -1
  4. package/dist/hooks/useToolCallElapsed.js.map +1 -1
  5. package/dist/index.d.ts +7 -4
  6. package/dist/index.js +5 -3
  7. package/dist/mcp-apps/McpAppRenderer.d.ts +30 -8
  8. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  9. package/dist/mcp-apps/McpAppRenderer.js +91 -47
  10. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  11. package/dist/mcp-apps/app-frame.d.ts.map +1 -1
  12. package/dist/mcp-apps/app-frame.js +57 -33
  13. package/dist/mcp-apps/app-frame.js.map +1 -1
  14. package/dist/mcp-apps/bridge.js +9 -4
  15. package/dist/mcp-apps/bridge.js.map +1 -1
  16. package/dist/mcp-apps/index.d.ts +2 -2
  17. package/dist/primitives/assistantModal/AssistantModalRoot.d.ts.map +1 -1
  18. package/dist/primitives/assistantModal/AssistantModalRoot.js +47 -79
  19. package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
  20. package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
  21. package/dist/primitives/composer/ComposerAttachmentDropzone.js +61 -138
  22. package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
  23. package/dist/primitives/composer/ComposerInput.js +3 -8
  24. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  25. package/dist/primitives/composer/ComposerInputPluginContext.d.ts +1 -2
  26. package/dist/primitives/composer/ComposerInputPluginContext.d.ts.map +1 -1
  27. package/dist/primitives/composer/ComposerInputPluginContext.js +1 -6
  28. package/dist/primitives/composer/ComposerInputPluginContext.js.map +1 -1
  29. package/dist/primitives/composer/trigger/TriggerPopoverAction.js +46 -15
  30. package/dist/primitives/composer/trigger/TriggerPopoverAction.js.map +1 -1
  31. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +40 -10
  32. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js.map +1 -1
  33. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.d.ts +6 -0
  34. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.d.ts.map +1 -0
  35. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.js +9 -0
  36. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.js.map +1 -0
  37. package/dist/primitives/composer/trigger/triggerKeyboardResource.d.ts.map +1 -1
  38. package/dist/primitives/composer/trigger/triggerKeyboardResource.js +53 -61
  39. package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
  40. package/dist/primitives/composer/trigger/triggerNavigationResource.d.ts.map +1 -1
  41. package/dist/primitives/composer/trigger/triggerNavigationResource.js +129 -141
  42. package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
  43. package/dist/primitives/message/MessageParts.js +2 -14
  44. package/dist/primitives/message/MessageParts.js.map +1 -1
  45. package/dist/primitives/message/MessagePartsGrouped.d.ts +3 -3
  46. package/dist/primitives/message/MessagePartsGrouped.js +2 -2
  47. package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
  48. package/dist/primitives/messagePart/MessagePartText.d.ts +2 -0
  49. package/dist/primitives/messagePart/MessagePartText.d.ts.map +1 -1
  50. package/dist/primitives/messagePart/MessagePartText.js +13 -36
  51. package/dist/primitives/messagePart/MessagePartText.js.map +1 -1
  52. package/dist/primitives/reasoning/useScrollLock.js +3 -1
  53. package/dist/primitives/reasoning/useScrollLock.js.map +1 -1
  54. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  55. package/dist/primitives/thread/useThreadViewportAutoScroll.js +3 -3
  56. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  57. package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
  58. package/dist/sandbox-host/SandboxHost.js +36 -9
  59. package/dist/sandbox-host/SandboxHost.js.map +1 -1
  60. package/dist/unstable/useLiveCompletionAdapter.js +24 -2
  61. package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
  62. package/dist/unstable/useMentionAdapter.d.ts +9 -3
  63. package/dist/unstable/useMentionAdapter.d.ts.map +1 -1
  64. package/dist/unstable/useMentionAdapter.js +50 -43
  65. package/dist/unstable/useMentionAdapter.js.map +1 -1
  66. package/dist/unstable/useMessageStallDetection.d.ts +4 -5
  67. package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
  68. package/dist/unstable/useMessageStallDetection.js +16 -15
  69. package/dist/unstable/useMessageStallDetection.js.map +1 -1
  70. package/dist/unstable/useModelContextSnapshot.d.ts +34 -0
  71. package/dist/unstable/useModelContextSnapshot.d.ts.map +1 -0
  72. package/dist/unstable/useModelContextSnapshot.js +61 -0
  73. package/dist/unstable/useModelContextSnapshot.js.map +1 -0
  74. package/dist/unstable/useSlashCommandAdapter.d.ts.map +1 -1
  75. package/dist/unstable/useSlashCommandAdapter.js +2 -8
  76. package/dist/unstable/useSlashCommandAdapter.js.map +1 -1
  77. package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts +14 -0
  78. package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts.map +1 -0
  79. package/dist/unstable/webmcp/WebMcpRegistrationResource.js +85 -0
  80. package/dist/unstable/webmcp/WebMcpRegistrationResource.js.map +1 -0
  81. package/dist/unstable/webmcp/convertTools.d.ts +21 -0
  82. package/dist/unstable/webmcp/convertTools.d.ts.map +1 -0
  83. package/dist/unstable/webmcp/convertTools.js +161 -0
  84. package/dist/unstable/webmcp/convertTools.js.map +1 -0
  85. package/dist/unstable/webmcp/useWebMcpProvider.d.ts +21 -0
  86. package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -0
  87. package/dist/unstable/webmcp/useWebMcpProvider.js +106 -0
  88. package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -0
  89. package/dist/unstable/webmcp/webmcp-host.d.ts +39 -0
  90. package/dist/unstable/webmcp/webmcp-host.d.ts.map +1 -0
  91. package/dist/unstable/webmcp/webmcp-host.js +29 -0
  92. package/dist/unstable/webmcp/webmcp-host.js.map +1 -0
  93. package/dist/utils/Primitive.d.ts +10 -2
  94. package/dist/utils/Primitive.d.ts.map +1 -1
  95. package/dist/utils/Primitive.js +25 -6
  96. package/dist/utils/Primitive.js.map +1 -1
  97. package/dist/utils/getSelectionMessageId.d.ts.map +1 -1
  98. package/dist/utils/getSelectionMessageId.js +17 -3
  99. package/dist/utils/getSelectionMessageId.js.map +1 -1
  100. package/package.json +13 -13
  101. package/src/context/providers/ThreadViewportProvider.tsx +2 -2
  102. package/src/hooks/useToolCallElapsed.ts +3 -0
  103. package/src/index.ts +14 -1
  104. package/src/mcp-apps/McpAppRenderer.test.tsx +253 -5
  105. package/src/mcp-apps/McpAppRenderer.tsx +147 -52
  106. package/src/mcp-apps/app-frame.test.tsx +437 -1
  107. package/src/mcp-apps/app-frame.tsx +76 -21
  108. package/src/mcp-apps/bridge.test.ts +98 -0
  109. package/src/mcp-apps/bridge.ts +14 -3
  110. package/src/mcp-apps/index.ts +5 -1
  111. package/src/primitives/assistantModal/AssistantModalRoot.test.tsx +123 -0
  112. package/src/primitives/assistantModal/AssistantModalRoot.tsx +11 -31
  113. package/src/primitives/assistantModal/AssistantModalTrigger.test.tsx +1 -16
  114. package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +43 -0
  115. package/src/primitives/composer/ComposerAttachmentDropzone.tsx +2 -10
  116. package/src/primitives/composer/ComposerInput.test.tsx +22 -0
  117. package/src/primitives/composer/ComposerInput.tsx +5 -9
  118. package/src/primitives/composer/ComposerInputPluginContext.tsx +0 -10
  119. package/src/primitives/composer/trigger/TriggerPopoverAction.tsx +4 -2
  120. package/src/primitives/composer/trigger/TriggerPopoverBehavior.test.tsx +109 -0
  121. package/src/primitives/composer/trigger/TriggerPopoverDirective.tsx +4 -2
  122. package/src/primitives/composer/trigger/matchesTriggerItemQuery.test.ts +52 -0
  123. package/src/primitives/composer/trigger/matchesTriggerItemQuery.ts +13 -0
  124. package/src/primitives/composer/trigger/triggerKeyboardResource.ts +13 -7
  125. package/src/primitives/composer/trigger/triggerNavigationResource.ts +8 -13
  126. package/src/primitives/message/MessageParts.tsx +1 -21
  127. package/src/primitives/message/MessagePartsGrouped.test.tsx +93 -0
  128. package/src/primitives/message/MessagePartsGrouped.tsx +6 -6
  129. package/src/primitives/messagePart/MessagePartText.test.tsx +69 -0
  130. package/src/primitives/messagePart/MessagePartText.tsx +22 -9
  131. package/src/primitives/reasoning/useScrollLock.test.tsx +129 -0
  132. package/src/primitives/reasoning/useScrollLock.ts +15 -1
  133. package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +38 -1
  134. package/src/primitives/thread/useThreadViewportAutoScroll.ts +7 -6
  135. package/src/sandbox-host/SandboxHost.test.tsx +160 -2
  136. package/src/sandbox-host/SandboxHost.tsx +50 -10
  137. package/src/tests/MessageParts.rendererOptions.test.tsx +117 -0
  138. package/src/tests/toolCallTiming.test.tsx +55 -1
  139. package/src/unstable/useComposerInputHistory.test.tsx +2 -1
  140. package/src/unstable/useLiveCompletionAdapter.test.tsx +202 -1
  141. package/src/unstable/useLiveCompletionAdapter.ts +38 -8
  142. package/src/unstable/useMentionAdapter.test.tsx +383 -0
  143. package/src/unstable/useMentionAdapter.ts +94 -57
  144. package/src/unstable/useMessageStallDetection.ts +26 -19
  145. package/src/unstable/useModelContextSnapshot.test.tsx +132 -0
  146. package/src/unstable/useModelContextSnapshot.ts +67 -0
  147. package/src/unstable/useSlashCommandAdapter.ts +2 -9
  148. package/src/unstable/webmcp/WebMcpRegistrationResource.ts +92 -0
  149. package/src/unstable/webmcp/__tests__/webmcp.fake.tsx +168 -0
  150. package/src/unstable/webmcp/convertTools.test.ts +553 -0
  151. package/src/unstable/webmcp/convertTools.ts +260 -0
  152. package/src/unstable/webmcp/useWebMcpProvider.test.tsx +466 -0
  153. package/src/unstable/webmcp/useWebMcpProvider.ts +148 -0
  154. package/src/unstable/webmcp/webmcp-host.test.ts +142 -0
  155. package/src/unstable/webmcp/webmcp-host.ts +83 -0
  156. package/src/utils/Primitive.test.tsx +46 -1
  157. package/src/utils/Primitive.tsx +37 -7
  158. package/src/utils/getSelectionMessageId.test.ts +176 -1
  159. package/src/utils/getSelectionMessageId.ts +21 -3
  160. package/dist/utils/invokeCallbackSafely.d.ts +0 -5
  161. package/dist/utils/invokeCallbackSafely.d.ts.map +0 -1
  162. package/dist/utils/invokeCallbackSafely.js +0 -15
  163. package/dist/utils/invokeCallbackSafely.js.map +0 -1
  164. package/dist/utils/useToolArgsFieldStatus.d.ts +0 -19
  165. package/dist/utils/useToolArgsFieldStatus.d.ts.map +0 -1
  166. package/dist/utils/useToolArgsFieldStatus.js +0 -23
  167. package/dist/utils/useToolArgsFieldStatus.js.map +0 -1
  168. package/src/utils/invokeCallbackSafely.ts +0 -11
  169. package/src/utils/useToolArgsFieldStatus.ts +0 -18
@@ -7,7 +7,8 @@ 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 type * as BridgeModule from "./bridge";
11
+ import { MCP_APP_MIME_TYPE, type McpAppHostContext } from "./types";
11
12
 
12
13
  const { sandboxHostMock, createMcpAppBridgeMock } = vi.hoisted(() => ({
13
14
  sandboxHostMock: vi.fn(),
@@ -99,4 +100,439 @@ describe("McpAppFrame", () => {
99
100
 
100
101
  sandboxBridge.dispose();
101
102
  });
103
+
104
+ it.each([
105
+ { initial: [], next: ["search"], allowed: true },
106
+ { initial: ["search"], next: [], allowed: false },
107
+ { initial: undefined, next: ["other"], allowed: false },
108
+ { initial: [], next: undefined, allowed: true },
109
+ ])(
110
+ "applies replacement tool allowlists: $initial -> $next",
111
+ async ({ initial, next, allowed }) => {
112
+ const { createMcpAppBridge } =
113
+ await vi.importActual<typeof BridgeModule>("./bridge");
114
+ createMcpAppBridgeMock.mockImplementationOnce(createMcpAppBridge);
115
+ const captured: { createBridge?: SandboxHostProps["createBridge"] } = {};
116
+ sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
117
+ captured.createBridge ??= props.createBridge;
118
+ return null;
119
+ });
120
+ const callTool = vi.fn(() => ({ content: [] }));
121
+ const view = (allowedTools: readonly string[] | undefined) => (
122
+ <McpAppFrame
123
+ app={{ resourceUri: "ui://example/widget" }}
124
+ resource={{
125
+ uri: "ui://example/widget",
126
+ mimeType: MCP_APP_MIME_TYPE,
127
+ html: "",
128
+ }}
129
+ handlers={
130
+ allowedTools === undefined
131
+ ? { callTool }
132
+ : { allowedTools, callTool }
133
+ }
134
+ />
135
+ );
136
+ const rendered = render(view(initial));
137
+ if (!captured.createBridge) throw new Error("Frame did not mount");
138
+ const sendMessage = vi.fn();
139
+ const bridge = captured.createBridge(
140
+ {
141
+ iframe: document.createElement("iframe"),
142
+ origin: "https://widget.example",
143
+ sendMessage,
144
+ },
145
+ { setHeight: vi.fn() },
146
+ );
147
+ try {
148
+ rendered.rerender(view(next));
149
+ bridge.onMessage(
150
+ new MessageEvent("message", {
151
+ data: {
152
+ jsonrpc: "2.0",
153
+ id: 1,
154
+ method: "tools/call",
155
+ params: { name: "search" },
156
+ },
157
+ }),
158
+ );
159
+ await Promise.resolve();
160
+ expect(sendMessage).toHaveBeenCalledWith(
161
+ expect.objectContaining(
162
+ allowed
163
+ ? { id: 1, result: { content: [] } }
164
+ : { id: 1, error: expect.objectContaining({ code: -32602 }) },
165
+ ),
166
+ );
167
+ expect(callTool).toHaveBeenCalledTimes(allowed ? 1 : 0);
168
+ } finally {
169
+ bridge.dispose();
170
+ }
171
+ },
172
+ );
173
+
174
+ it("forwards size changes to the sandbox host and live handler", () => {
175
+ let createBridge: SandboxHostProps["createBridge"] | null = null;
176
+ sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
177
+ createBridge ??= props.createBridge;
178
+ return null;
179
+ });
180
+ const bridge: McpAppBridge = {
181
+ onMessage: vi.fn(),
182
+ dispose: vi.fn(),
183
+ notifyToolInput: vi.fn(),
184
+ notifyToolResult: vi.fn(),
185
+ notifyHostContextChanged: vi.fn(),
186
+ };
187
+ createMcpAppBridgeMock.mockReturnValue(bridge);
188
+ const initialOnSizeChange = vi.fn();
189
+ const replacementOnSizeChange = vi.fn();
190
+ const view = (onSizeChange: typeof initialOnSizeChange) => (
191
+ <McpAppFrame
192
+ app={{ resourceUri: "ui://example/widget" }}
193
+ resource={{
194
+ uri: "ui://example/widget",
195
+ mimeType: MCP_APP_MIME_TYPE,
196
+ html: "",
197
+ }}
198
+ handlers={{ onSizeChange }}
199
+ />
200
+ );
201
+ const rendered = render(view(initialOnSizeChange));
202
+
203
+ const setHeight = vi.fn();
204
+ const sandboxBridge = createBridge!(
205
+ {
206
+ iframe: document.createElement("iframe"),
207
+ origin: "https://widget.example",
208
+ sendMessage: vi.fn(),
209
+ },
210
+ { setHeight },
211
+ );
212
+ const options = createMcpAppBridgeMock.mock
213
+ .calls[0]![0] as CreateMcpAppBridgeOptions;
214
+
215
+ try {
216
+ const initialSize = { width: 640, height: 360 };
217
+ options.handlers?.onSizeChange?.(initialSize);
218
+ expect(setHeight).toHaveBeenCalledWith(360);
219
+ expect(initialOnSizeChange).toHaveBeenCalledWith(initialSize);
220
+
221
+ rendered.rerender(view(replacementOnSizeChange));
222
+ const replacementSize = { width: 800, height: 480 };
223
+ options.handlers?.onSizeChange?.(replacementSize);
224
+ expect(setHeight).toHaveBeenNthCalledWith(2, 480);
225
+ expect(initialOnSizeChange).toHaveBeenCalledOnce();
226
+ expect(replacementOnSizeChange).toHaveBeenCalledOnce();
227
+ expect(replacementOnSizeChange).toHaveBeenCalledWith(replacementSize);
228
+
229
+ const widthOnlySize = { width: 720 };
230
+ options.handlers?.onSizeChange?.(widthOnlySize);
231
+ expect(setHeight).toHaveBeenCalledTimes(2);
232
+ expect(replacementOnSizeChange).toHaveBeenCalledWith(widthOnlySize);
233
+ } finally {
234
+ sandboxBridge.dispose();
235
+ }
236
+ });
237
+
238
+ it("disposes the underlying bridge with the sandbox bridge", () => {
239
+ let createBridge: SandboxHostProps["createBridge"] | null = null;
240
+ sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
241
+ createBridge ??= props.createBridge;
242
+ return null;
243
+ });
244
+ const bridge: McpAppBridge = {
245
+ onMessage: vi.fn(),
246
+ dispose: vi.fn(),
247
+ notifyToolInput: vi.fn(),
248
+ notifyToolResult: vi.fn(),
249
+ notifyHostContextChanged: vi.fn(),
250
+ };
251
+ createMcpAppBridgeMock.mockReturnValue(bridge);
252
+ render(
253
+ <McpAppFrame
254
+ app={{ resourceUri: "ui://example/widget" }}
255
+ resource={{
256
+ uri: "ui://example/widget",
257
+ mimeType: MCP_APP_MIME_TYPE,
258
+ html: "",
259
+ }}
260
+ />,
261
+ );
262
+
263
+ const sandboxBridge = createBridge!(
264
+ {
265
+ iframe: document.createElement("iframe"),
266
+ origin: "https://widget.example",
267
+ sendMessage: vi.fn(),
268
+ },
269
+ { setHeight: vi.fn() },
270
+ );
271
+ sandboxBridge.dispose();
272
+
273
+ expect(bridge.dispose).toHaveBeenCalledOnce();
274
+ });
275
+
276
+ it("only notifies the widget when host context actually changes", () => {
277
+ let createBridge: SandboxHostProps["createBridge"] | null = null;
278
+ sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
279
+ createBridge ??= props.createBridge;
280
+ return null;
281
+ });
282
+ const bridge: McpAppBridge = {
283
+ onMessage: vi.fn(),
284
+ dispose: vi.fn(),
285
+ notifyToolInput: vi.fn(),
286
+ notifyToolResult: vi.fn(),
287
+ notifyHostContextChanged: vi.fn(),
288
+ };
289
+ createMcpAppBridgeMock.mockReturnValue(bridge);
290
+
291
+ const view = (hostContext: McpAppHostContext) => (
292
+ <McpAppFrame
293
+ app={{ resourceUri: "ui://example/widget" }}
294
+ resource={{
295
+ uri: "ui://example/widget",
296
+ mimeType: MCP_APP_MIME_TYPE,
297
+ html: "",
298
+ }}
299
+ hostContext={hostContext}
300
+ />
301
+ );
302
+ const rendered = render(
303
+ view({ displayMode: "inline", availableDisplayModes: ["inline", "pip"] }),
304
+ );
305
+
306
+ const sandboxBridge = createBridge!(
307
+ {
308
+ iframe: document.createElement("iframe"),
309
+ origin: "https://widget.example",
310
+ sendMessage: vi.fn(),
311
+ },
312
+ { setHeight: vi.fn() },
313
+ );
314
+ const options = createMcpAppBridgeMock.mock
315
+ .calls[0]![0] as CreateMcpAppBridgeOptions;
316
+ options.handlers?.onInitialized?.();
317
+
318
+ rendered.rerender(
319
+ view({ displayMode: "inline", availableDisplayModes: ["inline", "pip"] }),
320
+ );
321
+ expect(bridge.notifyHostContextChanged).not.toHaveBeenCalled();
322
+
323
+ rendered.rerender(
324
+ view({
325
+ displayMode: "fullscreen",
326
+ availableDisplayModes: ["inline", "pip"],
327
+ }),
328
+ );
329
+ expect(bridge.notifyHostContextChanged).toHaveBeenCalledTimes(1);
330
+ expect(bridge.notifyHostContextChanged).toHaveBeenCalledWith({
331
+ displayMode: "fullscreen",
332
+ availableDisplayModes: ["inline", "pip"],
333
+ });
334
+
335
+ // A hole is not a value, in either operand order.
336
+ const sparseModes: ("inline" | "pip")[] = new Array(2);
337
+ sparseModes[1] = "pip";
338
+ rendered.rerender(
339
+ view({ displayMode: "fullscreen", availableDisplayModes: sparseModes }),
340
+ );
341
+ expect(bridge.notifyHostContextChanged).toHaveBeenCalledTimes(2);
342
+
343
+ rendered.rerender(
344
+ view({
345
+ displayMode: "fullscreen",
346
+ availableDisplayModes: ["inline", "pip"],
347
+ }),
348
+ );
349
+ expect(bridge.notifyHostContextChanged).toHaveBeenCalledTimes(3);
350
+
351
+ sandboxBridge.dispose();
352
+ });
353
+ it("cancels a queued host context that returns to the delivered value", () => {
354
+ let createBridge: SandboxHostProps["createBridge"] | null = null;
355
+ sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
356
+ createBridge ??= props.createBridge;
357
+ return null;
358
+ });
359
+ const bridge: McpAppBridge = {
360
+ onMessage: vi.fn(),
361
+ dispose: vi.fn(),
362
+ notifyToolInput: vi.fn(),
363
+ notifyToolResult: vi.fn(),
364
+ notifyHostContextChanged: vi.fn(),
365
+ };
366
+ createMcpAppBridgeMock.mockReturnValue(bridge);
367
+
368
+ const view = (hostContext: McpAppHostContext) => (
369
+ <McpAppFrame
370
+ app={{ resourceUri: "ui://example/widget" }}
371
+ resource={{
372
+ uri: "ui://example/widget",
373
+ mimeType: MCP_APP_MIME_TYPE,
374
+ html: "",
375
+ }}
376
+ hostContext={hostContext}
377
+ />
378
+ );
379
+ const rendered = render(view({ displayMode: "inline" }));
380
+
381
+ const sandboxBridge = createBridge!(
382
+ {
383
+ iframe: document.createElement("iframe"),
384
+ origin: "https://widget.example",
385
+ sendMessage: vi.fn(),
386
+ },
387
+ { setHeight: vi.fn() },
388
+ );
389
+
390
+ rendered.rerender(view({ displayMode: "fullscreen" }));
391
+ rendered.rerender(view({ displayMode: "inline" }));
392
+
393
+ const options = createMcpAppBridgeMock.mock
394
+ .calls[0]![0] as CreateMcpAppBridgeOptions;
395
+ options.handlers?.onInitialized?.();
396
+
397
+ expect(bridge.notifyHostContextChanged).not.toHaveBeenCalled();
398
+
399
+ sandboxBridge.dispose();
400
+ });
401
+
402
+ it("treats an equal host context as unchanged when it is not strict JSON", () => {
403
+ let createBridge: SandboxHostProps["createBridge"] | null = null;
404
+ sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
405
+ createBridge ??= props.createBridge;
406
+ return null;
407
+ });
408
+ const bridge: McpAppBridge = {
409
+ onMessage: vi.fn(),
410
+ dispose: vi.fn(),
411
+ notifyToolInput: vi.fn(),
412
+ notifyToolResult: vi.fn(),
413
+ notifyHostContextChanged: vi.fn(),
414
+ };
415
+ createMcpAppBridgeMock.mockReturnValue(bridge);
416
+
417
+ const view = () => (
418
+ <McpAppFrame
419
+ app={{ resourceUri: "ui://example/widget" }}
420
+ resource={{
421
+ uri: "ui://example/widget",
422
+ mimeType: MCP_APP_MIME_TYPE,
423
+ html: "",
424
+ }}
425
+ hostContext={{ displayMode: "inline", workspace: undefined }}
426
+ />
427
+ );
428
+ const rendered = render(view());
429
+
430
+ const sandboxBridge = createBridge!(
431
+ {
432
+ iframe: document.createElement("iframe"),
433
+ origin: "https://widget.example",
434
+ sendMessage: vi.fn(),
435
+ },
436
+ { setHeight: vi.fn() },
437
+ );
438
+ const options = createMcpAppBridgeMock.mock
439
+ .calls[0]![0] as CreateMcpAppBridgeOptions;
440
+ options.handlers?.onInitialized?.();
441
+
442
+ rendered.rerender(view());
443
+ expect(bridge.notifyHostContextChanged).not.toHaveBeenCalled();
444
+
445
+ sandboxBridge.dispose();
446
+ });
447
+ it("notifies when a context value changes behind an unchanged key set", () => {
448
+ let createBridge: SandboxHostProps["createBridge"] | null = null;
449
+ sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
450
+ createBridge ??= props.createBridge;
451
+ return null;
452
+ });
453
+ const bridge: McpAppBridge = {
454
+ onMessage: vi.fn(),
455
+ dispose: vi.fn(),
456
+ notifyToolInput: vi.fn(),
457
+ notifyToolResult: vi.fn(),
458
+ notifyHostContextChanged: vi.fn(),
459
+ };
460
+ createMcpAppBridgeMock.mockReturnValue(bridge);
461
+
462
+ const view = (updatedAt: Date) => (
463
+ <McpAppFrame
464
+ app={{ resourceUri: "ui://example/widget" }}
465
+ resource={{
466
+ uri: "ui://example/widget",
467
+ mimeType: MCP_APP_MIME_TYPE,
468
+ html: "",
469
+ }}
470
+ hostContext={{ updatedAt }}
471
+ />
472
+ );
473
+ const rendered = render(view(new Date(1)));
474
+
475
+ const sandboxBridge = createBridge!(
476
+ {
477
+ iframe: document.createElement("iframe"),
478
+ origin: "https://widget.example",
479
+ sendMessage: vi.fn(),
480
+ },
481
+ { setHeight: vi.fn() },
482
+ );
483
+ const options = createMcpAppBridgeMock.mock
484
+ .calls[0]![0] as CreateMcpAppBridgeOptions;
485
+ options.handlers?.onInitialized?.();
486
+
487
+ rendered.rerender(view(new Date(2)));
488
+ expect(bridge.notifyHostContextChanged).toHaveBeenCalledTimes(1);
489
+
490
+ sandboxBridge.dispose();
491
+ });
492
+
493
+ it("does not repeat the empty context the bridge already delivered", () => {
494
+ let createBridge: SandboxHostProps["createBridge"] | null = null;
495
+ sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
496
+ createBridge ??= props.createBridge;
497
+ return null;
498
+ });
499
+ const bridge: McpAppBridge = {
500
+ onMessage: vi.fn(),
501
+ dispose: vi.fn(),
502
+ notifyToolInput: vi.fn(),
503
+ notifyToolResult: vi.fn(),
504
+ notifyHostContextChanged: vi.fn(),
505
+ };
506
+ createMcpAppBridgeMock.mockReturnValue(bridge);
507
+
508
+ const view = (hostContext?: McpAppHostContext) => (
509
+ <McpAppFrame
510
+ app={{ resourceUri: "ui://example/widget" }}
511
+ resource={{
512
+ uri: "ui://example/widget",
513
+ mimeType: MCP_APP_MIME_TYPE,
514
+ html: "",
515
+ }}
516
+ {...(hostContext === undefined ? {} : { hostContext })}
517
+ />
518
+ );
519
+ const rendered = render(view());
520
+
521
+ const sandboxBridge = createBridge!(
522
+ {
523
+ iframe: document.createElement("iframe"),
524
+ origin: "https://widget.example",
525
+ sendMessage: vi.fn(),
526
+ },
527
+ { setHeight: vi.fn() },
528
+ );
529
+ const options = createMcpAppBridgeMock.mock
530
+ .calls[0]![0] as CreateMcpAppBridgeOptions;
531
+ options.handlers?.onInitialized?.();
532
+
533
+ rendered.rerender(view({}));
534
+ expect(bridge.notifyHostContextChanged).not.toHaveBeenCalled();
535
+
536
+ sandboxBridge.dispose();
537
+ });
102
538
  });
@@ -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
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
46
+ return false;
47
+ for (let i = 0; i < a.length; i++) {
48
+ if (!isSameHostContext(a[i], b[i], depth + 1)) return false;
49
+ }
50
+ return true;
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
 
@@ -53,25 +96,33 @@ type LiveSnapshot = {
53
96
  output: unknown;
54
97
  };
55
98
 
99
+ type FrameLifecycle = {
100
+ onInitialized: () => void;
101
+ onSizeChange: (params: { width?: number; height?: number }) => void;
102
+ };
103
+
56
104
  // Proxy each per-call handler through liveRef so the bridge always dispatches
57
105
  // to the latest handler reference (e.g. inline callbacks closing over state).
58
106
  // Capability presence is snapshot at mount: a handler added later requires a
59
107
  // remount (keyed on resource URI) to expose the capability to the widget.
108
+ // allowedTools is the exception: it is never advertised in the ui/initialize
109
+ // response, so it stays a live getter, which means this object must reach the
110
+ // bridge uncopied and the frame passes its lifecycle work in rather than
111
+ // wrapping the result.
60
112
  function buildLiveHandlers(
61
113
  initial: McpAppBridgeHandlers | undefined,
62
114
  liveRef: { readonly current: LiveSnapshot },
115
+ lifecycle: FrameLifecycle,
63
116
  ): McpAppBridgeHandlers {
64
117
  const live = () => liveRef.current.handlers;
65
118
  const has = <K extends keyof McpAppBridgeHandlers>(key: K) =>
66
119
  initial?.[key] !== undefined;
67
120
  const out: McpAppBridgeHandlers = {};
68
- if (has("allowedTools")) {
69
- Object.defineProperty(out, "allowedTools", {
70
- get: () => live()?.allowedTools,
71
- enumerable: true,
72
- configurable: true,
73
- });
74
- }
121
+ Object.defineProperty(out, "allowedTools", {
122
+ get: () => live()?.allowedTools,
123
+ enumerable: true,
124
+ configurable: true,
125
+ });
75
126
  const liveCall = <K extends keyof McpAppBridgeHandlers>(
76
127
  key: K,
77
128
  ): NonNullable<McpAppBridgeHandlers[K]> =>
@@ -91,8 +142,14 @@ function buildLiveHandlers(
91
142
  out.updateModelContext = liveCall("updateModelContext");
92
143
  if (has("requestDisplayMode"))
93
144
  out.requestDisplayMode = liveCall("requestDisplayMode");
94
- out.onSizeChange = (p) => live()?.onSizeChange?.(p);
95
- out.onInitialized = () => live()?.onInitialized?.();
145
+ out.onSizeChange = (p) => {
146
+ lifecycle.onSizeChange(p);
147
+ live()?.onSizeChange?.(p);
148
+ };
149
+ out.onInitialized = () => {
150
+ lifecycle.onInitialized();
151
+ live()?.onInitialized?.();
152
+ };
96
153
  out.onRequestTeardown = (p) => live()?.onRequestTeardown?.(p);
97
154
  out.onLog = (p) => live()?.onLog?.(p);
98
155
  out.onError = (e) => live()?.onError?.(e);
@@ -172,23 +229,18 @@ export function McpAppFrame({
172
229
  }
173
230
  };
174
231
 
175
- const liveHandlers = buildLiveHandlers(current.handlers, liveRef);
176
- const liveOnInitialized = liveHandlers.onInitialized;
177
- const wrappedHandlers: McpAppBridgeHandlers = {
178
- ...liveHandlers,
232
+ const wrappedHandlers = buildLiveHandlers(current.handlers, liveRef, {
179
233
  onInitialized: () => {
180
234
  if (initTimeoutId !== null) {
181
235
  clearTimeout(initTimeoutId);
182
236
  initTimeoutId = null;
183
237
  }
184
238
  flushPending();
185
- liveOnInitialized?.();
186
239
  },
187
240
  onSizeChange: (p) => {
188
241
  if (p.height != null) host.setHeight(p.height);
189
- liveHandlers.onSizeChange?.(p);
190
242
  },
191
- };
243
+ });
192
244
 
193
245
  // Safety net: if the widget never sends notifications/initialized (broken
194
246
  // or non-spec-compliant), flush the queue anyway so the host doesn't
@@ -208,8 +260,10 @@ export function McpAppFrame({
208
260
 
209
261
  if (current.input !== undefined) pendingInputRef.current = current.input;
210
262
  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.
263
+ // hostContext is delivered inside the ui/initialize response, where the
264
+ // bridge defaults it to {}; recording that same normalized value keeps the
265
+ // first later change from repeating what the widget already holds.
266
+ lastSentHostContextRef.current = current.hostContext ?? {};
213
267
 
214
268
  return {
215
269
  onMessage: bridge.onMessage,
@@ -254,6 +308,7 @@ export function McpAppFrame({
254
308
  pendingHostContextRef,
255
309
  lastSentHostContextRef,
256
310
  (b, v) => b.notifyHostContextChanged(v),
311
+ isSameHostContext,
257
312
  );
258
313
 
259
314
  return (