@assistant-ui/react 0.15.15 → 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 (267) hide show
  1. package/README.md +3 -3
  2. package/dist/devtools/DevToolsHooks.d.ts +0 -2
  3. package/dist/devtools/DevToolsHooks.d.ts.map +1 -1
  4. package/dist/devtools/DevToolsHooks.js +10 -12
  5. package/dist/devtools/DevToolsHooks.js.map +1 -1
  6. package/dist/index.d.ts +4 -3
  7. package/dist/index.js +2 -2
  8. package/dist/mcp-apps/McpAppRenderer.d.ts +6 -1
  9. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  10. package/dist/mcp-apps/McpAppRenderer.js +20 -13
  11. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  12. package/dist/mcp-apps/McpAppsRemoteHost.d.ts.map +1 -1
  13. package/dist/mcp-apps/McpAppsRemoteHost.js +20 -17
  14. package/dist/mcp-apps/McpAppsRemoteHost.js.map +1 -1
  15. package/dist/mcp-apps/app-frame.d.ts.map +1 -1
  16. package/dist/mcp-apps/app-frame.js +189 -94
  17. package/dist/mcp-apps/app-frame.js.map +1 -1
  18. package/dist/mcp-apps/bridge.d.ts.map +1 -1
  19. package/dist/mcp-apps/bridge.js +2 -8
  20. package/dist/mcp-apps/bridge.js.map +1 -1
  21. package/dist/mcp-apps/index.d.ts +2 -2
  22. package/dist/model-context/frame/useAssistantFrameHost.d.ts +1 -1
  23. package/dist/model-context/frame/useAssistantFrameHost.js +10 -11
  24. package/dist/model-context/frame/useAssistantFrameHost.js.map +1 -1
  25. package/dist/model-context/makeAssistantVisible.js +1 -1
  26. package/dist/model-context/makeAssistantVisible.js.map +1 -1
  27. package/dist/primitives/actionBar/ActionBarCopy.js +3 -3
  28. package/dist/primitives/actionBar/ActionBarCopy.js.map +1 -1
  29. package/dist/primitives/actionBar/ActionBarExportMarkdown.d.ts.map +1 -1
  30. package/dist/primitives/actionBar/ActionBarExportMarkdown.js +7 -4
  31. package/dist/primitives/actionBar/ActionBarExportMarkdown.js.map +1 -1
  32. package/dist/primitives/actionBar/ActionBarFeedbackNegative.js +3 -3
  33. package/dist/primitives/actionBar/ActionBarFeedbackNegative.js.map +1 -1
  34. package/dist/primitives/actionBar/ActionBarFeedbackPositive.js +3 -3
  35. package/dist/primitives/actionBar/ActionBarFeedbackPositive.js.map +1 -1
  36. package/dist/primitives/actionBar/ActionBarRoot.js.map +1 -1
  37. package/dist/primitives/actionBar/ActionBarStopSpeaking.js +3 -3
  38. package/dist/primitives/actionBar/ActionBarStopSpeaking.js.map +1 -1
  39. package/dist/primitives/actionBarMore/ActionBarMoreContent.js.map +1 -1
  40. package/dist/primitives/actionBarMore/ActionBarMoreItem.js.map +1 -1
  41. package/dist/primitives/actionBarMore/ActionBarMoreRoot.js.map +1 -1
  42. package/dist/primitives/actionBarMore/ActionBarMoreSeparator.js.map +1 -1
  43. package/dist/primitives/actionBarMore/ActionBarMoreTrigger.js.map +1 -1
  44. package/dist/primitives/actionBarMore/scope.d.ts +2 -2
  45. package/dist/primitives/actionBarMore/scope.d.ts.map +1 -1
  46. package/dist/primitives/actionBarMore/scope.js.map +1 -1
  47. package/dist/primitives/assistantModal/AssistantModalAnchor.js.map +1 -1
  48. package/dist/primitives/assistantModal/AssistantModalContent.js +1 -1
  49. package/dist/primitives/assistantModal/AssistantModalContent.js.map +1 -1
  50. package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
  51. package/dist/primitives/assistantModal/AssistantModalTrigger.js.map +1 -1
  52. package/dist/primitives/assistantModal/scope.d.ts +2 -2
  53. package/dist/primitives/assistantModal/scope.d.ts.map +1 -1
  54. package/dist/primitives/assistantModal/scope.js.map +1 -1
  55. package/dist/primitives/branchPicker/BranchPickerRoot.d.ts.map +1 -1
  56. package/dist/primitives/branchPicker/BranchPickerRoot.js +20 -15
  57. package/dist/primitives/branchPicker/BranchPickerRoot.js.map +1 -1
  58. package/dist/primitives/composer/ComposerAttachmentDropzone.js +1 -1
  59. package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
  60. package/dist/primitives/composer/ComposerDictationTranscript.d.ts +2 -2
  61. package/dist/primitives/composer/ComposerDictationTranscript.js +2 -2
  62. package/dist/primitives/composer/ComposerDictationTranscript.js.map +1 -1
  63. package/dist/primitives/composer/ComposerInput.js +1 -3
  64. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  65. package/dist/primitives/composer/ComposerQuote.js +5 -5
  66. package/dist/primitives/composer/ComposerQuote.js.map +1 -1
  67. package/dist/primitives/composer/ComposerRoot.js +3 -3
  68. package/dist/primitives/composer/ComposerRoot.js.map +1 -1
  69. package/dist/primitives/composer/trigger/TriggerPopover.d.ts +5 -0
  70. package/dist/primitives/composer/trigger/TriggerPopover.d.ts.map +1 -1
  71. package/dist/primitives/composer/trigger/TriggerPopover.js +157 -130
  72. package/dist/primitives/composer/trigger/TriggerPopover.js.map +1 -1
  73. package/dist/primitives/composer/trigger/TriggerPopoverBack.js +3 -3
  74. package/dist/primitives/composer/trigger/TriggerPopoverBack.js.map +1 -1
  75. package/dist/primitives/composer/trigger/TriggerPopoverCategories.js +4 -4
  76. package/dist/primitives/composer/trigger/TriggerPopoverCategories.js.map +1 -1
  77. package/dist/primitives/composer/trigger/TriggerPopoverItems.js +4 -4
  78. package/dist/primitives/composer/trigger/TriggerPopoverItems.js.map +1 -1
  79. package/dist/primitives/composer/trigger/TriggerPopoverResource.d.ts +2 -0
  80. package/dist/primitives/composer/trigger/TriggerPopoverResource.d.ts.map +1 -1
  81. package/dist/primitives/composer/trigger/TriggerPopoverResource.js +63 -61
  82. package/dist/primitives/composer/trigger/TriggerPopoverResource.js.map +1 -1
  83. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.d.ts +2 -0
  84. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.d.ts.map +1 -1
  85. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js.map +1 -1
  86. package/dist/primitives/composer/trigger/detectTrigger.d.ts +21 -1
  87. package/dist/primitives/composer/trigger/detectTrigger.d.ts.map +1 -0
  88. package/dist/primitives/composer/trigger/detectTrigger.js +17 -3
  89. package/dist/primitives/composer/trigger/detectTrigger.js.map +1 -1
  90. package/dist/primitives/composer/trigger/index.d.ts +3 -1
  91. package/dist/primitives/composer/trigger/index.d.ts.map +1 -1
  92. package/dist/primitives/composer/trigger/index.js.map +1 -1
  93. package/dist/primitives/composer/trigger/triggerDetectionResource.d.ts +3 -4
  94. package/dist/primitives/composer/trigger/triggerDetectionResource.d.ts.map +1 -1
  95. package/dist/primitives/composer/trigger/triggerDetectionResource.js +15 -14
  96. package/dist/primitives/composer/trigger/triggerDetectionResource.js.map +1 -1
  97. package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts.map +1 -1
  98. package/dist/primitives/composer/trigger/triggerSelectionResource.js +10 -7
  99. package/dist/primitives/composer/trigger/triggerSelectionResource.js.map +1 -1
  100. package/dist/primitives/composer.d.ts +3 -2
  101. package/dist/primitives/composer.js.map +1 -1
  102. package/dist/primitives/error/ErrorMessage.js.map +1 -1
  103. package/dist/primitives/message/MessageIf.js.map +1 -1
  104. package/dist/primitives/message/MessageParts.js.map +1 -1
  105. package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
  106. package/dist/primitives/message/MessageRoot.js +4 -4
  107. package/dist/primitives/message/MessageRoot.js.map +1 -1
  108. package/dist/primitives/message.js +1 -1
  109. package/dist/primitives/messagePart/MessagePartText.js.map +1 -1
  110. package/dist/primitives/selectionToolbar/SelectionToolbarQuote.js +3 -3
  111. package/dist/primitives/selectionToolbar/SelectionToolbarQuote.js.map +1 -1
  112. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js +3 -3
  113. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js.map +1 -1
  114. package/dist/primitives/thread/ThreadIf.js.map +1 -1
  115. package/dist/primitives/thread/ThreadRoot.d.ts +3 -0
  116. package/dist/primitives/thread/ThreadRoot.d.ts.map +1 -1
  117. package/dist/primitives/thread/ThreadRoot.js +3 -0
  118. package/dist/primitives/thread/ThreadRoot.js.map +1 -1
  119. package/dist/primitives/thread/ThreadViewport.js +3 -3
  120. package/dist/primitives/thread/ThreadViewport.js.map +1 -1
  121. package/dist/primitives/thread/ThreadViewportFooter.js +3 -3
  122. package/dist/primitives/thread/ThreadViewportFooter.js.map +1 -1
  123. package/dist/primitives/thread/useThreadViewportAutoScroll.js +1 -1
  124. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  125. package/dist/primitives/threadList/ThreadListNew.js +3 -3
  126. package/dist/primitives/threadList/ThreadListNew.js.map +1 -1
  127. package/dist/primitives/threadListFocusGroup.d.ts +2 -1
  128. package/dist/primitives/threadListFocusGroup.d.ts.map +1 -1
  129. package/dist/primitives/threadListFocusGroup.js +2 -2
  130. package/dist/primitives/threadListFocusGroup.js.map +1 -1
  131. package/dist/primitives/threadListItem/ThreadListItemRoot.js +3 -3
  132. package/dist/primitives/threadListItem/ThreadListItemRoot.js.map +1 -1
  133. package/dist/primitives/threadListItem/ThreadListItemTrigger.js +1 -1
  134. package/dist/primitives/threadListItem/ThreadListItemTrigger.js.map +1 -1
  135. package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.js +1 -1
  136. package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.js.map +1 -1
  137. package/dist/primitives/threadListItemMore/ThreadListItemMoreItem.js.map +1 -1
  138. package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.js +1 -1
  139. package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.js.map +1 -1
  140. package/dist/primitives/threadListItemMore/ThreadListItemMoreSeparator.js.map +1 -1
  141. package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.js +1 -2
  142. package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.js.map +1 -1
  143. package/dist/primitives/threadListItemMore/scope.d.ts +2 -2
  144. package/dist/primitives/threadListItemMore/scope.d.ts.map +1 -1
  145. package/dist/primitives/threadListItemMore/scope.js.map +1 -1
  146. package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
  147. package/dist/sandbox-host/SandboxHost.js +138 -68
  148. package/dist/sandbox-host/SandboxHost.js.map +1 -1
  149. package/dist/unstable/useComposerInputHistory.js +1 -1
  150. package/dist/unstable/useLiveCompletionAdapter.js +3 -1
  151. package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
  152. package/dist/unstable/useSlashCommandAdapter.js +41 -27
  153. package/dist/unstable/useSlashCommandAdapter.js.map +1 -1
  154. package/dist/utils/Primitive.d.ts +2 -2
  155. package/dist/utils/Primitive.js +2 -2
  156. package/dist/utils/Primitive.js.map +1 -1
  157. package/dist/utils/createActionButton.js +3 -3
  158. package/dist/utils/createActionButton.js.map +1 -1
  159. package/dist/utils/hooks/useOnResizeContent.js +1 -1
  160. package/dist/utils/hooks/useOnResizeContent.js.map +1 -1
  161. package/dist/utils/hooks/useOnScrollToBottom.js +1 -1
  162. package/dist/utils/hooks/useOnScrollToBottom.js.map +1 -1
  163. package/dist/utils/invokeCallbackSafely.d.ts +5 -0
  164. package/dist/utils/invokeCallbackSafely.d.ts.map +1 -0
  165. package/dist/utils/invokeCallbackSafely.js +15 -0
  166. package/dist/utils/invokeCallbackSafely.js.map +1 -0
  167. package/dist/utils/smooth/SmoothContext.d.ts +30 -6
  168. package/dist/utils/smooth/SmoothContext.d.ts.map +1 -1
  169. package/dist/utils/smooth/useSmooth.d.ts +3 -0
  170. package/dist/utils/smooth/useSmooth.d.ts.map +1 -1
  171. package/dist/utils/smooth/useSmooth.js +17 -2
  172. package/dist/utils/smooth/useSmooth.js.map +1 -1
  173. package/dist/utils/useToolArgsFieldStatus.d.ts +5 -1
  174. package/package.json +12 -20
  175. package/src/devtools/DevToolsHooks.test.ts +40 -0
  176. package/src/devtools/DevToolsHooks.ts +10 -15
  177. package/src/index.ts +9 -1
  178. package/src/mcp-apps/McpAppRenderer.test.tsx +133 -4
  179. package/src/mcp-apps/McpAppRenderer.tsx +28 -21
  180. package/src/mcp-apps/McpAppsRemoteHost.concurrent.test.tsx +215 -0
  181. package/src/mcp-apps/McpAppsRemoteHost.ts +28 -12
  182. package/src/mcp-apps/app-frame.test.tsx +102 -0
  183. package/src/mcp-apps/app-frame.tsx +17 -4
  184. package/src/mcp-apps/bridge.test.ts +21 -0
  185. package/src/mcp-apps/bridge.ts +2 -14
  186. package/src/mcp-apps/index.ts +1 -0
  187. package/src/model-context/frame/AssistantFrame.test.ts +3 -3
  188. package/src/model-context/frame/SPEC_AssistantFrame.md +1 -1
  189. package/src/model-context/frame/useAssistantFrameHost.ts +2 -2
  190. package/src/model-context/makeAssistantVisible.tsx +1 -1
  191. package/src/primitives/actionBar/ActionBarCopy.tsx +1 -1
  192. package/src/primitives/actionBar/ActionBarExportMarkdown.test.tsx +67 -0
  193. package/src/primitives/actionBar/ActionBarExportMarkdown.tsx +4 -2
  194. package/src/primitives/actionBar/ActionBarFeedbackNegative.tsx +1 -1
  195. package/src/primitives/actionBar/ActionBarFeedbackPositive.tsx +1 -1
  196. package/src/primitives/actionBar/ActionBarStopSpeaking.tsx +1 -1
  197. package/src/primitives/actionBarMore/scope.ts +2 -2
  198. package/src/primitives/assistantModal/AssistantModalContent.tsx +1 -1
  199. package/src/primitives/assistantModal/scope.ts +2 -2
  200. package/src/primitives/branchPicker/BranchPickerRoot.tsx +5 -3
  201. package/src/primitives/composer/ComposerAttachmentDropzone.tsx +1 -1
  202. package/src/primitives/composer/ComposerDictationTranscript.tsx +2 -2
  203. package/src/primitives/composer/ComposerInput.test.tsx +2 -1
  204. package/src/primitives/composer/ComposerInput.tsx +3 -3
  205. package/src/primitives/composer/ComposerQuote.tsx +1 -1
  206. package/src/primitives/composer/ComposerRoot.tsx +1 -1
  207. package/src/primitives/composer/trigger/TriggerPopover.tsx +16 -1
  208. package/src/primitives/composer/trigger/TriggerPopoverBack.tsx +1 -1
  209. package/src/primitives/composer/trigger/TriggerPopoverCategories.tsx +1 -1
  210. package/src/primitives/composer/trigger/TriggerPopoverItems.tsx +1 -1
  211. package/src/primitives/composer/trigger/TriggerPopoverResource.ts +4 -1
  212. package/src/primitives/composer/trigger/TriggerPopoverRootContext.tsx +2 -0
  213. package/src/primitives/composer/trigger/detectTrigger.test.ts +88 -5
  214. package/src/primitives/composer/trigger/detectTrigger.ts +50 -9
  215. package/src/primitives/composer/trigger/index.ts +1 -0
  216. package/src/primitives/composer/trigger/triggerDetectionResource.ts +10 -7
  217. package/src/primitives/composer/trigger/triggerSelectionResource.test.ts +147 -0
  218. package/src/primitives/composer/trigger/triggerSelectionResource.ts +3 -4
  219. package/src/primitives/composer.ts +1 -0
  220. package/src/primitives/message/MessageRoot.tsx +1 -1
  221. package/src/primitives/selectionToolbar/SelectionToolbarQuote.tsx +1 -1
  222. package/src/primitives/selectionToolbar/SelectionToolbarRoot.tsx +1 -1
  223. package/src/primitives/thread/ThreadRoot.tsx +3 -0
  224. package/src/primitives/thread/ThreadViewport.tsx +1 -1
  225. package/src/primitives/thread/ThreadViewportFooter.tsx +1 -1
  226. package/src/primitives/thread/useThreadViewportAutoScroll.ts +1 -1
  227. package/src/primitives/threadList/ThreadListNew.tsx +1 -1
  228. package/src/primitives/threadListFocusGroup.ts +2 -2
  229. package/src/primitives/threadListItem/ThreadListItemRoot.tsx +1 -1
  230. package/src/primitives/threadListItem/ThreadListItemTrigger.tsx +1 -1
  231. package/src/primitives/threadListItemMore/ThreadListItemMoreContent.tsx +1 -1
  232. package/src/primitives/threadListItemMore/ThreadListItemMoreRoot.tsx +1 -1
  233. package/src/primitives/threadListItemMore/ThreadListItemMoreTrigger.tsx +2 -2
  234. package/src/primitives/threadListItemMore/scope.ts +2 -2
  235. package/src/sandbox-host/SandboxHost.test.tsx +106 -1
  236. package/src/sandbox-host/SandboxHost.tsx +22 -5
  237. package/src/tests/RemoteThreadListRuntime.adapterProvider.test.tsx +31 -1
  238. package/src/unstable/useLiveCompletionAdapter.test.tsx +117 -1
  239. package/src/unstable/useLiveCompletionAdapter.ts +4 -1
  240. package/src/unstable/useSlashCommandAdapter.test.tsx +141 -0
  241. package/src/unstable/useSlashCommandAdapter.ts +52 -17
  242. package/src/utils/Primitive.tsx +2 -2
  243. package/src/utils/createActionButton.tsx +1 -1
  244. package/src/utils/hooks/useOnResizeContent.ts +1 -1
  245. package/src/utils/hooks/useOnScrollToBottom.ts +1 -1
  246. package/src/utils/invokeCallbackSafely.ts +11 -0
  247. package/src/utils/smooth/useSmooth.test.tsx +110 -0
  248. package/src/utils/smooth/useSmooth.ts +21 -7
  249. package/dist/context/react/utils/ensureBinding.d.ts +0 -5
  250. package/dist/context/react/utils/ensureBinding.d.ts.map +0 -1
  251. package/dist/context/react/utils/ensureBinding.js +0 -24
  252. package/dist/context/react/utils/ensureBinding.js.map +0 -1
  253. package/dist/context/react/utils/useRuntimeState.d.ts +0 -13
  254. package/dist/context/react/utils/useRuntimeState.d.ts.map +0 -1
  255. package/dist/context/react/utils/useRuntimeState.js +0 -33
  256. package/dist/context/react/utils/useRuntimeState.js.map +0 -1
  257. package/dist/legacy-runtime/runtime/MessagePartRuntime.d.ts +0 -3
  258. package/dist/legacy-runtime/runtime/MessagePartRuntime.js +0 -2
  259. package/dist/legacy-runtime/runtime/MessageRuntime.d.ts +0 -3
  260. package/dist/legacy-runtime/runtime/MessageRuntime.js +0 -2
  261. package/dist/legacy-runtime/runtime/ThreadListRuntime.d.ts +0 -3
  262. package/dist/legacy-runtime/runtime/ThreadListRuntime.js +0 -2
  263. package/src/context/react/utils/ensureBinding.ts +0 -46
  264. package/src/context/react/utils/useRuntimeState.ts +0 -46
  265. package/src/legacy-runtime/runtime/MessagePartRuntime.ts +0 -2
  266. package/src/legacy-runtime/runtime/MessageRuntime.ts +0 -3
  267. package/src/legacy-runtime/runtime/ThreadListRuntime.ts +0 -4
