@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,124 @@
1
+ import type { McpAppResourceCSP } from "./types";
2
+
3
+ const isAllowedOrigin = (value: string, allowWebSocket: boolean): boolean => {
4
+ const scheme = allowWebSocket ? /^(?:https?|wss?):\/\//i : /^https?:\/\//i;
5
+ if (!scheme.test(value) || /[\s;'"`,]/.test(value)) {
6
+ return false;
7
+ }
8
+
9
+ try {
10
+ const url = new URL(value.replace("://*.", "://wildcard."));
11
+ return (
12
+ url.username === "" &&
13
+ url.password === "" &&
14
+ url.pathname === "/" &&
15
+ url.search === "" &&
16
+ url.hash === ""
17
+ );
18
+ } catch {
19
+ return false;
20
+ }
21
+ };
22
+
23
+ const getAllowedOrigins = (
24
+ values: unknown,
25
+ allowWebSocket = false,
26
+ ): string[] =>
27
+ Array.isArray(values)
28
+ ? [
29
+ ...new Set(
30
+ values.filter(
31
+ (value): value is string =>
32
+ typeof value === "string" &&
33
+ isAllowedOrigin(value, allowWebSocket),
34
+ ),
35
+ ),
36
+ ]
37
+ : [];
38
+
39
+ const directive = (name: string, values: readonly string[]) =>
40
+ `${name} ${values.join(" ")}`;
41
+
42
+ export const buildMcpAppCsp = (csp?: McpAppResourceCSP): string => {
43
+ const connectDomains = getAllowedOrigins(csp?.connectDomains, true);
44
+ const resourceDomains = getAllowedOrigins(csp?.resourceDomains);
45
+ const frameDomains = getAllowedOrigins(csp?.frameDomains);
46
+ const baseUriDomains = getAllowedOrigins(csp?.baseUriDomains);
47
+
48
+ return [
49
+ "default-src 'none'",
50
+ directive("script-src", ["'self'", "'unsafe-inline'", ...resourceDomains]),
51
+ directive("style-src", ["'self'", "'unsafe-inline'", ...resourceDomains]),
52
+ directive(
53
+ "connect-src",
54
+ connectDomains.length > 0 ? ["'self'", ...connectDomains] : ["'none'"],
55
+ ),
56
+ directive("img-src", ["'self'", "data:", ...resourceDomains]),
57
+ directive("font-src", ["'self'", ...resourceDomains]),
58
+ directive("media-src", ["'self'", "data:", ...resourceDomains]),
59
+ directive("frame-src", frameDomains.length > 0 ? frameDomains : ["'none'"]),
60
+ directive(
61
+ "base-uri",
62
+ baseUriDomains.length > 0 ? baseUriDomains : ["'self'"],
63
+ ),
64
+ "object-src 'none'",
65
+ "form-action 'none'",
66
+ ].join("; ");
67
+ };
68
+
69
+ const findDoctypeEnd = (html: string): number => {
70
+ let offset = 0;
71
+
72
+ while (offset < html.length) {
73
+ const whitespace = html.slice(offset).match(/^[\t\n\f\r ]+/)?.[0];
74
+ if (whitespace) {
75
+ offset += whitespace.length;
76
+ continue;
77
+ }
78
+ if (html.startsWith("<!--", offset)) {
79
+ if (html.startsWith("<!-->", offset)) {
80
+ offset += 5;
81
+ continue;
82
+ }
83
+ if (html.startsWith("<!--->", offset)) {
84
+ offset += 6;
85
+ continue;
86
+ }
87
+ const commentEnd = html.indexOf("-->", offset + 4);
88
+ const bangCommentEnd = html.indexOf("--!>", offset + 4);
89
+ if (commentEnd === -1 && bangCommentEnd === -1) return 0;
90
+ offset =
91
+ bangCommentEnd !== -1 &&
92
+ (commentEnd === -1 || bangCommentEnd < commentEnd)
93
+ ? bangCommentEnd + 4
94
+ : commentEnd + 3;
95
+ continue;
96
+ }
97
+ if (html.slice(offset, offset + 9).toLowerCase() === "<!doctype") {
98
+ break;
99
+ }
100
+ if (html.startsWith("<?", offset) || html.startsWith("<!", offset)) {
101
+ const bogusCommentEnd = html.indexOf(">", offset + 2);
102
+ if (bogusCommentEnd === -1) return 0;
103
+ offset = bogusCommentEnd + 1;
104
+ continue;
105
+ }
106
+ break;
107
+ }
108
+
109
+ if (html.slice(offset, offset + 9).toLowerCase() !== "<!doctype") {
110
+ return offset;
111
+ }
112
+ const end = html.indexOf(">", offset + 9);
113
+ return end === -1 ? 0 : end + 1;
114
+ };
115
+
116
+ export const applyMcpAppCsp = (
117
+ html: string,
118
+ csp?: McpAppResourceCSP,
119
+ ): string => {
120
+ const normalizedHtml = html.charCodeAt(0) === 0xfeff ? html.slice(1) : html;
121
+ const meta = `<meta http-equiv="Content-Security-Policy" content="${buildMcpAppCsp(csp)}">`;
122
+ const insertionPoint = findDoctypeEnd(normalizedHtml);
123
+ return `${normalizedHtml.slice(0, insertionPoint)}${meta}${normalizedHtml.slice(insertionPoint)}`;
124
+ };
@@ -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";
@@ -14,6 +14,7 @@ export type McpAppResourceCSP = {
14
14
  connectDomains?: string[];
15
15
  resourceDomains?: string[];
16
16
  frameDomains?: string[];
17
+ baseUriDomains?: string[];
17
18
  [k: string]: unknown;
18
19
  };
19
20
 
@@ -86,6 +87,20 @@ export type McpAppToolCallParams = {
86
87
  arguments?: Record<string, unknown>;
87
88
  };
88
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
+
89
104
  export type McpAppBridgeHandlers = {
90
105
  allowedTools?: readonly string[];
91
106
  callTool?: (params: McpAppToolCallParams) => Promise<unknown> | unknown;
@@ -98,7 +113,7 @@ export type McpAppBridgeHandlers = {
98
113
  mode: McpAppDisplayMode;
99
114
  }) => Promise<{ mode: McpAppDisplayMode }> | { mode: McpAppDisplayMode };
100
115
  onSizeChange?: (params: { width?: number; height?: number }) => void;
101
- onInitialized?: () => void;
116
+ onInitialized?: (params: McpAppInitializeParams) => void;
102
117
  onRequestTeardown?: (params: unknown) => void;
103
118
  onLog?: (params: unknown) => void;
104
119
  onError?: (error: Error) => void;
@@ -237,6 +237,51 @@ describe("AssistantFrame Integration", () => {
237
237
  unsubscribe();
238
238
  });
239
239
 
240
+ it("rejects a tool call the frame drops while it is disposed", async () => {
241
+ vi.mocked(iframeWindow.postMessage).mockImplementation((data: any) => {
242
+ Promise.resolve().then(() => {
243
+ messageHandlers.get("iframe")?.({
244
+ data,
245
+ source: parentWindow,
246
+ origin: window.location.origin,
247
+ } as MessageEvent);
248
+ });
249
+ });
250
+ vi.mocked(window.removeEventListener).mockImplementation(
251
+ (event: string, handler: any) => {
252
+ if (event === "message" && messageHandlers.get("iframe") === handler) {
253
+ messageHandlers.delete("iframe");
254
+ }
255
+ },
256
+ );
257
+ const registry = new ModelContextRegistry();
258
+ const toolExecute = vi.fn().mockResolvedValue("result");
259
+ registry.addTool({
260
+ toolName: "search",
261
+ parameters: z.object({}),
262
+ execute: toolExecute,
263
+ });
264
+ AssistantFrameProvider.addModelContextProvider(registry);
265
+ const host = new AssistantFrameHost(iframeWindow);
266
+ await vi.waitFor(() => {
267
+ expect(host.getModelContext().tools?.search).toBeDefined();
268
+ });
269
+
270
+ const result = host.getModelContext().tools!.search!.execute!(
271
+ {},
272
+ {} as any,
273
+ );
274
+ AssistantFrameProvider.dispose();
275
+
276
+ await expect(result).rejects.toThrow(
277
+ "AssistantFrameProvider has been disposed",
278
+ );
279
+ expect(toolExecute).not.toHaveBeenCalled();
280
+ expect(host.getModelContext().tools).toBeUndefined();
281
+
282
+ host.dispose();
283
+ });
284
+
240
285
  it("should handle multiple providers", async () => {
241
286
  // Setup multiple providers
242
287
  const registry1 = new ModelContextRegistry();
@@ -78,6 +78,7 @@ All messages are wrapped with a channel identifier to avoid conflicts with other
78
78
  2. **Tool Execution**
79
79
  - `tool-call`: Parent requests tool execution in iframe (where tools are defined)
80
80
  - `tool-result`: Iframe returns execution result or error to parent
81
+ - `provider-disposed`: Iframe tells the parent it was disposed, so the parent rejects tool calls the iframe will not answer
81
82
 
82
83
  #### Serialization
83
84
 
@@ -7,6 +7,7 @@ import { useAuiEvent } from "@assistant-ui/store";
7
7
 
8
8
  export namespace AssistantModalPrimitiveRoot {
9
9
  export type Props = PopoverPrimitive.PopoverProps & {
10
+ /** @deprecated Experimental since 2024-10-12. Not scheduled for removal; the API may change in any release. */
10
11
  unstable_openOnRunStart?: boolean | undefined;
11
12
  };
12
13
  }
@@ -1,4 +1,7 @@
1
1
  export { AttachmentPrimitiveRoot as Root } from "./attachment/AttachmentRoot";
2
- export { AttachmentPrimitiveThumb as unstable_Thumb } from "./attachment/AttachmentThumb";
2
+ export {
3
+ /** @deprecated Experimental since 2024-10-10. Not scheduled for removal; the API may change in any release. */
4
+ AttachmentPrimitiveThumb as unstable_Thumb,
5
+ } from "./attachment/AttachmentThumb";
3
6
  export { AttachmentPrimitiveName as Name } from "./attachment/AttachmentName";
4
7
  export { AttachmentPrimitiveRemove as Remove } from "./attachment/AttachmentRemove";
@@ -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,