@assistant-ui/react 0.15.16 → 0.15.17

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 (223) hide show
  1. package/README.md +3 -3
  2. package/dist/index.d.ts +4 -3
  3. package/dist/index.js +2 -2
  4. package/dist/mcp-apps/McpAppRenderer.d.ts +6 -1
  5. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  6. package/dist/mcp-apps/McpAppRenderer.js +19 -12
  7. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  8. package/dist/mcp-apps/McpAppsRemoteHost.d.ts.map +1 -1
  9. package/dist/mcp-apps/McpAppsRemoteHost.js +20 -17
  10. package/dist/mcp-apps/McpAppsRemoteHost.js.map +1 -1
  11. package/dist/mcp-apps/app-frame.d.ts.map +1 -1
  12. package/dist/mcp-apps/app-frame.js +189 -94
  13. package/dist/mcp-apps/app-frame.js.map +1 -1
  14. package/dist/mcp-apps/index.d.ts +2 -2
  15. package/dist/model-context/frame/useAssistantFrameHost.d.ts +1 -1
  16. package/dist/model-context/frame/useAssistantFrameHost.js +10 -11
  17. package/dist/model-context/frame/useAssistantFrameHost.js.map +1 -1
  18. package/dist/model-context/makeAssistantVisible.js +1 -1
  19. package/dist/model-context/makeAssistantVisible.js.map +1 -1
  20. package/dist/primitives/actionBar/ActionBarCopy.js +3 -3
  21. package/dist/primitives/actionBar/ActionBarCopy.js.map +1 -1
  22. package/dist/primitives/actionBar/ActionBarExportMarkdown.js +3 -3
  23. package/dist/primitives/actionBar/ActionBarExportMarkdown.js.map +1 -1
  24. package/dist/primitives/actionBar/ActionBarFeedbackNegative.js +3 -3
  25. package/dist/primitives/actionBar/ActionBarFeedbackNegative.js.map +1 -1
  26. package/dist/primitives/actionBar/ActionBarFeedbackPositive.js +3 -3
  27. package/dist/primitives/actionBar/ActionBarFeedbackPositive.js.map +1 -1
  28. package/dist/primitives/actionBar/ActionBarRoot.js.map +1 -1
  29. package/dist/primitives/actionBar/ActionBarStopSpeaking.js +3 -3
  30. package/dist/primitives/actionBar/ActionBarStopSpeaking.js.map +1 -1
  31. package/dist/primitives/actionBarMore/ActionBarMoreContent.js.map +1 -1
  32. package/dist/primitives/actionBarMore/ActionBarMoreItem.js.map +1 -1
  33. package/dist/primitives/actionBarMore/ActionBarMoreRoot.js.map +1 -1
  34. package/dist/primitives/actionBarMore/ActionBarMoreSeparator.js.map +1 -1
  35. package/dist/primitives/actionBarMore/ActionBarMoreTrigger.js.map +1 -1
  36. package/dist/primitives/actionBarMore/scope.d.ts +2 -2
  37. package/dist/primitives/actionBarMore/scope.d.ts.map +1 -1
  38. package/dist/primitives/actionBarMore/scope.js.map +1 -1
  39. package/dist/primitives/assistantModal/AssistantModalAnchor.js.map +1 -1
  40. package/dist/primitives/assistantModal/AssistantModalContent.js +1 -1
  41. package/dist/primitives/assistantModal/AssistantModalContent.js.map +1 -1
  42. package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
  43. package/dist/primitives/assistantModal/AssistantModalTrigger.js.map +1 -1
  44. package/dist/primitives/assistantModal/scope.d.ts +2 -2
  45. package/dist/primitives/assistantModal/scope.d.ts.map +1 -1
  46. package/dist/primitives/assistantModal/scope.js.map +1 -1
  47. package/dist/primitives/branchPicker/BranchPickerRoot.js.map +1 -1
  48. package/dist/primitives/composer/ComposerAttachmentDropzone.js +1 -1
  49. package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
  50. package/dist/primitives/composer/ComposerDictationTranscript.js.map +1 -1
  51. package/dist/primitives/composer/ComposerInput.js +1 -3
  52. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  53. package/dist/primitives/composer/ComposerQuote.js +5 -5
  54. package/dist/primitives/composer/ComposerQuote.js.map +1 -1
  55. package/dist/primitives/composer/ComposerRoot.js +3 -3
  56. package/dist/primitives/composer/ComposerRoot.js.map +1 -1
  57. package/dist/primitives/composer/trigger/TriggerPopover.d.ts +5 -0
  58. package/dist/primitives/composer/trigger/TriggerPopover.d.ts.map +1 -1
  59. package/dist/primitives/composer/trigger/TriggerPopover.js +157 -130
  60. package/dist/primitives/composer/trigger/TriggerPopover.js.map +1 -1
  61. package/dist/primitives/composer/trigger/TriggerPopoverBack.js +3 -3
  62. package/dist/primitives/composer/trigger/TriggerPopoverBack.js.map +1 -1
  63. package/dist/primitives/composer/trigger/TriggerPopoverCategories.js +4 -4
  64. package/dist/primitives/composer/trigger/TriggerPopoverCategories.js.map +1 -1
  65. package/dist/primitives/composer/trigger/TriggerPopoverItems.js +4 -4
  66. package/dist/primitives/composer/trigger/TriggerPopoverItems.js.map +1 -1
  67. package/dist/primitives/composer/trigger/TriggerPopoverResource.d.ts +2 -0
  68. package/dist/primitives/composer/trigger/TriggerPopoverResource.d.ts.map +1 -1
  69. package/dist/primitives/composer/trigger/TriggerPopoverResource.js +63 -61
  70. package/dist/primitives/composer/trigger/TriggerPopoverResource.js.map +1 -1
  71. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.d.ts +2 -0
  72. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.d.ts.map +1 -1
  73. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js.map +1 -1
  74. package/dist/primitives/composer/trigger/detectTrigger.d.ts +21 -1
  75. package/dist/primitives/composer/trigger/detectTrigger.d.ts.map +1 -0
  76. package/dist/primitives/composer/trigger/detectTrigger.js +17 -3
  77. package/dist/primitives/composer/trigger/detectTrigger.js.map +1 -1
  78. package/dist/primitives/composer/trigger/index.d.ts +3 -1
  79. package/dist/primitives/composer/trigger/index.d.ts.map +1 -1
  80. package/dist/primitives/composer/trigger/index.js.map +1 -1
  81. package/dist/primitives/composer/trigger/triggerDetectionResource.d.ts +3 -4
  82. package/dist/primitives/composer/trigger/triggerDetectionResource.d.ts.map +1 -1
  83. package/dist/primitives/composer/trigger/triggerDetectionResource.js +15 -14
  84. package/dist/primitives/composer/trigger/triggerDetectionResource.js.map +1 -1
  85. package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts.map +1 -1
  86. package/dist/primitives/composer/trigger/triggerSelectionResource.js +18 -19
  87. package/dist/primitives/composer/trigger/triggerSelectionResource.js.map +1 -1
  88. package/dist/primitives/composer.d.ts +3 -2
  89. package/dist/primitives/composer.js.map +1 -1
  90. package/dist/primitives/error/ErrorMessage.js.map +1 -1
  91. package/dist/primitives/message/MessageIf.js.map +1 -1
  92. package/dist/primitives/message/MessageParts.js.map +1 -1
  93. package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
  94. package/dist/primitives/message/MessageRoot.js +4 -4
  95. package/dist/primitives/message/MessageRoot.js.map +1 -1
  96. package/dist/primitives/messagePart/MessagePartText.js.map +1 -1
  97. package/dist/primitives/selectionToolbar/SelectionToolbarQuote.js +3 -3
  98. package/dist/primitives/selectionToolbar/SelectionToolbarQuote.js.map +1 -1
  99. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js +3 -3
  100. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js.map +1 -1
  101. package/dist/primitives/thread/ThreadIf.js.map +1 -1
  102. package/dist/primitives/thread/ThreadViewport.js +3 -3
  103. package/dist/primitives/thread/ThreadViewport.js.map +1 -1
  104. package/dist/primitives/thread/ThreadViewportFooter.js +3 -3
  105. package/dist/primitives/thread/ThreadViewportFooter.js.map +1 -1
  106. package/dist/primitives/thread/useThreadViewportAutoScroll.js +1 -1
  107. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  108. package/dist/primitives/threadList/ThreadListNew.js +3 -3
  109. package/dist/primitives/threadList/ThreadListNew.js.map +1 -1
  110. package/dist/primitives/threadListFocusGroup.d.ts +2 -1
  111. package/dist/primitives/threadListFocusGroup.d.ts.map +1 -1
  112. package/dist/primitives/threadListFocusGroup.js +2 -2
  113. package/dist/primitives/threadListFocusGroup.js.map +1 -1
  114. package/dist/primitives/threadListItem/ThreadListItemRoot.js +3 -3
  115. package/dist/primitives/threadListItem/ThreadListItemRoot.js.map +1 -1
  116. package/dist/primitives/threadListItem/ThreadListItemTrigger.js +1 -1
  117. package/dist/primitives/threadListItem/ThreadListItemTrigger.js.map +1 -1
  118. package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.js +1 -1
  119. package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.js.map +1 -1
  120. package/dist/primitives/threadListItemMore/ThreadListItemMoreItem.js.map +1 -1
  121. package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.js +1 -1
  122. package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.js.map +1 -1
  123. package/dist/primitives/threadListItemMore/ThreadListItemMoreSeparator.js.map +1 -1
  124. package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.js +1 -2
  125. package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.js.map +1 -1
  126. package/dist/primitives/threadListItemMore/scope.d.ts +2 -2
  127. package/dist/primitives/threadListItemMore/scope.d.ts.map +1 -1
  128. package/dist/primitives/threadListItemMore/scope.js.map +1 -1
  129. package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
  130. package/dist/sandbox-host/SandboxHost.js +137 -70
  131. package/dist/sandbox-host/SandboxHost.js.map +1 -1
  132. package/dist/unstable/useLiveCompletionAdapter.js +3 -1
  133. package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
  134. package/dist/unstable/useSlashCommandAdapter.js +41 -27
  135. package/dist/unstable/useSlashCommandAdapter.js.map +1 -1
  136. package/dist/utils/Primitive.d.ts +2 -2
  137. package/dist/utils/Primitive.js +2 -2
  138. package/dist/utils/Primitive.js.map +1 -1
  139. package/dist/utils/createActionButton.js +3 -3
  140. package/dist/utils/createActionButton.js.map +1 -1
  141. package/dist/utils/hooks/useOnResizeContent.js +1 -1
  142. package/dist/utils/hooks/useOnResizeContent.js.map +1 -1
  143. package/dist/utils/hooks/useOnScrollToBottom.js +1 -1
  144. package/dist/utils/hooks/useOnScrollToBottom.js.map +1 -1
  145. package/dist/utils/smooth/SmoothContext.d.ts +24 -0
  146. package/dist/utils/smooth/SmoothContext.d.ts.map +1 -1
  147. package/dist/utils/smooth/useSmooth.js +1 -1
  148. package/dist/utils/smooth/useSmooth.js.map +1 -1
  149. package/dist/utils/useToolArgsFieldStatus.d.ts +5 -1
  150. package/package.json +11 -19
  151. package/src/index.ts +9 -1
  152. package/src/mcp-apps/McpAppRenderer.test.tsx +133 -4
  153. package/src/mcp-apps/McpAppRenderer.tsx +28 -21
  154. package/src/mcp-apps/McpAppsRemoteHost.concurrent.test.tsx +215 -0
  155. package/src/mcp-apps/McpAppsRemoteHost.ts +28 -12
  156. package/src/mcp-apps/app-frame.test.tsx +102 -0
  157. package/src/mcp-apps/app-frame.tsx +17 -4
  158. package/src/mcp-apps/index.ts +1 -0
  159. package/src/model-context/frame/AssistantFrame.test.ts +3 -3
  160. package/src/model-context/frame/SPEC_AssistantFrame.md +1 -1
  161. package/src/model-context/frame/useAssistantFrameHost.ts +2 -2
  162. package/src/model-context/makeAssistantVisible.tsx +1 -1
  163. package/src/primitives/actionBar/ActionBarCopy.tsx +1 -1
  164. package/src/primitives/actionBar/ActionBarExportMarkdown.tsx +1 -1
  165. package/src/primitives/actionBar/ActionBarFeedbackNegative.tsx +1 -1
  166. package/src/primitives/actionBar/ActionBarFeedbackPositive.tsx +1 -1
  167. package/src/primitives/actionBar/ActionBarStopSpeaking.tsx +1 -1
  168. package/src/primitives/actionBarMore/scope.ts +2 -2
  169. package/src/primitives/assistantModal/AssistantModalContent.tsx +1 -1
  170. package/src/primitives/assistantModal/scope.ts +2 -2
  171. package/src/primitives/composer/ComposerAttachmentDropzone.tsx +1 -1
  172. package/src/primitives/composer/ComposerInput.test.tsx +2 -1
  173. package/src/primitives/composer/ComposerInput.tsx +3 -3
  174. package/src/primitives/composer/ComposerQuote.tsx +1 -1
  175. package/src/primitives/composer/ComposerRoot.tsx +1 -1
  176. package/src/primitives/composer/trigger/TriggerPopover.tsx +16 -1
  177. package/src/primitives/composer/trigger/TriggerPopoverBack.tsx +1 -1
  178. package/src/primitives/composer/trigger/TriggerPopoverCategories.tsx +1 -1
  179. package/src/primitives/composer/trigger/TriggerPopoverItems.tsx +1 -1
  180. package/src/primitives/composer/trigger/TriggerPopoverResource.ts +4 -1
  181. package/src/primitives/composer/trigger/TriggerPopoverRootContext.tsx +2 -0
  182. package/src/primitives/composer/trigger/detectTrigger.test.ts +88 -5
  183. package/src/primitives/composer/trigger/detectTrigger.ts +50 -9
  184. package/src/primitives/composer/trigger/index.ts +1 -0
  185. package/src/primitives/composer/trigger/triggerDetectionResource.ts +10 -7
  186. package/src/primitives/composer/trigger/triggerSelectionResource.test.ts +15 -3
  187. package/src/primitives/composer/trigger/triggerSelectionResource.ts +1 -4
  188. package/src/primitives/composer.ts +1 -0
  189. package/src/primitives/message/MessageRoot.tsx +1 -1
  190. package/src/primitives/selectionToolbar/SelectionToolbarQuote.tsx +1 -1
  191. package/src/primitives/selectionToolbar/SelectionToolbarRoot.tsx +1 -1
  192. package/src/primitives/thread/ThreadViewport.tsx +1 -1
  193. package/src/primitives/thread/ThreadViewportFooter.tsx +1 -1
  194. package/src/primitives/thread/useThreadViewportAutoScroll.ts +1 -1
  195. package/src/primitives/threadList/ThreadListNew.tsx +1 -1
  196. package/src/primitives/threadListFocusGroup.ts +2 -2
  197. package/src/primitives/threadListItem/ThreadListItemRoot.tsx +1 -1
  198. package/src/primitives/threadListItem/ThreadListItemTrigger.tsx +1 -1
  199. package/src/primitives/threadListItemMore/ThreadListItemMoreContent.tsx +1 -1
  200. package/src/primitives/threadListItemMore/ThreadListItemMoreRoot.tsx +1 -1
  201. package/src/primitives/threadListItemMore/ThreadListItemMoreTrigger.tsx +2 -2
  202. package/src/primitives/threadListItemMore/scope.ts +2 -2
  203. package/src/sandbox-host/SandboxHost.test.tsx +45 -1
  204. package/src/sandbox-host/SandboxHost.tsx +16 -3
  205. package/src/tests/RemoteThreadListRuntime.adapterProvider.test.tsx +31 -1
  206. package/src/unstable/useLiveCompletionAdapter.test.tsx +60 -1
  207. package/src/unstable/useLiveCompletionAdapter.ts +4 -1
  208. package/src/unstable/useSlashCommandAdapter.test.tsx +141 -0
  209. package/src/unstable/useSlashCommandAdapter.ts +52 -17
  210. package/src/utils/Primitive.tsx +2 -2
  211. package/src/utils/createActionButton.tsx +1 -1
  212. package/src/utils/hooks/useOnResizeContent.ts +1 -1
  213. package/src/utils/hooks/useOnScrollToBottom.ts +1 -1
  214. package/src/utils/smooth/useSmooth.ts +1 -1
  215. package/dist/legacy-runtime/runtime/MessagePartRuntime.d.ts +0 -3
  216. package/dist/legacy-runtime/runtime/MessagePartRuntime.js +0 -2
  217. package/dist/legacy-runtime/runtime/MessageRuntime.d.ts +0 -3
  218. package/dist/legacy-runtime/runtime/MessageRuntime.js +0 -2
  219. package/dist/legacy-runtime/runtime/ThreadListRuntime.d.ts +0 -3
  220. package/dist/legacy-runtime/runtime/ThreadListRuntime.js +0 -2
  221. package/src/legacy-runtime/runtime/MessagePartRuntime.ts +0 -2
  222. package/src/legacy-runtime/runtime/MessageRuntime.ts +0 -3
  223. package/src/legacy-runtime/runtime/ThreadListRuntime.ts +0 -4
