@assistant-ui/react 0.12.28 → 0.14.2

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 (187) hide show
  1. package/README.md +58 -42
  2. package/dist/client/ExternalThread.d.ts +7 -0
  3. package/dist/client/ExternalThread.d.ts.map +1 -1
  4. package/dist/client/ExternalThread.js +24 -18
  5. package/dist/client/ExternalThread.js.map +1 -1
  6. package/dist/client/InMemoryThreadList.d.ts.map +1 -1
  7. package/dist/client/InMemoryThreadList.js +3 -0
  8. package/dist/client/InMemoryThreadList.js.map +1 -1
  9. package/dist/client/SingleThreadList.d.ts.map +1 -1
  10. package/dist/client/SingleThreadList.js +3 -0
  11. package/dist/client/SingleThreadList.js.map +1 -1
  12. package/dist/context/providers/ThreadViewportProvider.d.ts.map +1 -1
  13. package/dist/context/providers/ThreadViewportProvider.js +2 -10
  14. package/dist/context/providers/ThreadViewportProvider.js.map +1 -1
  15. package/dist/context/stores/ThreadViewport.d.ts +46 -4
  16. package/dist/context/stores/ThreadViewport.d.ts.map +1 -1
  17. package/dist/context/stores/ThreadViewport.js +51 -7
  18. package/dist/context/stores/ThreadViewport.js.map +1 -1
  19. package/dist/index.d.ts +5 -30
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +3 -28
  22. package/dist/index.js.map +1 -1
  23. package/dist/legacy-runtime/cloud/auiV0.d.ts +10 -1
  24. package/dist/legacy-runtime/cloud/auiV0.d.ts.map +1 -1
  25. package/dist/legacy-runtime/cloud/auiV0.js +21 -3
  26. package/dist/legacy-runtime/cloud/auiV0.js.map +1 -1
  27. package/dist/legacy-runtime/runtime-cores/assistant-transport/utils.d.ts +1 -1
  28. package/dist/legacy-runtime/runtime-cores/assistant-transport/utils.d.ts.map +1 -1
  29. package/dist/legacy-runtime/runtime-cores/assistant-transport/utils.js +1 -1
  30. package/dist/legacy-runtime/runtime-cores/assistant-transport/utils.js.map +1 -1
  31. package/dist/mcp-apps/McpAppRenderer.d.ts +28 -0
  32. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -0
  33. package/dist/mcp-apps/McpAppRenderer.js +115 -0
  34. package/dist/mcp-apps/McpAppRenderer.js.map +1 -0
  35. package/dist/mcp-apps/McpAppsRemoteHost.d.ts +3 -0
  36. package/dist/mcp-apps/McpAppsRemoteHost.d.ts.map +1 -0
  37. package/dist/mcp-apps/McpAppsRemoteHost.js +27 -0
  38. package/dist/mcp-apps/McpAppsRemoteHost.js.map +1 -0
  39. package/dist/mcp-apps/app-frame.d.ts +3 -0
  40. package/dist/mcp-apps/app-frame.d.ts.map +1 -0
  41. package/dist/mcp-apps/app-frame.js +203 -0
  42. package/dist/mcp-apps/app-frame.js.map +1 -0
  43. package/dist/mcp-apps/bridge.d.ts +18 -0
  44. package/dist/mcp-apps/bridge.d.ts.map +1 -0
  45. package/dist/mcp-apps/bridge.js +290 -0
  46. package/dist/mcp-apps/bridge.js.map +1 -0
  47. package/dist/mcp-apps/index.d.ts +4 -0
  48. package/dist/mcp-apps/index.d.ts.map +1 -0
  49. package/dist/mcp-apps/index.js +3 -0
  50. package/dist/mcp-apps/index.js.map +1 -0
  51. package/dist/mcp-apps/types.d.ts +144 -0
  52. package/dist/mcp-apps/types.d.ts.map +1 -0
  53. package/dist/mcp-apps/types.js +3 -0
  54. package/dist/mcp-apps/types.js.map +1 -0
  55. package/dist/mcp-apps/utils.d.ts +5 -0
  56. package/dist/mcp-apps/utils.d.ts.map +1 -0
  57. package/dist/mcp-apps/utils.js +10 -0
  58. package/dist/mcp-apps/utils.js.map +1 -0
  59. package/dist/primitives/composer/ComposerInput.d.ts +6 -0
  60. package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
  61. package/dist/primitives/composer/ComposerInput.js +28 -6
  62. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  63. package/dist/primitives/composer/trigger/TriggerPopover.d.ts.map +1 -1
  64. package/dist/primitives/composer/trigger/TriggerPopover.js +17 -1
  65. package/dist/primitives/composer/trigger/TriggerPopover.js.map +1 -1
  66. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.d.ts +33 -0
  67. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.d.ts.map +1 -1
  68. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js +80 -11
  69. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js.map +1 -1
  70. package/dist/primitives/composer/trigger/triggerKeyboardResource.d.ts.map +1 -1
  71. package/dist/primitives/composer/trigger/triggerKeyboardResource.js +2 -1
  72. package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
  73. package/dist/primitives/message/MessageRoot.d.ts +6 -30
  74. package/dist/primitives/message/MessageRoot.d.ts.map +1 -1
  75. package/dist/primitives/message/MessageRoot.js +68 -25
  76. package/dist/primitives/message/MessageRoot.js.map +1 -1
  77. package/dist/primitives/messagePart/useMessagePartSource.d.ts +22 -3
  78. package/dist/primitives/messagePart/useMessagePartSource.d.ts.map +1 -1
  79. package/dist/primitives/thread/ThreadViewport.d.ts +38 -0
  80. package/dist/primitives/thread/ThreadViewport.d.ts.map +1 -1
  81. package/dist/primitives/thread/ThreadViewport.js +53 -5
  82. package/dist/primitives/thread/ThreadViewport.js.map +1 -1
  83. package/dist/primitives/thread/ThreadViewportFooter.d.ts +2 -1
  84. package/dist/primitives/thread/ThreadViewportFooter.d.ts.map +1 -1
  85. package/dist/primitives/thread/ThreadViewportFooter.js +2 -1
  86. package/dist/primitives/thread/ThreadViewportFooter.js.map +1 -1
  87. package/dist/primitives/thread/topAnchor/computeTopAnchorSlack.d.ts +22 -0
  88. package/dist/primitives/thread/topAnchor/computeTopAnchorSlack.d.ts.map +1 -0
  89. package/dist/primitives/thread/topAnchor/computeTopAnchorSlack.js +53 -0
  90. package/dist/primitives/thread/topAnchor/computeTopAnchorSlack.js.map +1 -0
  91. package/dist/primitives/thread/topAnchor/createReserveObservers.d.ts +5 -0
  92. package/dist/primitives/thread/topAnchor/createReserveObservers.d.ts.map +1 -0
  93. package/dist/primitives/thread/topAnchor/createReserveObservers.js +38 -0
  94. package/dist/primitives/thread/topAnchor/createReserveObservers.js.map +1 -0
  95. package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.d.ts +22 -0
  96. package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.d.ts.map +1 -0
  97. package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.js +75 -0
  98. package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.js.map +1 -0
  99. package/dist/primitives/thread/topAnchor/topAnchorTurn.d.ts +15 -0
  100. package/dist/primitives/thread/topAnchor/topAnchorTurn.d.ts.map +1 -0
  101. package/dist/primitives/thread/topAnchor/topAnchorTurn.js +13 -0
  102. package/dist/primitives/thread/topAnchor/topAnchorTurn.js.map +1 -0
  103. package/dist/primitives/thread/topAnchor/topAnchorUtils.d.ts +15 -0
  104. package/dist/primitives/thread/topAnchor/topAnchorUtils.d.ts.map +1 -0
  105. package/dist/primitives/thread/topAnchor/topAnchorUtils.js +51 -0
  106. package/dist/primitives/thread/topAnchor/topAnchorUtils.js.map +1 -0
  107. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.d.ts +7 -0
  108. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.d.ts.map +1 -0
  109. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js +18 -0
  110. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js.map +1 -0
  111. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  112. package/dist/primitives/thread/useThreadViewportAutoScroll.js +13 -1
  113. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  114. package/dist/primitives/thread.d.ts +0 -1
  115. package/dist/primitives/thread.d.ts.map +1 -1
  116. package/dist/primitives/thread.js +0 -1
  117. package/dist/primitives/thread.js.map +1 -1
  118. package/dist/primitives/threadList/ThreadListLoadMore.d.ts +13 -0
  119. package/dist/primitives/threadList/ThreadListLoadMore.d.ts.map +1 -0
  120. package/dist/primitives/threadList/ThreadListLoadMore.js +11 -0
  121. package/dist/primitives/threadList/ThreadListLoadMore.js.map +1 -0
  122. package/dist/primitives/threadList.d.ts +1 -0
  123. package/dist/primitives/threadList.d.ts.map +1 -1
  124. package/dist/primitives/threadList.js +1 -0
  125. package/dist/primitives/threadList.js.map +1 -1
  126. package/dist/utils/hooks/useManagedRef.d.ts.map +1 -1
  127. package/dist/utils/hooks/useManagedRef.js +1 -0
  128. package/dist/utils/hooks/useManagedRef.js.map +1 -1
  129. package/dist/utils/hooks/useOnResizeContent.d.ts.map +1 -1
  130. package/dist/utils/hooks/useOnResizeContent.js +1 -2
  131. package/dist/utils/hooks/useOnResizeContent.js.map +1 -1
  132. package/package.json +13 -13
  133. package/src/client/ExternalThread.ts +32 -19
  134. package/src/client/InMemoryThreadList.ts +3 -0
  135. package/src/client/SingleThreadList.ts +3 -0
  136. package/src/context/providers/ThreadViewportProvider.tsx +2 -12
  137. package/src/context/stores/ThreadViewport.ts +111 -11
  138. package/src/index.ts +20 -34
  139. package/src/legacy-runtime/cloud/auiV0.ts +37 -4
  140. package/src/legacy-runtime/runtime-cores/assistant-transport/utils.ts +1 -5
  141. package/src/mcp-apps/McpAppRenderer.tsx +215 -0
  142. package/src/mcp-apps/McpAppsRemoteHost.ts +52 -0
  143. package/src/mcp-apps/app-frame.tsx +280 -0
  144. package/src/mcp-apps/bridge.test.ts +391 -0
  145. package/src/mcp-apps/bridge.ts +435 -0
  146. package/src/mcp-apps/index.ts +16 -0
  147. package/src/mcp-apps/types.ts +158 -0
  148. package/src/mcp-apps/utils.ts +16 -0
  149. package/src/primitives/composer/ComposerInput.test.tsx +280 -0
  150. package/src/primitives/composer/ComposerInput.tsx +29 -6
  151. package/src/primitives/composer/trigger/TriggerPopover.tsx +21 -1
  152. package/src/primitives/composer/trigger/TriggerPopoverRootContext.test.tsx +152 -0
  153. package/src/primitives/composer/trigger/TriggerPopoverRootContext.tsx +134 -17
  154. package/src/primitives/composer/trigger/triggerKeyboardResource.test.ts +236 -0
  155. package/src/primitives/composer/trigger/triggerKeyboardResource.ts +2 -1
  156. package/src/primitives/message/MessageRoot.tsx +135 -57
  157. package/src/primitives/thread/ThreadViewport.tsx +95 -4
  158. package/src/primitives/thread/ThreadViewportFooter.tsx +2 -1
  159. package/src/primitives/thread/topAnchor/computeTopAnchorSlack.test.ts +131 -0
  160. package/src/primitives/thread/topAnchor/computeTopAnchorSlack.ts +94 -0
  161. package/src/primitives/thread/topAnchor/createReserveObservers.ts +50 -0
  162. package/src/primitives/thread/topAnchor/mountTopAnchorReserve.test.ts +131 -0
  163. package/src/primitives/thread/topAnchor/mountTopAnchorReserve.ts +127 -0
  164. package/src/primitives/thread/topAnchor/topAnchorTurn.test.ts +46 -0
  165. package/src/primitives/thread/topAnchor/topAnchorTurn.ts +30 -0
  166. package/src/primitives/thread/topAnchor/topAnchorUtils.ts +58 -0
  167. package/src/primitives/thread/topAnchor/useTopAnchorReserve.ts +19 -0
  168. package/src/primitives/thread/useThreadViewportAutoScroll.ts +15 -1
  169. package/src/primitives/thread.ts +0 -1
  170. package/src/primitives/threadList/ThreadListLoadMore.tsx +24 -0
  171. package/src/primitives/threadList.ts +1 -0
  172. package/src/tests/BaseComposerRuntimeCore.test.ts +4 -0
  173. package/src/tests/RemoteThreadListRuntime.adapterProvider.test.tsx +138 -0
  174. package/src/tests/RemoteThreadListRuntime.deferredProvider.test.tsx +28 -17
  175. package/src/tests/auiV0Encode.test.ts +55 -0
  176. package/src/utils/hooks/useManagedRef.ts +1 -0
  177. package/src/utils/hooks/useOnResizeContent.ts +1 -2
  178. package/dist/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.d.ts +0 -3
  179. package/dist/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.d.ts.map +0 -1
  180. package/dist/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.js +0 -3
  181. package/dist/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.js.map +0 -1
  182. package/dist/primitives/thread/ThreadViewportSlack.d.ts +0 -20
  183. package/dist/primitives/thread/ThreadViewportSlack.d.ts.map +0 -1
  184. package/dist/primitives/thread/ThreadViewportSlack.js +0 -80
  185. package/dist/primitives/thread/ThreadViewportSlack.js.map +0 -1
  186. package/src/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.ts +0 -6
  187. package/src/primitives/thread/ThreadViewportSlack.tsx +0 -116
