@assistant-ui/react 0.15.26 → 0.15.28

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 (37) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/mcp-apps/app-frame.js +2 -2
  4. package/dist/mcp-apps/app-frame.js.map +1 -1
  5. package/dist/mcp-apps/bridge.d.ts.map +1 -1
  6. package/dist/mcp-apps/bridge.js +28 -3
  7. package/dist/mcp-apps/bridge.js.map +1 -1
  8. package/dist/mcp-apps/index.d.ts +1 -1
  9. package/dist/mcp-apps/index.d.ts.map +1 -1
  10. package/dist/mcp-apps/types.d.ts +18 -1
  11. package/dist/mcp-apps/types.d.ts.map +1 -1
  12. package/dist/mcp-apps/types.js.map +1 -1
  13. package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
  14. package/dist/primitives/composer/ComposerAttachmentDropzone.js +14 -12
  15. package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
  16. package/dist/primitives/composer/ComposerRoot.d.ts.map +1 -1
  17. package/dist/primitives/composer/ComposerRoot.js +63 -55
  18. package/dist/primitives/composer/ComposerRoot.js.map +1 -1
  19. package/dist/unstable/useComposerInput.d.ts +2 -0
  20. package/dist/unstable/useComposerInput.d.ts.map +1 -1
  21. package/dist/unstable/useComposerInput.js +2 -0
  22. package/dist/unstable/useComposerInput.js.map +1 -1
  23. package/package.json +7 -7
  24. package/src/index.ts +1 -0
  25. package/src/mcp-apps/McpAppRenderer.test.tsx +7 -2
  26. package/src/mcp-apps/app-frame.test.tsx +64 -6
  27. package/src/mcp-apps/app-frame.tsx +2 -2
  28. package/src/mcp-apps/bridge.test.ts +154 -0
  29. package/src/mcp-apps/bridge.ts +51 -9
  30. package/src/mcp-apps/index.ts +1 -0
  31. package/src/mcp-apps/types.ts +15 -1
  32. package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +32 -1
  33. package/src/primitives/composer/ComposerAttachmentDropzone.tsx +14 -12
  34. package/src/primitives/composer/ComposerDisabledThread.test.tsx +121 -0
  35. package/src/primitives/composer/ComposerRoot.test.tsx +21 -1
  36. package/src/primitives/composer/ComposerRoot.tsx +8 -1
  37. package/src/unstable/useComposerInput.ts +2 -0
@@ -197,6 +197,160 @@ describe("createMcpAppBridge", () => {
197
197
  bridge.dispose();
198
198
  });
199
199
 