@@ -1,5 +1,5 @@
1
1
  // @vitest-environment jsdom
2
- import { act } from "react";
2
+ import { act, startTransition, Suspense } from "react";
3
3
  import { createRoot, type Root } from "react-dom/client";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
 
@@ -229,6 +229,67 @@ describe("SandboxHost", () => {
229
229
  expect(onError.mock.calls[0]![0].message).toBe("boom");
230
230
  });
231
231
 
232
+ it("does not report render failures after unmount", async () => {
233
+ let rejectRender!: (error: Error) => void;
234
+ renderHtmlMock.mockReturnValue(
235
+ new Promise((_, reject) => {
236
+ rejectRender = reject;
237
+ }),
238
+ );
239
+ const onError = vi.fn();
240
+
241
+ await act(async () => {
242
+ root.render(
243
+ <SandboxHost
244
+ content={{ html: "" }}
245
+ contentKey="k"
246
+ createBridge={() => ({ onMessage: vi.fn(), dispose: vi.fn() })}
247
+ onError={onError}
248
+ />,
249
+ );
250
+ });
251
+
252
+ await act(async () => {
253
+ root.unmount();
254
+ });
255
+ rejectRender(new Error("late failure"));
256
+ await flush();
257
+
258
+ expect(onError).not.toHaveBeenCalled();
259
+ });
260
+
261
+ it("contains failures thrown by onError", async () => {
262
+ const renderError = new Error("render failed");
263
+ const callbackError = new Error("error callback failed");
264
+ renderHtmlMock.mockRejectedValue(renderError);
265
+ const consoleError = vi
266
+ .spyOn(console, "error")
267
+ .mockImplementation(() => {});
268
+
269
+ try {
270
+ await act(async () => {
271
+ root.render(
272
+ <SandboxHost
273
+ content={{ html: "" }}
274
+ contentKey="k"
275
+ createBridge={() => ({ onMessage: vi.fn(), dispose: vi.fn() })}
276
+ onError={() => {
277
+ throw callbackError;
278
+ }}
279
+ />,
280
+ );
281
+ });
282
+ await flush();
283
+
284
+ expect(consoleError).toHaveBeenCalledWith(
285
+ "[assistant-ui] SandboxHost onError callback threw an error",
286
+ callbackError,
287
+ );
288
+ } finally {
289
+ consoleError.mockRestore();
290
+ }
291
+ });
292
+
232
293
  it("disposes the rendered frame when bridge creation fails", async () => {
233
294
  const rendered = fakeRendered();
234
295
  renderHtmlMock.mockResolvedValue(rendered);
@@ -257,4 +318,48 @@ describe("SandboxHost", () => {
257
318
  });
258
319
  expect(rendered.dispose).toHaveBeenCalledTimes(1);
259
320
  });