@@ -0,0 +1,280 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { act } from "react";
5
+ import { createRoot, type Root } from "react-dom/client";
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+ import { ComposerPrimitiveInput } from "./ComposerInput";
8
+
9
+ const setText = vi.fn<(text: string) => void>();
10
+ const setCursorPosition = vi.fn<(pos: number) => void>();
11
+
12
+ const composerState = {
13
+ isEditing: true,
14
+ text: "",
15
+ type: "thread" as const,
16
+ isEmpty: true,
17
+ canCancel: false,
18
+ dictation: undefined as undefined | { inputDisabled: boolean },
19
+ };
20
+
21
+ const threadState = {
22
+ isDisabled: false,
23
+ isRunning: false,
24
+ capabilities: { queue: false, attachments: false },
25
+ };
26
+
27
+ const plugin = {
28
+ handleKeyDown: () => false,
29
+ setCursorPosition,
30
+ };
31
+
32
+ let pluginRegistry: { getPlugins: () => (typeof plugin)[] } | null = null;
33
+
34
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
35
+
36
+ vi.mock("@assistant-ui/store", () => {
37
+ const aui = {
38
+ composer: () => ({
39
+ setText,
40
+ getState: () => composerState,
41
+ cancel: () => {},
42
+ send: () => {},
43
+ addAttachment: async () => {},
44
+ }),
45
+ thread: () => ({
46
+ getState: () => threadState,
47
+ }),
48
+ on: () => () => {},
49
+ };
50
+ type Selector<T> = (s: {
51
+ composer: typeof composerState;
52
+ thread: typeof threadState;
53
+ }) => T;
54
+ return {
55
+ useAui: () => aui,
56
+ useAuiState: <T,>(selector: Selector<T>) =>
57
+ selector({ composer: composerState, thread: threadState }),
58
+ };
59
+ });
60
+
61
+ vi.mock("@assistant-ui/tap", () => ({
62
+ flushResourcesSync: (fn: () => void) => fn(),
63
+ }));
64
+
65
+ vi.mock("./ComposerInputPluginContext", () => ({
66
+ useComposerInputPluginRegistryOptional: () => pluginRegistry,
67
+ }));
68
+
69
+ let activeAria: {
70
+ popoverId: string;
71
+ highlightedItemId: string | undefined;
72
+ } | null = null;
73
+
74
+ vi.mock("./trigger/TriggerPopoverRootContext", () => ({
75
+ useTriggerPopoverActiveAriaOptional: () => activeAria,
76
+ }));
77
+
78
+ vi.mock("@radix-ui/react-use-escape-keydown", () => ({
79
+ useEscapeKeydown: () => {},
80
+ }));
81
+
82
+ vi.mock("../../utils/hooks/useOnScrollToBottom", () => ({
83
+ useOnScrollToBottom: () => {},
84
+ }));
85
+
86
+ const setNativeValue = (textarea: HTMLTextAreaElement, value: string) => {
87
+ const setter = Object.getOwnPropertyDescriptor(
88
+ HTMLTextAreaElement.prototype,
89
+ "value",
90
+ )?.set;
91
+ setter?.call(textarea, value);
92
+ };
93
+
94
+ const fireInput = (
95
+ textarea: HTMLTextAreaElement,
96
+ value: string,
97
+ isComposing: boolean,
98
+ ) => {
99
+ setNativeValue(textarea, value);
100
+ textarea.dispatchEvent(
101
+ new InputEvent("input", { bubbles: true, isComposing }),
102
+ );
103
+ };
104
+
105
+ const fireCompositionStart = (textarea: HTMLTextAreaElement) => {
106
+ textarea.dispatchEvent(
107
+ new CompositionEvent("compositionstart", { bubbles: true }),
108
+ );
109
+ };
110
+
111
+ const fireCompositionEnd = (textarea: HTMLTextAreaElement, value: string) => {
112
+ setNativeValue(textarea, value);
113
+ textarea.dispatchEvent(
114
+ new CompositionEvent("compositionend", { bubbles: true }),
115
+ );
116
+ };
117
+
118
+ describe("ComposerPrimitiveInput", () => {
119
+ let container: HTMLDivElement;
120
+ let root: Root;
121
+
122
+ beforeEach(() => {
123
+ setText.mockReset();
124
+ setCursorPosition.mockReset();
125
+ composerState.isEditing = true;
126
+ composerState.text = "";
127
+ composerState.isEmpty = true;
128
+ composerState.dictation = undefined;
129
+ threadState.isDisabled = false;
130
+ threadState.isRunning = false;
131
+ threadState.capabilities = { queue: false, attachments: false };
132
+ pluginRegistry = null;
133
+ activeAria = null;
134
+
135
+ container = document.createElement("div");
136
+ document.body.appendChild(container);
137
+ root = createRoot(container);
138
+ });
139
+
140
+ afterEach(async () => {
141
+ await act(async () => {
142
+ root.unmount();
143
+ });
144
+ container.remove();
145
+ vi.restoreAllMocks();
146
+ });
147
+
148
+ const mount = async () => {
149
+ await act(async () => {
150
+ root.render(<ComposerPrimitiveInput data-testid="input" />);
151
+ });
152
+ const textarea = container.querySelector("textarea") as HTMLTextAreaElement;
153
+ expect(textarea).not.toBeNull();
154
+ return textarea;
155
+ };
156
+
157
+ it("syncs setText during active composition so React 19 cannot reset the textarea", async () => {
158
+ const textarea = await mount();
159
+
160
+ await act(async () => {
161
+ fireCompositionStart(textarea);
162
+ fireInput(textarea, "ㄱ", true);
163
+ });
164
+ expect(setText).toHaveBeenCalledWith("ㄱ");
165
+
166
+ await act(async () => {
167
+ fireInput(textarea, "가", true);
168
+ });
169
+ expect(setText).toHaveBeenLastCalledWith("가");
170
+ });
171
+
172
+ it("commits the final value on compositionend", async () => {
173
+ const textarea = await mount();
174
+
175
+ await act(async () => {
176
+ fireCompositionStart(textarea);
177
+ fireInput(textarea, "가", true);
178
+ });
179
+ expect(setText).toHaveBeenCalledTimes(1);
180
+
181
+ await act(async () => {
182
+ fireCompositionEnd(textarea, "가");
183
+ });
184
+ expect(setText).toHaveBeenCalledTimes(2);
185
+ expect(setText).toHaveBeenLastCalledWith("가");
186
+ });
187
+
188
+ it("recovers when compositionend is dropped before the next input", async () => {
189
+ const textarea = await mount();
190
+
191
+ await act(async () => {
192
+ fireCompositionStart(textarea);
193
+ fireInput(textarea, "hello", false);
194
+ });
195
+ expect(setText).toHaveBeenCalledWith("hello");
196
+
197
+ await act(async () => {
198
+ fireInput(textarea, "hello!", false);
199
+ });
200
+ expect(setText).toHaveBeenLastCalledWith("hello!");
201
+ });
202
+
203
+ it("skips plugin cursor tracking during composition but resumes after", async () => {
204
+ pluginRegistry = { getPlugins: () => [plugin] };
205
+ const textarea = await mount();
206
+
207
+ await act(async () => {
208
+ fireCompositionStart(textarea);
209
+ fireInput(textarea, "ㄱ", true);
210
+ });
211
+ expect(setText).toHaveBeenCalledWith("ㄱ");
212
+ expect(setCursorPosition).not.toHaveBeenCalled();
213
+
214
+ await act(async () => {
215
+ fireCompositionEnd(textarea, "가");
216
+ });
217
+ expect(setCursorPosition).toHaveBeenCalled();
218
+ });
219
+
220
+ it("tracks plugin cursor for non-composition input", async () => {
221
+ pluginRegistry = { getPlugins: () => [plugin] };
222
+ const textarea = await mount();
223
+
224
+ await act(async () => {
225
+ fireInput(textarea, "abc", false);
226
+ });
227
+ expect(setText).toHaveBeenCalledWith("abc");
228
+ expect(setCursorPosition).toHaveBeenCalled();
229
+ });
230
+
231
+ it("ignores input and compositionend when the composer is not editing", async () => {
232
+ composerState.isEditing = false;
233
+ const textarea = await mount();
234
+
235
+ await act(async () => {
236
+ fireInput(textarea, "abc", false);
237
+ fireCompositionStart(textarea);
238
+ fireCompositionEnd(textarea, "가");
239
+ });
240
+ expect(setText).not.toHaveBeenCalled();
241
+ });
242
+
243
+ it("does not apply ARIA combobox attributes when no trigger popover is open", async () => {
244
+ activeAria = null;
245
+ const textarea = await mount();
246
+
247
+ expect(textarea.getAttribute("aria-controls")).toBeNull();
248
+ expect(textarea.getAttribute("aria-expanded")).toBeNull();
249
+ expect(textarea.getAttribute("aria-haspopup")).toBeNull();
250
+ expect(textarea.getAttribute("aria-activedescendant")).toBeNull();
251
+ });
252
+
253
+ it("applies ARIA combobox attributes when a trigger popover is open", async () => {
254
+ activeAria = {
255
+ popoverId: "popover-1",
256
+ highlightedItemId: "popover-1-option-foo",
257
+ };
258
+ const textarea = await mount();
259
+
260
+ expect(textarea.getAttribute("aria-controls")).toBe("popover-1");
261
+ expect(textarea.getAttribute("aria-expanded")).toBe("true");
262
+ expect(textarea.getAttribute("aria-haspopup")).toBe("listbox");
263
+ expect(textarea.getAttribute("aria-activedescendant")).toBe(
264
+ "popover-1-option-foo",
265
+ );
266
+ });
267
+
268
+ it("omits aria-activedescendant when no item is highlighted", async () => {
269
+ activeAria = {
270
+ popoverId: "popover-1",
271
+ highlightedItemId: undefined,
272
+ };
273
+ const textarea = await mount();
274
+
275
+ expect(textarea.getAttribute("aria-controls")).toBe("popover-1");
276
+ expect(textarea.getAttribute("aria-expanded")).toBe("true");
277
+ expect(textarea.getAttribute("aria-haspopup")).toBe("listbox");
278
+ expect(textarea.getAttribute("aria-activedescendant")).toBeNull();
279
+ });
280
+ });
@@ -23,6 +23,7 @@ import { useOnScrollToBottom } from "../../utils/hooks/useOnScrollToBottom";
23
23
  import { useAuiState, useAui } from "@assistant-ui/store";
