@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
@@ -0,0 +1,29 @@
1
+ //#region src/unstable/webmcp/webmcp-host.ts
2
+ const isThenable = (value) => typeof value?.then === "function";
3
+ const resolveModelContext = () => {
4
+ const context = globalThis.document?.modelContext ?? globalThis.navigator?.modelContext;
5
+ if (!context) return void 0;
6
+ if (typeof context.registerTool === "function") return context;
7
+ console.warn("[assistant-ui] Ignoring a modelContext with no callable registerTool; WebMCP reports unsupported.");
8
+ };
9
+ const createHost = (context) => ({
10
+ available: true,
11
+ registerTool: (def, onError) => {
12
+ const controller = new AbortController();
13
+ const handle = context.registerTool(def, { signal: controller.signal });
14
+ if (isThenable(handle)) handle.then(() => {}, (error) => onError?.(error));
15
+ return () => controller.abort();
16
+ }
17
+ });
18
+ const getDefaultWebMcpHost = () => {
19
+ const context = resolveModelContext();
20
+ if (!context) return {
21
+ available: false,
22
+ registerTool: () => () => {}
23
+ };
24
+ return createHost(context);
25
+ };
26
+ //#endregion
27
+ export { getDefaultWebMcpHost };
28
+
29
+ //# sourceMappingURL=webmcp-host.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webmcp-host.js","names":["WebMcpContent","type","text","data","mimeType","WebMcpCallToolResult","content","isError","WebMcpToolDescriptor","name","description","inputSchema","execute","args","context","signal","AbortSignal","Promise","WebMcpModelContext","registerTool","tool","options","WebMcpHost","available","def","onError","error","ModelContextHost","modelContext","isThenable","value","then","resolveModelContext","globalThis","document","navigator","undefined","console","warn","createHost","controller","AbortController","handle","abort","getDefaultWebMcpHost"],"sources":["../../../src/unstable/webmcp/webmcp-host.ts"],"sourcesContent":["export type WebMcpContent =\n | { type: \"text\"; text: string }\n | { type: \"image\"; data: string; mimeType: string };\n\nexport type WebMcpCallToolResult = {\n content: WebMcpContent[];\n isError?: boolean;\n};\n\nexport type WebMcpToolDescriptor = {\n name: string;\n description: string;\n inputSchema: unknown;\n execute: (\n args: unknown,\n context?: { signal?: AbortSignal },\n ) => Promise<WebMcpCallToolResult>;\n};\n\nexport type WebMcpModelContext = {\n /**\n * Aborting `options.signal` is what unregisters the tool. The explainer\n * defines no other removal method, and a name-keyed one could not be used\n * safely: a later registration may already hold the name.\n */\n registerTool(\n tool: WebMcpToolDescriptor,\n options?: { signal?: AbortSignal },\n ): Promise<void> | void;\n};\n\nexport type WebMcpHost = {\n available: boolean;\n registerTool(\n def: WebMcpToolDescriptor,\n onError?: (error: unknown) => void,\n ): () => void;\n};\n\ntype ModelContextHost = { modelContext?: WebMcpModelContext } | undefined;\n\nconst isThenable = (value: unknown): value is Promise<unknown> =>\n typeof (value as { then?: unknown } | null | undefined)?.then === \"function\";\n\n// The explainer puts modelContext on document; navigator is tolerated for\n// older drafts and extensions that still inject it there.\nconst resolveModelContext = (): WebMcpModelContext | undefined => {\n const context =\n (globalThis.document as ModelContextHost)?.modelContext ??\n (globalThis.navigator as ModelContextHost)?.modelContext;\n if (!context) return undefined;\n if (typeof context.registerTool === \"function\") return context;\n console.warn(\n \"[assistant-ui] Ignoring a modelContext with no callable registerTool; WebMCP reports unsupported.\",\n );\n return undefined;\n};\n\nconst createHost = (context: WebMcpModelContext): WebMcpHost => ({\n available: true,\n registerTool: (def, onError) => {\n const controller = new AbortController();\n const handle = context.registerTool(def, { signal: controller.signal });\n if (isThenable(handle)) {\n handle.then(\n () => {},\n (error) => onError?.(error),\n );\n }\n return () => controller.abort();\n },\n});\n\nexport const getDefaultWebMcpHost = (): WebMcpHost => {\n const context = resolveModelContext();\n if (!context) {\n return {\n available: false,\n registerTool: () => () => {},\n };\n }\n return createHost(context);\n};\n"],"mappings":";AAyCA,MAAM6B,cAAcC,UAClB,OAAQA,OAAiDC,SAAS;AAIpE,MAAMC,4BAA4D;CAChE,MAAMlB,UACHmB,WAAWC,UAA+BN,gBAC1CK,WAAWE,WAAgCP;CAC9C,IAAI,CAACd,SAAS,OAAOsB,KAAAA;CACrB,IAAI,OAAOtB,QAAQK,iBAAiB,YAAY,OAAOL;CACvDuB,QAAQC,KACN,mGACF;AAEF;AAEA,MAAMC,cAAczB,aAA6C;CAC/DS,WAAW;CACXJ,eAAeK,KAAKC,YAAY;EAC9B,MAAMe,aAAa,IAAIC,gBAAgB;EACvC,MAAMC,SAAS5B,QAAQK,aAAaK,KAAK,EAAET,QAAQyB,WAAWzB,OAAO,CAAC;EACtE,IAAIc,WAAWa,MAAM,GACnBA,OAAOX,WACC,CAAC,IACNL,UAAUD,UAAUC,KAAK,CAC5B;EAEF,aAAac,WAAWG,MAAM;CAChC;AACF;AAEA,MAAaC,6BAAyC;CACpD,MAAM9B,UAAUkB,oBAAoB;CACpC,IAAI,CAAClB,SACH,OAAO;EACLS,WAAW;EACXJ,0BAA0B,CAAC;CAC7B;CAEF,OAAOoB,WAAWzB,OAAO;AAC3B"}
@@ -93,16 +93,16 @@ declare const Primitive: {
93
93
  }, "ref"> & {
94
94
  render?: ReactElement | undefined;
95
95
  } & RefAttributes<HTMLSpanElement>>;