321
+
322
+ it("keeps bridge options scoped to committed renders", async () => {
323
+ let resolveRender!: (frame: ReturnType<typeof fakeRendered>) => void;
324
+ renderHtmlMock.mockReturnValue(
325
+ new Promise((resolve) => {
326
+ resolveRender = resolve;
327
+ }),
328
+ );
329
+ const rendered = fakeRendered();
330
+ const bridge = { onMessage: vi.fn(), dispose: vi.fn() };
331
+ const createBridgeA = vi.fn(() => bridge);
332
+ const createBridgeB = vi.fn(() => bridge);
333
+ const interruptedRender = vi.fn();
334
+ const pending = new Promise<never>(() => {});
335
+ const Block = () => {
336
+ interruptedRender();
337
+ throw pending;
338
+ };
339
+ const view = (createBridge: typeof createBridgeA, blocked: boolean) => (
340
+ <Suspense fallback={null}>
341
+ <SandboxHost
342
+ content={{ html: "" }}
343
+ contentKey="k"
344
+ createBridge={createBridge}
345
+ />
346
+ {blocked ? <Block /> : null}
347
+ </Suspense>
348
+ );
349
+
350
+ await act(async () => {
351
+ root.render(view(createBridgeA, false));
352
+ });
353
+ act(() => {
354
+ startTransition(() => root.render(view(createBridgeB, true)));
355
+ });
356
+ await vi.waitFor(() => expect(interruptedRender).toHaveBeenCalled());
357
+ await act(async () => {
358
+ resolveRender(rendered);
359
+ await new Promise((resolve) => setTimeout(resolve, 0));
360
+ });
361
+
362
+ expect(createBridgeA).toHaveBeenCalledTimes(1);
363
+ expect(createBridgeB).not.toHaveBeenCalled();
364
+ });
260
365
  });