24
24
  import { flushResourcesSync } from "@assistant-ui/tap";
25
25
  import { useComposerInputPluginRegistryOptional } from "./ComposerInputPluginContext";
26
+ import { useTriggerPopoverActiveAriaOptional } from "./trigger/TriggerPopoverRootContext";
26
27
 
27
28
  export namespace ComposerPrimitiveInput {
28
29
  export type Element = HTMLTextAreaElement;
@@ -100,6 +101,12 @@ export namespace ComposerPrimitiveInput {
100
101
  * keyboard shortcuts, file paste support, and intelligent focus management.
101
102
  * It integrates with the composer context to manage message state and submission.
102
103
  *
104
+ * When rendered inside `Unstable_TriggerPopoverRoot` and a popover is open, the
105
+ * underlying `<textarea>` automatically receives `aria-controls`,
106
+ * `aria-expanded`, `aria-haspopup`, and `aria-activedescendant` for the
107
+ * combobox relationship. These computed attributes override user-provided
108
+ * values for those four ARIA props while the popover is open.
109
+ *
103
110
  * @example
104
111
  * ```tsx
105
112
  * // Ctrl/Cmd+Enter to submit (plain Enter inserts newline)
@@ -142,6 +149,7 @@ export const ComposerPrimitiveInput = forwardRef<
142
149
  ) => {
143
150
  const aui = useAui();
144
151
  const pluginRegistry = useComposerInputPluginRegistryOptional();
152
+ const activeAria = useTriggerPopoverActiveAriaOptional();
145
153
 
146
154
  const effectiveSubmitMode =
147
155
  submitMode ?? (submitOnEnter === false ? "none" : "enter");
@@ -197,13 +205,13 @@ export const ComposerPrimitiveInput = forwardRef<
197
205
  const threadState = aui.thread().getState();
198
206
  const hasQueue = threadState.capabilities.queue;
199
207
 
200
- // Steer hotkey: Cmd/Ctrl+Shift+Enter (respects submitMode="none" and isEmpty)
208
+ // Steer hotkey: Cmd/Ctrl+Shift+Enter (respects submitMode="none" and canSend)
201
209
  if (
202
210
  e.shiftKey &&
203
211
  (e.ctrlKey || e.metaKey) &&
204
212
  hasQueue &&
205
213
  effectiveSubmitMode !== "none" &&
206
- !aui.composer().getState().isEmpty
214
+ aui.composer().getState().canSend
207
215
  ) {
208
216
  e.preventDefault();
209
217
  aui.composer().send({ steer: true });
@@ -287,23 +295,38 @@ export const ComposerPrimitiveInput = forwardRef<
287
295
  return aui.on("threadListItem.switchedTo", focus);
288
296
  }, [unstable_focusOnThreadSwitched, focus, aui]);
289
297
 
298
+ const ariaComboboxProps = activeAria
299
+ ? {
300
+ "aria-controls": activeAria.popoverId,
301
+ "aria-expanded": true as const,
302
+ "aria-haspopup": "listbox" as const,
303
+ "aria-activedescendant": activeAria.highlightedItemId,
304
+ }
305
+ : {};
306
+
290
307
  const inputProps = {
291
308
  name: "input" as const,
292
309
  value,
293
310
  ...rest,
311
+ ...ariaComboboxProps,
294
312
  ref: ref as React.ForwardedRef<HTMLTextAreaElement>,
295
313
  disabled: isDisabled,
296
314
  onChange: composeEventHandlers(
297
315
  onChange,
298
316
  (e: React.ChangeEvent<HTMLTextAreaElement>) => {
299
317
  if (!aui.composer().getState().isEditing) return;
300
- const isComposing =
301
- (e.nativeEvent as { isComposing?: boolean }).isComposing === true ||
302
- compositionRef.current;
303
- if (isComposing) return;
318
+ const nativeIsComposing =
319
+ (e.nativeEvent as { isComposing?: boolean }).isComposing === true;
320
+ // recover stuck compositionRef when the browser drops compositionend
321
+ if (compositionRef.current && !nativeIsComposing) {
322
+ compositionRef.current = false;
323
+ }
324
+ const isComposing = nativeIsComposing || compositionRef.current;
325
+ // keep controlled value in sync mid-IME so react does not reset the textarea to a stale value
304
326
  flushResourcesSync(() => {
305
327
  aui.composer().setText(e.target.value);
306
328
  });
329
+ if (isComposing) return;
307
330
  const pos = e.target.selectionStart ?? e.target.value.length;
308
331
  if (pluginRegistry) {
309
332
  for (const plugin of pluginRegistry.getPlugins()) {
@@ -23,7 +23,10 @@ import {
23
23
  type TriggerPopoverResourceOutput,
24
24
  } from "./TriggerPopoverResource";
25
25
  import type { TriggerBehavior } from "./triggerSelectionResource";
26
- import { useTriggerPopoverRootContext } from "./TriggerPopoverRootContext";
26
+ import {
27
+ useTriggerPopoverAriaPublish,
28
+ useTriggerPopoverRootContext,
29
+ } from "./TriggerPopoverRootContext";
27
30
 
28
31
  const TriggerPopoverScopeContext =
29
32
  createContext<TriggerPopoverResourceOutput | null>(null);
@@ -179,6 +182,23 @@ export const ComposerPrimitiveTriggerPopover = forwardRef<
179
182
 
180
183
  const open = behavior !== null && resource.open;
181
184
 
185
+ const aria = useTriggerPopoverAriaPublish();
186
+
187
+ useEffect(() => {
188
+ if (!open) return undefined;
189
+ return () => {
190
+ aria.setActiveAria(char, null);
191
+ };
192
+ }, [aria, char, open]);
193
+
194
+ useEffect(() => {
195
+ if (!open) return;
196
+ aria.setActiveAria(char, {
197
+ popoverId,
198
+ highlightedItemId: resource.highlightedItemId,
199
+ });
200
+ }, [aria, char, popoverId, open, resource.highlightedItemId]);
201
+
182
202
  return (
183
203
  <TriggerBehaviorRegistrationContext.Provider value={registration}>
184
204
  <TriggerPopoverScopeContext.Provider value={resource}>
@@ -0,0 +1,152 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { act, type FC } from "react";
5
+ import { createRoot, type Root } from "react-dom/client";
6
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
7
+ import {
8
+ ComposerPrimitiveTriggerPopoverRoot,
9
+ type TriggerPopoverActiveAria,
10
+ useTriggerPopoverActiveAriaOptional,
11
+ useTriggerPopoverAriaPublish,
12
+ } from "./TriggerPopoverRootContext";
13
+
14
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
15
+
16
+ type PublishHandle = ReturnType<typeof useTriggerPopoverAriaPublish>;
17
+
18
+ describe("TriggerPopoverRootContext active ARIA", () => {
19
+ let container: HTMLDivElement;
20
+ let root: Root;
21
+
22
+ beforeEach(() => {
23
+ container = document.createElement("div");
24
+ document.body.appendChild(container);
25
+ root = createRoot(container);
26
+ });
27
+
28
+ afterEach(async () => {
29
+ await act(async () => {
30
+ root.unmount();
31
+ });
32
+ container.remove();
33
+ });
34
+
35
+ const renderWithRoot = async () => {
36
+ const publishRef = { current: null as PublishHandle | null };
37
+ const ariaRef = { current: null as TriggerPopoverActiveAria | null };
38
+
39
+ const Probe: FC = () => {
40
+ publishRef.current = useTriggerPopoverAriaPublish();
41
+ ariaRef.current = useTriggerPopoverActiveAriaOptional();
42
+ return null;
43
+ };
44
+
45
+ await act(async () => {
46
+ root.render(
47
+ <ComposerPrimitiveTriggerPopoverRoot>
48
+ <Probe />
49
+ </ComposerPrimitiveTriggerPopoverRoot>,
50
+ );
51
+ });
52
+
53
+ return {
54
+ publish: () => publishRef.current as PublishHandle,
55
+ aria: () => ariaRef.current,
56
+ };
57
+ };
58
+
59
+ it("returns null initially inside a root", async () => {
60
+ const { aria } = await renderWithRoot();
61
+ expect(aria()).toBeNull();
62
+ });
63
+
64
+ it("publishes a descriptor and surfaces it via the hook", async () => {
65
+ const { publish, aria } = await renderWithRoot();
66
+
67
+ await act(async () => {
68
+ publish().setActiveAria("@", {
69
+ popoverId: "popover-mention",
70
+ highlightedItemId: "popover-mention-option-a",
71
+ });
72
+ });
73
+
74
+ expect(aria()).toEqual({
75
+ popoverId: "popover-mention",
76
+ highlightedItemId: "popover-mention-option-a",
77
+ });
78
+ });
79
+
80
+ it("clears the descriptor when the owning char releases it", async () => {
81
+ const { publish, aria } = await renderWithRoot();
82
+
83
+ await act(async () => {
84
+ publish().setActiveAria("@", {
85
+ popoverId: "popover-mention",
86
+ highlightedItemId: undefined,
87
+ });
88
+ });
89
+ expect(aria()).not.toBeNull();
90
+
91
+ await act(async () => {
92
+ publish().setActiveAria("@", null);
93
+ });
94
+ expect(aria()).toBeNull();
95
+ });
96
+
97
+ it("ignores a clear call from a non-owning char", async () => {
98
+ const { publish, aria } = await renderWithRoot();
99
+
100
+ await act(async () => {
101
+ publish().setActiveAria("@", {
102
+ popoverId: "popover-mention",
103
+ highlightedItemId: undefined,
104
+ });
105
+ });
106
+
107
+ await act(async () => {
108
+ publish().setActiveAria("/", null);
109
+ });
110
+
111
+ expect(aria()).toEqual({
112
+ popoverId: "popover-mention",
113
+ highlightedItemId: undefined,
114
+ });
115
+ });
116
+
117
+ it("replaces the descriptor when a different char takes over", async () => {
118
+ const { publish, aria } = await renderWithRoot();
119
+
120
+ await act(async () => {
121
+ publish().setActiveAria("@", {
122
+ popoverId: "popover-mention",
123
+ highlightedItemId: "popover-mention-option-a",
124
+ });
125
+ });
126
+ await act(async () => {
127
+ publish().setActiveAria("/", {
128
+ popoverId: "popover-slash",
129
+ highlightedItemId: "popover-slash-option-x",
130
+ });
131
+ });
132
+
133
+ expect(aria()).toEqual({
134
+ popoverId: "popover-slash",
135
+ highlightedItemId: "popover-slash-option-x",
136
+ });
137
+ });
138
+
139
+ it("returns null when the consumer is rendered outside a root", async () => {
140
+ const ariaRef = { current: null as TriggerPopoverActiveAria | null };
141
+ const Solo: FC = () => {
142
+ ariaRef.current = useTriggerPopoverActiveAriaOptional();
143
+ return null;
144
+ };
145
+
146
+ await act(async () => {
147
+ root.render(<Solo />);
148
+ });
149
+
150
+ expect(ariaRef.current).toBeNull();
151
+ });
152
+ });