@assistant-ui/react 0.15.25 → 0.15.27

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 (159) hide show
  1. package/dist/hooks/useToolCallElapsed.d.ts.map +1 -1
  2. package/dist/hooks/useToolCallElapsed.js +3 -2
  3. package/dist/hooks/useToolCallElapsed.js.map +1 -1
  4. package/dist/index.d.ts +31 -5
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  7. package/dist/mcp-apps/McpAppRenderer.js +3 -2
  8. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  9. package/dist/mcp-apps/app-frame.d.ts.map +1 -1
  10. package/dist/mcp-apps/app-frame.js +58 -48
  11. package/dist/mcp-apps/app-frame.js.map +1 -1
  12. package/dist/mcp-apps/bridge.d.ts.map +1 -1
  13. package/dist/mcp-apps/bridge.js +28 -3
  14. package/dist/mcp-apps/bridge.js.map +1 -1
  15. package/dist/mcp-apps/csp.d.ts +4 -0
  16. package/dist/mcp-apps/csp.d.ts.map +1 -0
  17. package/dist/mcp-apps/csp.js +93 -0
  18. package/dist/mcp-apps/csp.js.map +1 -0
  19. package/dist/mcp-apps/index.d.ts +1 -1
  20. package/dist/mcp-apps/index.d.ts.map +1 -1
  21. package/dist/mcp-apps/types.d.ts +19 -1
  22. package/dist/mcp-apps/types.d.ts.map +1 -1
  23. package/dist/mcp-apps/types.js.map +1 -1
  24. package/dist/primitives/assistantModal/AssistantModalRoot.d.ts +1 -0
  25. package/dist/primitives/assistantModal/AssistantModalRoot.d.ts.map +1 -1
  26. package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
  27. package/dist/primitives/attachment.d.ts +3 -1
  28. package/dist/primitives/attachment.d.ts.map +1 -1
  29. package/dist/primitives/attachment.js.map +1 -1
  30. package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
  31. package/dist/primitives/composer/ComposerAttachmentDropzone.js +14 -12
  32. package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
  33. package/dist/primitives/composer/ComposerCancel.d.ts +5 -1
  34. package/dist/primitives/composer/ComposerCancel.d.ts.map +1 -1
  35. package/dist/primitives/composer/ComposerCancel.js +14 -1
  36. package/dist/primitives/composer/ComposerCancel.js.map +1 -1
  37. package/dist/primitives/composer/ComposerInput.d.ts +14 -0
  38. package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
  39. package/dist/primitives/composer/ComposerInput.js +4 -3
  40. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  41. package/dist/primitives/composer/ComposerRoot.d.ts.map +1 -1
  42. package/dist/primitives/composer/ComposerRoot.js +24 -19
  43. package/dist/primitives/composer/ComposerRoot.js.map +1 -1
  44. package/dist/primitives/composer/composerInputSelector.d.ts +3 -0
  45. package/dist/primitives/composer/composerInputSelector.d.ts.map +1 -0
  46. package/dist/primitives/composer/composerInputSelector.js +7 -0
  47. package/dist/primitives/composer/composerInputSelector.js.map +1 -0
  48. package/dist/primitives/composer/useComposerCancelWithFocus.d.ts +2 -0
  49. package/dist/primitives/composer/useComposerCancelWithFocus.d.ts.map +1 -0
  50. package/dist/primitives/composer/useComposerCancelWithFocus.js +42 -0
  51. package/dist/primitives/composer/useComposerCancelWithFocus.js.map +1 -0
  52. package/dist/primitives/composer.d.ts +35 -14
  53. package/dist/primitives/composer.d.ts.map +1 -1
  54. package/dist/primitives/composer.js.map +1 -1
  55. package/dist/primitives/message/MessagePartsGrouped.d.ts +8 -1
  56. package/dist/primitives/message/MessagePartsGrouped.d.ts.map +1 -1
  57. package/dist/primitives/message/MessagePartsGrouped.js +58 -278
  58. package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
  59. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.d.ts.map +1 -1
  60. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js +5 -2
  61. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js.map +1 -1
  62. package/dist/primitives/thread/ThreadViewport.d.ts.map +1 -1
  63. package/dist/primitives/thread/ThreadViewport.js +4 -3
  64. package/dist/primitives/thread/ThreadViewport.js.map +1 -1
  65. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js +2 -2
  66. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js.map +1 -1
  67. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  68. package/dist/primitives/thread/useThreadViewportAutoScroll.js +90 -21
  69. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  70. package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
  71. package/dist/sandbox-host/SandboxHost.js +3 -2
  72. package/dist/sandbox-host/SandboxHost.js.map +1 -1
  73. package/dist/unstable/useComposerInput.d.ts +9 -4
  74. package/dist/unstable/useComposerInput.d.ts.map +1 -1
  75. package/dist/unstable/useComposerInput.js +6 -4
  76. package/dist/unstable/useComposerInput.js.map +1 -1
  77. package/dist/unstable/useComposerInputHistory.d.ts +3 -2
  78. package/dist/unstable/useComposerInputHistory.d.ts.map +1 -1
  79. package/dist/unstable/useComposerInputHistory.js +2 -2
  80. package/dist/unstable/useComposerInputHistory.js.map +1 -1
  81. package/dist/unstable/useLiveCompletionAdapter.d.ts +3 -2
  82. package/dist/unstable/useLiveCompletionAdapter.d.ts.map +1 -1
  83. package/dist/unstable/useLiveCompletionAdapter.js +8 -7
  84. package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
  85. package/dist/unstable/useMentionAdapter.d.ts +12 -3
  86. package/dist/unstable/useMentionAdapter.d.ts.map +1 -1
  87. package/dist/unstable/useMentionAdapter.js +2 -2
  88. package/dist/unstable/useMentionAdapter.js.map +1 -1
  89. package/dist/unstable/useMessageStallDetection.d.ts +4 -2
  90. package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
  91. package/dist/unstable/useMessageStallDetection.js +2 -2
  92. package/dist/unstable/useMessageStallDetection.js.map +1 -1
  93. package/dist/unstable/useSlashCommandAdapter.d.ts +5 -2
  94. package/dist/unstable/useSlashCommandAdapter.d.ts.map +1 -1
  95. package/dist/unstable/useSlashCommandAdapter.js +5 -4
  96. package/dist/unstable/useSlashCommandAdapter.js.map +1 -1
  97. package/dist/unstable/webmcp/useWebMcpProvider.d.ts +4 -0
  98. package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -1
  99. package/dist/unstable/webmcp/useWebMcpProvider.js +2 -0
  100. package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -1
  101. package/dist/utils/getSelectionMessageId.d.ts.map +1 -1
  102. package/dist/utils/getSelectionMessageId.js +31 -8
  103. package/dist/utils/getSelectionMessageId.js.map +1 -1
  104. package/dist/utils/hooks/useOnResizeContent.d.ts.map +1 -1
  105. package/dist/utils/hooks/useOnResizeContent.js +2 -22
  106. package/dist/utils/hooks/useOnResizeContent.js.map +1 -1
  107. package/dist/utils/useIsomorphicLayoutEffect.d.ts +3 -0
  108. package/dist/utils/useIsomorphicLayoutEffect.d.ts.map +1 -0
  109. package/dist/utils/useIsomorphicLayoutEffect.js +6 -0
  110. package/dist/utils/useIsomorphicLayoutEffect.js.map +1 -0
  111. package/package.json +10 -10
  112. package/src/hooks/useToolCallElapsed.test.tsx +4 -2
  113. package/src/hooks/useToolCallElapsed.ts +3 -2
  114. package/src/index.ts +20 -2
  115. package/src/mcp-apps/McpAppRenderer.test.tsx +7 -2
  116. package/src/mcp-apps/McpAppRenderer.tsx +3 -9
  117. package/src/mcp-apps/app-frame.test.tsx +102 -6
  118. package/src/mcp-apps/app-frame.tsx +7 -10
  119. package/src/mcp-apps/bridge.test.ts +154 -0
  120. package/src/mcp-apps/bridge.ts +51 -9
  121. package/src/mcp-apps/csp.test.ts +112 -0
  122. package/src/mcp-apps/csp.ts +124 -0
  123. package/src/mcp-apps/index.ts +1 -0
  124. package/src/mcp-apps/types.ts +16 -1
  125. package/src/model-context/frame/AssistantFrame.test.ts +45 -0
  126. package/src/model-context/frame/SPEC_AssistantFrame.md +1 -0
  127. package/src/primitives/assistantModal/AssistantModalRoot.tsx +1 -0
  128. package/src/primitives/attachment.ts +4 -1
  129. package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +32 -1
  130. package/src/primitives/composer/ComposerAttachmentDropzone.tsx +14 -12
  131. package/src/primitives/composer/ComposerCancel.focus.test.tsx +307 -0
  132. package/src/primitives/composer/ComposerCancel.ts +8 -1
  133. package/src/primitives/composer/ComposerDisabledThread.test.tsx +121 -0
  134. package/src/primitives/composer/ComposerInput.tsx +19 -1
  135. package/src/primitives/composer/ComposerRoot.tsx +6 -5
  136. package/src/primitives/composer/composerInputSelector.ts +4 -0
  137. package/src/primitives/composer/useComposerCancelWithFocus.ts +57 -0
  138. package/src/primitives/composer.ts +56 -21
  139. package/src/primitives/message/MessagePartsGrouped.test.tsx +106 -2
  140. package/src/primitives/message/MessagePartsGrouped.tsx +36 -134
  141. package/src/primitives/selectionToolbar/SelectionToolbarRoot.test.tsx +20 -12
  142. package/src/primitives/selectionToolbar/SelectionToolbarRoot.tsx +6 -2
  143. package/src/primitives/thread/ThreadViewport.tsx +3 -3
  144. package/src/primitives/thread/topAnchor/useTopAnchorReserve.ts +2 -2
  145. package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +666 -63
  146. package/src/primitives/thread/useThreadViewportAutoScroll.ts +201 -73
  147. package/src/sandbox-host/SandboxHost.tsx +3 -8
  148. package/src/unstable/useComposerInput.ts +9 -4
  149. package/src/unstable/useComposerInputHistory.ts +3 -2
  150. package/src/unstable/useLiveCompletionAdapter.ts +9 -14
  151. package/src/unstable/useMentionAdapter.ts +12 -3
  152. package/src/unstable/useMessageStallDetection.ts +4 -2
  153. package/src/unstable/useSlashCommandAdapter.ts +8 -4
  154. package/src/unstable/webmcp/useWebMcpProvider.ts +4 -0
  155. package/src/utils/getSelectionMessageId.test.ts +231 -0
  156. package/src/utils/getSelectionMessageId.ts +45 -7
  157. package/src/utils/hooks/useOnResizeContent.ts +2 -29
  158. package/src/utils/useIsomorphicLayoutEffect.test.tsx +21 -0
  159. 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
