@assistant-ui/react 0.15.24 → 0.15.26

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 (204) hide show
  1. package/dist/hooks/useToolCallElapsed.d.ts +3 -1
  2. package/dist/hooks/useToolCallElapsed.d.ts.map +1 -1
  3. package/dist/hooks/useToolCallElapsed.js +8 -8
  4. package/dist/hooks/useToolCallElapsed.js.map +1 -1
  5. package/dist/index.d.ts +39 -7
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +3 -3
  8. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  9. package/dist/mcp-apps/McpAppRenderer.js +3 -2
  10. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  11. package/dist/mcp-apps/app-frame.d.ts.map +1 -1
  12. package/dist/mcp-apps/app-frame.js +56 -46
  13. package/dist/mcp-apps/app-frame.js.map +1 -1
  14. package/dist/mcp-apps/csp.d.ts +4 -0
  15. package/dist/mcp-apps/csp.d.ts.map +1 -0
  16. package/dist/mcp-apps/csp.js +93 -0
  17. package/dist/mcp-apps/csp.js.map +1 -0
  18. package/dist/mcp-apps/types.d.ts +1 -0
  19. package/dist/mcp-apps/types.d.ts.map +1 -1
  20. package/dist/mcp-apps/types.js.map +1 -1
  21. package/dist/model-context/makeAssistantVisible.d.ts +1 -0
  22. package/dist/model-context/makeAssistantVisible.d.ts.map +1 -1
  23. package/dist/model-context/makeAssistantVisible.js +31 -12
  24. package/dist/model-context/makeAssistantVisible.js.map +1 -1
  25. package/dist/primitives/assistantModal/AssistantModalRoot.d.ts +1 -0
  26. package/dist/primitives/assistantModal/AssistantModalRoot.d.ts.map +1 -1
  27. package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
  28. package/dist/primitives/attachment.d.ts +3 -1
  29. package/dist/primitives/attachment.d.ts.map +1 -1
  30. package/dist/primitives/attachment.js.map +1 -1
  31. package/dist/primitives/composer/ComposerCancel.d.ts +5 -1
  32. package/dist/primitives/composer/ComposerCancel.d.ts.map +1 -1
  33. package/dist/primitives/composer/ComposerCancel.js +14 -1
  34. package/dist/primitives/composer/ComposerCancel.js.map +1 -1
  35. package/dist/primitives/composer/ComposerInput.d.ts +15 -1
  36. package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
  37. package/dist/primitives/composer/ComposerInput.js +5 -4
  38. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  39. package/dist/primitives/composer/ComposerRoot.d.ts.map +1 -1
  40. package/dist/primitives/composer/ComposerRoot.js +24 -19
  41. package/dist/primitives/composer/ComposerRoot.js.map +1 -1
  42. package/dist/primitives/composer/composerInputSelector.d.ts +3 -0
  43. package/dist/primitives/composer/composerInputSelector.d.ts.map +1 -0
  44. package/dist/primitives/composer/composerInputSelector.js +7 -0
  45. package/dist/primitives/composer/composerInputSelector.js.map +1 -0
  46. package/dist/primitives/composer/trigger/TriggerPopover.d.ts +11 -11
  47. package/dist/primitives/composer/trigger/TriggerPopover.d.ts.map +1 -1
  48. package/dist/primitives/composer/trigger/TriggerPopover.js +62 -60
  49. package/dist/primitives/composer/trigger/TriggerPopover.js.map +1 -1
  50. package/dist/primitives/composer/trigger/TriggerPopoverAction.d.ts +8 -8
  51. package/dist/primitives/composer/trigger/TriggerPopoverAction.d.ts.map +1 -1
  52. package/dist/primitives/composer/trigger/TriggerPopoverAction.js +6 -6
  53. package/dist/primitives/composer/trigger/TriggerPopoverAction.js.map +1 -1
  54. package/dist/primitives/composer/trigger/TriggerPopoverCategories.d.ts +3 -3
  55. package/dist/primitives/composer/trigger/TriggerPopoverCategories.d.ts.map +1 -1
  56. package/dist/primitives/composer/trigger/TriggerPopoverCategories.js.map +1 -1
  57. package/dist/primitives/composer/trigger/TriggerPopoverDirective.d.ts +9 -9
  58. package/dist/primitives/composer/trigger/TriggerPopoverDirective.d.ts.map +1 -1
  59. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +7 -7
  60. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js.map +1 -1
  61. package/dist/primitives/composer/trigger/TriggerPopoverItems.d.ts +5 -5
  62. package/dist/primitives/composer/trigger/TriggerPopoverItems.d.ts.map +1 -1
  63. package/dist/primitives/composer/trigger/TriggerPopoverItems.js.map +1 -1
  64. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.d.ts +8 -8
  65. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js +8 -8
  66. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js.map +1 -1
  67. package/dist/primitives/composer/trigger/index.d.ts +1 -1
  68. package/dist/primitives/composer/useComposerCancelWithFocus.d.ts +2 -0
  69. package/dist/primitives/composer/useComposerCancelWithFocus.d.ts.map +1 -0
  70. package/dist/primitives/composer/useComposerCancelWithFocus.js +42 -0
  71. package/dist/primitives/composer/useComposerCancelWithFocus.js.map +1 -0
  72. package/dist/primitives/composer.d.ts +55 -15
  73. package/dist/primitives/composer.d.ts.map +1 -1
  74. package/dist/primitives/composer.js +8 -1
  75. package/dist/primitives/composer.js.map +1 -1
  76. package/dist/primitives/message/MessagePartsGrouped.d.ts +8 -1
  77. package/dist/primitives/message/MessagePartsGrouped.d.ts.map +1 -1
  78. package/dist/primitives/message/MessagePartsGrouped.js +58 -278
  79. package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
  80. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.d.ts.map +1 -1
  81. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js +5 -2
  82. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js.map +1 -1
  83. package/dist/primitives/thread/ThreadLoadEarlier.d.ts +19 -0
  84. package/dist/primitives/thread/ThreadLoadEarlier.d.ts.map +1 -0
  85. package/dist/primitives/thread/ThreadLoadEarlier.js +36 -0
  86. package/dist/primitives/thread/ThreadLoadEarlier.js.map +1 -0
  87. package/dist/primitives/thread/ThreadMessages.d.ts +1 -1
  88. package/dist/primitives/thread/ThreadMessages.d.ts.map +1 -1
  89. package/dist/primitives/thread/ThreadMessages.js +2 -2
  90. package/dist/primitives/thread/ThreadViewport.d.ts.map +1 -1
  91. package/dist/primitives/thread/ThreadViewport.js +4 -3
  92. package/dist/primitives/thread/ThreadViewport.js.map +1 -1
  93. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js +2 -2
  94. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js.map +1 -1
  95. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  96. package/dist/primitives/thread/useThreadViewportAutoScroll.js +195 -17
  97. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  98. package/dist/primitives/thread.d.ts +2 -0
  99. package/dist/primitives/thread.d.ts.map +1 -1
  100. package/dist/primitives/thread.js +5 -2
  101. package/dist/primitives/thread.js.map +1 -1
  102. package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
  103. package/dist/sandbox-host/SandboxHost.js +9 -3
  104. package/dist/sandbox-host/SandboxHost.js.map +1 -1
  105. package/dist/unstable/useComposerInput.d.ts +7 -4
  106. package/dist/unstable/useComposerInput.d.ts.map +1 -1
  107. package/dist/unstable/useComposerInput.js +4 -4
  108. package/dist/unstable/useComposerInput.js.map +1 -1
  109. package/dist/unstable/useComposerInputHistory.d.ts +3 -2
  110. package/dist/unstable/useComposerInputHistory.d.ts.map +1 -1
  111. package/dist/unstable/useComposerInputHistory.js +2 -2
  112. package/dist/unstable/useComposerInputHistory.js.map +1 -1
  113. package/dist/unstable/useLiveCompletionAdapter.d.ts +3 -2
  114. package/dist/unstable/useLiveCompletionAdapter.d.ts.map +1 -1
  115. package/dist/unstable/useLiveCompletionAdapter.js +8 -7
  116. package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
  117. package/dist/unstable/useMentionAdapter.d.ts +12 -3
  118. package/dist/unstable/useMentionAdapter.d.ts.map +1 -1
  119. package/dist/unstable/useMentionAdapter.js +2 -2
  120. package/dist/unstable/useMentionAdapter.js.map +1 -1
  121. package/dist/unstable/useMessageStallDetection.d.ts +4 -2
  122. package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
  123. package/dist/unstable/useMessageStallDetection.js +12 -8
  124. package/dist/unstable/useMessageStallDetection.js.map +1 -1
  125. package/dist/unstable/useSlashCommandAdapter.d.ts +5 -2
  126. package/dist/unstable/useSlashCommandAdapter.d.ts.map +1 -1
  127. package/dist/unstable/useSlashCommandAdapter.js +5 -4
  128. package/dist/unstable/useSlashCommandAdapter.js.map +1 -1
  129. package/dist/unstable/webmcp/useWebMcpProvider.d.ts +4 -0
  130. package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -1
  131. package/dist/unstable/webmcp/useWebMcpProvider.js +2 -0
  132. package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -1
  133. package/dist/utils/getSelectionMessageId.d.ts.map +1 -1
  134. package/dist/utils/getSelectionMessageId.js +31 -8
  135. package/dist/utils/getSelectionMessageId.js.map +1 -1
  136. package/dist/utils/hooks/useOnResizeContent.d.ts.map +1 -1
  137. package/dist/utils/hooks/useOnResizeContent.js +2 -22
  138. package/dist/utils/hooks/useOnResizeContent.js.map +1 -1
  139. package/dist/utils/smooth/useSmooth.js +6 -6
  140. package/dist/utils/smooth/useSmooth.js.map +1 -1
  141. package/dist/utils/useIsomorphicLayoutEffect.d.ts +3 -0
  142. package/dist/utils/useIsomorphicLayoutEffect.d.ts.map +1 -0
  143. package/dist/utils/useIsomorphicLayoutEffect.js +6 -0
  144. package/dist/utils/useIsomorphicLayoutEffect.js.map +1 -0
  145. package/package.json +12 -11
  146. package/src/hooks/useToolCallElapsed.test.tsx +37 -0
  147. package/src/hooks/useToolCallElapsed.ts +10 -7
  148. package/src/index.ts +47 -5
  149. package/src/mcp-apps/McpAppRenderer.tsx +3 -9
  150. package/src/mcp-apps/app-frame.test.tsx +38 -0
  151. package/src/mcp-apps/app-frame.tsx +5 -8
  152. package/src/mcp-apps/csp.test.ts +112 -0
  153. package/src/mcp-apps/csp.ts +124 -0
  154. package/src/mcp-apps/types.ts +1 -0
  155. package/src/model-context/frame/AssistantFrame.test.ts +45 -0
  156. package/src/model-context/frame/SPEC_AssistantFrame.md +1 -0
  157. package/src/model-context/makeAssistantVisible.test.tsx +315 -5
  158. package/src/model-context/makeAssistantVisible.tsx +43 -7
  159. package/src/primitives/assistantModal/AssistantModalRoot.tsx +1 -0
  160. package/src/primitives/attachment.ts +4 -1
  161. package/src/primitives/composer/ComposerCancel.focus.test.tsx +307 -0
  162. package/src/primitives/composer/ComposerCancel.ts +8 -1
  163. package/src/primitives/composer/ComposerInput.tsx +20 -2
  164. package/src/primitives/composer/ComposerRoot.tsx +6 -5
  165. package/src/primitives/composer/composerInputSelector.ts +4 -0
  166. package/src/primitives/composer/trigger/TriggerPopover.tsx +11 -11
  167. package/src/primitives/composer/trigger/TriggerPopoverAction.tsx +10 -13
  168. package/src/primitives/composer/trigger/TriggerPopoverCategories.tsx +2 -2
  169. package/src/primitives/composer/trigger/TriggerPopoverDirective.tsx +11 -14
  170. package/src/primitives/composer/trigger/TriggerPopoverExports.test.ts +61 -0
  171. package/src/primitives/composer/trigger/TriggerPopoverItems.tsx +3 -3
  172. package/src/primitives/composer/trigger/TriggerPopoverRootContext.tsx +8 -8
  173. package/src/primitives/composer/useComposerCancelWithFocus.ts +57 -0
  174. package/src/primitives/composer.ts +70 -15
  175. package/src/primitives/message/MessagePartsGrouped.test.tsx +106 -2
  176. package/src/primitives/message/MessagePartsGrouped.tsx +36 -134
  177. package/src/primitives/message/MessageRoot.test.tsx +125 -2
  178. package/src/primitives/selectionToolbar/SelectionToolbarRoot.test.tsx +20 -12
  179. package/src/primitives/selectionToolbar/SelectionToolbarRoot.tsx +6 -2
  180. package/src/primitives/thread/ThreadLoadEarlier.tsx +35 -0
  181. package/src/primitives/thread/ThreadMessages.tsx +1 -0
  182. package/src/primitives/thread/ThreadViewport.tsx +3 -3
  183. package/src/primitives/thread/topAnchor/useTopAnchorReserve.ts +2 -2
  184. package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +922 -68
  185. package/src/primitives/thread/useThreadViewportAutoScroll.ts +330 -56
  186. package/src/primitives/thread.ts +2 -0
  187. package/src/sandbox-host/SandboxHost.test.tsx +34 -0
  188. package/src/sandbox-host/SandboxHost.tsx +11 -12
  189. package/src/tests/toolCallTiming.test.tsx +19 -2
  190. package/src/unstable/useComposerInput.ts +7 -4
  191. package/src/unstable/useComposerInputHistory.ts +3 -2
  192. package/src/unstable/useLiveCompletionAdapter.ts +9 -14
  193. package/src/unstable/useMentionAdapter.ts +12 -3
  194. package/src/unstable/useMessageStallDetection.test.tsx +40 -0
  195. package/src/unstable/useMessageStallDetection.ts +14 -8
  196. package/src/unstable/useSlashCommandAdapter.ts +8 -4
  197. package/src/unstable/webmcp/useWebMcpProvider.ts +4 -0
  198. package/src/utils/getSelectionMessageId.test.ts +231 -0
  199. package/src/utils/getSelectionMessageId.ts +45 -7
  200. package/src/utils/hooks/useOnResizeContent.ts +2 -29
  201. package/src/utils/smooth/useSmooth.test.tsx +16 -5
  202. package/src/utils/smooth/useSmooth.ts +7 -7
  203. package/src/utils/useIsomorphicLayoutEffect.test.tsx +21 -0
  204. package/src/utils/useIsomorphicLayoutEffect.ts +9 -0
