@assistant-ui/react 0.15.17 → 0.15.19

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 (169) hide show
  1. package/README.md +8 -2
  2. package/dist/context/providers/ThreadViewportProvider.js +2 -2
  3. package/dist/context/providers/ThreadViewportProvider.js.map +1 -1
  4. package/dist/hooks/useToolCallElapsed.js.map +1 -1
  5. package/dist/index.d.ts +7 -4
  6. package/dist/index.js +5 -3
  7. package/dist/mcp-apps/McpAppRenderer.d.ts +30 -8
  8. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  9. package/dist/mcp-apps/McpAppRenderer.js +91 -47
  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 +57 -33
  13. package/dist/mcp-apps/app-frame.js.map +1 -1
  14. package/dist/mcp-apps/bridge.js +9 -4
  15. package/dist/mcp-apps/bridge.js.map +1 -1
  16. package/dist/mcp-apps/index.d.ts +2 -2
  17. package/dist/primitives/assistantModal/AssistantModalRoot.d.ts.map +1 -1
  18. package/dist/primitives/assistantModal/AssistantModalRoot.js +47 -79
  19. package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
  20. package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
  21. package/dist/primitives/composer/ComposerAttachmentDropzone.js +61 -138
  22. package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
  23. package/dist/primitives/composer/ComposerInput.js +3 -8
  24. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  25. package/dist/primitives/composer/ComposerInputPluginContext.d.ts +1 -2
  26. package/dist/primitives/composer/ComposerInputPluginContext.d.ts.map +1 -1
  27. package/dist/primitives/composer/ComposerInputPluginContext.js +1 -6
  28. package/dist/primitives/composer/ComposerInputPluginContext.js.map +1 -1
  29. package/dist/primitives/composer/trigger/TriggerPopoverAction.js +46 -15
  30. package/dist/primitives/composer/trigger/TriggerPopoverAction.js.map +1 -1
  31. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +40 -10
  32. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js.map +1 -1
  33. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.d.ts +6 -0
  34. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.d.ts.map +1 -0
  35. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.js +9 -0
  36. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.js.map +1 -0
  37. package/dist/primitives/composer/trigger/triggerKeyboardResource.d.ts.map +1 -1
  38. package/dist/primitives/composer/trigger/triggerKeyboardResource.js +53 -61
  39. package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
  40. package/dist/primitives/composer/trigger/triggerNavigationResource.d.ts.map +1 -1
  41. package/dist/primitives/composer/trigger/triggerNavigationResource.js +129 -141
  42. package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
  43. package/dist/primitives/message/MessageParts.js +2 -14
  44. package/dist/primitives/message/MessageParts.js.map +1 -1
  45. package/dist/primitives/message/MessagePartsGrouped.d.ts +3 -3
  46. package/dist/primitives/message/MessagePartsGrouped.js +2 -2
  47. package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
  48. package/dist/primitives/messagePart/MessagePartText.d.ts +2 -0
  49. package/dist/primitives/messagePart/MessagePartText.d.ts.map +1 -1
  50. package/dist/primitives/messagePart/MessagePartText.js +13 -36
  51. package/dist/primitives/messagePart/MessagePartText.js.map +1 -1
  52. package/dist/primitives/reasoning/useScrollLock.js +3 -1
  53. package/dist/primitives/reasoning/useScrollLock.js.map +1 -1
  54. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  55. package/dist/primitives/thread/useThreadViewportAutoScroll.js +3 -3
  56. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  57. package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
  58. package/dist/sandbox-host/SandboxHost.js +36 -9
  59. package/dist/sandbox-host/SandboxHost.js.map +1 -1
  60. package/dist/unstable/useLiveCompletionAdapter.js +24 -2
  61. package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
  62. package/dist/unstable/useMentionAdapter.d.ts +9 -3
  63. package/dist/unstable/useMentionAdapter.d.ts.map +1 -1
  64. package/dist/unstable/useMentionAdapter.js +50 -43
  65. package/dist/unstable/useMentionAdapter.js.map +1 -1
  66. package/dist/unstable/useMessageStallDetection.d.ts +4 -5
  67. package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
  68. package/dist/unstable/useMessageStallDetection.js +16 -15
  69. package/dist/unstable/useMessageStallDetection.js.map +1 -1
  70. package/dist/unstable/useModelContextSnapshot.d.ts +34 -0
  71. package/dist/unstable/useModelContextSnapshot.d.ts.map +1 -0
  72. package/dist/unstable/useModelContextSnapshot.js +61 -0
  73. package/dist/unstable/useModelContextSnapshot.js.map +1 -0
  74. package/dist/unstable/useSlashCommandAdapter.d.ts.map +1 -1
  75. package/dist/unstable/useSlashCommandAdapter.js +2 -8
  76. package/dist/unstable/useSlashCommandAdapter.js.map +1 -1
  77. package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts +14 -0
  78. package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts.map +1 -0
  79. package/dist/unstable/webmcp/WebMcpRegistrationResource.js +85 -0
  80. package/dist/unstable/webmcp/WebMcpRegistrationResource.js.map +1 -0
  81. package/dist/unstable/webmcp/convertTools.d.ts +21 -0
  82. package/dist/unstable/webmcp/convertTools.d.ts.map +1 -0
  83. package/dist/unstable/webmcp/convertTools.js +161 -0
  84. package/dist/unstable/webmcp/convertTools.js.map +1 -0
  85. package/dist/unstable/webmcp/useWebMcpProvider.d.ts +21 -0
  86. package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -0
  87. package/dist/unstable/webmcp/useWebMcpProvider.js +106 -0
  88. package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -0
  89. package/dist/unstable/webmcp/webmcp-host.d.ts +39 -0
  90. package/dist/unstable/webmcp/webmcp-host.d.ts.map +1 -0
  91. package/dist/unstable/webmcp/webmcp-host.js +29 -0
  92. package/dist/unstable/webmcp/webmcp-host.js.map +1 -0
  93. package/dist/utils/Primitive.d.ts +10 -2
  94. package/dist/utils/Primitive.d.ts.map +1 -1
  95. package/dist/utils/Primitive.js +25 -6
  96. package/dist/utils/Primitive.js.map +1 -1
  97. package/dist/utils/getSelectionMessageId.d.ts.map +1 -1
  98. package/dist/utils/getSelectionMessageId.js +17 -3
  99. package/dist/utils/getSelectionMessageId.js.map +1 -1
  100. package/package.json +13 -13
  101. package/src/context/providers/ThreadViewportProvider.tsx +2 -2
  102. package/src/hooks/useToolCallElapsed.ts +3 -0
  103. package/src/index.ts +14 -1
  104. package/src/mcp-apps/McpAppRenderer.test.tsx +253 -5
  105. package/src/mcp-apps/McpAppRenderer.tsx +147 -52
  106. package/src/mcp-apps/app-frame.test.tsx +437 -1
  107. package/src/mcp-apps/app-frame.tsx +76 -21
  108. package/src/mcp-apps/bridge.test.ts +98 -0
  109. package/src/mcp-apps/bridge.ts +14 -3
  110. package/src/mcp-apps/index.ts +5 -1
  111. package/src/primitives/assistantModal/AssistantModalRoot.test.tsx +123 -0
  112. package/src/primitives/assistantModal/AssistantModalRoot.tsx +11 -31
  113. package/src/primitives/assistantModal/AssistantModalTrigger.test.tsx +1 -16
  114. package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +43 -0
  115. package/src/primitives/composer/ComposerAttachmentDropzone.tsx +2 -10
  116. package/src/primitives/composer/ComposerInput.test.tsx +22 -0
  117. package/src/primitives/composer/ComposerInput.tsx +5 -9
  118. package/src/primitives/composer/ComposerInputPluginContext.tsx +0 -10
  119. package/src/primitives/composer/trigger/TriggerPopoverAction.tsx +4 -2
  120. package/src/primitives/composer/trigger/TriggerPopoverBehavior.test.tsx +109 -0
  121. package/src/primitives/composer/trigger/TriggerPopoverDirective.tsx +4 -2
  122. package/src/primitives/composer/trigger/matchesTriggerItemQuery.test.ts +52 -0
  123. package/src/primitives/composer/trigger/matchesTriggerItemQuery.ts +13 -0
  124. package/src/primitives/composer/trigger/triggerKeyboardResource.ts +13 -7
  125. package/src/primitives/composer/trigger/triggerNavigationResource.ts +8 -13
  126. package/src/primitives/message/MessageParts.tsx +1 -21
  127. package/src/primitives/message/MessagePartsGrouped.test.tsx +93 -0
  128. package/src/primitives/message/MessagePartsGrouped.tsx +6 -6
  129. package/src/primitives/messagePart/MessagePartText.test.tsx +69 -0
  130. package/src/primitives/messagePart/MessagePartText.tsx +22 -9
  131. package/src/primitives/reasoning/useScrollLock.test.tsx +129 -0
  132. package/src/primitives/reasoning/useScrollLock.ts +15 -1
  133. package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +38 -1
  134. package/src/primitives/thread/useThreadViewportAutoScroll.ts +7 -6
  135. package/src/sandbox-host/SandboxHost.test.tsx +160 -2
  136. package/src/sandbox-host/SandboxHost.tsx +50 -10
  137. package/src/tests/MessageParts.rendererOptions.test.tsx +117 -0
  138. package/src/tests/toolCallTiming.test.tsx +55 -1
  139. package/src/unstable/useComposerInputHistory.test.tsx +2 -1
  140. package/src/unstable/useLiveCompletionAdapter.test.tsx +202 -1
  141. package/src/unstable/useLiveCompletionAdapter.ts +38 -8
  142. package/src/unstable/useMentionAdapter.test.tsx +383 -0
  143. package/src/unstable/useMentionAdapter.ts +94 -57
  144. package/src/unstable/useMessageStallDetection.ts +26 -19
  145. package/src/unstable/useModelContextSnapshot.test.tsx +132 -0
  146. package/src/unstable/useModelContextSnapshot.ts +67 -0
  147. package/src/unstable/useSlashCommandAdapter.ts +2 -9
  148. package/src/unstable/webmcp/WebMcpRegistrationResource.ts +92 -0
  149. package/src/unstable/webmcp/__tests__/webmcp.fake.tsx +168 -0
  150. package/src/unstable/webmcp/convertTools.test.ts +553 -0
  151. package/src/unstable/webmcp/convertTools.ts +260 -0
  152. package/src/unstable/webmcp/useWebMcpProvider.test.tsx +466 -0
  153. package/src/unstable/webmcp/useWebMcpProvider.ts +148 -0
  154. package/src/unstable/webmcp/webmcp-host.test.ts +142 -0
  155. package/src/unstable/webmcp/webmcp-host.ts +83 -0
  156. package/src/utils/Primitive.test.tsx +46 -1
  157. package/src/utils/Primitive.tsx +37 -7
  158. package/src/utils/getSelectionMessageId.test.ts +176 -1
  159. package/src/utils/getSelectionMessageId.ts +21 -3
  160. package/dist/utils/invokeCallbackSafely.d.ts +0 -5
  161. package/dist/utils/invokeCallbackSafely.d.ts.map +0 -1
  162. package/dist/utils/invokeCallbackSafely.js +0 -15
  163. package/dist/utils/invokeCallbackSafely.js.map +0 -1
  164. package/dist/utils/useToolArgsFieldStatus.d.ts +0 -19
  165. package/dist/utils/useToolArgsFieldStatus.d.ts.map +0 -1
  166. package/dist/utils/useToolArgsFieldStatus.js +0 -23
  167. package/dist/utils/useToolArgsFieldStatus.js.map +0 -1
  168. package/src/utils/invokeCallbackSafely.ts +0 -11
  169. package/src/utils/useToolArgsFieldStatus.ts +0 -18