@@ -1,11 +1,18 @@
1
1
  "use client";
2
2
 
3
- import { type CSSProperties, useEffect, useRef, useState } from "react";
3
+ import {
4
+ type CSSProperties,
5
+ useEffect,
6
+ useLayoutEffect,
7
+ useRef,
8
+ useState,
9
+ } from "react";
4
10
  import {
5
11
  type RenderedFrame,
6
12
  SafeContentFrame,
7
13
  type SandboxOption,
8
14
  } from "safe-content-frame";
15
+ import { invokeCallbackSafely } from "../utils/invokeCallbackSafely";
9
16
 
10
17
  const DEFAULT_PRODUCT = "assistant-ui-sandbox";
11
18
  const DEFAULT_MAX_HEIGHT = 800;
@@ -80,8 +87,15 @@ export function SandboxHost({
80
87
  undefined,
81
88
  );
82
89
 
83
- const liveRef = useRef<LiveSnapshot>(null!);
84
- liveRef.current = { content, sandbox, createBridge, onError };
90
+ const liveRef = useRef<LiveSnapshot>({
91
+ content,
92
+ sandbox,
93
+ createBridge,
94
+ onError,
95
+ });
96
+ useLayoutEffect(() => {
97
+ liveRef.current = { content, sandbox, createBridge, onError };
98
+ }, [content, sandbox, createBridge, onError]);
85
99
 
86
100
  useEffect(() => {
87
101
  const container = containerRef.current;
@@ -147,10 +161,13 @@ export function SandboxHost({
147
161
  window.addEventListener("message", onMessage);
148
162
  })
149
163
  .catch((err) => {
164
+ if (cancelled) return;
150
165
  frame?.dispose();
151
166
  frame = null;
152
- liveRef.current.onError?.(
153
- err instanceof Error ? err : new Error(String(err)),
167
+ const error = err instanceof Error ? err : new Error(String(err));
168
+ invokeCallbackSafely(
169
+ () => liveRef.current.onError?.(error),
170
+ "SandboxHost onError",
154
171
  );
155
172
  });
156
173
 
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment jsdom
2
2
 
3
3
  import { act, render, waitFor } from "@testing-library/react";
4
- import type { FC, PropsWithChildren } from "react";
4
+ import { useState, type FC, type PropsWithChildren } from "react";
5
5
  import { describe, expect, it } from "vitest";
6
6
  import {
7
7
  RuntimeAdapterProvider,
@@ -107,6 +107,36 @@ describe("RemoteThreadListAdapter.unstable_Provider", () => {
107
107
  expect(capture.adapters?.history).toBe(dummyHistory);
108
108
  });
109
109
 
110
+ it("picks up a post-mount useAdapters bag", async () => {
111
+ const capture: { adapters: CapturedAdapters } = { adapters: null };
112
+ const firstHistory: ThreadHistoryAdapter = {
113
+ load: async () => ({ messages: [] }),
114
+ append: async () => {},
115
+ };
116
+ const secondHistory: ThreadHistoryAdapter = {
117
+ load: async () => ({ messages: [] }),
118
+ append: async () => {},
119
+ };
120
+
121
+ let bump: (() => void) | undefined;
122
+ const adapter = makeAdapter({
123
+ unstable_useAdapters: function useTestAdapters() {
124
+ const [swapped, setSwapped] = useState(false);
125
+ bump = () => setSwapped(true);
126
+ return { history: swapped ? secondHistory : firstHistory };
127
+ },
128
+ });
129
+
130
+ await renderAndWaitForBinder(adapter, capture);
131
+ expect(capture.adapters?.history).toBe(firstHistory);
132
+
133
+ await act(async () => {
134
+ bump!();
135
+ });
136
+
137
+ expect(capture.adapters?.history).toBe(secondHistory);
138
+ });
139
+
110
140
  it("picks up a swapped Provider on re-render", async () => {
111
141
  const capture: { adapters: CapturedAdapters } = { adapters: null };
112
142
  const firstHistory: ThreadHistoryAdapter = {
@@ -1,5 +1,6 @@
1
1
  /** @vitest-environment jsdom */
2
- import { act, renderHook } from "@testing-library/react";
2
+ import { startTransition, Suspense } from "react";
3
+ import { act, render, renderHook } from "@testing-library/react";
3
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
5
  import type { Unstable_TriggerItem } from "@assistant-ui/core";
5
6
  import { unstable_useLiveCompletionAdapter } from "./useLiveCompletionAdapter";
@@ -203,6 +204,121 @@ describe("unstable_useLiveCompletionAdapter", () => {
203
204
  ]);
204
205
  });
205
206
 
207
+ it("keeps cached results when only the fetcher identity changes", async () => {
208
+ const first = vi.fn(async () => [item("alice")]);
209
+ const second = vi.fn(async () => [item("bob")]);
210
+ const { result, rerender } = renderHook(
211
+ ({ fetcher }) =>
212
+ unstable_useLiveCompletionAdapter({
213
+ fetcher,
214
+ cacheKey: "workspace-a",
215
+ debounceMs: 0,
216
+ }),
217
+ { initialProps: { fetcher: first } },
218
+ );
219
+
220
+ await act(async () => {
221
+ result.current.adapter.search!("alice");
222
+ await vi.advanceTimersByTimeAsync(0);
223
+ });
224
+ expect(result.current.adapter.search!("alice")).toEqual([item("alice")]);
225
+
226
+ await act(async () => {
227
+ rerender({ fetcher: second });
228
+ await vi.advanceTimersByTimeAsync(0);
229
+ });
230
+ expect(second).not.toHaveBeenCalled();
231
+ expect(result.current.adapter.search!("alice")).toEqual([item("alice")]);
232
+ });
233
+
234
+ it("keeps pending queries scoped to the committed fetcher", async () => {
235
+ const fetcherA = vi.fn(async () => [item("workspace-a")]);
236
+ const fetcherB = vi.fn(async () => [item("workspace-b")]);
237
+ const interruptedRender = vi.fn();
238
+ const pending = new Promise<never>(() => {});
239
+ let adapter!: ReturnType<
240
+ typeof unstable_useLiveCompletionAdapter
241
+ >["adapter"];
242
+ const Harness = ({
243
+ fetcher,
244
+ cacheKey,
245
+ blocked,
246
+ }: {
247
+ fetcher: typeof fetcherA;
248
+ cacheKey: string;
249
+ blocked: boolean;
250
+ }) => {
251
+ const result = unstable_useLiveCompletionAdapter({
252
+ fetcher,
253
+ cacheKey,
254
+ debounceMs: 50,
255
+ });
256
+ if (blocked) {
257
+ interruptedRender();
258
+ throw pending;
259
+ }
260
+ adapter = result.adapter;
261
+ return null;
262
+ };
263
+ const view = (
264
+ fetcher: typeof fetcherA,
265
+ cacheKey: string,
266
+ blocked: boolean,
267
+ ) => (
268
+ <Suspense fallback={null}>
269
+ <Harness fetcher={fetcher} cacheKey={cacheKey} blocked={blocked} />
270
+ </Suspense>
271
+ );
272
+ const rendered = render(view(fetcherA, "workspace-a", false));
273
+
274
+ await act(async () => {
275
+ adapter.search!("alice");
276
+ await vi.advanceTimersByTimeAsync(0);
277
+ });
278
+ act(() => {
279
+ startTransition(() =>
280
+ rendered.rerender(view(fetcherB, "workspace-b", true)),
281
+ );
282
+ });
283
+ expect(interruptedRender).toHaveBeenCalled();
284
+ await act(async () => {
285
+ await vi.advanceTimersByTimeAsync(50);
286
+ });
287
+
288
+ expect(fetcherA).toHaveBeenCalledOnce();
289
+ expect(fetcherB).not.toHaveBeenCalled();
290
+ });
291
+
292
+ it("drops a pending result when only the cache key changes", async () => {
293
+ let resolveFirst!: (items: readonly Unstable_TriggerItem[]) => void;
294
+ const first = new Promise<readonly Unstable_TriggerItem[]>((resolve) => {
295
+ resolveFirst = resolve;
296
+ });
297
+ const fetcher = vi.fn(() => first);
298
+ const { result, rerender } = renderHook(
299
+ ({ cacheKey }) =>
300
+ unstable_useLiveCompletionAdapter({ fetcher, cacheKey, debounceMs: 0 }),
301
+ { initialProps: { cacheKey: "workspace-a" } },
302
+ );
303
+
304
+ await act(async () => {
305
+ result.current.adapter.search!("alice");
306
+ await vi.advanceTimersByTimeAsync(0);
307
+ });
308
+ expect(fetcher).toHaveBeenCalledTimes(1);
309
+
310
+ await act(async () => {
311
+ rerender({ cacheKey: "workspace-b" });
312
+ });
313
+ await act(async () => {
314
+ resolveFirst([item("workspace-a")]);
315
+ });
316
+
317
+ expect(result.current.isLoading).toBe(false);
318
+ expect(fetcher).toHaveBeenCalledTimes(1);
319
+ expect(result.current.adapter.search!("alice")).toEqual([]);
320
+ });
321
+
206
322
  it("allows a failed query to be retried", async () => {
207
323
  const fetcher = vi
208
324
  .fn<(query: string) => Promise<readonly Unstable_TriggerItem[]>>()
@@ -74,7 +74,10 @@ export function unstable_useLiveCompletionAdapter(
74
74
  const [isLoading, setIsLoading] = useState(false);
75
75
 
76
76
  const fetcherRef = useRef(fetcher);
77
- fetcherRef.current = fetcher;
77
+ // The debounce timer must only observe fetchers from committed renders.
78
+ useLayoutEffect(() => {
79
+ fetcherRef.current = fetcher;
80
+ }, [fetcher]);
78
81
 
79
82
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
80
83
  const tokenRef = useRef(0);
@@ -0,0 +1,141 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, renderHook } from "@testing-library/react";
4
+ import { startTransition, Suspense, type PropsWithChildren } from "react";
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import {
7
+ unstable_useSlashCommandAdapter,
8
+ type Unstable_SlashCommand,
9
+ } from "./useSlashCommandAdapter";
10
+
11
+ const command = (
12
+ label: string,
13
+ execute: () => void,
14
+ ): Unstable_SlashCommand => ({
15
+ id: "run",
16
+ label,
17
+ execute,
18
+ });
19
+
20
+ describe("unstable_useSlashCommandAdapter", () => {
21
+ it("keeps commands scoped to committed renders", () => {
22
+ const executeA = vi.fn();
23
+ const executeB = vi.fn();
24
+ const commandsA = [command("Workspace A", executeA)];
25
+ const commandsB = [command("Workspace B", executeB)];
26
+ const renderedLabel = vi.fn();
27
+ const interruptedRender = vi.fn();
28
+ const pending = new Promise<never>(() => {});
29
+ let blocked = false;
30
+
31
+ const Blocker = () => {
32
+ if (blocked) {
33
+ interruptedRender();
34
+ throw pending;
35
+ }
36
+ return null;
37
+ };
38
+ const Wrapper = ({ children }: PropsWithChildren) => (
39
+ <Suspense fallback={null}>
40
+ {children}
41
+ <Blocker />
42
+ </Suspense>
43
+ );
44
+
45
+ const { result, rerender } = renderHook(
46
+ ({ commands }) => {
47
+ renderedLabel(commands[0]?.label);
48
+ return unstable_useSlashCommandAdapter({ commands });
49
+ },
50
+ {
51
+ initialProps: { commands: commandsA },
52
+ wrapper: Wrapper,
53
+ },
54
+ );
55
+
56
+ act(() => {
57
+ blocked = true;
58
+ startTransition(() => rerender({ commands: commandsB }));
59
+ });
60
+
61
+ expect(interruptedRender).toHaveBeenCalled();
62
+ expect(renderedLabel).toHaveBeenCalledWith("Workspace B");
63
+ expect(result.current.adapter.search?.("")).toEqual([
64
+ { id: "run", type: "command", label: "Workspace A" },
65
+ ]);
66
+
67
+ result.current.action.onExecute({
68
+ id: "run",
69
+ type: "command",
70
+ label: "Workspace A",
71
+ });
72
+ expect(executeA).toHaveBeenCalledOnce();
73
+ expect(executeB).not.toHaveBeenCalled();
74
+ });
75
+
76
+ it("updates search while keeping actions stable after commands commit", () => {
77
+ const executeA = vi.fn();
78
+ const executeB = vi.fn();
79
+ const commandsA = [command("Workspace A", executeA)];
80
+ const commandsB = [command("Workspace B", executeB)];
81
+ const { result, rerender } = renderHook(
82
+ ({ commands }) => unstable_useSlashCommandAdapter({ commands }),
83
+ { initialProps: { commands: commandsA } },
84
+ );
85
+ const initial = result.current;
86
+
87
+ rerender({ commands: commandsB });
88
+
89
+ expect(result.current.adapter).not.toBe(initial.adapter);
90
+ expect(result.current.action).toBe(initial.action);
91
+ expect(result.current.adapter.search?.("")).toEqual([
92
+ { id: "run", type: "command", label: "Workspace B" },
93
+ ]);
94
+
95
+ result.current.action.onExecute({
96
+ id: "run",
97
+ type: "command",
98
+ label: "Workspace B",
99
+ });
100
+ expect(executeA).not.toHaveBeenCalled();
101
+ expect(executeB).toHaveBeenCalledOnce();
102
+ });
103
+
104
+ it("matches the displayed label for a command with no explicit label", () => {
105
+ const { result } = renderHook(() =>
106
+ unstable_useSlashCommandAdapter({
107
+ commands: [{ id: "summarize", execute: vi.fn() }],
108
+ }),
109
+ );
110
+
111
+ expect(result.current.adapter.search?.("/")).toEqual([
112
+ { id: "summarize", type: "command", label: "/summarize" },
113
+ ]);
114
+ expect(result.current.adapter.search?.("sum")).toEqual([
115
+ { id: "summarize", type: "command", label: "/summarize" },
116
+ ]);
117
+ expect(result.current.adapter.search?.("nope")).toEqual([]);
118
+ });
119
+
120
+ it("keeps the adapter stable for equivalent inline commands", () => {
121
+ const executeA = vi.fn();
122
+ const executeB = vi.fn();
123
+ const { result, rerender } = renderHook(
124
+ ({ execute }) =>
125
+ unstable_useSlashCommandAdapter({
126
+ commands: [{ id: "run", label: "Run", execute }],
127
+ }),
128
+ { initialProps: { execute: executeA } },
129
+ );
130
+ const initialAdapter = result.current.adapter;
131
+
132
+ rerender({ execute: executeB });
133
+
134
+ expect(result.current.adapter).toBe(initialAdapter);
135
+ act(() =>
136
+ result.current.action.onExecute(result.current.adapter.search!("")[0]!),
137
+ );
138
+ expect(executeA).not.toHaveBeenCalled();
139
+ expect(executeB).toHaveBeenCalledOnce();
140
+ });
141
+ });
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useMemo, useRef } from "react";
3
+ import { useLayoutEffect, useMemo, useRef } from "react";
4
4
  import type {
5
5
  Unstable_TriggerAdapter,
6
6
  Unstable_TriggerItem,
@@ -61,34 +61,51 @@ export function unstable_useSlashCommandAdapter(
61
61
  const { commands, removeOnExecute } = options;
62
62
 
63
63
  const commandsRef = useRef(commands);
64
- commandsRef.current = commands;
64
+ const committedItemsRef = useRef<readonly Unstable_TriggerItem[]>(undefined);
65
+ const nextItems = commands.map(toItem);
66
+ // A referential cache, never state: `items` is always structurally equal to
67
+ // `nextItems`, so the render-time read cannot change what the adapter shows.
68
+ const items = areTriggerItemsEqual(committedItemsRef.current, nextItems)
69
+ ? committedItemsRef.current
70
+ : nextItems;
65
71
 
66
- return useMemo(() => {
67
- const adapter: Unstable_TriggerAdapter = {
72
+ // The adapter's callbacks must only observe commands from committed renders.
73
+ useLayoutEffect(() => {
74
+ commandsRef.current = commands;
75
+ committedItemsRef.current = items;
76
+ }, [commands, items]);
77
+
78
+ const adapter = useMemo<Unstable_TriggerAdapter>(
79
+ () => ({
68
80
  categories: () => [],
69
81
  categoryItems: () => [],
70
82
  search: (query: string) => {
71
83
  const lower = query.toLowerCase();
72
- return commandsRef.current
73
- .filter((c) => matchesQuery(c, lower))
74
- .map(toItem);
84
+ return items.filter((item) => matchesQuery(item, lower));
75
85
  },
76
- };
86
+ }),
87
+ [items],
88
+ );
77
89
 
78
- const action: Unstable_SlashCommandAction = {
90
+ const action = useMemo<Unstable_SlashCommandAction>(
91
+ () => ({
79
92
  onExecute: (item) => {
80
93
  commandsRef.current.find((c) => c.id === item.id)?.execute();
81
94
  },
82
95
  ...(removeOnExecute !== undefined ? { removeOnExecute } : {}),
83
- };
96
+ }),
97
+ [removeOnExecute],
98
+ );
84
99
 
85
- return {
100
+ return useMemo(
101
+ () => ({
86
102
  adapter,
87
103
  action,
88
104
  ...(options.iconMap ? { iconMap: options.iconMap } : {}),
89
105
  ...(options.fallbackIcon ? { fallbackIcon: options.fallbackIcon } : {}),
90
- };
91
- }, [removeOnExecute, options.iconMap, options.fallbackIcon]);
106
+ }),
107
+ [adapter, action, options.iconMap, options.fallbackIcon],
108
+ );
92
109
  }
93
110
 
94
111
  function toItem(cmd: Unstable_SlashCommand): Unstable_TriggerItem {
@@ -101,10 +118,28 @@ function toItem(cmd: Unstable_SlashCommand): Unstable_TriggerItem {
101
118
  };
102
119
  }
103
120
 
104
- function matchesQuery(cmd: Unstable_SlashCommand, lower: string): boolean {
121
+ function matchesQuery(item: Unstable_TriggerItem, lower: string): boolean {
105
122
  if (!lower) return true;
106
- if (cmd.id.toLowerCase().includes(lower)) return true;
107
- if (cmd.label?.toLowerCase().includes(lower)) return true;
108
- if (cmd.description?.toLowerCase().includes(lower)) return true;
123
+ if (item.id.toLowerCase().includes(lower)) return true;
124
+ if (item.label.toLowerCase().includes(lower)) return true;
125
+ if (item.description?.toLowerCase().includes(lower)) return true;
109
126
  return false;
110
127
  }
128
+
129
+ function areTriggerItemsEqual(
130
+ previous: readonly Unstable_TriggerItem[] | undefined,
131
+ next: readonly Unstable_TriggerItem[],
132
+ ): previous is readonly Unstable_TriggerItem[] {
133
+ if (!previous || previous.length !== next.length) return false;
134
+
135
+ return previous.every((item, index) => {
136
+ const nextItem = next[index];
137
+ return (
138
+ nextItem !== undefined &&
139
+ item.id === nextItem.id &&
140
+ item.label === nextItem.label &&
141
+ item.description === nextItem.description &&
142
+ item.metadata?.icon === nextItem.metadata?.icon
143
+ );
144
+ });
145
+ }
@@ -11,10 +11,10 @@ import {
11
11
  forwardRef,
12
12
  isValidElement,
13
13
  } from "react";
14
- import { Primitive as RadixPrimitive } from "@radix-ui/react-primitive";
14
+ import { Primitive as RadixPrimitive } from "radix-ui/internal";
15
15
 
16
16
  /**
17
- * Thin wrapper around `@radix-ui/react-primitive` that adds `render` prop support.
17
+ * Thin wrapper around Radix `Primitive` that adds `render` prop support.
18
18
  *
19
19
  * When `render` is provided, it is converted to the equivalent `asChild` pattern:
20
20
  * render={<Comp props />} + children → asChild + <Comp props>{children}</Comp>
@@ -5,7 +5,7 @@ import {
5
5
  type MouseEventHandler,
6
6
  } from "react";
7
7
  import { Primitive } from "./Primitive";
8
- import { composeEventHandlers } from "@radix-ui/primitive";
8
+ import { composeEventHandlers } from "radix-ui/internal";
9
9
 
10
10
  type ActionButtonCallback<TProps> = (
11
11
  props: TProps,
@@ -1,4 +1,4 @@
1
- import { useCallbackRef } from "@radix-ui/react-use-callback-ref";
1
+ import { useCallbackRef } from "radix-ui/internal";
2
2
  import { useCallback } from "react";
3
3
  import { useManagedRef } from "./useManagedRef";
4
4
 
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useCallbackRef } from "@radix-ui/react-use-callback-ref";
3
+ import { useCallbackRef } from "radix-ui/internal";
4
4
  import { useEffect } from "react";
5
5
  import { useThreadViewport } from "../../context/react/ThreadViewportContext";
6
6
 
@@ -0,0 +1,11 @@
1
+ export const invokeCallbackSafely = (invoke: () => unknown, name: string) => {
2
+ const reportFailure = (error: unknown) => {
3
+ console.error(`[assistant-ui] ${name} callback threw an error`, error);
4
+ };
5
+
6
+ try {
7
+ void Promise.resolve(invoke()).catch(reportFailure);
8
+ } catch (error) {
9
+ reportFailure(error);
10
+ }
11
+ };