@@ -0,0 +1,307 @@
1
+ /** @vitest-environment jsdom */
2
+ import { act, fireEvent, render, screen } from "@testing-library/react";
3
+ import type { ComponentProps, PropsWithChildren, ReactNode } from "react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import {
6
+ AssistantRuntimeProvider,
7
+ useExternalStoreRuntime,
8
+ } from "@assistant-ui/core/react";
9
+ import type { ThreadMessageLike } from "@assistant-ui/core";
10
+ import { ComposerPrimitiveRoot } from "./ComposerRoot";
11
+ import { ComposerPrimitiveInput } from "./ComposerInput";
12
+ import { ComposerPrimitiveCancel } from "./ComposerCancel";
13
+ import { ThreadPrimitiveRoot } from "../thread/ThreadRoot";
14
+ import { ThreadPrimitiveViewportProvider } from "../../context/providers/ThreadViewportProvider";
15
+ import { ThreadPrimitiveMessages } from "../thread/ThreadMessages";
16
+ import { ActionBarPrimitiveEdit } from "../actionBar/ActionBarEdit";
17
+
18
+ const messages = [{ id: "user-message", text: "Original message" }];
19
+ const { cancelOverride } = vi.hoisted(() => ({ cancelOverride: vi.fn() }));
20
+ vi.mock("@assistant-ui/core/react", async (importOriginal) => {
21
+ const original =
22
+ await importOriginal<typeof import("@assistant-ui/core/react")>();
23
+ return {
24
+ ...original,
25
+ useComposerCancel: () => {
26
+ const behavior = original.useComposerCancel();
27
+ return cancelOverride() ?? behavior;
28
+ },
29
+ };
30
+ });
31
+ const convertMessage = (
32
+ message: (typeof messages)[number],
33
+ ): ThreadMessageLike => ({
34
+ id: message.id,
35
+ role: "user",
36
+ content: [{ type: "text", text: message.text }],
37
+ });
38
+
39
+ const Provider = ({
40
+ children,
41
+ isRunning = false,
42
+ onCancel,
43
+ }: PropsWithChildren<{
44
+ isRunning?: boolean;
45
+ onCancel?: () => Promise<void>;
46
+ }>) => {
47
+ const runtime = useExternalStoreRuntime({
48
+ messages,
49
+ convertMessage,
50
+ onNew: async () => {},
51
+ onEdit: async () => {},
52
+ isRunning,
53
+ onCancel,
54
+ });
55
+ return (
56
+ <AssistantRuntimeProvider runtime={runtime}>
57
+ {children}
58
+ </AssistantRuntimeProvider>
59
+ );
60
+ };
61
+
62
+ const Message = () => (
63
+ <ActionBarPrimitiveEdit>Edit message</ActionBarPrimitiveEdit>
64
+ );
65
+
66
+ type Options = {
67
+ nonForm?: boolean;
68
+ disabledMain?: boolean;
69
+ showMain?: boolean;
70
+ contentEditable?: boolean;
71
+ beforeMain?: ReactNode;
72
+ onCancelClick?: ComponentProps<typeof ComposerPrimitiveCancel>["onClick"];
73
+ };
74
+
75
+ const setup = ({
76
+ nonForm,
77
+ disabledMain,
78
+ showMain = true,
79
+ contentEditable,
80
+ beforeMain,
81
+ onCancelClick,
82
+ }: Options = {}) => {
83
+ const EditComposer = () => (
84
+ <ComposerPrimitiveRoot render={nonForm ? <div /> : undefined}>
85
+ <ComposerPrimitiveInput aria-label="Edit composer" autoFocus />
86
+ <ComposerPrimitiveCancel onClick={onCancelClick}>
87
+ Cancel edit
88
+ </ComposerPrimitiveCancel>
89
+ </ComposerPrimitiveRoot>
90
+ );
91
+ render(
92
+ <Provider>
93
+ <input aria-label="Outside thread" />
94
+ <ThreadPrimitiveRoot>
95
+ <ThreadPrimitiveViewportProvider>
96
+ <ThreadPrimitiveMessages components={{ Message, EditComposer }} />
97
+ {beforeMain}
98
+ {showMain && (
99
+ <ComposerPrimitiveRoot render={nonForm ? <section /> : undefined}>
100
+ {contentEditable ? (
101
+ <div
102
+ role="textbox"
103
+ aria-label="Main composer"
104
+ contentEditable
105
+ />
106
+ ) : (
107
+ <ComposerPrimitiveInput
108
+ aria-label="Main composer"
109
+ disabled={disabledMain}
110
+ />
111
+ )}
112
+ </ComposerPrimitiveRoot>
113
+ )}
114
+ </ThreadPrimitiveViewportProvider>
115
+ </ThreadPrimitiveRoot>
116
+ </Provider>,
117
+ );
118
+ fireEvent.click(screen.getByRole("button", { name: "Edit message" }));
119
+ const input = screen.getByRole("textbox", { name: "Edit composer" });
120
+ act(() => input.focus());
121
+ return { input, cancel: screen.getByRole("button", { name: "Cancel edit" }) };
122
+ };
123
+
124
+ afterEach(() => {
125
+ cancelOverride.mockReset();
126
+ });
127
+
128
+ describe("edit composer cancellation focus", () => {
129
+ it.each(["Escape", "Cancel"])(
130
+ "returns focus to the main composer after %s",
131
+ (action) => {
132
+ const { input, cancel } = setup();
133
+ if (action === "Escape") {
134
+ fireEvent.keyDown(input, { key: "Escape" });
135
+ } else {
136
+ act(() => cancel.focus());
137
+ fireEvent.click(cancel);
138
+ }
139
+ expect(
140
+ screen.queryByRole("textbox", { name: "Edit composer" }),
141
+ ).toBeNull();
142
+ expect(document.activeElement).toBe(
143
+ screen.getByRole("textbox", { name: "Main composer" }),
144
+ );
145
+ },
146
+ );
147
+
148
+ it("supports non-form composer roots and a contenteditable destination", () => {
149
+ const { input } = setup({ nonForm: true, contentEditable: true });
150
+ fireEvent.keyDown(input, { key: "Escape" });
151
+ expect(document.activeElement).toBe(
152
+ screen.getByRole("textbox", { name: "Main composer" }),
153
+ );
154
+ });
155
+
156
+ it("does not focus a nested thread's composer", () => {
157
+ const { input } = setup({
158
+ beforeMain: (
159
+ <Provider>
160
+ <ThreadPrimitiveRoot>
161
+ <ComposerPrimitiveRoot>
162
+ <textarea aria-label="Nested composer" />
163
+ </ComposerPrimitiveRoot>
164
+ </ThreadPrimitiveRoot>
165
+ </Provider>
166
+ ),
167
+ });
168
+ fireEvent.keyDown(input, { key: "Escape" });
169
+ expect(document.activeElement).toBe(
170
+ screen.getByRole("textbox", { name: "Main composer" }),
171
+ );
172
+ });
173
+
174
+ it.each(["hidden", "inert"] as const)(
175
+ "skips a %s main composer before the usable destination",
176
+ (attribute) => {
177
+ const { input } = setup({
178
+ beforeMain: (
179
+ <ComposerPrimitiveRoot {...{ [attribute]: true }}>
180
+ <textarea aria-label="Unavailable composer" />
181
+ </ComposerPrimitiveRoot>
182
+ ),
183
+ });
184
+ fireEvent.keyDown(input, { key: "Escape" });
185
+ expect(document.activeElement).toBe(
186
+ screen.getByRole("textbox", { name: "Main composer" }),
187
+ );
188
+ },
189
+ );
190
+
191
+ it.each([{ showMain: false }, { disabledMain: true }])(
192
+ "cancels safely without a usable destination: %j",
193
+ (options) => {
194
+ const { input } = setup(options);
195
+ fireEvent.keyDown(input, { key: "Escape" });
196
+ expect(
197
+ screen.queryByRole("textbox", { name: "Edit composer" }),
198
+ ).toBeNull();
199
+ expect(document.activeElement).toBe(document.body);
200
+ },
201
+ );
202
+
203
+ it("preserves focus deliberately moved by the consumer", () => {
204
+ const { cancel } = setup({
205
+ onCancelClick: () =>
206
+ screen.getByRole("textbox", { name: "Outside thread" }).focus(),
207
+ });
208
+ act(() => cancel.focus());
209
+ fireEvent.click(cancel);
210
+ expect(screen.queryByRole("textbox", { name: "Edit composer" })).toBeNull();
211
+ expect(document.activeElement).toBe(
212
+ screen.getByRole("textbox", { name: "Outside thread" }),
213
+ );
214
+ });
215
+
216
+ it("restores focus when cancellation hides the connected edit composer", () => {
217
+ cancelOverride.mockReturnValue({
218
+ disabled: false,
219
+ cancel: () => {
220
+ const active = document.activeElement as HTMLElement;
221
+ active.closest<HTMLElement>('[data-aui-composer-type="edit"]')!.hidden =
222
+ true;
223
+ active.blur();
224
+ },
225
+ });
226
+ const { cancel } = setup();
227
+ act(() => cancel.focus());
228
+ fireEvent.click(cancel);
229
+
230
+ expect(cancel.isConnected).toBe(true);
231
+ expect(cancel.closest("[hidden]")).not.toBeNull();
232
+ expect(document.activeElement).toBe(
233
+ screen.getByRole("textbox", { name: "Main composer" }),
234
+ );
235
+ });
236
+
237
+ it("does not cancel or restore focus when the consumer prevents the action", () => {
238
+ const { cancel } = setup({
239
+ onCancelClick: (event) => event.preventDefault(),
240
+ });
241
+ act(() => cancel.focus());
242
+ fireEvent.click(cancel);
243
+ expect(
244
+ screen.queryByRole("textbox", { name: "Edit composer" }),
245
+ ).not.toBeNull();
246
+ expect(document.activeElement).toBe(cancel);
247
+ });
248
+
249
+ it("leaves focus and editing unchanged when cancellation throws", () => {
250
+ const error = new Error("Cancellation failed");
251
+ const cancel = vi.fn(() => {
252
+ throw error;
253
+ });
254
+ cancelOverride.mockReturnValue({
255
+ disabled: false,
256
+ cancel,
257
+ });
258
+ const { cancel: button } = setup();
259
+ act(() => button.focus());
260
+ const onError = (event: ErrorEvent) => {
261
+ if (event.error === error) event.preventDefault();
262
+ };
263
+ window.addEventListener("error", onError);
264
+ try {
265
+ fireEvent.click(button);
266
+ expect(cancel).toHaveBeenCalledOnce();
267
+ expect(
268
+ screen.queryByRole("textbox", { name: "Edit composer" }),
269
+ ).not.toBeNull();
270
+ expect(document.activeElement).toBe(button);
271
+ } finally {
272
+ window.removeEventListener("error", onError);
273
+ }
274
+ });
275
+
276
+ it.each(["input", "button"])(
277
+ "does not restore focus after pointer cancellation with focus on the %s",
278
+ (source) => {
279
+ const { input, cancel } = setup();
280
+ act(() => (source === "input" ? input : cancel).focus());
281
+ fireEvent.click(cancel, { detail: 1 });
282
+ expect(
283
+ screen.queryByRole("textbox", { name: "Edit composer" }),
284
+ ).toBeNull();
285
+ expect(document.activeElement).toBe(document.body);
286
+ },
287
+ );
288
+
289
+ it("does not move focus when cancelling a running thread", () => {
290
+ const onCancel = vi.fn(async () => {});
291
+ render(
292
+ <Provider isRunning onCancel={onCancel}>
293
+ <ThreadPrimitiveRoot>
294
+ <ComposerPrimitiveRoot>
295
+ <textarea aria-label="Main composer" />
296
+ <ComposerPrimitiveCancel>Stop run</ComposerPrimitiveCancel>
297
+ </ComposerPrimitiveRoot>
298
+ </ThreadPrimitiveRoot>
299
+ </Provider>,
300
+ );
301
+ const cancel = screen.getByRole("button", { name: "Stop run" });
302
+ act(() => cancel.focus());
303
+ fireEvent.click(cancel);
304
+ expect(onCancel).toHaveBeenCalledOnce();
305
+ expect(document.activeElement).toBe(cancel);
306
+ });
307
+ });
@@ -5,12 +5,16 @@ import {
5
5
  type ActionButtonProps,
6
6
  createActionButton,
7
7
  } from "../../utils/createActionButton";