@@ -37,6 +37,104 @@ async function flush() {
37
37
  }
38
38
 
39
39
  describe("createMcpAppBridge", () => {
40
+ it("ignores requests and notifications after disposal", async () => {
41
+ const { frame, captured } = makeFrame();
42
+ const callTool = vi.fn().mockResolvedValue({ ok: true });
43
+ const onInitialized = vi.fn();
44
+ const bridge = createMcpAppBridge({
45
+ frame,
46
+ handlers: { callTool, onInitialized },
47
+ });
48
+
49
+ bridge.dispose();
50
+ expect(() => bridge.dispose()).not.toThrow();
51
+
52
+ deliver(bridge, {
53
+ jsonrpc: "2.0",
54
+ id: 1,
55
+ method: "tools/call",
56
+ params: { name: "search" },
57
+ });
58
+ deliver(bridge, {
59
+ jsonrpc: "2.0",
60
+ method: "notifications/initialized",
61
+ });
62
+ await flush();
63
+
64
+ expect(callTool).not.toHaveBeenCalled();
65
+ expect(onInitialized).not.toHaveBeenCalled();
66
+ expect(captured).toEqual([]);
67
+ });
68
+
69
+ it("does not send a response after disposal", async () => {
70
+ const { frame, captured } = makeFrame();
71
+ let resolveCall!: (value: unknown) => void;
72
+ const callTool = vi.fn(
73
+ () =>
74
+ new Promise((resolve) => {
75
+ resolveCall = resolve;
76
+ }),
77
+ );
78
+ const bridge = createMcpAppBridge({ frame, handlers: { callTool } });
79
+
80
+ deliver(bridge, {
81
+ jsonrpc: "2.0",
82
+ id: 1,
83
+ method: "tools/call",
84
+ params: { name: "search" },
85
+ });
86
+ expect(callTool).toHaveBeenCalledOnce();
87
+
88
+ bridge.dispose();
89
+ resolveCall({ ok: true });
90
+ await flush();
91
+
92
+ expect(captured).toEqual([]);
93
+ });
94
+
95
+ it("does not report an async error after disposal", async () => {
96
+ const { frame, captured } = makeFrame();
97
+ let rejectCall!: (reason?: unknown) => void;
98
+ const callTool = vi.fn(
99
+ () =>
100
+ new Promise((_resolve, reject) => {
101
+ rejectCall = reject;
102
+ }),
103
+ );
104
+ const onError = vi.fn();
105
+ const bridge = createMcpAppBridge({
106
+ frame,
107
+ handlers: { callTool, onError },
108
+ });
109
+
110
+ deliver(bridge, {
111
+ jsonrpc: "2.0",
112
+ id: 1,
113
+ method: "tools/call",
114
+ params: { name: "search" },
115
+ });
116
+ expect(callTool).toHaveBeenCalledOnce();
117
+
118
+ bridge.dispose();
119
+ rejectCall(new Error("tool failed"));
120
+ await flush();
121
+
122
+ expect(onError).not.toHaveBeenCalled();
123
+ expect(captured).toEqual([]);
124
+ });
125
+
126
+ it("does not send host notifications after disposal", () => {
127
+ const { frame, captured } = makeFrame();
128
+ const bridge = createMcpAppBridge({ frame });
129
+
130
+ bridge.dispose();
131
+ bridge.notifyToolInput({ query: "hello" });
132
+ bridge.notifyToolResult({ answer: "world" });
133
+ bridge.notifyHostContextChanged({ theme: "dark" });
134
+
135
+ expect(captured).toEqual([]);
136
+ });
137
+
40
138
  it("responds to ui/initialize with host info, version, and capabilities", async () => {
41
139
  const { frame, captured } = makeFrame();
42
140
  const bridge = createMcpAppBridge({
@@ -1,5 +1,5 @@
1
1
  import type { SandboxHostFrame } from "../sandbox-host/SandboxHost";
2
- import { invokeCallbackSafely } from "../utils/invokeCallbackSafely";
2
+ import { invokeUserCallback } from "@assistant-ui/core/internal";
3
3
  import {
4
4
  MCP_APP_PROTOCOL_VERSION,
5
5
  type McpAppBridgeHandlers,
@@ -90,8 +90,10 @@ export function createMcpAppBridge(
90
90
  hostInfo = DEFAULT_HOST_INFO,
91
91
  hostContext = {},
92
92
  } = opts;
93
+ let disposed = false;
93
94
 
94
95
  const post = (msg: McpAppJsonRpcMessage) => {
96
+ if (disposed) return;
95
97
  frame.sendMessage(msg);
96
98
  };
97
99
 
@@ -125,7 +127,13 @@ export function createMcpAppBridge(
125
127
  };
126
128
 
127
129
  const reportError = (error: Error) => {
128
- invokeCallbackSafely(() => handlers.onError?.(error), "MCP App onError");
130
+ if (disposed) return;
131
+ invokeUserCallback(
132
+ "assistant-ui",
133
+ "MCP App onError",
134
+ handlers.onError?.bind(handlers),
135
+ error,
136
+ );
129
137
  };
130
138
 
131
139
  const handleRequest = async (req: McpAppJsonRpcRequest) => {
@@ -430,6 +438,7 @@ export function createMcpAppBridge(
430
438
  // The host applies the cross-origin guard before delegating; this only
431
439
  // validates the JSON-RPC envelope.
432
440
  const onMessage = (event: MessageEvent) => {
441
+ if (disposed) return;
433
442
  if (!isJsonRpcMessage(event.data)) return;
434
443
 
435
444
  const msg = event.data;
@@ -442,7 +451,9 @@ export function createMcpAppBridge(
442
451
 
443
452
  return {
444
453
  onMessage,
445
- dispose: () => {},
454
+ dispose: () => {
455
+ disposed = true;
456
+ },
446
457
  notifyToolInput: (input: unknown) => {
447
458
  post({
448
459
  jsonrpc: "2.0",
@@ -1,4 +1,8 @@
1
- export { McpAppRenderer, type McpAppRendererOptions } from "./McpAppRenderer";
1
+ export {
2
+ McpAppRenderer,
3
+ type McpAppPartOptions,
4
+ type McpAppRendererOptions,
5
+ } from "./McpAppRenderer";
2
6
  export { McpAppsRemoteHost } from "./McpAppsRemoteHost";
3
7
  export { getMcpAppFromToolPart } from "./utils";
4
8
  export type {
@@ -0,0 +1,123 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import {
4
+ act,
5
+ cleanup,
6
+ fireEvent,
7
+ render,
8
+ screen,
9
+ } from "@testing-library/react";
10
+ import { useState } from "react";
11
+ import { afterEach, describe, expect, it, vi } from "vitest";
12
+ import { AssistantRuntimeProvider } from "../../context";
13
+ import { useLocalRuntime } from "../../legacy-runtime/runtime-cores/local/useLocalRuntime";
14
+ import { AssistantModalPrimitiveContent } from "./AssistantModalContent";
15
+ import { AssistantModalPrimitiveRoot } from "./AssistantModalRoot";
16
+ import { AssistantModalPrimitiveTrigger } from "./AssistantModalTrigger";
17
+
18
+ const adapter = {
19
+ async *run() {
20
+ yield { content: [{ type: "text" as const, text: "Hello" }] };
21
+ },
22
+ };
23
+
24
+ const Modal = ({
25
+ controlled = true,
26
+ onOpenChange,
27
+ ...props
28
+ }: AssistantModalPrimitiveRoot.Props & { controlled?: boolean }) => {
29
+ const runtime = useLocalRuntime(adapter);
30
+ const [open, setOpen] = useState(false);
31
+ return (
32
+ <AssistantRuntimeProvider runtime={runtime}>
33
+ <button onClick={() => runtime.thread.append("Hello")}>Start run</button>
34
+ <AssistantModalPrimitiveRoot
35
+ {...(controlled ? { open } : {})}
36
+ onOpenChange={(value) => {
37
+ setOpen(value);
38
+ onOpenChange?.(value);
39
+ }}
40
+ {...props}
41
+ >
42
+ <AssistantModalPrimitiveTrigger>
43
+ Toggle chat
44
+ </AssistantModalPrimitiveTrigger>
45
+ <AssistantModalPrimitiveContent aria-label="Chat">
46
+ Response
47
+ </AssistantModalPrimitiveContent>
48
+ </AssistantModalPrimitiveRoot>
49
+ </AssistantRuntimeProvider>
50
+ );
51
+ };
52
+
53
+ afterEach(cleanup);
54
+
55
+ describe("AssistantModalPrimitiveRoot run start", () => {
56
+ it.each([true, false])(
57
+ "opens on run start and closes through the trigger (controlled: %s)",
58
+ async (controlled) => {
59
+ const onOpenChange = vi.fn();
60
+ render(<Modal controlled={controlled} onOpenChange={onOpenChange} />);
61
+ expect(screen.queryByRole("dialog")).toBeNull();
62
+
63
+ await act(async () => {
64
+ fireEvent.click(screen.getByRole("button", { name: "Start run" }));
65
+ });
66
+ expect(await screen.findByRole("dialog", { name: "Chat" })).toBeDefined();
67
+ expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(true);
68
+
69
+ await act(async () => {
70
+ fireEvent.click(screen.getByRole("button", { name: "Start run" }));
71
+ });
72
+ expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(true);
73
+
74
+ fireEvent.click(screen.getByRole("button", { name: "Toggle chat" }));
75
+ expect(screen.queryByRole("dialog")).toBeNull();
76
+ },
77
+ );
78
+
79
+ it.each([{ open: true }, { controlled: false, defaultOpen: true }])(
80
+ "does not request opening when already open (%o)",
81
+ async (props) => {
82
+ const onOpenChange = vi.fn();
83
+ render(<Modal {...props} onOpenChange={onOpenChange} />);
84
+ expect(screen.getByRole("dialog", { name: "Chat" })).toBeDefined();
85
+
86
+ await act(async () => {
87
+ fireEvent.click(screen.getByRole("button", { name: "Start run" }));
88
+ });
89
+
90
+ expect(onOpenChange).not.toHaveBeenCalled();
91
+ expect(screen.getByRole("dialog", { name: "Chat" })).toBeDefined();
92
+ },
93
+ );
94
+
95
+ it("notifies the current owner without overriding its controlled value", async () => {
96
+ const previous = vi.fn();
97
+ const current = vi.fn();
98
+ const view = render(<Modal open={false} onOpenChange={previous} />);
99
+ view.rerender(<Modal open={false} onOpenChange={current} />);
100
+
101
+ await act(async () => {
102
+ fireEvent.click(screen.getByRole("button", { name: "Start run" }));
103
+ });
104
+
105
+ expect(current).toHaveBeenCalledExactlyOnceWith(true);
106
+ expect(previous).not.toHaveBeenCalled();
107
+ expect(screen.queryByRole("dialog")).toBeNull();
108
+ });
109
+
110
+ it("does not request opening when auto-open is disabled", async () => {
111
+ const onOpenChange = vi.fn();
112
+ render(
113
+ <Modal unstable_openOnRunStart={false} onOpenChange={onOpenChange} />,
114
+ );
115
+
116
+ await act(async () => {
117
+ fireEvent.click(screen.getByRole("button", { name: "Start run" }));
118
+ });
119
+
120
+ expect(onOpenChange).not.toHaveBeenCalled();
121
+ expect(screen.queryByRole("dialog")).toBeNull();
122
+ });
123
+ });
@@ -1,9 +1,9 @@
1
1
  "use client";
2
2
 
3
- import { type FC, useEffect, useState } from "react";
3
+ import { type FC, useState } from "react";
4
4
  import { Popover as PopoverPrimitive } from "radix-ui";
5
5
  import { type ScopedProps, usePopoverScope } from "./scope";
6
- import { useAui } from "@assistant-ui/store";
6
+ import { useAuiEvent } from "@assistant-ui/store";
7
7
 
8
8
  export namespace AssistantModalPrimitiveRoot {
9
9
  export type Props = PopoverPrimitive.PopoverProps & {
@@ -11,54 +11,34 @@ export namespace AssistantModalPrimitiveRoot {
11
11
  };
12
12
  }
13
13
 
14
- const useAssistantModalOpenState = ({
15
- defaultOpen = false,
16
- unstable_openOnRunStart = true,
17
- }: {
18
- defaultOpen?: boolean | undefined;
19
- unstable_openOnRunStart?: boolean | undefined;
20
- }) => {
21
- const state = useState(defaultOpen);
22
-
23
- const [, setOpen] = state;
24
- const aui = useAui();
25
- useEffect(() => {
26
- if (!unstable_openOnRunStart) return undefined;
27
-
28
- return aui.on("thread.runStart", () => {
29
- setOpen(true);
30
- });
31
- }, [unstable_openOnRunStart, aui, setOpen]);
32
-
33
- return state;
34
- };
35
-
36
14
  export const AssistantModalPrimitiveRoot: FC<
37
15
  AssistantModalPrimitiveRoot.Props
38
16
  > = ({
39
17
  __scopeAssistantModal,
40
- defaultOpen,
41
- unstable_openOnRunStart,
18
+ defaultOpen = false,
19
+ unstable_openOnRunStart = true,
42
20
  open,
43
21
  onOpenChange,
44
22
  ...rest
45
23
  }: ScopedProps<AssistantModalPrimitiveRoot.Props>) => {
46
24
  const scope = usePopoverScope(__scopeAssistantModal);
47
25
 
48
- const [modalOpen, setOpen] = useAssistantModalOpenState({
49
- defaultOpen,
50
- unstable_openOnRunStart,
51
- });
26
+ const [modalOpen, setOpen] = useState(defaultOpen);
27
+ const isOpen = open ?? modalOpen;
52
28
 
53
29
  const openChangeHandler = (open: boolean) => {
54
30
  onOpenChange?.(open);
55
31
  setOpen(open);
56
32
  };
57
33
 
34
+ useAuiEvent("thread.runStart", () => {
35
+ if (unstable_openOnRunStart && !isOpen) openChangeHandler(true);
36
+ });
37
+
58
38
  return (
59
39
  <PopoverPrimitive.Root
60
40
  {...scope}
61
- open={open === undefined ? modalOpen : open}
41
+ open={isOpen}
62
42
  onOpenChange={openChangeHandler}
63
43
  {...rest}
64
44
  />
@@ -1,7 +1,6 @@
1
1
  import { renderToStaticMarkup } from "react-dom/server";
2
2
  import { describe, expect, it, vi } from "vitest";
3
3
  import { AssistantModalPrimitiveAnchor } from "./AssistantModalAnchor";
4
- import { AssistantModalPrimitiveContent } from "./AssistantModalContent";
5
4
  import { AssistantModalPrimitiveRoot } from "./AssistantModalRoot";
6
5
  import { AssistantModalPrimitiveTrigger } from "./AssistantModalTrigger";
7
6
 
@@ -9,9 +8,7 @@ vi.mock("@assistant-ui/store", async (importOriginal) => {
9
8
  const actual = await importOriginal<typeof import("@assistant-ui/store")>();
10
9
  return {
11
10
  ...actual,
12
- useAui: () => ({
13
- on: () => () => {},
14
- }),
11
+ useAuiEvent: () => {},
15
12
  };
16
13
  });
17
14
 
@@ -65,16 +62,4 @@ describe("AssistantModalPrimitive render props", () => {
65
62
  expect(renderHtml).toContain('class="anchor"');
66
63
  expect(renderHtml).toContain("Anchor");
67
64
  });
68
-
69
- it("accepts render on Trigger, Anchor, and Content", () => {
70
- const tree = (
71
- <AssistantModalPrimitiveRoot open>
72
- <AssistantModalPrimitiveTrigger render={<button type="button" />} />
73
- <AssistantModalPrimitiveAnchor render={<div />} />
74
- <AssistantModalPrimitiveContent render={<div />} />
75
- </AssistantModalPrimitiveRoot>
76
- );
77
-
78
- expect(tree).toBeTruthy();
79
- });
80
65
  });
@@ -74,6 +74,49 @@ describe("ComposerPrimitiveAttachmentDropzone", () => {
74
74
  vi.restoreAllMocks();
75
75
  });
76
76
 
77
+ it("composes a render element and keeps the drag props attached", async () => {
78
+ await act(async () => {
79
+ root.render(
80
+ <ComposerPrimitiveAttachmentDropzone
81
+ data-testid="dropzone"
82
+ render={<section className="child" />}
83
+ className="parent"
84
+ >
85
+ <div>outer</div>
86
+ </ComposerPrimitiveAttachmentDropzone>,
87
+ );
88
+ });
89
+
90
+ const dropzone = container.querySelector(
91
+ "section[data-testid='dropzone']",
92
+ ) as HTMLElement;
93
+ expect(dropzone).not.toBeNull();
94
+ expect(dropzone.textContent).toBe("outer");
95
+ expect(dropzone.className).toContain("parent");
96
+ expect(dropzone.className).toContain("child");
97
+
98
+ await act(async () => {
99
+ dropzone.dispatchEvent(createDragEvent("dragenter", ["Files"]));
100
+ });
101
+
102
+ expect(dropzone.getAttribute("data-dragging")).toBe("true");
103
+ });
104
+
105
+ it("falls back to the render element's own children", async () => {
106
+ await act(async () => {
107
+ root.render(
108
+ <ComposerPrimitiveAttachmentDropzone
109
+ data-testid="dropzone"
110
+ render={<section>fallback</section>}
111
+ />,
112
+ );
113
+ });
114
+
115
+ expect(
116
+ container.querySelector("section[data-testid='dropzone']")?.textContent,
117
+ ).toBe("fallback");
118
+ });
119
+
77
120
  it("starts all dropped attachments before awaiting completion", async () => {
78
121
  const resolvers: Array<() => void> = [];
79
122
  addAttachment.mockImplementation(
@@ -5,7 +5,6 @@ import {
5
5
  useCallback,
6
6
  useState,
7
7
  type ReactElement,
8
- cloneElement,
9
8
  isValidElement,
10
9
  } from "react";
11
10
 
@@ -13,6 +12,7 @@ import { composeEventHandlers } from "radix-ui/internal";
13
12
  import { Slot } from "radix-ui";
14
13
  import type React from "react";
15
14
  import { useAui } from "@assistant-ui/store";
15
+ import { renderSlot } from "../../utils/Primitive";
16
16
 
17
17
  export namespace ComposerPrimitiveAttachmentDropzone {
18
18
  export type Element = HTMLDivElement;
@@ -116,15 +116,7 @@ export const ComposerPrimitiveAttachmentDropzone = forwardRef<
116
116
  };
117
117
 
118
118
  if (render && isValidElement(render)) {
119
- const renderChildren =
120
- children !== undefined
121
- ? children
122
- : (render.props as Record<string, unknown>).children;
123
- return (
124
- <Slot.Root {...mergedProps}>
125
- {cloneElement(render, undefined, renderChildren as React.ReactNode)}
126
- </Slot.Root>
127
- );
119
+ return renderSlot(render, children, mergedProps);
128
120
  }
129
121
 
130
122
  const Comp = asChild ? Slot.Root : "div";
@@ -240,6 +240,28 @@ describe("ComposerPrimitiveInput", () => {
240
240
  return textarea;
241
241
  };
242
242
 
243
+ it("composes a render element with the computed input props", async () => {
244
+ await act(async () => {
245
+ root.render(
246
+ <form>
247
+ <ComposerPrimitiveInput
248
+ render={<textarea data-testid="custom" className="child" />}
249
+ className="parent"
250
+ />
251
+ </form>,
252
+ );
253
+ });
254
+
255
+ const textarea = container.querySelector(
256
+ "textarea[data-testid='custom']",
257
+ ) as HTMLTextAreaElement;
258
+ expect(textarea).not.toBeNull();
259
+ expect(textarea.className).toContain("parent");
260
+ expect(textarea.className).toContain("child");
261
+ expect(textarea.name).toBe("input");
262
+ expect(textarea.hasAttribute("render")).toBe(false);
263
+ });
264
+
243
265
  it("syncs setText during active composition so React 19 cannot reset the textarea", async () => {
244
266
  const textarea = await mount();
245
267
 
@@ -12,7 +12,6 @@ import {
12
12
  useCallback,
13
13
  useEffect,
14
14
  useRef,
15
- cloneElement,
16
15
  isValidElement,
17
16
  } from "react";
18
17
  import TextareaAutosize, {
@@ -22,6 +21,7 @@ import TextareaAutosize, {
22
21
  import { useEscapeKeydown } from "radix-ui/internal";
23
22
  import { useOnScrollToBottom } from "../../utils/hooks/useOnScrollToBottom";
24
23
  import { useMediaQuery } from "../../utils/hooks/useMediaQuery";
24
+ import { renderSlot } from "../../utils/Primitive";
25
25
  import { useAui } from "@assistant-ui/store";
26
26
  import { flushTapSync } from "@assistant-ui/tap";
27
27
  import { useComposerInputPluginRegistryOptional } from "./ComposerInputPluginContext";
@@ -434,14 +434,10 @@ export const ComposerPrimitiveInput = forwardRef<
434
434
  };
435
435
 
436
436
  if (render && isValidElement(render)) {
437
- const renderChildren =
438
- (rest as any).children !== undefined
439
- ? ((rest as any).children as ReactNode)
440
- : ((render.props as Record<string, unknown>).children as ReactNode);
441
- return (
442
- <Slot.Root {...inputProps}>
443
- {cloneElement(render, undefined, renderChildren)}
444
- </Slot.Root>
437
+ return renderSlot(
438
+ render,
439
+ (rest as { children?: ReactNode }).children,
440
+ inputProps,
445
441
  );
446
442
  }
447
443
 
@@ -51,16 +51,6 @@ export type ComposerInputPluginRegistry = {
51
51
  const ComposerInputPluginRegistryContext =
52
52
  createContext<ComposerInputPluginRegistry | null>(null);
53
53
 
54
- export const useComposerInputPluginRegistry =
55
- (): ComposerInputPluginRegistry => {
56
- const ctx = useContext(ComposerInputPluginRegistryContext);
57
- if (!ctx)
58
- throw new Error(
59
- "useComposerInputPluginRegistry must be used within a ComposerInputPluginProvider",
60
- );
61
- return ctx;
62
- };
63
-
64
54
  export const useComposerInputPluginRegistryOptional =
65
55
  (): ComposerInputPluginRegistry | null => {
66
56
  return useContext(ComposerInputPluginRegistryContext);
@@ -5,7 +5,7 @@ import type {
5
5
  Unstable_TriggerItem,
6
6
  } from "@assistant-ui/core";
7
7
  import { unstable_defaultDirectiveFormatter } from "@assistant-ui/core";
8
- import { useEffect, useRef, type FC } from "react";
8
+ import { useEffect, useInsertionEffect, useRef, type FC } from "react";
9
9
  import { useTriggerBehaviorRegistration } from "./TriggerPopover";
10
10
  import type { TriggerBehavior } from "./triggerSelectionResource";
11
11
 
@@ -42,7 +42,9 @@ export const ComposerPrimitiveTriggerPopoverAction: FC<
42
42
  > = ({ formatter, onExecute, removeOnExecute }) => {
43
43
  const { register } = useTriggerBehaviorRegistration();
44
44
  const onExecuteRef = useRef(onExecute);
45
- onExecuteRef.current = onExecute;
45
+ useInsertionEffect(() => {
46
+ onExecuteRef.current = onExecute;
47
+ }, [onExecute]);
46
48
 
47
49
  useEffect(() => {
48
50
  const behavior: TriggerBehavior = {