96
- svg: ForwardRefExoticComponent<Omit<import("react").SVGProps<SVGSVGElement> & {
96
+ ul: ForwardRefExoticComponent<Omit<import("react").ClassAttributes<HTMLUListElement> & import("react").HTMLAttributes<HTMLUListElement> & {
97
97
  asChild?: boolean;
98
98
  }, "ref"> & {
99
99
  render?: ReactElement | undefined;
100
- } & RefAttributes<SVGSVGElement>>;
101
- ul: ForwardRefExoticComponent<Omit<import("react").ClassAttributes<HTMLUListElement> & import("react").HTMLAttributes<HTMLUListElement> & {
100
+ } & RefAttributes<HTMLUListElement>>;
101
+ svg: ForwardRefExoticComponent<Omit<import("react").SVGProps<SVGSVGElement> & {
102
102
  asChild?: boolean;
103
103
  }, "ref"> & {
104
104
  render?: ReactElement | undefined;
105
- } & RefAttributes<HTMLUListElement>>;
105
+ } & RefAttributes<SVGSVGElement>>;
106
106
  };
107
107
  //#endregion
108
108
  export { Primitive, type PrimitiveProps, type WithRenderPropProps, withRenderProp };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistant-ui/react",
3
- "version": "0.15.17",
3
+ "version": "0.15.18",
4
4
  "description": "Open-source TypeScript/React library for building production-grade AI chat experiences",
5
5
  "keywords": [
6
6
  "ai",
@@ -55,15 +55,15 @@
55
55
  ],
56
56
  "sideEffects": false,