8
+ import type { MouseEvent } from "react";
8
9
  import { useComposerCancel as useComposerCancelBehavior } from "@assistant-ui/core/react";
10
+ import { useComposerCancelWithFocus } from "./useComposerCancelWithFocus";
9
11
 
10
12
  const useComposerCancel = () => {
11
13
  const { disabled, cancel } = useComposerCancelBehavior();
14
+ const cancelWithFocus = useComposerCancelWithFocus(cancel);
12
15
  if (disabled) return null;
13
- return cancel;
16
+ return (event: MouseEvent<HTMLButtonElement>) =>
17
+ cancelWithFocus(event.detail === 0 ? event.currentTarget : null);
14
18
  };
15
19
 
16
20
  export namespace ComposerPrimitiveCancel {
@@ -27,6 +31,9 @@ export namespace ComposerPrimitiveCancel {
27
31
  *
28
32
  * This component automatically handles the cancel functionality and is disabled
29
33
  * when canceling is not available.
34
+ * Keyboard or assistive-technology activation after editing returns focus to the
35
+ * main thread composer when available, unless a handler has moved focus elsewhere.
36
+ * Pointer activation preserves the existing focus behavior.
30
37
  *
31
38
  * @example
32
39
  * ```tsx
@@ -27,6 +27,7 @@ import { useAui } from "@assistant-ui/store";
27
27
  import { flushTapSync } from "@assistant-ui/tap";
28
28
  import { useComposerInputPluginRegistryOptional } from "./ComposerInputPluginContext";
29
29
  import { useComposerCompactContextOptional } from "./ComposerCompactContext";
30
+ import { useComposerCancelWithFocus } from "./useComposerCancelWithFocus";
30
31
  import {
31
32
  useComposerInputDisabled,
32
33
  useComposerInputValue,
@@ -50,21 +51,32 @@ export namespace ComposerPrimitiveInput {
50
51
  render?: ReactElement | undefined;
51
52
  /**
52
53
  * Whether to cancel message composition when Escape is pressed.
54
+ * After cancelling an edit, focus returns to the main thread composer when
55
+ * available, unless a handler has moved focus elsewhere.
53
56
  * @default true
54
57
  */
55
58
  cancelOnEscape?: boolean | undefined;
56
59
  /**
57
60
  * Whether to automatically focus the input when a new run starts.
61
+ *
62
+ * @deprecated Experimental since 2024-10-12. Not scheduled for removal; the API may change in any release.
63
+ *
58
64
  * @default true
59
65
  */
60
66
  unstable_focusOnRunStart?: boolean | undefined;
61
67
  /**
62
68
  * Whether to automatically focus the input when scrolling to bottom.
69
+ *
70
+ * @deprecated Experimental since 2024-10-12. Not scheduled for removal; the API may change in any release.
71
+ *
63
72
  * @default true
64
73
  */
65
74
  unstable_focusOnScrollToBottom?: boolean | undefined;
66
75
  /**
67
76
  * Whether to automatically focus the input when switching threads.
77
+ *
78
+ * @deprecated Experimental since 2024-10-12. Not scheduled for removal; the API may change in any release.
79
+ *
68
80
  * @default true
69
81
  */
70
82
  unstable_focusOnThreadSwitched?: boolean | undefined;
@@ -73,6 +85,9 @@ export namespace ComposerPrimitiveInput {
73
85
  * instead of submitting, detected via
74
86
  * `(pointer: coarse) and (not (any-pointer: fine))`. Only takes effect
75
87
  * when `submitMode` resolves to `"enter"`.
88
+ *
89
+ * @deprecated Experimental since 2026-05-24. Not scheduled for removal; the API may change in any release.
90
+ *
76
91
  * @default false
77
92
  */
78
93
  unstable_insertNewlineOnTouchEnter?: boolean | undefined;
@@ -119,7 +134,7 @@ export namespace ComposerPrimitiveInput {
119
134
  * keyboard shortcuts, file paste support, and intelligent focus management.
120
135
  * It integrates with the composer context to manage message state and submission.
121
136
  *
122
- * When rendered inside `Unstable_TriggerPopoverRoot` and a popover is open, the
137
+ * When rendered inside `TriggerPopoverRoot` and a popover is open, the
123
138
  * underlying `<textarea>` automatically receives `aria-controls`,
124
139
  * `aria-expanded`, `aria-haspopup`, and `aria-activedescendant` for the
125
140
  * combobox relationship. These computed attributes override user-provided
@@ -192,6 +207,9 @@ export const ComposerPrimitiveInput = forwardRef<
192
207
  const value = useComposerInputValue();
193
208
  const isDisabled = useComposerInputDisabled(disabledProp);
194
209
  const textareaRef = useRef<HTMLTextAreaElement>(null);
210
+ const cancel = useComposerCancelWithFocus(
211
+ useCallback(() => aui.composer.cancel(), [aui]),
212
+ );
195
213
  const ref = useComposedRefs(forwardedRef, textareaRef);
196
214
  // suppress text/cursor broadcasts during IME composition
197
215
  const compositionRef = useRef(false);
@@ -214,7 +232,7 @@ export const ComposerPrimitiveInput = forwardRef<
214
232
 
215
233
  const composer = aui.composer;
216
234
  if (composer.getState().canCancel) {
217
- composer.cancel();
235
+ cancel(textareaRef.current);
218
236
  e.preventDefault();
219
237
  }
220
238
  });
@@ -14,11 +14,10 @@ import {
14
14
  } from "react";
15
15
  import { useComposerSend } from "./ComposerSend";
16
16
  import { ComposerCompactContext } from "./ComposerCompactContext";
17
-
18
- const CONTENT_EDITABLE_SELECTOR =
19
- "[contenteditable]:not([contenteditable='false'])";
20
-
21
- const COMPOSER_INPUT_SELECTOR = `textarea:not(:disabled), ${CONTENT_EDITABLE_SELECTOR}`;
17
+ import {
18
+ COMPOSER_INPUT_SELECTOR,
19
+ CONTENT_EDITABLE_SELECTOR,
20
+ } from "./composerInputSelector";
22
21
 
23
22
  // Keeps tabindex="-1": roving-tabindex widgets click-focus items at -1.
24
23
  const INTERACTIVE_ELEMENT_SELECTOR = [
@@ -82,6 +81,7 @@ export const ComposerPrimitiveRoot = forwardRef<
82
81
  ComposerPrimitiveRoot.Props
83
82
  >(({ onSubmit, onMouseDown, compact, ...rest }, forwardedRef) => {
84
83
  const send = useComposerSend();
84
+ const composerType = useAuiState((s) => s.composer.type);
85
85
 
86
86
  const [multiline, setMultiline] = useState(false);
87
87
  const compactContext = useMemo(() => ({ setMultiline }), []);
@@ -126,6 +126,7 @@ export const ComposerPrimitiveRoot = forwardRef<
126
126
  <ComposerCompactContext.Provider value={compact ? compactContext : null}>
127
127
  <Primitive.form
128
128
  {...rest}
129
+ data-aui-composer-type={composerType}
129
130
  data-compact={isCompact ? "" : undefined}
130
131
  ref={forwardedRef}
131
132
  onSubmit={composeEventHandlers(onSubmit, handleSubmit)}
@@ -0,0 +1,4 @@
1
+ export const CONTENT_EDITABLE_SELECTOR =
2
+ "[contenteditable]:not([contenteditable='false'])";
3
+
4
+ export const COMPOSER_INPUT_SELECTOR = `textarea:not(:disabled), ${CONTENT_EDITABLE_SELECTOR}`;
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useAui, useAuiState } from "@assistant-ui/store";
4
4
  import { useResource } from "@assistant-ui/tap";
5
- import type { Unstable_TriggerAdapter } from "@assistant-ui/core";
5
+ import type { TriggerAdapter } from "@assistant-ui/core";
6
6
  import {
7
7
  createContext,
8
8
  forwardRef,
@@ -74,7 +74,7 @@ export namespace ComposerPrimitiveTriggerPopover {
74
74
  /** Overrides trigger detection for both textarea and Lexical inputs. `endOffset` is the exclusive replace bound. */
75
75
  readonly matcher?: TriggerMatcher | undefined;
76
76
  /** Adapter providing categories and items. */
77
- readonly adapter?: Unstable_TriggerAdapter | undefined;
77
+ readonly adapter?: TriggerAdapter | undefined;
78
78
  /** Whether the adapter is resolving items, surfaced to the popover scope for async sources. @default false */
79
79
  readonly isLoading?: boolean | undefined;
80
80
  };
@@ -89,22 +89,22 @@ export namespace ComposerPrimitiveTriggerPopover {
89
89
  * `<TriggerPopover.Directive>` or `<TriggerPopover.Action>` as a child. Without
90
90
  * a behavior the trigger stays closed.
91
91
  *
92
- * Must be placed inside `ComposerPrimitive.Unstable_TriggerPopoverRoot`.
92
+ * Must be placed inside `ComposerPrimitive.TriggerPopoverRoot`.
93
93
  *
94
94
  * @example
95
95
  * ```tsx
96
- * <ComposerPrimitive.Unstable_TriggerPopover
96
+ * <ComposerPrimitive.TriggerPopover
97
97
  * char="@"
98
98
  * adapter={mentionAdapter}
99
99
  * >
100
- * <ComposerPrimitive.Unstable_TriggerPopover.Directive formatter={formatter} />
101
- * <ComposerPrimitive.Unstable_TriggerPopoverCategories>
100
+ * <ComposerPrimitive.TriggerPopover.Directive formatter={formatter} />
101
+ * <ComposerPrimitive.TriggerPopoverCategories>
102
102
  * {(cats) => cats.map(...)}
103
- * </ComposerPrimitive.Unstable_TriggerPopoverCategories>
104
- * <ComposerPrimitive.Unstable_TriggerPopoverItems>
103
+ * </ComposerPrimitive.TriggerPopoverCategories>
104
+ * <ComposerPrimitive.TriggerPopoverItems>
105
105
  * {(items) => items.map(...)}
106
- * </ComposerPrimitive.Unstable_TriggerPopoverItems>
107
- * </ComposerPrimitive.Unstable_TriggerPopover>
106
+ * </ComposerPrimitive.TriggerPopoverItems>
107
+ * </ComposerPrimitive.TriggerPopover>
108
108
  * ```
109
109
  */
110
110
  export const ComposerPrimitiveTriggerPopover = forwardRef<
@@ -196,7 +196,7 @@ export const ComposerPrimitiveTriggerPopover = forwardRef<
196
196
  ...(behavior ? { behavior } : {}),
197
197
  resource: getResource(),
198
198
  });
199
- }, [root, char, hasMatcher, behavior]);
199
+ }, [root, char, hasMatcher, registeredMatcher, behavior]);
200
200
 
201
201
  const pluginRegistry = useComposerInputPluginRegistryOptional();
202
202
  useEffect(() => {
@@ -1,20 +1,17 @@
1
1
  "use client";
2
2
 
3
- import type {
4
- Unstable_DirectiveFormatter,
5
- Unstable_TriggerItem,
6
- } from "@assistant-ui/core";
7
- import { unstable_defaultDirectiveFormatter } from "@assistant-ui/core";
3
+ import type { DirectiveFormatter, TriggerItem } from "@assistant-ui/core";
4
+ import { defaultDirectiveFormatter } from "@assistant-ui/core";
8
5
  import { useEffect, useInsertionEffect, useRef, type FC } from "react";
9
6
  import { useTriggerBehaviorRegistration } from "./TriggerPopover";
10
7
  import type { TriggerBehavior } from "./triggerSelectionResource";
11
8
 
12
9
  export namespace ComposerPrimitiveTriggerPopoverAction {
13
10
  export type Props = {
14
- /** Defaults to `unstable_defaultDirectiveFormatter`. */
15
- readonly formatter?: Unstable_DirectiveFormatter | undefined;
11
+ /** Defaults to `defaultDirectiveFormatter`. */
12
+ readonly formatter?: DirectiveFormatter | undefined;
16
13
  /** Fires the moment an item is selected; runs regardless of `removeOnExecute`. */
17
- readonly onExecute: (item: Unstable_TriggerItem) => void;
14
+ readonly onExecute: (item: TriggerItem) => void;
18
15
  /** When true, strips the trigger text after executing. Defaults to `false` (keeps audit-trail chip). */
19
16
  readonly removeOnExecute?: boolean | undefined;
20
17
  };
@@ -25,16 +22,16 @@ export namespace ComposerPrimitiveTriggerPopoverAction {
25
22
  * optionally leaving a directive chip behind as an audit trail. Render exactly
26
23
  * one behavior sub-primitive per `<TriggerPopover>`.
27
24
  *
28
- * Exposed as `ComposerPrimitive.Unstable_TriggerPopover.Action`.
25
+ * Exposed as `ComposerPrimitive.TriggerPopover.Action`.
29
26
  *
30
27
  * @example
31
28
  * ```tsx
32
- * <ComposerPrimitive.Unstable_TriggerPopover char="/" adapter={slashAdapter}>
33
- * <ComposerPrimitive.Unstable_TriggerPopover.Action
29
+ * <ComposerPrimitive.TriggerPopover char="/" adapter={slashAdapter}>
30
+ * <ComposerPrimitive.TriggerPopover.Action
34
31
  * onExecute={(item) => commandHandlers[item.id]?.()}
35
32
  * removeOnExecute={false}
36
33
  * />
37
- * </ComposerPrimitive.Unstable_TriggerPopover>
34
+ * </ComposerPrimitive.TriggerPopover>
38
35
  * ```
39
36
  */
40
37
  export const ComposerPrimitiveTriggerPopoverAction: FC<
@@ -49,7 +46,7 @@ export const ComposerPrimitiveTriggerPopoverAction: FC<
49
46
  useEffect(() => {
50
47
  const behavior: TriggerBehavior = {
51
48
  kind: "action",
52
- formatter: formatter ?? unstable_defaultDirectiveFormatter,
49
+ formatter: formatter ?? defaultDirectiveFormatter,
53
50
  onExecute: (item) => onExecuteRef.current(item),
54
51
  ...(removeOnExecute !== undefined ? { removeOnExecute } : {}),
55
52
  };
@@ -10,7 +10,7 @@ import {
10
10
  } from "react";
11
11
  import { composeEventHandlers } from "radix-ui/internal";
12
12
  import { useTriggerPopoverScopeContext } from "./TriggerPopover";
13
- import type { Unstable_TriggerCategory } from "@assistant-ui/core";
13
+ import type { TriggerCategory } from "@assistant-ui/core";
14
14
 
15
15
  export namespace ComposerPrimitiveTriggerPopoverCategories {
16
16
  export type Element = ComponentRef<typeof Primitive.div>;
@@ -18,7 +18,7 @@ export namespace ComposerPrimitiveTriggerPopoverCategories {
18
18
  ComponentPropsWithoutRef<typeof Primitive.div>,
19
19
  "children"
20
20
  > & {
21
- children: (categories: readonly Unstable_TriggerCategory[]) => ReactNode;
21
+ children: (categories: readonly TriggerCategory[]) => ReactNode;
22
22
  };
23
23
  }
24
24
 
@@ -1,20 +1,17 @@
1
1
  "use client";
2
2
 
3
- import type {
4
- Unstable_DirectiveFormatter,
5
- Unstable_TriggerItem,
6
- } from "@assistant-ui/core";
7
- import { unstable_defaultDirectiveFormatter } from "@assistant-ui/core";
3
+ import type { DirectiveFormatter, TriggerItem } from "@assistant-ui/core";
4
+ import { defaultDirectiveFormatter } from "@assistant-ui/core";
8
5
  import { useEffect, useInsertionEffect, useRef, type FC } from "react";
9
6
  import { useTriggerBehaviorRegistration } from "./TriggerPopover";
10
7
  import type { TriggerBehavior } from "./triggerSelectionResource";
11
8
 
12
9
  export namespace ComposerPrimitiveTriggerPopoverDirective {
13
10
  export type Props = {
14
- /** Defaults to `unstable_defaultDirectiveFormatter`. */
15
- readonly formatter?: Unstable_DirectiveFormatter | undefined;
11
+ /** Defaults to `defaultDirectiveFormatter`. */
12
+ readonly formatter?: DirectiveFormatter | undefined;
16
13
  /** Fires after an item has been inserted into the composer. */
17
- readonly onInserted?: ((item: Unstable_TriggerItem) => void) | undefined;
14
+ readonly onInserted?: ((item: TriggerItem) => void) | undefined;
18
15
  };
19
16
  }
20
17
 
@@ -22,16 +19,16 @@ export namespace ComposerPrimitiveTriggerPopoverDirective {
22
19
  * Configures a `<TriggerPopover>` to insert a directive chip when an item is
23
20
  * selected. Render exactly one behavior sub-primitive per `<TriggerPopover>`.
24
21
  *
25
- * Exposed as `ComposerPrimitive.Unstable_TriggerPopover.Directive`.
22
+ * Exposed as `ComposerPrimitive.TriggerPopover.Directive`.
26
23
  *
27
24
  * @example
28
25
  * ```tsx
29
- * <ComposerPrimitive.Unstable_TriggerPopover char="@" adapter={mentionAdapter}>
30
- * <ComposerPrimitive.Unstable_TriggerPopover.Directive
31
- * formatter={unstable_defaultDirectiveFormatter}
26
+ * <ComposerPrimitive.TriggerPopover char="@" adapter={mentionAdapter}>
27
+ * <ComposerPrimitive.TriggerPopover.Directive
28
+ * formatter={defaultDirectiveFormatter}
32
29
  * onInserted={(item) => track("mention", item.id)}
33
30
  * />
34
- * </ComposerPrimitive.Unstable_TriggerPopover>
31
+ * </ComposerPrimitive.TriggerPopover>
35
32
  * ```
36
33
  */
37
34
  export const ComposerPrimitiveTriggerPopoverDirective: FC<
@@ -46,7 +43,7 @@ export const ComposerPrimitiveTriggerPopoverDirective: FC<
46
43
  useEffect(() => {
47
44
  const behavior: TriggerBehavior = {
48
45
  kind: "directive",
49
- formatter: formatter ?? unstable_defaultDirectiveFormatter,
46
+ formatter: formatter ?? defaultDirectiveFormatter,
50
47
  onInserted: (item) => onInsertedRef.current?.(item),
51
48
  };
52
49
  return register(behavior);