@@ -0,0 +1,121 @@
1
+ /** @vitest-environment jsdom */
2
+ import {
3
+ act,
4
+ cleanup,
5
+ fireEvent,
6
+ render,
7
+ screen,
8
+ } from "@testing-library/react";
9
+ import { afterEach, describe, expect, it, vi } from "vitest";
10
+ import {
11
+ AssistantRuntimeProvider,
12
+ useExternalStoreRuntime,
13
+ } from "@assistant-ui/core/react";
14
+ import type { AttachmentAdapter, ThreadMessage } from "@assistant-ui/core";
15
+ import { ComposerPrimitiveAddAttachment } from "./ComposerAddAttachment";
16
+ import { ComposerPrimitiveInput } from "./ComposerInput";
17
+ import { ComposerPrimitiveRoot } from "./ComposerRoot";
18
+ import { ComposerPrimitiveSend } from "./ComposerSend";
19
+ import { ThreadPrimitiveViewportProvider } from "../../context/providers/ThreadViewportProvider";
20
+
21
+ const makeAttachmentAdapter = () => {
22
+ const add = vi.fn(async ({ file }: { file: File }) => ({
23
+ id: file.name,
24
+ type: "file" as const,
25
+ name: file.name,
26
+ contentType: file.type,
27
+ file,
28
+ status: {
29
+ type: "requires-action" as const,
30
+ reason: "composer-send" as const,
31
+ },
32
+ }));
33
+ const adapter: AttachmentAdapter = {
34
+ accept: "*",
35
+ add,
36
+ remove: async () => {},
37
+ send: async (attachment) => ({
38
+ ...attachment,
39
+ status: { type: "complete" },
40
+ content: [{ type: "text", text: "file body" }],
41
+ }),
42
+ };
43
+ return { adapter, add };
44
+ };
45
+
46
+ const App = ({
47
+ isDisabled,
48
+ onNew,
49
+ attachments,
50
+ }: {
51
+ isDisabled: boolean;
52
+ onNew: () => Promise<void>;
53
+ attachments: AttachmentAdapter;
54
+ }) => {
55
+ const runtime = useExternalStoreRuntime<ThreadMessage>({
56
+ messages: [],
57
+ isDisabled,
58
+ onNew,
59
+ adapters: { attachments },
60
+ });
61
+ return (
62
+ <AssistantRuntimeProvider runtime={runtime}>
63
+ <ThreadPrimitiveViewportProvider>
64
+ <ComposerPrimitiveRoot>
65
+ <ComposerPrimitiveInput aria-label="input" />
66
+ <ComposerPrimitiveAddAttachment>
67
+ attach
68
+ </ComposerPrimitiveAddAttachment>
69
+ <ComposerPrimitiveSend>send</ComposerPrimitiveSend>
70
+ </ComposerPrimitiveRoot>
71
+ </ThreadPrimitiveViewportProvider>
72
+ </AssistantRuntimeProvider>
73
+ );
74
+ };
75
+
76
+ const button = (name: string) =>
77
+ screen.getByRole("button", { name }) as HTMLButtonElement;
78
+
79
+ const settle = () =>
80
+ act(async () => {
81
+ await new Promise((resolve) => setTimeout(resolve, 0));
82
+ });
83
+
84
+ afterEach(() => {
85
+ cleanup();
86
+ document.body.querySelectorAll('input[type="file"]').forEach((input) => {
87
+ input.remove();
88
+ });
89
+ });
90
+
91
+ describe("composer on a disabled thread", () => {
92
+ it("blocks sending a draft that was entered before the thread was disabled", async () => {
93
+ const onNew = vi.fn(async () => {});
94
+ const { adapter } = makeAttachmentAdapter();
95
+ const view = render(
96
+ <App isDisabled={false} onNew={onNew} attachments={adapter} />,
97
+ );
98
+ fireEvent.change(screen.getByRole("textbox", { name: "input" }), {
99
+ target: { value: "hello" },
100
+ });
101
+
102
+ view.rerender(<App isDisabled onNew={onNew} attachments={adapter} />);
103
+ await settle();
104
+
105
+ expect(button("send").disabled).toBe(true);
106
+ fireEvent.click(button("send"));
107
+ await settle();
108
+ expect(onNew).not.toHaveBeenCalled();
109
+ });
110
+
111
+ it("disables attachment selection", () => {
112
+ const onNew = vi.fn(async () => {});
113
+ const { adapter, add } = makeAttachmentAdapter();
114
+ render(<App isDisabled onNew={onNew} attachments={adapter} />);
115
+
116
+ expect(button("attach").disabled).toBe(true);
117
+ fireEvent.click(button("attach"));
118
+ expect(document.body.querySelector('input[type="file"]')).toBeNull();
119
+ expect(add).not.toHaveBeenCalled();
120
+ });
121
+ });
@@ -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;
@@ -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}`;
@@ -0,0 +1,57 @@
1
+ "use client";
2
+
3
+ import { useCallback } from "react";
4
+ import { useAui } from "@assistant-ui/store";
5
+ import { useThreadRootElementRef } from "../thread/ThreadRootElementContext";
6
+ import { COMPOSER_INPUT_SELECTOR } from "./composerInputSelector";
7
+
8
+ const COMPOSER_SELECTOR = "[data-aui-composer-type]";
9
+ const THREAD_SELECTOR = "[data-aui-thread-root]";
10
+
11
+ export const useComposerCancelWithFocus = (cancel: () => void) => {
12
+ const aui = useAui();
13
+ const threadRootRef = useThreadRootElementRef();
14
+
15
+ const cancelWithFocus = useCallback(
16
+ (source: HTMLElement | null) => {
17
+ const root = threadRootRef?.current;
18
+ const doc = source?.ownerDocument;
19
+ const active = doc?.activeElement;
20
+ const composer = source?.closest(COMPOSER_SELECTOR);
21
+ const shouldRestore =
22
+ aui.composer.getState().type === "edit" &&
23
+ root &&
24
+ active &&
25
+ composer?.getAttribute("data-aui-composer-type") === "edit" &&
26
+ composer.contains(active) &&
27
+ composer.closest(THREAD_SELECTOR) === root;
28
+ const candidates = shouldRestore
29
+ ? Array.from(
30
+ root.querySelectorAll<HTMLElement>(COMPOSER_INPUT_SELECTOR),
31
+ ).filter(
32
+ (input) =>
33
+ input.closest(THREAD_SELECTOR) === root &&
34
+ input
35
+ .closest(COMPOSER_SELECTOR)
36
+ ?.getAttribute("data-aui-composer-type") === "thread",
37
+ )
38
+ : [];
39
+
40
+ cancel();
41
+
42
+ if (!doc || !active || !shouldRestore) return;
43
+ if (doc.activeElement !== active && doc.activeElement !== doc.body)
44
+ return;
45
+
46
+ for (const input of candidates) {
47
+ if (!input.isConnected || input.closest("[hidden], [inert]")) continue;
48
+ input.focus({ preventScroll: true });
49
+ if (doc.activeElement !== active && doc.activeElement !== doc.body)
50
+ break;
51
+ }
52
+ },
53
+ [aui, cancel, threadRootRef],
54
+ );
55
+
56
+ return cancelWithFocus;
57
+ };