200
+ it("passes view initialization metadata to onInitialized", () => {
201
+ const { frame } = makeFrame();
202
+ const onInitialized = vi.fn();
203
+ const bridge = createMcpAppBridge({ frame, handlers: { onInitialized } });
204
+ const params = {
205
+ protocolVersion: "2026-01-26",
206
+ appInfo: { name: "example-app", version: "1.2.3" },
207
+ appCapabilities: {
208
+ availableDisplayModes: ["inline", "fullscreen"],
209
+ tools: { listChanged: true },
210
+ },
211
+ };
212
+
213
+ deliver(bridge, {
214
+ jsonrpc: "2.0",
215
+ id: 1,
216
+ method: "ui/initialize",
217
+ params,
218
+ });
219
+ expect(onInitialized).not.toHaveBeenCalled();
220
+ deliver(bridge, {
221
+ jsonrpc: "2.0",
222
+ method: "notifications/initialized",
223
+ });
224
+
225
+ expect(onInitialized).toHaveBeenCalledOnce();
226
+ expect(onInitialized).toHaveBeenCalledWith(params);
227
+ bridge.dispose();
228
+ });
229
+
230
+ it("omits malformed initialization fields and keeps the protocol fallback", async () => {
231
+ const { frame, captured } = makeFrame();
232
+ const onInitialized = vi.fn();
233
+ const bridge = createMcpAppBridge({ frame, handlers: { onInitialized } });
234
+
235
+ deliver(bridge, {
236
+ jsonrpc: "2.0",
237
+ id: 1,
238
+ method: "ui/initialize",
239
+ params: {
240
+ protocolVersion: 1,
241
+ appInfo: { name: "malformed" },
242
+ appCapabilities: { availableDisplayModes: "fullscreen" },
243
+ },
244
+ });
245
+ deliver(bridge, {
246
+ jsonrpc: "2.0",
247
+ method: "notifications/initialized",
248
+ });
249
+ await flush();
250
+
251
+ expect(onInitialized).toHaveBeenCalledOnce();
252
+ expect(onInitialized).toHaveBeenCalledWith({
253
+ appCapabilities: {},
254
+ });
255
+ expect((captured[0] as McpAppJsonRpcResponse).result).toMatchObject({
256
+ protocolVersion: MCP_APP_PROTOCOL_VERSION,
257
+ });
258
+ bridge.dispose();
259
+ });
260
+
261
+ it("keeps only the display modes the host recognizes", () => {
262
+ const { frame } = makeFrame();
263
+ const onInitialized = vi.fn();
264
+ const bridge = createMcpAppBridge({ frame, handlers: { onInitialized } });
265
+
266
+ deliver(bridge, {
267
+ jsonrpc: "2.0",
268
+ id: 1,
269
+ method: "ui/initialize",
270
+ params: {
271
+ appCapabilities: { availableDisplayModes: ["inline", "sidebar"] },
272
+ },
273
+ });
274
+ deliver(bridge, {
275
+ jsonrpc: "2.0",
276
+ method: "notifications/initialized",
277
+ });
278
+
279
+ expect(onInitialized).toHaveBeenCalledWith({
280
+ appCapabilities: { availableDisplayModes: ["inline"] },
281
+ });
282
+ bridge.dispose();
283
+ });
284
+
285
+ it("reads a sparse display mode list without walking its length", () => {
286
+ const { frame } = makeFrame();
287
+ const onInitialized = vi.fn();
288
+ const bridge = createMcpAppBridge({ frame, handlers: { onInitialized } });
289
+ const availableDisplayModes = ["inline"];
290
+ availableDisplayModes.length = 2 ** 32 - 1;
291
+
292
+ deliver(bridge, {
293
+ jsonrpc: "2.0",
294
+ id: 1,
295
+ method: "ui/initialize",
296
+ params: { appCapabilities: { availableDisplayModes } },
297
+ });
298
+ deliver(bridge, {
299
+ jsonrpc: "2.0",
300
+ method: "notifications/initialized",
301
+ });
302
+
303
+ expect(onInitialized).toHaveBeenCalledWith({
304
+ appCapabilities: { availableDisplayModes: ["inline"] },
305
+ });
306
+ bridge.dispose();
307
+ });
308
+
309
+ it("initializes when the display mode list carries its own methods", async () => {
310
+ const { frame, captured } = makeFrame();
311
+ const onInitialized = vi.fn();
312
+ const bridge = createMcpAppBridge({ frame, handlers: { onInitialized } });
313
+
314
+ deliver(bridge, {
315
+ jsonrpc: "2.0",
316
+ id: 1,
317
+ method: "ui/initialize",
318
+ params: {
319
+ appCapabilities: {
320
+ availableDisplayModes: Object.assign(["inline", "pip"], {
321
+ filter: 0,
322
+ constructor: 0,
323
+ }),
324
+ },
325
+ },
326
+ });
327
+ deliver(bridge, {
328
+ jsonrpc: "2.0",
329
+ method: "notifications/initialized",
330
+ });
331
+ await flush();
332
+
333
+ expect((captured[0] as McpAppJsonRpcResponse).error).toBeUndefined();
334
+ expect(onInitialized).toHaveBeenCalledWith({
335
+ appCapabilities: { availableDisplayModes: ["inline", "pip"] },
336
+ });
337
+ bridge.dispose();
338
+ });
339
+
340
+ it("calls onInitialized with empty params when ui/initialize never arrived", () => {
341
+ const { frame } = makeFrame();
342
+ const onInitialized = vi.fn();
343
+ const bridge = createMcpAppBridge({ frame, handlers: { onInitialized } });
344
+
345
+ deliver(bridge, {
346
+ jsonrpc: "2.0",
347
+ method: "notifications/initialized",
348
+ });
349
+
350
+ expect(onInitialized).toHaveBeenCalledWith({});
351
+ bridge.dispose();
352
+ });
353
+
200
354
  it("routes tools/call to handler", async () => {
201
355
  const { frame, captured } = makeFrame();
202
356
  const callTool = vi.fn().mockResolvedValue({ ok: true });
@@ -6,6 +6,7 @@ import {
6
6
  type McpAppDisplayMode,
7
7
  type McpAppHostContext,
8
8
  type McpAppHostInfo,
9
+ type McpAppInitializeParams,
9
10
  type McpAppJsonRpcMessage,
10
11
  type McpAppJsonRpcNotification,
11
12
  type McpAppJsonRpcRequest,
@@ -19,6 +20,50 @@ const VALID_DISPLAY_MODES = [
19
20
  "pip",
20
21
  ] as const satisfies readonly McpAppDisplayMode[];
21
22
 
23
+ function isMcpAppDisplayMode(value: unknown): value is McpAppDisplayMode {
24
+ return VALID_DISPLAY_MODES.some((mode) => mode === value);
25
+ }
26
+
27
+ function isAppInfo(
28
+ value: unknown,
29
+ ): value is NonNullable<McpAppInitializeParams["appInfo"]> {
30
+ return (
31
+ isRecord(value) &&
32
+ typeof value.name === "string" &&
33
+ typeof value.version === "string"
34
+ );
35
+ }
36
+
37
+ function getAppCapabilities(
38
+ value: unknown,
39
+ ): McpAppInitializeParams["appCapabilities"] {
40
+ if (!isRecord(value)) return undefined;
41
+ const { availableDisplayModes, ...rest } = value;
42
+ return {
43
+ ...rest,
44
+ // Object.values reads only the entries present, so a sparse list claiming a huge length stays cheap, and the list's own methods are never called.
45
+ ...(Array.isArray(availableDisplayModes)
46
+ ? {
47
+ availableDisplayModes: Object.values(availableDisplayModes).filter(
48
+ isMcpAppDisplayMode,
49
+ ),
50
+ }
51
+ : {}),
52
+ };
53
+ }
54
+
55
+ function getInitializeParams(params: unknown): McpAppInitializeParams {
56
+ if (!isRecord(params)) return {};
57
+ const appCapabilities = getAppCapabilities(params.appCapabilities);
58
+ return {
59
+ ...(isAppInfo(params.appInfo) ? { appInfo: params.appInfo } : {}),
60
+ ...(appCapabilities ? { appCapabilities } : {}),
61
+ ...(typeof params.protocolVersion === "string"
62
+ ? { protocolVersion: params.protocolVersion }
63
+ : {}),
64
+ };
65
+ }
66
+
22
67
  export type McpAppBridgeFrame = SandboxHostFrame;
23
68
 
24
69
  export type CreateMcpAppBridgeOptions = {
@@ -91,6 +136,7 @@ export function createMcpAppBridge(
91
136
  hostContext = {},
92
137
  } = opts;
93
138
  let disposed = false;
139
+ let initializeParams: McpAppInitializeParams = {};
94
140
 
95
141
  const post = (msg: McpAppJsonRpcMessage) => {
96
142
  if (disposed) return;
@@ -142,10 +188,9 @@ export function createMcpAppBridge(
142
188
 
143
189
  switch (normalizeMethod(req.method)) {
144
190
  case "ui/initialize": {
191
+ initializeParams = getInitializeParams(params);
145
192
  const requestedProtocolVersion =
146
- isRecord(params) && typeof params.protocolVersion === "string"
147
- ? params.protocolVersion
148
- : MCP_APP_PROTOCOL_VERSION;
193
+ initializeParams.protocolVersion ?? MCP_APP_PROTOCOL_VERSION;
149
194
  respond(req.id, {
150
195
  result: {
151
196
  protocolVersion: requestedProtocolVersion,
@@ -360,10 +405,7 @@ export function createMcpAppBridge(
360
405
  return;
361
406
  }
362
407
  const modeParams = (params ?? {}) as { mode?: unknown };
363
- if (
364
- typeof modeParams.mode !== "string" ||
365
- !VALID_DISPLAY_MODES.includes(modeParams.mode as McpAppDisplayMode)
366
- ) {
408
+ if (!isMcpAppDisplayMode(modeParams.mode)) {
367
409
  errorResponse(
368
410
  req.id,
369
411
  JSONRPC_ERROR.invalidParams,
@@ -373,7 +415,7 @@ export function createMcpAppBridge(
373
415
  }
374
416
  respond(req.id, {
375
417
  result: await handlers.requestDisplayMode({
376
- mode: modeParams.mode as McpAppDisplayMode,
418
+ mode: modeParams.mode,
377
419
  }),
378
420
  });
379
421
  return;
@@ -398,7 +440,7 @@ export function createMcpAppBridge(
398
440
  try {
399
441
  switch (normalizeMethod(note.method)) {
400
442
  case "notifications/initialized": {
401
- handlers.onInitialized?.();
443
+ handlers.onInitialized?.(initializeParams);
402
444
  return;
403
445
  }
404
446
  case "notifications/size_changed": {
@@ -17,6 +17,7 @@ export type {
17
17
  McpAppsHost,
18
18
  McpAppsRemoteHostOptions,
19
19
  McpAppToolCallParams,
20
+ McpAppInitializeParams,
20
21
  McpAppBridgeHandlers,
21
22
  ToolCallMessagePartMcpMetadata,
22
23
  } from "./types";
@@ -87,6 +87,20 @@ export type McpAppToolCallParams = {
87
87
  arguments?: Record<string, unknown>;
88
88
  };
89
89
 
90
+ /**
91
+ * The params of the app's `ui/initialize` request, as `onInitialized`
92
+ * receives them. A field that fails validation is omitted, and
93
+ * `availableDisplayModes` keeps only the modes the bridge recognizes.
94
+ */
95
+ export type McpAppInitializeParams = {
96
+ appInfo?: { name: string; version: string; [k: string]: unknown };
97
+ appCapabilities?: {
98
+ availableDisplayModes?: McpAppDisplayMode[];
99
+ [k: string]: unknown;
100
+ };
101
+ protocolVersion?: string;
102
+ };
103
+
90
104
  export type McpAppBridgeHandlers = {
91
105
  allowedTools?: readonly string[];
92
106
  callTool?: (params: McpAppToolCallParams) => Promise<unknown> | unknown;
@@ -99,7 +113,7 @@ export type McpAppBridgeHandlers = {
99
113
  mode: McpAppDisplayMode;
100
114
  }) => Promise<{ mode: McpAppDisplayMode }> | { mode: McpAppDisplayMode };
101
115
  onSizeChange?: (params: { width?: number; height?: number }) => void;
102
- onInitialized?: () => void;
116
+ onInitialized?: (params: McpAppInitializeParams) => void;
103
117
  onRequestTeardown?: (params: unknown) => void;
104
118
  onLog?: (params: unknown) => void;
105
119
  onError?: (error: Error) => void;
@@ -6,9 +6,10 @@ import { createRoot, type Root } from "react-dom/client";
6
6
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
7
  import { ComposerPrimitiveAttachmentDropzone } from "./ComposerAttachmentDropzone";
8
8
 
9
- const { addAttachment, threadCapabilities } = vi.hoisted(() => ({
9
+ const { addAttachment, threadCapabilities, threadState } = vi.hoisted(() => ({
10
10
  addAttachment: vi.fn<(file: File) => Promise<void>>(),
11
11
  threadCapabilities: { attachments: true },
12
+ threadState: { isDisabled: false },
12
13
  }));
13
14
 
14
15
  (globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
@@ -25,6 +26,9 @@ vi.mock("@assistant-ui/store", async (importOriginal) => {
25
26
  getState: () => ({ capabilities: threadCapabilities }),
26
27
  },
27
28
  }),
29
+ useAuiState: (
30
+ selector: (state: { thread: typeof threadState }) => unknown,
31
+ ) => selector({ thread: threadState }),
28
32
  };
29
33
  });
30
34
 
@@ -53,6 +57,7 @@ describe("ComposerPrimitiveAttachmentDropzone", () => {
53
57
  beforeEach(async () => {
54
58
  addAttachment.mockReset();
55
59
  threadCapabilities.attachments = true;
60
+ threadState.isDisabled = false;
56
61
  container = document.createElement("div");
57
62
  document.body.appendChild(container);
58
63
  root = createRoot(container);
@@ -250,6 +255,32 @@ describe("ComposerPrimitiveAttachmentDropzone", () => {
250
255
  expect(dropzone.hasAttribute("data-dragging")).toBe(false);
251
256
  });
252
257
 
258
+ it("blocks file drops when the thread is disabled", async () => {
259
+ threadState.isDisabled = true;
260
+ await act(async () => {
261
+ root.render(
262
+ <ComposerPrimitiveAttachmentDropzone data-testid="dropzone">
263
+ <div>dropzone</div>
264
+ </ComposerPrimitiveAttachmentDropzone>,
265
+ );
266
+ });
267
+ const dropzone = container.querySelector("[data-testid='dropzone']")!;
268
+ const over = createDragEvent("dragover", ["Files"]);
269
+ const drop = createDropEvent([
270
+ new File(["file"], "photo.png", { type: "image/png" }),
271
+ ]);
272
+
273
+ await act(async () => {
274
+ dropzone.dispatchEvent(over);
275
+ dropzone.dispatchEvent(drop);
276
+ });
277
+
278
+ expect(over.defaultPrevented).toBe(true);
279
+ expect(drop.defaultPrevented).toBe(true);
280
+ expect(addAttachment).not.toHaveBeenCalled();
281
+ expect(dropzone.hasAttribute("data-dragging")).toBe(false);
282
+ });
283
+
253
284
  it("ignores non-file drags", async () => {
254
285
  const dropzone = container.querySelector("[data-testid='dropzone']");
255
286
  expect(dropzone).not.toBeNull();
@@ -12,7 +12,7 @@ import {
12
12
  import { composeEventHandlers } from "radix-ui/internal";
13
13
  import { Slot } from "radix-ui";
14
14
  import type React from "react";
15
- import { useAui } from "@assistant-ui/store";
15
+ import { useAui, useAuiState } from "@assistant-ui/store";
16
16
  import { renderSlot } from "../../utils/Primitive";
17
17
 
18
18
  export namespace ComposerPrimitiveAttachmentDropzone {
@@ -30,13 +30,15 @@ export const ComposerPrimitiveAttachmentDropzone = forwardRef<
30
30
  >(({ disabled, asChild = false, render, children, ...rest }, ref) => {
31
31
  const [isDragging, setIsDragging] = useState(false);
32
32
  const aui = useAui();
33
+ const disabledByThread = useAuiState((s) => s.thread.isDisabled);
34
+ const isDisabled = disabled || disabledByThread;
33
35
 
34
36
  useEffect(() => {
35
- if (!disabled) return;
37
+ if (!isDisabled) return;
36
38
  // Disabled handlers cannot clear the latch, so reset it when the enabled period ends.
37
39
  // eslint-disable-next-line react-hooks/set-state-in-effect
38
40
  setIsDragging(false);
39
- }, [disabled]);
41
+ }, [isDisabled]);
40
42
 
41
43
  // An unprevented file drop navigates the tab to the file, so file drags are
42
44
  // claimed via preventDefault even when the runtime does not support attachments.
@@ -44,31 +46,31 @@ export const ComposerPrimitiveAttachmentDropzone = forwardRef<
44
46
  (e: React.DragEvent) => {
45
47
  if (!e.dataTransfer.types.includes("Files")) return;
46
48
  e.preventDefault();
47
- if (disabled || !aui.thread.getState().capabilities.attachments) {
49
+ if (isDisabled || !aui.thread.getState().capabilities.attachments) {
48
50
  e.dataTransfer.dropEffect = "none";
49
51
  return;
50
52
  }
51
53
  setIsDragging(true);
52
54
  },
53
- [disabled, aui],
55
+ [isDisabled, aui],
54
56
  );
55
57
 
56
58
  const handleDragOverCapture = useCallback(
57
59
  (e: React.DragEvent) => {
58
60
  if (!e.dataTransfer.types.includes("Files")) return;
59
61
  e.preventDefault();
60
- if (disabled || !aui.thread.getState().capabilities.attachments) {
62
+ if (isDisabled || !aui.thread.getState().capabilities.attachments) {
61
63
  e.dataTransfer.dropEffect = "none";
62
64
  return;
63
65
  }
64
66
  if (!isDragging) setIsDragging(true);
65
67
  },
66
- [disabled, isDragging, aui],
68
+ [isDisabled, isDragging, aui],
67
69
  );
68
70
 
69
71
  const handleDragLeaveCapture = useCallback(
70
72
  (e: React.DragEvent) => {
71
- if (disabled) return;
73
+ if (isDisabled) return;
72
74
  e.preventDefault();
73
75
  const next = e.relatedTarget as Node | null;
74
76
  if (next && e.currentTarget.contains(next)) {
@@ -76,7 +78,7 @@ export const ComposerPrimitiveAttachmentDropzone = forwardRef<
76
78
  }
77
79
  setIsDragging(false);
78
80
  },
79
- [disabled],
81
+ [isDisabled],
80
82
  );
81
83
 
82
84
  const handleDrop = useCallback(
@@ -84,7 +86,7 @@ export const ComposerPrimitiveAttachmentDropzone = forwardRef<
84
86
  setIsDragging(false);
85
87
  if (!e.dataTransfer.types.includes("Files")) return;
86
88
  e.preventDefault();
87
- if (disabled) return;
89
+ if (isDisabled) return;
88
90
  const files = Array.from(e.dataTransfer.files);
89
91
  if (!aui.thread.getState().capabilities.attachments || files.length === 0)
90
92
  return;
@@ -99,11 +101,11 @@ export const ComposerPrimitiveAttachmentDropzone = forwardRef<
99
101
  }),
100
102
  );
101
103
  },
102
- [disabled, aui],
104
+ [isDisabled, aui],
103
105
  );
104
106
 
105
107
  const mergedProps = {
106
- ...(isDragging && !disabled ? { "data-dragging": "true" } : null),
108
+ ...(isDragging && !isDisabled ? { "data-dragging": "true" } : null),
107
109
  ...rest,
108
110
  onDragEnterCapture: composeEventHandlers(
109
111
  rest.onDragEnterCapture,
@@ -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
+ });
@@ -1,5 +1,5 @@
1
1
  /** @vitest-environment jsdom */
2
- import { act } from "react";
2
+ import { act, version } from "react";
3
3
  import type { MouseEvent, ReactNode } from "react";
4
4
  import { createPortal } from "react-dom";
5
5
  import { createRoot } from "react-dom/client";
@@ -12,6 +12,8 @@ import { useComposerCompactContextOptional } from "./ComposerCompactContext";
12
12
  import type { ComposerCompactContextValue } from "./ComposerCompactContext";
13
13
  import { ComposerPrimitiveRoot } from "./ComposerRoot";
14
14
 
15
+ const onReact18 = version.startsWith("18.");
16
+
15
17
  type StoreState = {
16
18
  composer: {
17
19
  attachments: object[];
@@ -77,6 +79,24 @@ describe("ComposerPrimitiveRoot click-to-focus", () => {
77
79
  };
78
80
  };
79
81
 
82
+ it("forwards the inert attribute and removes it when disabled", () => {
83
+ const { container, rerender } = render(<ComposerPrimitiveRoot inert />);
84
+ const form = container.querySelector("form");
85
+
86
+ expect(form?.hasAttribute("inert")).toBe(true);
87
+
88
+ rerender(<ComposerPrimitiveRoot inert={false} />);
89
+ expect(form?.hasAttribute("inert")).toBe(false);
90
+ });
91
+
92
+ it.skipIf(!onReact18)("keeps a string inert on React 18", () => {
93
+ const { container } = render(
94
+ <ComposerPrimitiveRoot inert={"" as unknown as boolean} />,
95
+ );
96
+
97
+ expect(container.querySelector("form")?.hasAttribute("inert")).toBe(true);
98
+ });
99
+
80
100
  it("focuses the textarea when primary-button mousedown starts on blank form space", () => {
81
101
  const { blankSpace, textarea } = setupComposer();
82
102
 
@@ -11,6 +11,7 @@ import {
11
11
  type MouseEvent,
12
12
  useMemo,
13
13
  useState,
14
+ version,
14
15
  } from "react";
15
16
  import { useComposerSend } from "./ComposerSend";
16
17
  import { ComposerCompactContext } from "./ComposerCompactContext";
@@ -56,6 +57,9 @@ export namespace ComposerPrimitiveRoot {
56
57
  };
57
58
  }
58
59
 
60
+ // React 18 drops a boolean `inert`, so it receives the attribute's string form.
61
+ const isReact18 = version.startsWith("18.");
62
+
59
63
  /**
60
64
  * The root form container for message composition.
61
65
  *
@@ -79,7 +83,7 @@ export namespace ComposerPrimitiveRoot {
79
83
  export const ComposerPrimitiveRoot = forwardRef<
80
84
  ComposerPrimitiveRoot.Element,
81
85
  ComposerPrimitiveRoot.Props
82
- >(({ onSubmit, onMouseDown, compact, ...rest }, forwardedRef) => {
86
+ >(({ onSubmit, onMouseDown, compact, inert, ...rest }, forwardedRef) => {
83
87
  const send = useComposerSend();
84
88
  const composerType = useAuiState((s) => s.composer.type);
85
89
 
@@ -95,6 +99,8 @@ export const ComposerPrimitiveRoot = forwardRef<
95
99
  : false,
96
100
  );
97
101
  const isCompact = stateAllowsCompact && !multiline;
102
+ const inertAttribute =
103
+ isReact18 && typeof inert === "boolean" ? (inert ? "" : undefined) : inert;
98
104
 
99
105
  const handleSubmit = (e: FormEvent) => {
100
106
  e.preventDefault();
@@ -126,6 +132,7 @@ export const ComposerPrimitiveRoot = forwardRef<
126
132
  <ComposerCompactContext.Provider value={compact ? compactContext : null}>
127
133
  <Primitive.form
128
134
  {...rest}
135
+ inert={inertAttribute as boolean | undefined}
129
136
  data-aui-composer-type={composerType}
130
137
  data-compact={isCompact ? "" : undefined}
131
138
  ref={forwardedRef}
@@ -68,6 +68,8 @@ export type Unstable_ComposerInput = {
68
68
  * disabled={isDisabled}
69
69
  * onChange={(e) => setText(e.target.value)}
70
70
  * onKeyDown={(e) => {
71
+ * if (e.nativeEvent.isComposing || e.keyCode === 229) return;
72
+ *
71
73
  * if (e.key === "Enter" && !e.shiftKey && canSend) {
72
74
  * e.preventDefault();
73
75
  * send();