@assistant-ui/react 0.15.31 → 0.15.32

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 (211) hide show
  1. package/dist/_virtual/_rolldown/runtime.js +1 -2
  2. package/dist/adapters/OpenAIRealtimeVoiceAdapter.js +1 -2
  3. package/dist/assistant-transport.js +1 -2
  4. package/dist/cloud-renderer/CloudRendererHost.js +1 -2
  5. package/dist/context/ReadonlyStore.js +1 -2
  6. package/dist/context/providers/MessageProvider.js +1 -2
  7. package/dist/context/providers/ThreadViewportProvider.js +1 -2
  8. package/dist/context/react/ThreadViewportContext.js +1 -2
  9. package/dist/context/react/utils/createContextHook.js +1 -2
  10. package/dist/context/react/utils/createContextStoreHook.js +1 -2
  11. package/dist/context/stores/ThreadViewport.js +1 -2
  12. package/dist/devtools/DevToolsHooks.js +1 -2
  13. package/dist/hooks/useMessageQuote.js +1 -2
  14. package/dist/hooks/useMessageTiming.js +1 -2
  15. package/dist/hooks/useToolCallElapsed.js +1 -2
  16. package/dist/internal.js +1 -2
  17. package/dist/legacy-runtime/AssistantRuntimeProvider.js +1 -2
  18. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  19. package/dist/mcp-apps/McpAppRenderer.js +3 -3
  20. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  21. package/dist/mcp-apps/McpAppsRemoteHost.js +1 -2
  22. package/dist/mcp-apps/app-frame.js +1 -2
  23. package/dist/mcp-apps/bridge.js +1 -2
  24. package/dist/mcp-apps/csp.js +1 -2
  25. package/dist/mcp-apps/types.js +1 -2
  26. package/dist/mcp-apps/utils.js +1 -2
  27. package/dist/model-context/frame/useAssistantFrameHost.js +1 -2
  28. package/dist/model-context/makeAssistantVisible.js +1 -2
  29. package/dist/primitives/actionBar/ActionBarCopy.js +1 -2
  30. package/dist/primitives/actionBar/ActionBarEdit.js +1 -2
  31. package/dist/primitives/actionBar/ActionBarExportMarkdown.js +1 -2
  32. package/dist/primitives/actionBar/ActionBarFeedbackNegative.js +1 -2
  33. package/dist/primitives/actionBar/ActionBarFeedbackPositive.js +1 -2
  34. package/dist/primitives/actionBar/ActionBarInteractionContext.js +1 -2
  35. package/dist/primitives/actionBar/ActionBarReload.js +1 -2
  36. package/dist/primitives/actionBar/ActionBarRoot.js +1 -2
  37. package/dist/primitives/actionBar/ActionBarSpeak.js +1 -2
  38. package/dist/primitives/actionBar/ActionBarStopSpeaking.js +1 -2
  39. package/dist/primitives/actionBar/useActionBarFloatStatus.js +1 -2
  40. package/dist/primitives/actionBar.js +1 -2
  41. package/dist/primitives/actionBarMore/ActionBarMoreContent.js +1 -2
  42. package/dist/primitives/actionBarMore/ActionBarMoreItem.js +1 -2
  43. package/dist/primitives/actionBarMore/ActionBarMoreRoot.js +1 -2
  44. package/dist/primitives/actionBarMore/ActionBarMoreSeparator.js +1 -2
  45. package/dist/primitives/actionBarMore/ActionBarMoreTrigger.js +1 -2
  46. package/dist/primitives/actionBarMore/scope.js +1 -2
  47. package/dist/primitives/actionBarMore.js +1 -2
  48. package/dist/primitives/assistantModal/AssistantModalAnchor.js +1 -2
  49. package/dist/primitives/assistantModal/AssistantModalContent.js +1 -2
  50. package/dist/primitives/assistantModal/AssistantModalRoot.js +1 -2
  51. package/dist/primitives/assistantModal/AssistantModalTrigger.js +1 -2
  52. package/dist/primitives/assistantModal/popoverRenderPrimitives.js +1 -2
  53. package/dist/primitives/assistantModal/scope.js +1 -2
  54. package/dist/primitives/assistantModal.js +1 -2
  55. package/dist/primitives/attachment/AttachmentName.js +1 -2
  56. package/dist/primitives/attachment/AttachmentRemove.js +1 -2
  57. package/dist/primitives/attachment/AttachmentRoot.js +1 -2
  58. package/dist/primitives/attachment/AttachmentThumb.js +1 -2
  59. package/dist/primitives/attachment.js +1 -2
  60. package/dist/primitives/branchPicker/BranchPickerCount.js +1 -2
  61. package/dist/primitives/branchPicker/BranchPickerNext.js +1 -2
  62. package/dist/primitives/branchPicker/BranchPickerNumber.js +1 -2
  63. package/dist/primitives/branchPicker/BranchPickerPrevious.js +1 -2
  64. package/dist/primitives/branchPicker/BranchPickerRoot.js +1 -2
  65. package/dist/primitives/branchPicker.js +1 -2
  66. package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.d.ts +5 -4
  67. package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.d.ts.map +1 -1
  68. package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.js +57 -20
  69. package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.js.map +1 -1
  70. package/dist/primitives/chainOfThought/ChainOfThoughtRoot.js +1 -2
  71. package/dist/primitives/chainOfThought.js +1 -2
  72. package/dist/primitives/composer/ComposerAddAttachment.js +1 -2
  73. package/dist/primitives/composer/ComposerAttachmentDropzone.js +1 -2
  74. package/dist/primitives/composer/ComposerCancel.js +1 -2
  75. package/dist/primitives/composer/ComposerCompactContext.js +1 -2
  76. package/dist/primitives/composer/ComposerDictate.js +1 -2
  77. package/dist/primitives/composer/ComposerDictationTranscript.js +1 -2
  78. package/dist/primitives/composer/ComposerInput.js +1 -2
  79. package/dist/primitives/composer/ComposerInputPluginContext.js +1 -2
  80. package/dist/primitives/composer/ComposerQuote.js +1 -2
  81. package/dist/primitives/composer/ComposerResume.js +1 -2
  82. package/dist/primitives/composer/ComposerRoot.js +1 -2
  83. package/dist/primitives/composer/ComposerSend.js +1 -2
  84. package/dist/primitives/composer/ComposerStopDictation.js +1 -2
  85. package/dist/primitives/composer/composerInputSelector.js +1 -2
  86. package/dist/primitives/composer/trigger/TriggerPopover.js +1 -2
  87. package/dist/primitives/composer/trigger/TriggerPopoverAction.js +1 -2
  88. package/dist/primitives/composer/trigger/TriggerPopoverBack.js +1 -2
  89. package/dist/primitives/composer/trigger/TriggerPopoverCategories.js +1 -2
  90. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +1 -2
  91. package/dist/primitives/composer/trigger/TriggerPopoverItems.js +1 -2
  92. package/dist/primitives/composer/trigger/TriggerPopoverResource.js +1 -2
  93. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js +1 -2
  94. package/dist/primitives/composer/trigger/detectTrigger.js +1 -2
  95. package/dist/primitives/composer/trigger/index.js +1 -2
  96. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.js +1 -2
  97. package/dist/primitives/composer/trigger/triggerDetectionResource.js +1 -2
  98. package/dist/primitives/composer/trigger/triggerKeyboardResource.js +1 -2
  99. package/dist/primitives/composer/trigger/triggerNavigationResource.js +1 -2
  100. package/dist/primitives/composer/trigger/triggerSelectionResource.js +1 -2
  101. package/dist/primitives/composer/useComposerCancelWithFocus.js +1 -2
  102. package/dist/primitives/composer/useComposerInputState.js +1 -2
  103. package/dist/primitives/composer.js +1 -2
  104. package/dist/primitives/dropdownMenuRenderPrimitives.js +1 -2
  105. package/dist/primitives/error/ErrorMessage.js +1 -2
  106. package/dist/primitives/error/ErrorRoot.js +1 -2
  107. package/dist/primitives/error.js +1 -2
  108. package/dist/primitives/message/MessageError.js +1 -2
  109. package/dist/primitives/message/MessageIf.js +1 -2
  110. package/dist/primitives/message/MessageParts.js +1 -2
  111. package/dist/primitives/message/MessagePartsGrouped.js +1 -2
  112. package/dist/primitives/message/MessageRoot.js +1 -2
  113. package/dist/primitives/message.js +1 -2
  114. package/dist/primitives/messagePart/MessagePartImage.js +1 -2
  115. package/dist/primitives/messagePart/MessagePartText.js +1 -2
  116. package/dist/primitives/messagePart/useMessagePartData.js +1 -2
  117. package/dist/primitives/messagePart/useMessagePartFile.js +1 -2
  118. package/dist/primitives/messagePart/useMessagePartImage.js +1 -2
  119. package/dist/primitives/messagePart/useMessagePartReasoning.js +1 -2
  120. package/dist/primitives/messagePart/useMessagePartSource.js +1 -2
  121. package/dist/primitives/messagePart/useMessagePartText.js +1 -2
  122. package/dist/primitives/messagePart.js +1 -2
  123. package/dist/primitives/queueItem/QueueItemRemove.js +1 -2
  124. package/dist/primitives/queueItem/QueueItemSteer.js +1 -2
  125. package/dist/primitives/queueItem/QueueItemText.js +1 -2
  126. package/dist/primitives/queueItem.js +1 -2
  127. package/dist/primitives/reasoning/useScrollLock.d.ts.map +1 -1
  128. package/dist/primitives/reasoning/useScrollLock.js +41 -25
  129. package/dist/primitives/reasoning/useScrollLock.js.map +1 -1
  130. package/dist/primitives/selectionToolbar/SelectionToolbarQuote.js +1 -2
  131. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js +1 -2
  132. package/dist/primitives/selectionToolbar.js +1 -2
  133. package/dist/primitives/suggestion/SuggestionDescription.js +1 -2
  134. package/dist/primitives/suggestion/SuggestionTitle.js +1 -2
  135. package/dist/primitives/suggestion/SuggestionTrigger.js +1 -2
  136. package/dist/primitives/suggestion.js +1 -2
  137. package/dist/primitives/thread/ThreadEmpty.js +1 -2
  138. package/dist/primitives/thread/ThreadIf.js +1 -2
  139. package/dist/primitives/thread/ThreadLoadEarlier.js +1 -2
  140. package/dist/primitives/thread/ThreadRoot.js +1 -2
  141. package/dist/primitives/thread/ThreadRootElementContext.js +1 -2
  142. package/dist/primitives/thread/ThreadScrollToBottom.js +1 -2
  143. package/dist/primitives/thread/ThreadSuggestion.js +1 -2
  144. package/dist/primitives/thread/ThreadViewport.js +1 -2
  145. package/dist/primitives/thread/ThreadViewportFooter.js +1 -2
  146. package/dist/primitives/thread/topAnchor/computeTopAnchorSlack.js +1 -2
  147. package/dist/primitives/thread/topAnchor/createReserveObservers.js +1 -2
  148. package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.js +1 -2
  149. package/dist/primitives/thread/topAnchor/topAnchorTurn.js +1 -2
  150. package/dist/primitives/thread/topAnchor/topAnchorUtils.js +1 -2
  151. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js +1 -2
  152. package/dist/primitives/thread/useThreadViewportAutoScroll.js +1 -2
  153. package/dist/primitives/thread.js +1 -2
  154. package/dist/primitives/threadList/ThreadListLoadMore.js +1 -2
  155. package/dist/primitives/threadList/ThreadListNew.js +1 -2
  156. package/dist/primitives/threadList/ThreadListRoot.js +1 -2
  157. package/dist/primitives/threadList.js +1 -2
  158. package/dist/primitives/threadListFocusGroup.js +1 -2
  159. package/dist/primitives/threadListItem/ThreadListItemArchive.js +1 -2
  160. package/dist/primitives/threadListItem/ThreadListItemDelete.js +1 -2
  161. package/dist/primitives/threadListItem/ThreadListItemRoot.d.ts.map +1 -1
  162. package/dist/primitives/threadListItem/ThreadListItemRoot.js +2 -3
  163. package/dist/primitives/threadListItem/ThreadListItemRoot.js.map +1 -1
  164. package/dist/primitives/threadListItem/ThreadListItemTrigger.js +1 -2
  165. package/dist/primitives/threadListItem/ThreadListItemUnarchive.js +1 -2
  166. package/dist/primitives/threadListItem.js +1 -2
  167. package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.js +1 -2
  168. package/dist/primitives/threadListItemMore/ThreadListItemMoreItem.js +1 -2
  169. package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.js +1 -2
  170. package/dist/primitives/threadListItemMore/ThreadListItemMoreSeparator.js +1 -2
  171. package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.js +1 -2
  172. package/dist/primitives/threadListItemMore/scope.js +1 -2
  173. package/dist/primitives/threadListItemMore.js +1 -2
  174. package/dist/sandbox-host/SandboxHost.js +1 -2
  175. package/dist/unstable/useComposerInput.js +1 -2
  176. package/dist/unstable/useComposerInputHistory.js +1 -2
  177. package/dist/unstable/useLiveCompletionAdapter.js +1 -2
  178. package/dist/unstable/useMentionAdapter.js +1 -2
  179. package/dist/unstable/useMessageStallDetection.js +1 -2
  180. package/dist/unstable/useModelContextSnapshot.js +1 -2
  181. package/dist/unstable/useSlashCommandAdapter.js +1 -2
  182. package/dist/unstable/webmcp/WebMcpRegistrationResource.js +1 -2
  183. package/dist/unstable/webmcp/convertTools.js +1 -2
  184. package/dist/unstable/webmcp/useWebMcpProvider.js +1 -2
  185. package/dist/unstable/webmcp/webmcp-host.js +1 -2
  186. package/dist/utils/Primitive.js +1 -2
  187. package/dist/utils/createActionButton.js +1 -2
  188. package/dist/utils/getSelectionMessageId.js +1 -2
  189. package/dist/utils/hooks/useManagedRef.js +1 -2
  190. package/dist/utils/hooks/useMediaQuery.js +1 -2
  191. package/dist/utils/hooks/useOnScrollToBottom.js +1 -2
  192. package/dist/utils/hooks/useSizeHandle.js +1 -2
  193. package/dist/utils/isCompositionKey.js +1 -2
  194. package/dist/utils/smooth/SmoothContext.js +1 -2
  195. package/dist/utils/smooth/useSmooth.d.ts.map +1 -1
  196. package/dist/utils/smooth/useSmooth.js +11 -4
  197. package/dist/utils/smooth/useSmooth.js.map +1 -1
  198. package/dist/utils/useIsomorphicLayoutEffect.js +0 -1
  199. package/package.json +6 -6
  200. package/src/mcp-apps/McpAppRenderer.composer.test.tsx +111 -0
  201. package/src/mcp-apps/McpAppRenderer.test.tsx +24 -23
  202. package/src/mcp-apps/McpAppRenderer.tsx +9 -1
  203. package/src/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.test.tsx +91 -0
  204. package/src/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.tsx +54 -0
  205. package/src/primitives/reasoning/useScrollLock.test.tsx +71 -2
  206. package/src/primitives/reasoning/useScrollLock.ts +78 -51
  207. package/src/primitives/threadListItem/ThreadListItemRoot.tsx +4 -1
  208. package/src/primitives/threadListItem/ThreadListKeyboardNav.test.tsx +25 -0
  209. package/src/utils/smooth/useSmooth.test.tsx +113 -4
  210. package/src/utils/smooth/useSmooth.ts +16 -2
  211. package/src/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.ts +0 -49
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistant-ui/react",
3
- "version": "0.15.31",
3
+ "version": "0.15.32",
4
4
  "description": "Open-source TypeScript/React library for building production-grade AI chat experiences",