@@ -2,6 +2,7 @@
2
2
 
3
3
  import {
4
4
  useEffect,
5
+ useLayoutEffect,
5
6
  useMemo,
6
7
  useRef,
7
8
  useState,
@@ -47,6 +48,14 @@ export type McpAppRendererOptions = {
47
48
  hostInfo?: McpAppHostInfo;
48
49
  /** Delivered to the widget on initialize and pushed via `notifications/host_context/changed` on change. */
49
50
  hostContext?: McpAppHostContext;
51
+ /**
52
+ * Optional widget interaction and lifecycle handlers. Data-plane handlers
53
+ * (`callTool`, `readResource`, `listResources`) always use `host`.
54
+ */
55
+ handlers?: Omit<
56
+ McpAppBridgeHandlers,
57
+ "callTool" | "readResource" | "listResources"
58
+ >;
50
59
  /** Rendered when no MCP app is on the part, or while load is in flight / failed (unless overridden). */
51
60
  fallback?: ReactNode;
52
61
  /** Rendered while the resource is loading. Defaults to `fallback`. */
@@ -106,13 +115,9 @@ function InlineRenderer({
106
115
  const aui = useAui();
107
116
  const app = getMcpAppFromToolPart(part);
108
117
  const cachedAppRef = useRef<McpAppMetadata | undefined>(undefined);
109
- if (
110
- app != null &&
111
- (cachedAppRef.current?.resourceUri !== app.resourceUri ||
112
- cachedAppRef.current?.serverId !== app.serverId)
113
- ) {
114
- cachedAppRef.current = app;
115
- }
118
+ useLayoutEffect(() => {
119
+ if (app != null) cachedAppRef.current = app;
120
+ }, [app]);
116
121
  const appForRender = app ?? cachedAppRef.current;
117
122
 
118
123
  const [loadedResource, setLoadedResource] = useState<LoadedResourceState>();
@@ -120,8 +125,7 @@ function InlineRenderer({
120
125
  const host = useHostStore((state) => state.host);
121
126
  const resourceUri = appForRender?.resourceUri;
122
127
  const serverId = appForRender?.serverId;
123
- const serverIdRef = useRef<string | undefined>(undefined);
124
- serverIdRef.current = serverId;
128
+ const callerHandlers = opts.handlers;
125
129
  useEffect(() => {
126
130
  if (resourceUri == null) return;
127
131
  let cancelled = false;
@@ -165,34 +169,37 @@ function InlineRenderer({
165
169
 
166
170
  const bridgeHandlers = useMemo<McpAppBridgeHandlers>(
167
171
  () => ({
168
- openLink: defaultOpenLink,
169
- sendMessage: (params) => {
170
- const text = extractSendMessageText(params);
171
- if (!text) return { ok: false, reason: "unrecognised params shape" };
172
- aui.thread.append({ content: [{ type: "text", text }] });
173
- return { ok: true };
174
- },
172
+ ...callerHandlers,
173
+ openLink: callerHandlers?.openLink ?? defaultOpenLink,
174
+ sendMessage:
175
+ callerHandlers?.sendMessage ??
176
+ ((params) => {
177
+ const text = extractSendMessageText(params);
178
+ if (!text) return { ok: false, reason: "unrecognised params shape" };
179
+ aui.thread.append({ content: [{ type: "text", text }] });
180
+ return { ok: true };
181
+ }),
175
182
  callTool: (params) =>
176
183
  useHostStore.getState().host.callTool({
177
184
  ...params,
178
- ...(serverIdRef.current ? { serverId: serverIdRef.current } : {}),
185
+ ...(serverId ? { serverId } : {}),
179
186
  }),
180
187
  readResource: (params) =>
181
188
  useHostStore.getState().host.readResource({
182
189
  ...params,
183
- ...(serverIdRef.current ? { serverId: serverIdRef.current } : {}),
190
+ ...(serverId ? { serverId } : {}),
184
191
  }),
185
192
  listResources: (params) => {
186
- if (!serverIdRef.current) {
193
+ if (!serverId) {
187
194
  return useHostStore.getState().host.listResources(params);
188
195
  }
189
196
  return useHostStore.getState().host.listResources({
190
197
  ...(isRecord(params) ? params : {}),
191
- serverId: serverIdRef.current,
198
+ serverId,
192
199
  });
193
200
  },
194
201
  }),
195
- [aui, useHostStore],
202
+ [aui, callerHandlers, serverId, useHostStore],
196
203
  );
197
204
 
198
205
  const loadedResourceForApp =
@@ -0,0 +1,215 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, render, waitFor } from "@testing-library/react";
4
+ import { useResource } from "@assistant-ui/tap";
5
+ import {
6
+ startTransition,
7
+ Suspense,
8
+ useLayoutEffect,
9
+ type ReactNode,
10
+ } from "react";
11
+ import { describe, expect, it, vi } from "vitest";
12
+ import type { McpAppsHost } from "./types";
13
+ import { McpAppsRemoteHost } from "./McpAppsRemoteHost";
14
+
15
+ describe("McpAppsRemoteHost concurrent rendering", () => {
16
+ it("keeps committed authorization options during an interrupted render", async () => {
17
+ const pending = new Promise<never>(() => {});
18
+ const fetch = vi.fn(async () => Response.json({ content: [] }));
19
+ let committedHost: McpAppsHost | undefined;
20
+
21
+ const Probe = ({
22
+ authorization,
23
+ suspend,
24
+ children,
25
+ }: {
26
+ authorization: string;
27
+ suspend: boolean;
28
+ children: ReactNode;
29
+ }) => {
30
+ const host = useResource(
31
+ McpAppsRemoteHost({
32
+ url: "/api/mcp-apps",
33
+ fetch,
34
+ headers: { authorization },
35
+ }),
36
+ );
37
+ useLayoutEffect(() => {
38
+ committedHost = host;
39
+ }, [host]);
40
+ if (suspend) throw pending;
41
+ return children;
42
+ };
43
+
44
+ const view = render(
45
+ <Suspense fallback={null}>
46
+ <Probe authorization="Bearer workspace-a" suspend={false}>
47
+ workspace-a
48
+ </Probe>
49
+ </Suspense>,
50
+ );
51
+
52
+ act(() => {
53
+ startTransition(() => {
54
+ view.rerender(
55
+ <Suspense fallback={null}>
56
+ <Probe authorization="Bearer workspace-b" suspend>
57
+ workspace-b
58
+ </Probe>
59
+ </Suspense>,
60
+ );
61
+ });
62
+ });
63
+
64
+ expect(view.container.textContent).toBe("workspace-a");
65
+ await committedHost?.callTool({ name: "search" });
66
+
67
+ expect(fetch).toHaveBeenCalledWith("/api/mcp-apps", {
68
+ method: "POST",
69
+ headers: {
70
+ "content-type": "application/json",
71
+ authorization: "Bearer workspace-a",
72
+ },
73
+ body: JSON.stringify({
74
+ method: "tools/call",
75
+ params: { name: "search" },
76
+ }),
77
+ });
78
+
79
+ view.unmount();
80
+ });
81
+
82
+ it("keeps the URL and authorization from one committed options snapshot", async () => {
83
+ const fetch = vi.fn(async () => Response.json({ content: [] }));
84
+ let currentHost: McpAppsHost | undefined;
85
+
86
+ const Probe = ({
87
+ url,
88
+ authorization,
89
+ }: {
90
+ url: string;
91
+ authorization: string;
92
+ }) => {
93
+ const host = useResource(
94
+ McpAppsRemoteHost({
95
+ url,
96
+ fetch,
97
+ headers: { authorization },
98
+ }),
99
+ );
100
+ useLayoutEffect(() => {
101
+ currentHost = host;
102
+ void host.callTool({ name: "layout-request" });
103
+ }, [host]);
104
+ return null;
105
+ };
106
+
107
+ const view = render(
108
+ <Probe url="/workspace-a/mcp" authorization="Bearer workspace-a" />,
109
+ );
110
+ fetch.mockClear();
111
+
112
+ view.rerender(
113
+ <Probe url="/workspace-b/mcp" authorization="Bearer workspace-b" />,
114
+ );
115
+
116
+ expect(fetch).toHaveBeenNthCalledWith(1, "/workspace-b/mcp", {
117
+ method: "POST",
118
+ headers: {
119
+ "content-type": "application/json",
120
+ authorization: "Bearer workspace-b",
121
+ },
122
+ body: JSON.stringify({
123
+ method: "tools/call",
124
+ params: { name: "layout-request" },
125
+ }),
126
+ });
127
+
128
+ await currentHost?.callTool({ name: "committed-request" });
129
+
130
+ expect(fetch).toHaveBeenNthCalledWith(2, "/workspace-b/mcp", {
131
+ method: "POST",
132
+ headers: {
133
+ "content-type": "application/json",
134
+ authorization: "Bearer workspace-b",
135
+ },
136
+ body: JSON.stringify({
137
+ method: "tools/call",
138
+ params: { name: "committed-request" },
139
+ }),
140
+ });
141
+
142
+ view.unmount();
143
+ });
144
+
145
+ it("refreshes pending options when a URL switch render retries", async () => {
146
+ let authorization = "Bearer workspace-b-old";
147
+ let resumed = false;
148
+ let resume: (() => void) | undefined;
149
+ const pending = new Promise<void>((resolve) => {
150
+ resume = () => {
151
+ resumed = true;
152
+ resolve();
153
+ };
154
+ });
155
+ const fetch = vi.fn(async () => Response.json({ content: [] }));
156
+
157
+ const Probe = ({ url }: { url: string }) => {
158
+ const host = useResource(
159
+ McpAppsRemoteHost({
160
+ url,
161
+ fetch,
162
+ headers: {
163
+ authorization:
164
+ url === "/workspace-a/mcp" ? "Bearer workspace-a" : authorization,
165
+ },
166
+ }),
167
+ );
168
+ useLayoutEffect(() => {
169
+ void host.callTool({ name: "layout-request" });
170
+ }, [host]);
171
+ if (url === "/workspace-b/mcp" && !resumed) throw pending;
172
+ return <span>{url}</span>;
173
+ };
174
+
175
+ const view = render(
176
+ <Suspense fallback={null}>
177
+ <Probe url="/workspace-a/mcp" />
178
+ </Suspense>,
179
+ );
180
+ try {
181
+ fetch.mockClear();
182
+
183
+ act(() => {
184
+ startTransition(() => {
185
+ view.rerender(
186
+ <Suspense fallback={null}>
187
+ <Probe url="/workspace-b/mcp" />
188
+ </Suspense>,
189
+ );
190
+ });
191
+ });
192
+ expect(view.container.textContent).toBe("/workspace-a/mcp");
193
+
194
+ authorization = "Bearer workspace-b-new";
195
+ await act(async () => resume?.());
196
+ await waitFor(() =>
197
+ expect(view.container.textContent).toBe("/workspace-b/mcp"),
198
+ );
199
+
200
+ expect(fetch).toHaveBeenCalledWith("/workspace-b/mcp", {
201
+ method: "POST",
202
+ headers: {
203
+ "content-type": "application/json",
204
+ authorization: "Bearer workspace-b-new",
205
+ },
206
+ body: JSON.stringify({
207
+ method: "tools/call",
208
+ params: { name: "layout-request" },
209
+ }),
210
+ });
211
+ } finally {
212
+ view.unmount();
213
+ }
214
+ });
215
+ });
@@ -1,4 +1,4 @@
1
- import { useMemo, useRef } from "react";
1
+ import { useEffect, useMemo, useRef } from "react";
2
2
  import { resource } from "@assistant-ui/tap";
3
3
  import type {
4
4
  McpAppResource,
@@ -111,20 +111,17 @@ const useMcpAppsRemoteHost = (
111
111
  options: McpAppsRemoteHostOptions,
112
112
  ): McpAppsHost => {
113
113
  const optionsRef = useRef(options);
114
- optionsRef.current = options;
114
+ useEffect(() => {
115
+ optionsRef.current = options;
116
+ });
115
117
 
116
118
  const url = options.url;
117
119
 
118
- return useMemo((): McpAppsHost => {
119
- const getCurrentOptions = (): McpAppsRemoteHostOptions => {
120
- const current = optionsRef.current;
121
- return {
122
- url,
123
- ...(current.fetch !== undefined ? { fetch: current.fetch } : {}),
124
- ...(current.headers !== undefined ? { headers: current.headers } : {}),
125
- };
126
- };
127
- return {
120
+ const hostState = useMemo(() => {
121
+ let pendingOptions = options;
122
+ const getCurrentOptions = () =>
123
+ optionsRef.current.url === url ? optionsRef.current : pendingOptions;
124
+ const host: McpAppsHost = {
128
125
  loadResource: async (params) => {
129
126
  const options = getCurrentOptions();
130
127
  return parseMcpAppResource(
@@ -139,7 +136,26 @@ const useMcpAppsRemoteHost = (
139
136
  listResources: (params) =>
140
137
  postToHost(getCurrentOptions(), "resources/list", params),
141
138
  };
139
+ return {
140
+ host,
141
+ updatePendingOptions: (next: McpAppsRemoteHostOptions) => {
142
+ pendingOptions = next;
143
+ },
144
+ };
145
+ // oxlint-disable-next-line react/exhaustive-deps -- URL changes replace the host identity; pending and same-URL options are refreshed outside the memo
142
146
  }, [url]);
147
+
148
+ // The pending snapshot is read only while the committed ref still lags this
149
+ // host's URL, and every write to it carries that same render's options, so
150
+ // no render can pair one URL with another's credentials. A committed host can
151
+ // be written here (a child layout effect re-rendering synchronously runs
152
+ // before passive effects publish the ref), which stays coherent for the same
153
+ // reason.
154
+ if (optionsRef.current.url !== url) {
155
+ hostState.updatePendingOptions(options);
156
+ }
157
+
158
+ return hostState.host;
143
159
  };
144
160
 
145
161
  export const McpAppsRemoteHost = resource(useMcpAppsRemoteHost);
@@ -0,0 +1,102 @@
1
+ // @vitest-environment jsdom
2
+ import { act, startTransition, Suspense } from "react";
3
+ import { cleanup, render } from "@testing-library/react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import type {
6
+ SandboxBridge,
7
+ SandboxHostProps,
8
+ } from "../sandbox-host/SandboxHost";
9
+ import type { CreateMcpAppBridgeOptions, McpAppBridge } from "./bridge";
10
+ import { MCP_APP_MIME_TYPE } from "./types";
11
+
12
+ const { sandboxHostMock, createMcpAppBridgeMock } = vi.hoisted(() => ({
13
+ sandboxHostMock: vi.fn(),
14
+ createMcpAppBridgeMock: vi.fn(),
15
+ }));
16
+
17
+ vi.mock("../sandbox-host/SandboxHost", () => ({
18
+ SandboxHost: sandboxHostMock,
19
+ }));
20
+
21
+ vi.mock("./bridge", async (importOriginal) => ({
22
+ ...(await importOriginal()),
23
+ createMcpAppBridge: createMcpAppBridgeMock,
24
+ }));
25
+
26
+ import { McpAppFrame } from "./app-frame";
27
+
28
+ describe("McpAppFrame", () => {
29
+ afterEach(() => {
30
+ cleanup();
31
+ vi.clearAllMocks();
32
+ });
33
+
34
+ it("keeps bridge options scoped to committed renders", async () => {
35
+ let committedCreateBridge: SandboxHostProps["createBridge"] | null = null;
36
+ sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
37
+ committedCreateBridge ??= props.createBridge;
38
+ return null;
39
+ });
40
+ const bridge: McpAppBridge = {
41
+ onMessage: vi.fn(),
42
+ dispose: vi.fn(),
43
+ notifyToolInput: vi.fn(),
44
+ notifyToolResult: vi.fn(),
45
+ notifyHostContextChanged: vi.fn(),
46
+ };
47
+ createMcpAppBridgeMock.mockReturnValue(bridge);
48
+ const callToolA = vi.fn();
49
+ const callToolB = vi.fn();
50
+ const interruptedRender = vi.fn();
51
+ const pending = new Promise<never>(() => {});
52
+ const Block = () => {
53
+ interruptedRender();
54
+ throw pending;
55
+ };
56
+ const view = (
57
+ workspace: string,
58
+ callTool: typeof callToolA,
59
+ blocked: boolean,
60
+ ) => (
61
+ <Suspense fallback={null}>
62
+ <McpAppFrame
63
+ app={{ resourceUri: "ui://example/widget" }}
64
+ resource={{
65
+ uri: "ui://example/widget",
66
+ mimeType: MCP_APP_MIME_TYPE,
67
+ html: "",
68
+ }}
69
+ handlers={{ callTool }}
70
+ hostContext={{ workspace }}
71
+ />
72
+ {blocked ? <Block /> : null}
73
+ </Suspense>
74
+ );
75
+ const rendered = render(view("workspace-a", callToolA, false));
76
+
77
+ act(() => {
78
+ startTransition(() =>
79
+ rendered.rerender(view("workspace-b", callToolB, true)),
80
+ );
81
+ });
82
+ expect(interruptedRender).toHaveBeenCalled();
83
+ expect(committedCreateBridge).not.toBeNull();
84
+
85
+ const sandboxBridge: SandboxBridge = committedCreateBridge!(
86
+ {
87
+ iframe: document.createElement("iframe"),
88
+ origin: "https://widget.example",
89
+ sendMessage: vi.fn(),
90
+ },
91
+ { setHeight: vi.fn() },
92
+ );
93
+ const options = createMcpAppBridgeMock.mock
94
+ .calls[0]![0] as CreateMcpAppBridgeOptions;
95
+ expect(options.hostContext).toEqual({ workspace: "workspace-a" });
96
+ await options.handlers?.callTool?.({ name: "search" });
97
+ expect(callToolA).toHaveBeenCalledOnce();
98
+ expect(callToolB).not.toHaveBeenCalled();
99
+
100
+ sandboxBridge.dispose();
101
+ });
102
+ });
@@ -1,6 +1,11 @@
1
1
  "use client";
2
2
 
3
- import { type MutableRefObject, useEffect, useRef } from "react";
3
+ import {
4
+ type MutableRefObject,
5
+ useEffect,
6
+ useLayoutEffect,
7
+ useRef,
8
+ } from "react";
4
9
  import { type McpAppBridge, createMcpAppBridge } from "./bridge";
5
10
  import {
6
11
  SandboxHost,
@@ -121,14 +126,22 @@ export function McpAppFrame({
121
126
  undefined,
122
127
  );
123
128
 
124
- const liveRef = useRef<LiveSnapshot>(null!);
125
- liveRef.current = {
129
+ const liveRef = useRef<LiveSnapshot>({
126
130
  handlers,
127
131
  hostInfo,
128
132
  hostContext,
129
133
  input,
130
134
  output,
131
- };
135
+ });
136
+ useLayoutEffect(() => {
137
+ liveRef.current = {
138
+ handlers,
139
+ hostInfo,
140
+ hostContext,
141
+ input,
142
+ output,
143
+ };
144
+ }, [handlers, hostInfo, hostContext, input, output]);
132
145
 
133
146
  const createBridge = (
134
147
  frame: SandboxHostFrame,
@@ -13,5 +13,6 @@ export type {
13
13
  McpAppsHost,
14
14
  McpAppsRemoteHostOptions,
15
15
  McpAppToolCallParams,
16
+ McpAppBridgeHandlers,
16
17
  ToolCallMessagePartMcpMetadata,
17
18
  } from "./types";
@@ -27,7 +27,7 @@ describe("AssistantFrame Integration", () => {
27
27
  parentHandler({
28
28
  data,
29
29
  source: iframeWindow,
30
- origin: "*",
30
+ origin: window.location.origin,
31
31
  } as MessageEvent);
32
32
  });
33
33
  }
@@ -44,7 +44,7 @@ describe("AssistantFrame Integration", () => {
44
44
  iframeHandler({
45
45
  data,
46
46
  source: parentWindow, // parent window is the source for subscription
47
- origin: "*",
47
+ origin: window.location.origin,
48
48
  } as MessageEvent);
49
49
  });
50
50
  }
@@ -104,7 +104,7 @@ describe("AssistantFrame Integration", () => {
104
104
  type: "model-context-request",
105
105
  }),
106
106
  }),
107
- "*",
107
+ window.location.origin,
108
108
  );
109
109
  });
110
110
 
@@ -88,7 +88,7 @@ All messages are wrapped with a channel identifier to avoid conflicts with other
88
88
 
89
89
  #### Security Considerations
90
90
 
91
- 1. **Origin Validation**: Both sides can specify `targetOrigin` to restrict message sources; providers share a fail-closed origin policy
91
+ 1. **Origin Validation**: Both sides default to the current page's origin and can specify an exact `targetOrigin` for cross-origin frames; providers share a fail-closed origin policy
92
92
  2. **Window Reference**: Host (parent) only accepts messages from the specific iframe window it's connected to
93
93
  3. **Message Channel**: Using a unique channel identifier prevents cross-talk with other postMessage users
94
94
 
@@ -20,7 +20,7 @@ type UseAssistantFrameHostOptions = {
20
20
  *
21
21
  * useAssistantFrameHost({
22
22
  * iframeRef,
23
- * targetOrigin: "https://trusted-domain.com", // optional
23
+ * targetOrigin: "https://trusted-domain.com",
24
24
  * });
25
25
  *
26
26
  * return <iframe ref={iframeRef} src="..." />;
@@ -29,7 +29,7 @@ type UseAssistantFrameHostOptions = {
29
29
  */
30
30
  export const useAssistantFrameHost = ({
31
31
  iframeRef,
32
- targetOrigin = "*",
32
+ targetOrigin,
33
33
  register,
34
34
  }: UseAssistantFrameHostOptions): void => {
35
35
  useEffect(() => {
@@ -12,7 +12,7 @@ import {
12
12
  useContext,
13
13
  } from "react";
14
14
  import { useAui } from "@assistant-ui/store";
15
- import { useComposedRefs } from "@radix-ui/react-compose-refs";
15
+ import { useComposedRefs } from "radix-ui/internal";
16
16
  import { tool } from "@assistant-ui/core";
17
17
 
18
18
  const click = tool({
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { forwardRef } from "react";
4
4
  import type { ActionButtonProps } from "../../utils/createActionButton";
5
- import { composeEventHandlers } from "@radix-ui/primitive";
5
+ import { composeEventHandlers } from "radix-ui/internal";
6
6
  import { Primitive } from "../../utils/Primitive";
7
7
  import { useActionBarCopy } from "@assistant-ui/core/react";
8
8
  import { useAuiState } from "@assistant-ui/store";
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { forwardRef, useCallback } from "react";
4
4
  import type { ActionButtonProps } from "../../utils/createActionButton";
5
- import { composeEventHandlers } from "@radix-ui/primitive";
5
+ import { composeEventHandlers } from "radix-ui/internal";
6
6
  import { Primitive } from "../../utils/Primitive";
7
7
  import { useAuiState, useAui } from "@assistant-ui/store";
8
8
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { forwardRef } from "react";
4
4
  import type { ActionButtonProps } from "../../utils/createActionButton";
5
- import { composeEventHandlers } from "@radix-ui/primitive";
5
+ import { composeEventHandlers } from "radix-ui/internal";
6
6
  import { Primitive } from "../../utils/Primitive";
7
7
  import { useAuiState } from "@assistant-ui/store";
8
8
  import { useActionBarFeedbackNegative as useActionBarFeedbackNegativeBehavior } from "@assistant-ui/core/react";
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { forwardRef } from "react";
4
4
  import type { ActionButtonProps } from "../../utils/createActionButton";
5
- import { composeEventHandlers } from "@radix-ui/primitive";
5
+ import { composeEventHandlers } from "radix-ui/internal";
6
6
  import { useAuiState } from "@assistant-ui/store";
7
7
  import { Primitive } from "../../utils/Primitive";
8
8
  import { useActionBarFeedbackPositive as useActionBarFeedbackPositiveBehavior } from "@assistant-ui/core/react";
@@ -3,7 +3,7 @@
3
3
  import { forwardRef } from "react";
4
4
  import type { ActionButtonProps } from "../../utils/createActionButton";
5
5
  import { Primitive } from "../../utils/Primitive";
6
- import { composeEventHandlers } from "@radix-ui/primitive";
6
+ import { composeEventHandlers } from "radix-ui/internal";
7
7
  import { useActionBarStopSpeaking as useActionBarStopSpeakingBehavior } from "@assistant-ui/core/react";
8
8
 
9
9
  const useActionBarStopSpeaking = () => {
@@ -1,8 +1,8 @@
1
1
  import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
2
- import type { Scope } from "@radix-ui/react-context";
2
+ import type { Context } from "radix-ui/internal";
3
3
 
4
4
  export const useDropdownMenuScope: ReturnType<
5
5
  typeof DropdownMenuPrimitive.createDropdownMenuScope
6
6
  > = DropdownMenuPrimitive.createDropdownMenuScope();
7
7
 
8
- export type ScopedProps<P> = P & { __scopeActionBarMore?: Scope };
8
+ export type ScopedProps<P> = P & { __scopeActionBarMore?: Context.Scope };
@@ -6,7 +6,7 @@ import {
6
6
  forwardRef,
7
7
  } from "react";
8
8
  import { Popover as PopoverPrimitive } from "radix-ui";
9
- import { composeEventHandlers } from "@radix-ui/primitive";
9
+ import { composeEventHandlers } from "radix-ui/internal";
10
10
  import type { WithRenderPropProps } from "../../utils/Primitive";
11
11
  import { PopoverRenderContent } from "./popoverRenderPrimitives";
12
12
  import { type ScopedProps, usePopoverScope } from "./scope";
@@ -1,7 +1,7 @@
1
1
  import { Popover as PopoverPrimitive } from "radix-ui";
2
- import type { Scope } from "@radix-ui/react-context";
2
+ import type { Context } from "radix-ui/internal";
3
3
 
4
4
  export const usePopoverScope: ReturnType<
5
5
  typeof PopoverPrimitive.createPopoverScope
6
6
  > = PopoverPrimitive.createPopoverScope();
7
- export type ScopedProps<P> = P & { __scopeAssistantModal?: Scope };
7
+ export type ScopedProps<P> = P & { __scopeAssistantModal?: Context.Scope };