57
57
  "dependencies": {
58
- "@assistant-ui/core": "^0.3.16",
59
- "@assistant-ui/store": "^0.3.11",
60
- "@assistant-ui/tap": "^0.9.15",
61
- "assistant-cloud": "^0.1.42",
62
- "assistant-stream": "^0.3.40",
58
+ "@assistant-ui/core": "^0.3.17",
59
+ "@assistant-ui/store": "^0.3.12",
60
+ "@assistant-ui/tap": "^0.9.16",
61
+ "assistant-cloud": "^0.1.43",
62
+ "assistant-stream": "^0.3.41",
63
63
  "radix-ui": "^1.6.7",
64
64
  "react-textarea-autosize": "^8.5.9",
65
- "safe-content-frame": "^0.0.28",
66
- "zod": "^4.4.3",
65
+ "safe-content-frame": "^0.0.29",
66
+ "zod": "^4.5.4",
67
67
  "zustand": "^5.0.15"
68
68
  },
69
69
  "peerDependencies": {
@@ -81,11 +81,11 @@
81
81
  }
82
82
  },
83
83
  "devDependencies": {
84
- "@assistant-ui/vite": "0.0.15",
85
- "@assistant-ui/x-buildutils": "0.0.24",
86
- "@testing-library/react": "^16.3.2",
84
+ "@assistant-ui/vite": "0.0.16",
85
+ "@assistant-ui/x-buildutils": "0.0.25",
86
+ "@testing-library/react": "^16.3.3",
87
87
  "@types/json-schema": "^7.0.15",
88
- "@types/node": "^26.2.0",
88
+ "@types/node": "^26.4.0",
89
89
  "@types/react": "^19.2.18",
90
90
  "@types/react-dom": "^19.2.5",
91
91
  "jsdom": "^30.0.1",
package/src/index.ts CHANGED
@@ -43,6 +43,8 @@ export type {
43
43
  ThreadListItemState,
44
44
  } from "@assistant-ui/core";
45
45
 
46
+ export { toolApprovalAcceptsText } from "@assistant-ui/core";
47
+
46
48
  export { useCloudThreadListRuntime } from "@assistant-ui/core/react";
47
49
  export { AssistantCloud } from "assistant-cloud";
48
50
 
@@ -360,6 +362,7 @@ export type {
360
362
  GenerativeUISpec,
361
363
  Unstable_AudioMessagePart,
362
364
  RespondToToolApprovalOptions,
365
+ ToolApprovalDisplay,
363
366
  ToolApprovalOption,
364
367
  ToolApprovalOptionKind,
365
368
  ToolApprovalResponse,
@@ -515,6 +518,7 @@ export {
515
518
  getMcpAppFromToolPart,
516
519
  } from "./mcp-apps";
517
520
  export type {
521
+ McpAppPartOptions,
518
522
  McpAppRendererOptions,
519
523
  McpAppMetadata,
520
524
  McpAppResource,
@@ -531,3 +535,12 @@ export type {
531
535
  ToolCallMessagePartMcpMetadata,
532
536
  } from "./mcp-apps";
533
537
  export type { McpAppResourceOutput } from "@assistant-ui/core/react";
538
+ export type { ShimLoadError, ShimLoadErrorCode } from "safe-content-frame";
539
+
540
+ // Unstable - WebMCP provider (exposes frontend tools to a WebMCP-capable browser)
541
+ export {
542
+ unstable_useWebMcpProvider,
543
+ type Unstable_WebMcpProviderOptions,
544
+ type Unstable_WebMcpProviderResult,
545
+ } from "./unstable/webmcp/useWebMcpProvider";
546
+ export { defaultWebMcpFilter as unstable_defaultWebMcpFilter } from "./unstable/webmcp/convertTools";
@@ -9,16 +9,21 @@ import type {
9
9
  import { beforeEach, describe, expect, it, vi } from "vitest";
10
10
  import type {
11
11
  McpAppBridgeHandlers,
12
+ McpAppFrameProps,
13
+ McpAppHostContext,
12
14
  McpAppsHost,
13
15
  McpAppsRemoteHostOptions,
14
16
  } from "./types";
15
17
 
16
- const { framePropsMock } = vi.hoisted(() => ({ framePropsMock: vi.fn() }));
18
+ const { appendMock, framePropsMock } = vi.hoisted(() => ({
19
+ appendMock: vi.fn(),
20
+ framePropsMock: vi.fn(),
21
+ }));
17
22
 
18
23
  vi.mock("@assistant-ui/store", async (importOriginal) => ({
19
24
  ...(await importOriginal()),
20
25
  useAui: () => ({
21
- thread: () => ({ append: vi.fn() }),
26
+ thread: { append: appendMock },
22
27
  }),
23
28
  }));
24
29
 
@@ -43,9 +48,12 @@ const createDeferred = <T,>() => {
43
48
  return { promise, resolve };
44
49
  };
45
50
 
46
- const createPart = (serverId?: string): ToolCallMessagePartProps => ({
51
+ const createPart = (
52
+ serverId?: string,
53
+ toolCallId = "call-1",
54
+ ): ToolCallMessagePartProps => ({
47
55
  type: "tool-call",
48
- toolCallId: "call-1",
56
+ toolCallId,
49
57
  toolName: "search",
50
58
  args: {},
51
59
  argsText: "{}",
@@ -105,6 +113,60 @@ function MemoizedHarness({ host }: { host: McpAppsHost }) {
105
113
  return <MemoizedPart Renderer={renderer.render} />;
106
114
  }
107
115
 
116
+ const MemoizedPartById = memo(function MemoizedPartById({
117
+ Renderer,
118
+ toolCallId,
119
+ }: {
120
+ Renderer: ToolCallMessagePartComponent;
121
+ toolCallId: string;
122
+ }) {
123
+ return <Renderer {...createPart(undefined, toolCallId)} />;
124
+ });
125
+
126
+ function OptionsHarness({
127
+ host,
128
+ hostContext,
129
+ handlers,
130
+ forPart,
131
+ toolCallIds = ["call-1"],
132
+ }: {
133
+ host: McpAppsHost;
134
+ hostContext?: McpAppHostContext;
135
+ handlers?: McpAppRendererOptions["handlers"];
136
+ forPart?: McpAppRendererOptions["forPart"];
137
+ toolCallIds?: string[];
138
+ }) {
139
+ const renderer = useResource(
140
+ McpAppRenderer({
141
+ host: Host({ host }),
142
+ ...(hostContext === undefined ? {} : { hostContext }),
143
+ ...(handlers === undefined ? {} : { handlers }),
144
+ ...(forPart === undefined ? {} : { forPart }),
145
+ }),
146
+ );
147
+ return toolCallIds.map((toolCallId) => (
148
+ <MemoizedPartById
149
+ key={toolCallId}
150
+ Renderer={renderer.render}
151
+ toolCallId={toolCallId}
152
+ />
153
+ ));
154
+ }
155
+
156
+ const framePropsCalls = () =>
157
+ framePropsMock.mock.calls.map(([props]) => props as McpAppFrameProps);
158
+
159
+ const loadingHost = (): McpAppsHost => ({
160
+ loadResource: vi.fn(async ({ uri }) => ({
161
+ uri,
162
+ mimeType: "text/html;profile=mcp-app" as const,
163
+ html: "",
164
+ })),
165
+ callTool: vi.fn(),
166
+ readResource: vi.fn(),
167
+ listResources: vi.fn(),
168
+ });
169
+
108
170
  function RemoteHarness({
109
171
  url,
110
172
  headers,
@@ -127,9 +189,146 @@ function RemoteHarness({
127
189
 
128
190
  describe("McpAppRenderer", () => {
129
191
  beforeEach(() => {
192
+ appendMock.mockReset();
130
193
  framePropsMock.mockReset();
131
194
  });
132
195
 
196
+ it("loads newly mounted parts through the committed host", async () => {
197
+ const hostA = loadingHost();
198
+ const hostB = loadingHost();
199
+ const view = render(
200
+ <OptionsHarness host={hostA} toolCallIds={["call-1"]} />,
201
+ );
202
+ await waitFor(() => expect(hostA.loadResource).toHaveBeenCalledOnce());
203
+ vi.mocked(hostA.loadResource).mockClear();
204
+
205
+ view.rerender(
206
+ <OptionsHarness host={hostB} toolCallIds={["call-1", "call-2"]} />,
207
+ );
208
+ await waitFor(() => expect(hostB.loadResource).toHaveBeenCalled());
209
+
210
+ expect(hostA.loadResource).not.toHaveBeenCalled();
211
+ });
212
+
213
+ it("merges forPart handlers over the thread-wide handlers", async () => {
214
+ const onInitialized = vi.fn();
215
+ render(
216
+ <OptionsHarness
217
+ host={loadingHost()}
218
+ handlers={{ onInitialized }}
219
+ forPart={(part) => ({
220
+ handlers: {
221
+ requestDisplayMode: ({ mode }) => {
222
+ void part;
223
+ return { mode };
224
+ },
225
+ },
226
+ })}
227
+ />,
228
+ );
229
+
230
+ await waitFor(() => expect(framePropsCalls().length).toBeGreaterThan(0));
231
+ const handlers = framePropsCalls().at(-1)?.handlers;
232
+ expect(handlers?.requestDisplayMode).toBeDefined();
233
+ handlers?.onInitialized?.();
234
+ expect(onInitialized).toHaveBeenCalledOnce();
235
+ });
236
+
237
+ it("leaves mounted parts alone when renderer options are unchanged", async () => {
238
+ const host = loadingHost();
239
+ const hostContext: McpAppHostContext = { displayMode: "inline" };
240
+ const view = () => <OptionsHarness host={host} hostContext={hostContext} />;
241
+
242
+ const rendered = render(view());
243
+ await waitFor(() => expect(framePropsCalls().length).toBeGreaterThan(0));
244
+ const rendersBefore = framePropsCalls().length;
245
+
246
+ rendered.rerender(view());
247
+ expect(framePropsCalls().length).toBe(rendersBefore);
248
+ });
249
+
250
+ it("delivers a changed renderer host context to mounted parts", async () => {
251
+ const host = loadingHost();
252
+ const view = render(
253
+ <OptionsHarness host={host} hostContext={{ displayMode: "inline" }} />,
254
+ );
255
+ await waitFor(() =>
256
+ expect(framePropsCalls().at(-1)?.hostContext).toEqual({
257
+ displayMode: "inline",
258
+ }),
259
+ );
260
+
261
+ view.rerender(
262
+ <OptionsHarness
263
+ host={host}
264
+ hostContext={{ displayMode: "fullscreen" }}
265
+ />,
266
+ );
267
+ await waitFor(() =>
268
+ expect(framePropsCalls().at(-1)?.hostContext).toEqual({
269
+ displayMode: "fullscreen",
270
+ }),
271
+ );
272
+ });
273
+
274
+ it("resolves host context per part through forPart", async () => {
275
+ const modes: Record<string, string> = {
276
+ "call-1": "inline",
277
+ "call-2": "fullscreen",
278
+ };
279
+ render(
280
+ <OptionsHarness
281
+ host={loadingHost()}
282
+ hostContext={{ theme: "light" }}
283
+ toolCallIds={["call-1", "call-2"]}
284
+ forPart={(part) => ({
285
+ hostContext: {
286
+ theme: "light",
287
+ displayMode: modes[part.toolCallId] as "inline" | "fullscreen",
288
+ },
289
+ })}
290
+ />,
291
+ );
292
+
293
+ await waitFor(() => expect(framePropsCalls().length).toBeGreaterThan(1));
294
+ const delivered = framePropsCalls().map((props) => props.hostContext);
295
+ expect(delivered).toContainEqual({
296
+ theme: "light",
297
+ displayMode: "inline",
298
+ });
299
+ expect(delivered).toContainEqual({
300
+ theme: "light",
301
+ displayMode: "fullscreen",
302
+ });
303
+ });
304
+
305
+ it("gives forPart handlers the part that owns the frame", async () => {
306
+ const promoted: string[] = [];
307
+ render(
308
+ <OptionsHarness
309
+ host={loadingHost()}
310
+ toolCallIds={["call-1", "call-2"]}
311
+ forPart={(part) => ({
312
+ hostContext: { toolCallId: part.toolCallId },
313
+ handlers: {
314
+ requestDisplayMode: ({ mode }) => {
315
+ promoted.push(`${part.toolCallId}:${mode}`);
316
+ return { mode };
317
+ },
318
+ },
319
+ })}
320
+ />,
321
+ );
322
+
323
+ await waitFor(() => expect(framePropsCalls().length).toBeGreaterThan(1));
324
+ const second = framePropsCalls().find(
325
+ (props) => props.hostContext?.["toolCallId"] === "call-2",
326
+ );
327
+ await second?.handlers?.requestDisplayMode?.({ mode: "fullscreen" });
328
+
329
+ expect(promoted).toEqual(["call-2:fullscreen"]);
330
+ });
331
+
133
332
  it("injects serverId into loadResource and reloads when it changes", async () => {
134
333
  const loadResource = vi.fn(async ({ uri }: { uri: string }) => ({
135
334
  uri,
@@ -250,7 +449,7 @@ describe("McpAppRenderer", () => {
250
449
  framePropsMock.mockClear();
251
450
  view.rerender(<MemoizedHarness host={nextHost} />);
252
451
 
253
- expect(nextHost.loadResource).toHaveBeenCalledTimes(1);
452
+ await waitFor(() => expect(nextHost.loadResource).toHaveBeenCalledTimes(1));
254
453
  expect(framePropsMock).not.toHaveBeenCalled();
255
454
 
256
455
  nextResource.resolve({
@@ -473,6 +672,55 @@ describe("McpAppRenderer", () => {
473
672
  expect(listResources).toHaveBeenCalledWith(params);
474
673
  });
475
674
 
675
+ it("appends MCP Apps message content in the default sendMessage handler", async () => {
676
+ render(<Harness host={loadingHost()} />);
677
+ await waitFor(() => expect(framePropsMock).toHaveBeenCalled());
678
+ const handlers = framePropsMock.mock.lastCall?.[0]
679
+ .handlers as McpAppBridgeHandlers;
680
+
681
+ expect(
682
+ handlers.sendMessage?.({
683
+ role: "user",
684
+ content: [
685
+ { type: "text", text: "Open Grade 5 Math" },
686
+ { type: "image", data: "image", mimeType: "image/png" },
687
+ { type: "text", text: "Period 4" },
688
+ ],
689
+ }),
690
+ ).toEqual({ ok: true });
691
+ expect(appendMock).toHaveBeenCalledWith({
692
+ content: [
693
+ { type: "text", text: "Open Grade 5 Math" },
694
+ { type: "text", text: "Period 4" },
695
+ ],
696
+ });
697
+
698
+ expect(handlers.sendMessage?.({ text: "typed instead" })).toEqual({
699
+ ok: true,
700
+ });
701
+ expect(appendMock).toHaveBeenLastCalledWith({
702
+ content: [{ type: "text", text: "typed instead" }],
703
+ });
704
+
705
+ expect(
706
+ handlers.sendMessage?.({
707
+ role: "user",
708
+ content: [{ type: "image", data: "image", mimeType: "image/png" }],
709
+ }),
710
+ ).toEqual({
711
+ isError: true,
712
+ ok: false,
713
+ reason: "no text content to append",
714
+ });
715
+
716
+ expect(handlers.sendMessage?.({ role: "user" })).toEqual({
717
+ isError: true,
718
+ ok: false,
719
+ reason: "unrecognised params shape",
720
+ });
721
+ expect(appendMock).toHaveBeenCalledTimes(2);
722
+ });
723
+
476
724
  it("uses caller UI handlers and keeps data-plane handlers on the host", async () => {
477
725
  const host: McpAppsHost = {
478
726
  loadResource: vi.fn(async ({ uri }) => ({