5
5
  "keywords": [
6
6
  "ai",
@@ -55,16 +55,16 @@
55
55
  ],
56
56
  "sideEffects": false,
57
57
  "dependencies": {
58
- "@assistant-ui/core": "^0.3.31",
58
+ "@assistant-ui/core": "^0.3.32",
59
59
  "@assistant-ui/store": "^0.3.22",
60
60
  "@assistant-ui/tap": "^0.9.24",
61
- "assistant-cloud": "^0.2.8",
62
- "assistant-stream": "^0.3.54",
61
+ "assistant-cloud": "^0.2.9",
62
+ "assistant-stream": "^0.3.55",
63
63
  "radix-ui": "^1.8.1",
64
64
  "react-textarea-autosize": "^8.5.9",
65
65
  "safe-content-frame": "^0.0.34",
66
66
  "zod": "^4.6.7",
67
- "zustand": "^5.0.15"
67
+ "zustand": "^5.0.16"
68
68
  },
69
69
  "peerDependencies": {
70
70
  "@types/react": "*",
@@ -82,7 +82,7 @@
82
82
  },
83
83
  "devDependencies": {
84
84
  "@assistant-ui/vite": "0.0.21",
85
- "@assistant-ui/x-buildutils": "0.0.31",
85
+ "@assistant-ui/x-buildutils": "0.0.32",
86
86
  "@assistant-ui/x-react18": "0.0.0",
87
87
  "@testing-library/react": "^16.3.3",
88
88
  "@types/json-schema": "^7.0.15",
@@ -0,0 +1,111 @@
1
+ // @vitest-environment jsdom
2
+ import { act, render, waitFor } from "@testing-library/react";
3
+ import { resource, useResource } from "@assistant-ui/tap";
4
+ import {
5
+ AssistantRuntimeProvider,
6
+ MessageByIndexProvider,
7
+ useExternalStoreRuntime,
8
+ type ToolCallMessagePartProps,
9
+ } from "@assistant-ui/core/react";
10
+ import type { ThreadMessageLike } from "@assistant-ui/core";
11
+ import { expect, it, vi } from "vitest";
12
+ import type { AssistantRuntime } from "../index";
13
+ import type { McpAppBridgeHandlers, McpAppsHost } from "./types";
14
+
15
+ const { framePropsMock } = vi.hoisted(() => ({ framePropsMock: vi.fn() }));
16
+
17
+ vi.mock("./app-frame", () => ({
18
+ McpAppFrame: (props: unknown) => {
19
+ framePropsMock(props);
20
+ return null;
21
+ },
22
+ }));
23
+
24
+ import { McpAppRenderer } from "./McpAppRenderer";
25
+
26
+ const Host = resource(({ host }: { host: McpAppsHost }) => host);
27
+
28
+ const host: McpAppsHost = {
29
+ loadResource: vi.fn(async ({ uri }) => ({
30
+ uri,
31
+ mimeType: "text/html;profile=mcp-app" as const,
32
+ html: "",
33
+ })),
34
+ callTool: vi.fn(),
35
+ readResource: vi.fn(),
36
+ listResources: vi.fn(),
37
+ };
38
+
39
+ const part: ToolCallMessagePartProps = {
40
+ type: "tool-call",
41
+ toolCallId: "call-1",
42
+ toolName: "search",
43
+ args: {},
44
+ argsText: "{}",
45
+ status: { type: "complete" },
46
+ mcp: { app: { resourceUri: "ui://example/search" } },
47
+ addResult: vi.fn(),
48
+ resume: vi.fn(),
49
+ respondToApproval: vi.fn(),
50
+ };
51
+
52
+ const messages: ThreadMessageLike[] = [
53
+ { role: "assistant", content: "Here is the app" },
54
+ ];
55
+
56
+ const AppPart = () => {
57
+ const renderer = useResource(McpAppRenderer({ host: Host({ host }) }));
58
+ const Renderer = renderer.render;
59
+ return <Renderer {...part} />;
60
+ };
61
+
62
+ let runtime!: AssistantRuntime;
63
+
64
+ const App = () => {
65
+ runtime = useExternalStoreRuntime({
66
+ messages,
67
+ convertMessage: (message: ThreadMessageLike) => message,
68
+ onNew: async () => {},
69
+ });
70
+ return (
71
+ <AssistantRuntimeProvider runtime={runtime}>
72
+ <MessageByIndexProvider index={0}>
73
+ <AppPart />
74
+ </MessageByIndexProvider>
75
+ </AssistantRuntimeProvider>
76
+ );
77
+ };
78
+
79
+ const renderApp = async () => {
80
+ framePropsMock.mockClear();
81
+ render(<App />);
82
+ await waitFor(() => expect(framePropsMock).toHaveBeenCalled());
83
+ const handlers = framePropsMock.mock.lastCall?.[0]
84
+ .handlers as McpAppBridgeHandlers;
85
+ return handlers;
86
+ };
87
+
88
+ it("fills the thread composer from an MCP App message inside a message", async () => {
89
+ const handlers = await renderApp();
90
+
91
+ let result: unknown;
92
+ act(() => {
93
+ result = handlers.sendMessage?.({ text: "Open Grade 5 Math" });
94
+ });
95
+
96
+ expect(result).toEqual({ ok: true });
97
+ expect(runtime.thread.composer.getState().text).toBe("Open Grade 5 Math");
98
+ });
99
+
100
+ it("keeps the user's draft when an MCP App message arrives", async () => {
101
+ const handlers = await renderApp();
102
+ act(() => runtime.thread.composer.setText("Show my classes"));
103
+
104
+ act(() => {
105
+ handlers.sendMessage?.({ text: "Open Grade 5 Math" });
106
+ });
107
+
108
+ expect(runtime.thread.composer.getState().text).toBe(
109
+ "Show my classes Open Grade 5 Math",
110
+ );
111
+ });
@@ -16,15 +16,22 @@ import type {
16
16
  McpAppsRemoteHostOptions,
17
17
  } from "./types";
18
18
 
19
- const { appendMock, framePropsMock } = vi.hoisted(() => ({
19
+ const { appendMock, setTextMock, framePropsMock } = vi.hoisted(() => ({
20
20
  appendMock: vi.fn(),
21
+ setTextMock: vi.fn(),
21
22
  framePropsMock: vi.fn(),
22
23
  }));
23
24
 
24
25
  vi.mock("@assistant-ui/store", async (importOriginal) => ({
25
26
  ...(await importOriginal()),
26
27
  useAui: () => ({
27
- thread: { append: appendMock },
28
+ thread: {
29
+ append: appendMock,
30
+ composer: () => ({
31
+ setText: setTextMock,
32
+ getState: () => ({ text: "" }),
33
+ }),
34
+ },
28
35
  }),
29
36
  }));
30
37
 
@@ -190,6 +197,7 @@ function RemoteHarness({
190
197
  describe("McpAppRenderer", () => {
191
198
  beforeEach(() => {
192
199
  appendMock.mockReset();
200
+ setTextMock.mockReset();
193
201
  framePropsMock.mockReset();
194
202
  });
195
203
 
@@ -676,7 +684,7 @@ describe("McpAppRenderer", () => {
676
684
  expect(listResources).toHaveBeenCalledWith(params);
677
685
  });
678
686
 
679
- it("appends MCP Apps message content in the default sendMessage handler", async () => {
687
+ it("puts MCP Apps text content in the composer without appending", async () => {
680
688
  render(<Harness host={loadingHost()} />);
681
689
  await waitFor(() => expect(framePropsMock).toHaveBeenCalled());
682
690
  const handlers = framePropsMock.mock.lastCall?.[0]
@@ -692,19 +700,13 @@ describe("McpAppRenderer", () => {
692
700
  ],
693
701
  }),
694
702
  ).toEqual({ ok: true });
695
- expect(appendMock).toHaveBeenCalledWith({
696
- content: [
697
- { type: "text", text: "Open Grade 5 Math" },
698
- { type: "text", text: "Period 4" },
699
- ],
700
- });
703
+ expect(setTextMock).toHaveBeenCalledWith("Open Grade 5 Math\nPeriod 4");
704
+ expect(appendMock).not.toHaveBeenCalled();
701
705
 
702
706
  expect(handlers.sendMessage?.({ text: "typed instead" })).toEqual({
703
707
  ok: true,
704
708
  });
705
- expect(appendMock).toHaveBeenLastCalledWith({
706
- content: [{ type: "text", text: "typed instead" }],
707
- });
709
+ expect(setTextMock).toHaveBeenLastCalledWith("typed instead");
708
710
 
709
711
  expect(
710
712
  handlers.sendMessage?.({
@@ -722,7 +724,8 @@ describe("McpAppRenderer", () => {
722
724
  ok: false,
723
725
  reason: "unrecognised params shape",
724
726
  });
725
- expect(appendMock).toHaveBeenCalledTimes(2);
727
+ expect(setTextMock).toHaveBeenCalledTimes(2);
728
+ expect(appendMock).not.toHaveBeenCalled();
726
729
  });
727
730
 
728
731
  it("extracts sparse message content without traversing holes", async () => {
@@ -755,9 +758,8 @@ describe("McpAppRenderer", () => {
755
758
  }
756
759
 
757
760
  expect(visitedHole).not.toHaveBeenCalled();
758
- expect(appendMock).toHaveBeenCalledWith({
759
- content: [{ type: "text", text: "sparse prompt" }],
760
- });
761
+ expect(setTextMock).toHaveBeenCalledExactlyOnceWith("sparse prompt");
762
+ expect(appendMock).not.toHaveBeenCalled();
761
763
  });
762
764
 
763
765
  it("ignores non-index message content properties after structured clone", async () => {
@@ -781,13 +783,10 @@ describe("McpAppRenderer", () => {
781
783
  ).toEqual({ ok: true });
782
784
  }
783
785
 
784
- expect(appendMock).toHaveBeenCalledTimes(2);
785
- expect(appendMock).toHaveBeenNthCalledWith(1, {
786
- content: [{ type: "text", text: "message text" }],
787
- });
788
- expect(appendMock).toHaveBeenNthCalledWith(2, {
789
- content: [{ type: "text", text: "message text" }],
790
- });
786
+ expect(setTextMock).toHaveBeenCalledTimes(2);
787
+ expect(setTextMock).toHaveBeenNthCalledWith(1, "message text");
788
+ expect(setTextMock).toHaveBeenNthCalledWith(2, "message text");
789
+ expect(appendMock).not.toHaveBeenCalled();
791
790
  });
792
791
 
793
792
  it("uses caller UI handlers and keeps data-plane handlers on the host", async () => {
@@ -838,6 +837,8 @@ describe("McpAppRenderer", () => {
838
837
  expect(updateModelContext).toHaveBeenCalledWith({ text: "context" });
839
838
  expect(openLink).toHaveBeenCalledWith({ url: "https://example.com" });
840
839
  expect(sendMessage).toHaveBeenCalledWith({ text: "hello" });
840
+ expect(setTextMock).not.toHaveBeenCalled();
841
+ expect(appendMock).not.toHaveBeenCalled();
841
842
  expect(onInitialized).toHaveBeenCalledOnce();
842
843
  expect(host.callTool).toHaveBeenCalledWith({ name: "search" });
843
844
  expect(host.readResource).toHaveBeenCalledWith({ uri: "ui://resource" });
@@ -277,7 +277,15 @@ function InlineRenderer({
277
277
  if ("reason" in result) {
278
278
  return { isError: true, ok: false, reason: result.reason };
279
279
  }
280
- aui.thread.append({ content: result.content });
280
+ const composer = aui.thread.composer();
281
+ composer.setText(
282
+ [
283
+ composer.getState().text,
284
+ result.content.map((part) => part.text).join("\n"),
285
+ ]
286
+ .filter((part) => part.trim())
287
+ .join(" "),
288
+ );
281
289
  return { ok: true };
282
290
  }),
283
291
  callTool: (params) =>
@@ -0,0 +1,91 @@
1
+ // @vitest-environment jsdom
2
+ import { createRef, type MouseEvent, type Ref } from "react";
3
+ import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { AuiProvider, useAui, useAuiState } from "@assistant-ui/store";
6
+ import { ChainOfThoughtClient } from "@assistant-ui/core/store";
7
+ import { ChainOfThoughtPrimitiveAccordionTrigger } from "./ChainOfThoughtAccordionTrigger";
8
+
9
+ afterEach(cleanup);
10
+
11
+ const Content = () => {
12
+ const collapsed = useAuiState((s) => s.chainOfThought.collapsed);
13
+ return (
14
+ <div data-testid="reasoning" hidden={collapsed}>
15
+ Reasoning details
16
+ </div>
17
+ );
18
+ };
19
+
20
+ const App = ({
21
+ triggerRef,
22
+ ...props
23
+ }: ChainOfThoughtPrimitiveAccordionTrigger.Props & {
24
+ triggerRef?: Ref<HTMLButtonElement>;
25
+ }) => {
26
+ const aui = useAui({
27
+ chainOfThought: ChainOfThoughtClient({
28
+ parts: [
29
+ {
30
+ type: "reasoning",
31
+ text: "Reasoning details",
32
+ status: { type: "complete" },
33
+ },
34
+ ],
35
+ getMessagePart: () => {
36
+ throw new Error("Part lookup is not used by the toggle");
37
+ },
38
+ }),
39
+ });
40
+ return (
41
+ <AuiProvider value={aui}>
42
+ <ChainOfThoughtPrimitiveAccordionTrigger {...props} ref={triggerRef} />
43
+ <Content />
44
+ </AuiProvider>
45
+ );
46
+ };
47
+
48
+ describe("ChainOfThoughtPrimitiveAccordionTrigger", () => {
49
+ it.each(["button", "render", "asChild"] as const)(
50
+ "exposes expansion and forwards the ref with %s",
51
+ async (mode) => {
52
+ const ref = createRef<HTMLButtonElement>();
53
+ const { getByRole, getByTestId } = render(
54
+ <App
55
+ triggerRef={ref}
56
+ {...(mode === "render" ? { render: <button /> } : {})}
57
+ {...(mode === "asChild" ? { asChild: true } : {})}
58
+ >
59
+ {mode === "asChild" ? <button>Thinking</button> : "Thinking"}
60
+ </App>,
61
+ );
62
+ const button = getByRole("button", { name: "Thinking" });
63
+ const content = getByTestId("reasoning");
64
+ expect(ref.current).toBe(button);
65
+ expect(content.hidden).toBe(true);
66
+ expect(button.getAttribute("aria-expanded")).toBe("false");
67
+ fireEvent.click(button);
68
+ await waitFor(() => expect(content.hidden).toBe(false));
69
+ expect(button.getAttribute("aria-expanded")).toBe("true");
70
+ fireEvent.click(button);
71
+ await waitFor(() => expect(content.hidden).toBe(true));
72
+ expect(button.getAttribute("aria-expanded")).toBe("false");
73
+ },
74
+ );
75
+
76
+ it("lets the caller veto expansion", () => {
77
+ const onClick = vi.fn((event: MouseEvent) => event.preventDefault());
78
+ const { getByRole, getByTestId } = render(
79
+ <App onClick={onClick}>Thinking</App>,
80
+ );
81
+ fireEvent.click(getByRole("button"));
82
+ expect(onClick).toHaveBeenCalledOnce();
83
+ expect(getByTestId("reasoning").hidden).toBe(true);
84
+ });
85
+
86
+ it("does not expand a disabled trigger", () => {
87
+ const { getByRole, getByTestId } = render(<App disabled>Thinking</App>);
88
+ fireEvent.click(getByRole("button"));
89
+ expect(getByTestId("reasoning").hidden).toBe(true);
90
+ });
91
+ });
@@ -0,0 +1,54 @@
1
+ "use client";
2
+
3
+ import type { ActionButtonElement } from "../../utils/createActionButton";
4
+ import { type ComponentPropsWithoutRef, forwardRef, useCallback } from "react";
5
+ import { useAuiState, useAui } from "@assistant-ui/store";
6
+ import { composeEventHandlers } from "radix-ui/internal";
7
+ import { Primitive } from "../../utils/Primitive";
8
+
9
+ export namespace ChainOfThoughtPrimitiveAccordionTrigger {
10
+ export type Element = ActionButtonElement;
11
+ /**
12
+ * Props for the ChainOfThoughtPrimitive.AccordionTrigger component.
13
+ * Inherits all button element props and action button functionality.
14
+ */
15
+ export type Props = ComponentPropsWithoutRef<typeof Primitive.button>;
16
+ }
17
+
18
+ /**
19
+ * A button component that toggles the collapsed state of the chain of thought accordion.
20
+ *
21
+ * This component automatically handles the toggle functionality, expanding or collapsing
22
+ * the chain of thought parts when clicked.
23
+ * Exposes the current disclosure state through `aria-expanded`.
24
+ *
25
+ * @example
26
+ * ```tsx
27
+ * <ChainOfThoughtPrimitive.AccordionTrigger>
28
+ * Toggle Reasoning
29
+ * </ChainOfThoughtPrimitive.AccordionTrigger>
30
+ * ```
31
+ */
32
+ export const ChainOfThoughtPrimitiveAccordionTrigger = forwardRef<
33
+ ChainOfThoughtPrimitiveAccordionTrigger.Element,
34
+ ChainOfThoughtPrimitiveAccordionTrigger.Props
35
+ >(({ onClick, ...props }, ref) => {
36
+ const aui = useAui();
37
+ const collapsed = useAuiState((s) => s.chainOfThought.collapsed);
38
+ const toggle = useCallback(() => {
39
+ aui.chainOfThought.setCollapsed(!collapsed);
40
+ }, [aui, collapsed]);
41
+
42
+ return (
43
+ <Primitive.button
44
+ type="button"
45
+ {...props}
46
+ ref={ref}
47
+ aria-expanded={!collapsed}
48
+ onClick={composeEventHandlers(onClick, toggle)}
49
+ />
50
+ );
51
+ });
52
+
53
+ ChainOfThoughtPrimitiveAccordionTrigger.displayName =
54
+ "ChainOfThoughtPrimitive.AccordionTrigger";
@@ -31,9 +31,29 @@ function stubWidths(
31
31
  function lockWithin(container: HTMLElement) {
32
32
  const animated = document.createElement("div");
33
33
  container.appendChild(animated);
34
+ return createLock(animated).lock;
35
+ }
36
+
37
+ function createLock(animated: HTMLElement) {
34
38
  const ref = { current: animated };
35
- const { result } = renderHook(() => useScrollLock(ref, 200));
36
- return result.current;
39
+ const { result, unmount } = renderHook(() => useScrollLock(ref, 200));
40
+ return { lock: result.current, dispose: unmount };
41
+ }
42
+
43
+ function createSharedScroller() {
44
+ const scroller = document.createElement("div");
45
+ scroller.style.overflowY = "auto";
46
+ scroller.style.borderWidth = "0px";
47
+ scroller.style.paddingRight = "4px";
48
+ document.body.appendChild(scroller);
49
+ Object.defineProperty(scroller, "offsetWidth", { value: 306 });
50
+ Object.defineProperty(scroller, "clientWidth", {
51
+ get: () => (scroller.style.scrollbarWidth === "none" ? 306 : 300),
52
+ });
53
+ const first = document.createElement("div");
54
+ const second = document.createElement("div");
55
+ scroller.append(first, second);
56
+ return { scroller, first: createLock(first), second: createLock(second) };
37
57
  }
38
58
 
39
59
  describe("useScrollLock", () => {
@@ -115,6 +135,55 @@ describe("useScrollLock", () => {
115
135
  expect(scroller.style.scrollbarWidth).toBe("");
116
136
  });
117
137
 
138
+ it("keeps overlapping animations locked until the last one finishes", () => {
139
+ vi.useFakeTimers();
140
+ const { scroller, first, second } = createSharedScroller();
141
+ scroller.scrollTop = 120;
142
+
143
+ first.lock();
144
+ vi.advanceTimersByTime(100);
145
+ second.lock();
146
+ expect(scroller.style.paddingRight).toBe("10px");
147
+
148
+ vi.advanceTimersByTime(100);
149
+ expect(scroller.style.scrollbarWidth).toBe("none");
150
+ expect(scroller.style.paddingRight).toBe("10px");
151
+ scroller.scrollTop = 90;
152
+ scroller.dispatchEvent(new Event("scroll"));
153
+ expect(scroller.scrollTop).toBe(120);
154
+
155
+ vi.advanceTimersByTime(100);
156
+ expect(scroller.style.scrollbarWidth).toBe("");
157
+ expect(scroller.style.paddingRight).toBe("4px");
158
+ scroller.scrollTop = 90;
159
+ scroller.dispatchEvent(new Event("scroll"));
160
+ expect(scroller.scrollTop).toBe(90);
161
+ });
162
+
163
+ it.each(["first", "second"] as const)(
164
+ "preserves a restarted shared lock when the %s owner is disposed first",
165
+ (disposedFirst) => {
166
+ vi.useFakeTimers();
167
+ const shared = createSharedScroller();
168
+ const { scroller, first, second } = shared;
169
+ first.lock();
170
+ second.lock();
171
+ vi.advanceTimersByTime(100);
172
+ first.lock();
173
+
174
+ shared[disposedFirst].dispose();
175
+ expect(scroller.style.scrollbarWidth).toBe("none");
176
+ expect(scroller.style.paddingRight).toBe("10px");
177
+
178
+ shared[disposedFirst === "first" ? "second" : "first"].dispose();
179
+ expect(scroller.style.scrollbarWidth).toBe("");
180
+ expect(scroller.style.paddingRight).toBe("4px");
181
+ scroller.style.scrollbarWidth = "thin";
182
+ vi.advanceTimersByTime(200);
183
+ expect(scroller.style.scrollbarWidth).toBe("thin");
184
+ },
185
+ );
186
+
118
187
  it("leaves the padding alone when no scrollbar takes space", () => {
119
188
  const scroller = document.createElement("div");
120
189
  scroller.style.overflowY = "auto";
@@ -12,6 +12,74 @@ const findScrollableAncestor = (element: HTMLElement | null) => {
12
12
  return null;
13
13
  };
14
14
 
15
+ const scrollLocks = new WeakMap<
16
+ HTMLElement,
17
+ { count: number; restore: () => void }
18
+ >();
19
+
20
+ const acquireScrollLock = (scrollContainer: HTMLElement) => {
21
+ const existing = scrollLocks.get(scrollContainer);
22
+ if (existing) {
23
+ existing.count++;
24
+ return existing;
25
+ }
26
+
27
+ const scrollPosition = scrollContainer.scrollTop;
28
+ const scrollbarWidth = scrollContainer.style.scrollbarWidth;
29
+
30
+ // Hiding the scrollbar collapses its gutter on classic scrollbars, which
31
+ // shifts centered content horizontally; compensate with padding on the
32
+ // side the scrollbar occupies (the left side in RTL).
33
+ const computed = getComputedStyle(scrollContainer);
34
+ const paddingSide =
35
+ computed.direction === "rtl" ? "paddingLeft" : "paddingRight";
36
+ const previousPadding = scrollContainer.style[paddingSide];
37
+ const elementScrollbarSize =
38
+ scrollContainer.offsetWidth -
39
+ scrollContainer.clientWidth -
40
+ parseFloat(computed.borderLeftWidth) -
41
+ parseFloat(computed.borderRightWidth);
42
+ // A root element's offsetWidth already excludes the viewport scrollbar, so
43
+ // the element formula reports zero once the scroll propagates to the
44
+ // viewport, and only then does the viewport measure apply. A body that
45
+ // scrolls in its own right is measured by the element formula like any
46
+ // other scroller, whether or not the viewport is scrolling too.
47
+ const ownerDocument = scrollContainer.ownerDocument;
48
+ const isRootScroller =
49
+ scrollContainer === ownerDocument.documentElement ||
50
+ scrollContainer === ownerDocument.body;
51
+ const scrollbarSize =
52
+ isRootScroller && elementScrollbarSize <= 0
53
+ ? (ownerDocument.defaultView?.innerWidth ?? 0) -
54
+ ownerDocument.documentElement.clientWidth
55
+ : elementScrollbarSize;
56
+
57
+ scrollContainer.style.scrollbarWidth = "none";
58
+ if (scrollbarSize > 0) {
59
+ scrollContainer.style[paddingSide] = `${
60
+ parseFloat(computed[paddingSide]) + scrollbarSize
61
+ }px`;
62
+ }
63
+
64
+ const restoreStyles = () => {
65
+ scrollContainer.style.scrollbarWidth = scrollbarWidth;
66
+ scrollContainer.style[paddingSide] = previousPadding;
67
+ };
68
+
69
+ const resetPosition = () => (scrollContainer.scrollTop = scrollPosition);
70
+ scrollContainer.addEventListener("scroll", resetPosition);
71
+
72
+ const lock = {
73
+ count: 1,
74
+ restore: () => {
75
+ scrollContainer.removeEventListener("scroll", resetPosition);
76
+ restoreStyles();
77
+ },
78
+ };
79
+ scrollLocks.set(scrollContainer, lock);
80
+ return lock;
81
+ };
82
+
15
83
  /**
16
84
  * Locks scroll position during collapsible/height animations and hides scrollbar.
17
85
  *
@@ -59,61 +127,20 @@ export const useScrollLock = <T extends HTMLElement = HTMLElement>(
59
127
  return;
60
128
  }
61
129
 
62
- const scrollPosition = scrollContainer.scrollTop;
63
- const scrollbarWidth = scrollContainer.style.scrollbarWidth;
64
-
65
- // Hiding the scrollbar collapses its gutter on classic scrollbars, which
66
- // shifts centered content horizontally; compensate with padding on the
67
- // side the scrollbar occupies (the left side in RTL).
68
- const computed = getComputedStyle(scrollContainer);
69
- const paddingSide =
70
- computed.direction === "rtl" ? "paddingLeft" : "paddingRight";
71
- const previousPadding = scrollContainer.style[paddingSide];
72
- const elementScrollbarSize =
73
- scrollContainer.offsetWidth -
74
- scrollContainer.clientWidth -
75
- parseFloat(computed.borderLeftWidth) -
76
- parseFloat(computed.borderRightWidth);
77
- // A root element's offsetWidth already excludes the viewport scrollbar, so
78
- // the element formula reports zero once the scroll propagates to the
79
- // viewport, and only then does the viewport measure apply. A body that
80
- // scrolls in its own right is measured by the element formula like any
81
- // other scroller, whether or not the viewport is scrolling too.
82
- const ownerDocument = scrollContainer.ownerDocument;
83
- const isRootScroller =
84
- scrollContainer === ownerDocument.documentElement ||
85
- scrollContainer === ownerDocument.body;
86
- const scrollbarSize =
87
- isRootScroller && elementScrollbarSize <= 0
88
- ? (ownerDocument.defaultView?.innerWidth ?? 0) -
89
- ownerDocument.documentElement.clientWidth
90
- : elementScrollbarSize;
91
-
92
- scrollContainer.style.scrollbarWidth = "none";
93
- if (scrollbarSize > 0) {
94
- scrollContainer.style[paddingSide] = `${
95
- parseFloat(computed[paddingSide]) + scrollbarSize
96
- }px`;
97
- }
98
-
99
- const restoreStyles = () => {
100
- scrollContainer.style.scrollbarWidth = scrollbarWidth;
101
- scrollContainer.style[paddingSide] = previousPadding;
102
- };
130
+ const lock = acquireScrollLock(scrollContainer);
103
131
 
104
- const resetPosition = () => (scrollContainer.scrollTop = scrollPosition);
105
- scrollContainer.addEventListener("scroll", resetPosition);
106
-
107
- const timeoutId = setTimeout(() => {
108
- scrollContainer.removeEventListener("scroll", resetPosition);
109
- restoreStyles();
110
- cleanupRef.current = null;
111
- }, animationDuration);
132
+ const timeoutId = setTimeout(
133
+ () => cleanupRef.current?.(),
134
+ animationDuration,
135
+ );
112
136
 
113
137
  cleanupRef.current = () => {
114
138
  clearTimeout(timeoutId);
115
- scrollContainer.removeEventListener("scroll", resetPosition);
116
- restoreStyles();
139
+ if (--lock.count === 0) {
140
+ lock.restore();
141
+ scrollLocks.delete(scrollContainer);
142
+ }
143
+ cleanupRef.current = null;
117
144
  };
118
145
  }, [animationDuration, animatedElementRef]);
119
146
 
@@ -51,7 +51,10 @@ export const ThreadListItemPrimitiveRoot = forwardRef<
51
51
  if (event.key === "ArrowDown" || event.key === "ArrowUp") {
52
52
  const triggers = getItems()
53
53
  .map((item) => item.ref.current)
54
- .filter((node): node is HTMLButtonElement => node !== null);
54
+ .filter(
55
+ (node): node is HTMLButtonElement =>
56
+ node !== null && !node.disabled,
57
+ );
55
58
  const next =
56
59
  triggers[
57
60
  triggers.indexOf(trigger) + (event.key === "ArrowDown" ? 1 : -1)