@assistant-ui/react 0.14.28 → 0.15.0

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 (190) hide show
  1. package/dist/client/ExternalThread.d.ts +11 -1
  2. package/dist/client/ExternalThread.d.ts.map +1 -1
  3. package/dist/client/ExternalThread.js +762 -516
  4. package/dist/client/ExternalThread.js.map +1 -1
  5. package/dist/client/InMemoryThreadList.js +3 -3
  6. package/dist/client/InMemoryThreadList.js.map +1 -1
  7. package/dist/context/index.d.ts +1 -8
  8. package/dist/context/index.js +1 -8
  9. package/dist/index.d.ts +1 -8
  10. package/dist/index.js +1 -8
  11. package/dist/legacy-runtime/cloud/auiV0.d.ts +12 -1
  12. package/dist/legacy-runtime/cloud/auiV0.d.ts.map +1 -1
  13. package/dist/legacy-runtime/cloud/auiV0.js +2 -1
  14. package/dist/legacy-runtime/cloud/auiV0.js.map +1 -1
  15. package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.d.ts +1 -1
  16. package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.js +7 -6
  17. package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.js.map +1 -1
  18. package/dist/legacy-runtime/runtime-cores/assistant-transport/runManager.d.ts.map +1 -1
  19. package/dist/legacy-runtime/runtime-cores/assistant-transport/runManager.js +30 -15
  20. package/dist/legacy-runtime/runtime-cores/assistant-transport/runManager.js.map +1 -1
  21. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.d.ts.map +1 -1
  22. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js +11 -7
  23. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js.map +1 -1
  24. package/dist/legacy-runtime/runtime-cores/assistant-transport/useConvertedState.js +1 -0
  25. package/dist/legacy-runtime/runtime-cores/assistant-transport/useConvertedState.js.map +1 -1
  26. package/dist/mcp-apps/McpAppRenderer.js +1 -1
  27. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  28. package/dist/model-context/makeAssistantVisible.js +26 -25
  29. package/dist/model-context/makeAssistantVisible.js.map +1 -1
  30. package/dist/primitives/actionBar/ActionBarExportMarkdown.js +3 -3
  31. package/dist/primitives/actionBar/ActionBarExportMarkdown.js.map +1 -1
  32. package/dist/primitives/actionBar/ActionBarRoot.js +3 -3
  33. package/dist/primitives/actionBar/ActionBarRoot.js.map +1 -1
  34. package/dist/primitives/actionBar/useActionBarFloatStatus.d.ts +7 -6
  35. package/dist/primitives/actionBar/useActionBarFloatStatus.d.ts.map +1 -1
  36. package/dist/primitives/actionBar/useActionBarFloatStatus.js +10 -11
  37. package/dist/primitives/actionBar/useActionBarFloatStatus.js.map +1 -1
  38. package/dist/primitives/attachment/AttachmentRemove.js +4 -3
  39. package/dist/primitives/attachment/AttachmentRemove.js.map +1 -1
  40. package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.js +4 -3
  41. package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.js.map +1 -1
  42. package/dist/primitives/composer/ComposerAddAttachment.d.ts.map +1 -1
  43. package/dist/primitives/composer/ComposerAddAttachment.js +11 -5
  44. package/dist/primitives/composer/ComposerAddAttachment.js.map +1 -1
  45. package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
  46. package/dist/primitives/composer/ComposerAttachmentDropzone.js +2 -4
  47. package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
  48. package/dist/primitives/composer/ComposerInput.js +18 -16
  49. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  50. package/dist/primitives/composer/ComposerQuote.js +1 -1
  51. package/dist/primitives/composer/ComposerQuote.js.map +1 -1
  52. package/dist/primitives/composer/ComposerSend.js +1 -0
  53. package/dist/primitives/composer/ComposerSend.js.map +1 -1
  54. package/dist/primitives/composer/ComposerStopDictation.js +4 -3
  55. package/dist/primitives/composer/ComposerStopDictation.js.map +1 -1
  56. package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts.map +1 -1
  57. package/dist/primitives/composer/trigger/triggerSelectionResource.js +3 -3
  58. package/dist/primitives/composer/trigger/triggerSelectionResource.js.map +1 -1
  59. package/dist/primitives/message/MessagePartsGrouped.d.ts.map +1 -1
  60. package/dist/primitives/message/MessagePartsGrouped.js +67 -89
  61. package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
  62. package/dist/primitives/message/MessageRoot.js +3 -3
  63. package/dist/primitives/message/MessageRoot.js.map +1 -1
  64. package/dist/primitives/queueItem/QueueItemRemove.js +4 -3
  65. package/dist/primitives/queueItem/QueueItemRemove.js.map +1 -1
  66. package/dist/primitives/queueItem/QueueItemSteer.js +4 -3
  67. package/dist/primitives/queueItem/QueueItemSteer.js.map +1 -1
  68. package/dist/primitives/selectionToolbar/SelectionToolbarQuote.js +1 -1
  69. package/dist/primitives/selectionToolbar/SelectionToolbarQuote.js.map +1 -1
  70. package/dist/primitives/suggestion/SuggestionTrigger.js +17 -15
  71. package/dist/primitives/suggestion/SuggestionTrigger.js.map +1 -1
  72. package/dist/primitives/thread/ThreadScrollToBottom.js +1 -0
  73. package/dist/primitives/thread/ThreadScrollToBottom.js.map +1 -1
  74. package/dist/unstable/useComposerInput.js +2 -2
  75. package/dist/unstable/useComposerInput.js.map +1 -1
  76. package/dist/unstable/useComposerInputHistory.js +6 -6
  77. package/dist/unstable/useComposerInputHistory.js.map +1 -1
  78. package/dist/unstable/useMentionAdapter.js +1 -1
  79. package/dist/unstable/useMentionAdapter.js.map +1 -1
  80. package/dist/utils/hooks/useOnResizeContent.js +1 -0
  81. package/dist/utils/hooks/useOnResizeContent.js.map +1 -1
  82. package/dist/utils/hooks/useSizeHandle.js +1 -0
  83. package/dist/utils/hooks/useSizeHandle.js.map +1 -1
  84. package/dist/utils/smooth/SmoothContext.js +3 -3
  85. package/dist/utils/smooth/SmoothContext.js.map +1 -1
  86. package/dist/utils/smooth/useSmooth.d.ts.map +1 -1
  87. package/dist/utils/smooth/useSmooth.js +3 -3
  88. package/dist/utils/smooth/useSmooth.js.map +1 -1
  89. package/package.json +9 -9
  90. package/src/client/ExternalThread.ts +345 -56
  91. package/src/client/InMemoryThreadList.ts +3 -3
  92. package/src/context/index.ts +0 -44
  93. package/src/legacy-runtime/cloud/auiV0.ts +14 -0
  94. package/src/legacy-runtime/runtime-cores/assistant-transport/commandQueue.ts +4 -1
  95. package/src/legacy-runtime/runtime-cores/assistant-transport/runManager.ts +37 -10
  96. package/src/legacy-runtime/runtime-cores/assistant-transport/transport-delivery.test.tsx +79 -0
  97. package/src/legacy-runtime/runtime-cores/assistant-transport/transport-parentid.test.tsx +97 -0
  98. package/src/legacy-runtime/runtime-cores/assistant-transport/transport-scheduling.test.ts +113 -2
  99. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransport.spec.md +10 -2
  100. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.test.tsx +185 -0
  101. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.ts +21 -5
  102. package/src/mcp-apps/McpAppRenderer.tsx +1 -1
  103. package/src/model-context/makeAssistantVisible.test.tsx +1 -1
  104. package/src/model-context/makeAssistantVisible.tsx +1 -1
  105. package/src/primitives/actionBar/ActionBarExportMarkdown.tsx +1 -1
  106. package/src/primitives/actionBar/useActionBarFloatStatus.ts +7 -5
  107. package/src/primitives/attachment/AttachmentRemove.ts +1 -1
  108. package/src/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.ts +1 -1
  109. package/src/primitives/composer/ComposerAddAttachment.test.tsx +108 -0
  110. package/src/primitives/composer/ComposerAddAttachment.ts +11 -5
  111. package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +5 -4
  112. package/src/primitives/composer/ComposerAttachmentDropzone.tsx +4 -3
  113. package/src/primitives/composer/ComposerInput.test.tsx +104 -7
  114. package/src/primitives/composer/ComposerInput.tsx +22 -20
  115. package/src/primitives/composer/ComposerQuote.tsx +1 -1
  116. package/src/primitives/composer/ComposerStopDictation.ts +1 -1
  117. package/src/primitives/composer/trigger/triggerSelectionResource.ts +7 -9
  118. package/src/primitives/message/MessagePartsGrouped.tsx +6 -8
  119. package/src/primitives/message/MessageRoot.tsx +1 -1
  120. package/src/primitives/queueItem/QueueItemRemove.ts +1 -1
  121. package/src/primitives/queueItem/QueueItemSteer.ts +1 -1
  122. package/src/primitives/selectionToolbar/SelectionToolbarQuote.tsx +1 -1
  123. package/src/primitives/suggestion/SuggestionTrigger.ts +9 -9
  124. package/src/tests/DataRenderers.test.tsx +212 -0
  125. package/src/tests/RemoteThreadListRuntime.adapterProvider.test.tsx +1 -11
  126. package/src/tests/RemoteThreadListRuntime.deferredProvider.test.tsx +3 -3
  127. package/src/tests/external-thread-attachments.test.tsx +300 -11
  128. package/src/tests/external-thread-branches.test.tsx +10 -10
  129. package/src/tests/external-thread-parity.test.tsx +230 -0
  130. package/src/tests/generative-ui.test.tsx +1 -46
  131. package/src/tests/local-runtime-queue.test.tsx +29 -34
  132. package/src/tests/messagePartSwitchRace.test.tsx +46 -15
  133. package/src/tests/messagePartTypeMismatch.test.tsx +2 -2
  134. package/src/tests/threadMessageById.test.tsx +0 -7
  135. package/src/unstable/useComposerInput.test.tsx +2 -2
  136. package/src/unstable/useComposerInput.ts +2 -2
  137. package/src/unstable/useComposerInputHistory.test.tsx +3 -3
  138. package/src/unstable/useComposerInputHistory.ts +6 -6
  139. package/src/unstable/useMentionAdapter.ts +1 -1
  140. package/src/utils/smooth/SmoothContext.tsx +1 -1
  141. package/src/utils/smooth/useSmooth.test.tsx +1 -1
  142. package/src/utils/smooth/useSmooth.ts +8 -5
  143. package/dist/context/react/utils/createStateHookForRuntime.d.ts +0 -34
  144. package/dist/context/react/utils/createStateHookForRuntime.d.ts.map +0 -1
  145. package/dist/context/react/utils/createStateHookForRuntime.js +0 -21
  146. package/dist/context/react/utils/createStateHookForRuntime.js.map +0 -1
  147. package/dist/legacy-runtime/hooks/AssistantContext.d.ts +0 -74
  148. package/dist/legacy-runtime/hooks/AssistantContext.d.ts.map +0 -1
  149. package/dist/legacy-runtime/hooks/AssistantContext.js +0 -29
  150. package/dist/legacy-runtime/hooks/AssistantContext.js.map +0 -1
  151. package/dist/legacy-runtime/hooks/AttachmentContext.d.ts +0 -1007
  152. package/dist/legacy-runtime/hooks/AttachmentContext.d.ts.map +0 -1
  153. package/dist/legacy-runtime/hooks/AttachmentContext.js +0 -47
  154. package/dist/legacy-runtime/hooks/AttachmentContext.js.map +0 -1
  155. package/dist/legacy-runtime/hooks/ComposerContext.d.ts +0 -141
  156. package/dist/legacy-runtime/hooks/ComposerContext.d.ts.map +0 -1
  157. package/dist/legacy-runtime/hooks/ComposerContext.js +0 -65
  158. package/dist/legacy-runtime/hooks/ComposerContext.js.map +0 -1
  159. package/dist/legacy-runtime/hooks/MessageContext.d.ts +0 -155
  160. package/dist/legacy-runtime/hooks/MessageContext.d.ts.map +0 -1
  161. package/dist/legacy-runtime/hooks/MessageContext.js +0 -68
  162. package/dist/legacy-runtime/hooks/MessageContext.js.map +0 -1
  163. package/dist/legacy-runtime/hooks/MessagePartContext.d.ts +0 -44
  164. package/dist/legacy-runtime/hooks/MessagePartContext.d.ts.map +0 -1
  165. package/dist/legacy-runtime/hooks/MessagePartContext.js +0 -26
  166. package/dist/legacy-runtime/hooks/MessagePartContext.js.map +0 -1
  167. package/dist/legacy-runtime/hooks/ThreadContext.d.ts +0 -139
  168. package/dist/legacy-runtime/hooks/ThreadContext.d.ts.map +0 -1
  169. package/dist/legacy-runtime/hooks/ThreadContext.js +0 -84
  170. package/dist/legacy-runtime/hooks/ThreadContext.js.map +0 -1
  171. package/dist/legacy-runtime/hooks/ThreadListItemContext.d.ts +0 -44
  172. package/dist/legacy-runtime/hooks/ThreadListItemContext.d.ts.map +0 -1
  173. package/dist/legacy-runtime/hooks/ThreadListItemContext.js +0 -26
  174. package/dist/legacy-runtime/hooks/ThreadListItemContext.js.map +0 -1
  175. package/src/context/react/utils/createStateHookForRuntime.ts +0 -82
  176. package/src/legacy-runtime/hooks/AssistantContext.ts +0 -67
  177. package/src/legacy-runtime/hooks/AttachmentContext.ts +0 -99
  178. package/src/legacy-runtime/hooks/ComposerContext.ts +0 -131
  179. package/src/legacy-runtime/hooks/MessageContext.ts +0 -127
  180. package/src/legacy-runtime/hooks/MessagePartContext.ts +0 -32
  181. package/src/legacy-runtime/hooks/ThreadContext.ts +0 -120
  182. package/src/legacy-runtime/hooks/ThreadListItemContext.ts +0 -36
  183. package/src/tests/BaseComposerRuntimeCore.test.ts +0 -621
  184. package/src/tests/BaseSubscribable.test.ts +0 -54
  185. package/src/tests/DataRenderers.test.ts +0 -355
  186. package/src/tests/ExternalStoreThreadRuntimeCore.test.ts +0 -685
  187. package/src/tests/LocalThreadRuntimeCore.test.ts +0 -324
  188. package/src/tests/auiV0Encode.test.ts +0 -188
  189. package/src/tests/external-message-converter.test.ts +0 -346
  190. package/src/tests/shouldContinue.test.ts +0 -122
@@ -0,0 +1,185 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, render, waitFor } from "@testing-library/react";
4
+ import type { FC } from "react";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { useAui } from "@assistant-ui/store";
7
+ import { AssistantRuntimeProvider } from "../../../context";
8
+ import {
9
+ useAssistantTransportRuntime,
10
+ useAssistantTransportSendCommand,
11
+ } from "./useAssistantTransportRuntime";
12
+ import type {
13
+ AssistantTransportCommand,
14
+ AssistantTransportOptions,
15
+ AssistantTransportStateConverter,
16
+ } from "./types";
17
+
18
+ const converter: AssistantTransportStateConverter<unknown> = (
19
+ _state,
20
+ meta,
21
+ ) => ({
22
+ messages: [],
23
+ isRunning: meta.isSending,
24
+ });
25
+
26
+ const createMessageCommand = (text: string): AssistantTransportCommand => ({
27
+ type: "add-message",
28
+ message: {
29
+ role: "user",
30
+ parts: [{ type: "text", text }],
31
+ },
32
+ parentId: null,
33
+ sourceId: null,
34
+ });
35
+
36
+ const createStreamResponse = () => {
37
+ let controller!: ReadableStreamDefaultController<Uint8Array>;
38
+ const stream = new ReadableStream<Uint8Array>({
39
+ start(c) {
40
+ controller = c;
41
+ },
42
+ });
43
+ return {
44
+ response: new Response(stream, { status: 200 }),
45
+ close: () => controller.close(),
46
+ };
47
+ };
48
+
49
+ type StreamResponse = ReturnType<typeof createStreamResponse>;
50
+
51
+ type RecordedRequest = {
52
+ url: string;
53
+ init: RequestInit;
54
+ body: Record<string, any>;
55
+ };
56
+
57
+ const installFetch = () => {
58
+ const requests: RecordedRequest[] = [];
59
+ const servers: StreamResponse[] = [];
60
+
61
+ vi.stubGlobal(
62
+ "fetch",
63
+ async (url: RequestInfo | URL, init: RequestInit = {}) => {
64
+ requests.push({
65
+ url: String(url),
66
+ init,
67
+ body: JSON.parse(init.body as string),
68
+ });
69
+ const server = createStreamResponse();
70
+ servers.push(server);
71
+ return server.response;
72
+ },
73
+ );
74
+
75
+ return { requests, servers };
76
+ };
77
+
78
+ const mountRuntime = (
79
+ options?: Partial<AssistantTransportOptions<unknown>>,
80
+ ) => {
81
+ const captured: {
82
+ aui?: ReturnType<typeof useAui>;
83
+ sendCommand?: (command: AssistantTransportCommand) => void;
84
+ } = {};
85
+ const Capture: FC = () => {
86
+ captured.aui = useAui();
87
+ captured.sendCommand = useAssistantTransportSendCommand();
88
+ return null;
89
+ };
90
+ const App: FC = () => {
91
+ const runtime = useAssistantTransportRuntime({
92
+ initialState: {},
93
+ api: "https://example.com/api",
94
+ headers: {},
95
+ converter,
96
+ ...options,
97
+ });
98
+ return (
99
+ <AssistantRuntimeProvider runtime={runtime}>
100
+ <Capture />
101
+ </AssistantRuntimeProvider>
102
+ );
103
+ };
104
+ const utils = render(<App />);
105
+ return {
106
+ aui: () => captured.aui!,
107
+ sendCommand: (command: AssistantTransportCommand) =>
108
+ captured.sendCommand!(command),
109
+ ...utils,
110
+ };
111
+ };
112
+
113
+ afterEach(() => {
114
+ vi.unstubAllGlobals();
115
+ });
116
+
117
+ describe("useAssistantTransportRuntime", () => {
118
+ it("no-ops a follow-up run that finds an empty queue", async () => {
119
+ const fetchMock = installFetch();
120
+ const onError = vi.fn();
121
+ const { aui, sendCommand } = mountRuntime({ onError });
122
+ await waitFor(() =>
123
+ expect(
124
+ (aui().thread.getState().extras as { sendCommand?: unknown })
125
+ ?.sendCommand,
126
+ ).toBeTypeOf("function"),
127
+ );
128
+
129
+ // Two synchronous sends coalesce into one request plus one follow-up run.
130
+ act(() => {
131
+ sendCommand(createMessageCommand("a"));
132
+ sendCommand(createMessageCommand("b"));
133
+ });
134
+
135
+ await waitFor(() => expect(fetchMock.requests).toHaveLength(1));
136
+ expect(
137
+ fetchMock.requests[0]!.body["commands"].map((c: any) => c.type),
138
+ ).toEqual(["add-message", "add-message"]);
139
+
140
+ act(() => fetchMock.servers[0]!.close());
141
+
142
+ await waitFor(() => expect(aui().thread.getState().isRunning).toBe(false));
143
+ await act(async () => {});
144
+ expect(onError).not.toHaveBeenCalled();
145
+ expect(fetchMock.requests).toHaveLength(1);
146
+ });
147
+
148
+ it("flushes commands enqueued during a resume run in a follow-up run", async () => {
149
+ const fetchMock = installFetch();
150
+ const { aui, sendCommand } = mountRuntime({
151
+ resumeApi: "https://example.com/resume",
152
+ });
153
+ await waitFor(() =>
154
+ expect(
155
+ (aui().thread.getState().extras as { sendCommand?: unknown })
156
+ ?.sendCommand,
157
+ ).toBeTypeOf("function"),
158
+ );
159
+
160
+ act(() => sendCommand(createMessageCommand("a")));
161
+ await waitFor(() => expect(fetchMock.requests).toHaveLength(1));
162
+
163
+ // Both land while the first run is active and coalesce into one follow-up.
164
+ act(() => {
165
+ sendCommand(createMessageCommand("b"));
166
+ aui().thread.resumeRun({ parentId: null });
167
+ });
168
+
169
+ act(() => fetchMock.servers[0]!.close());
170
+ await waitFor(() => expect(fetchMock.requests).toHaveLength(2));
171
+ expect(fetchMock.requests[1]!.url).toBe("https://example.com/resume");
172
+ expect(fetchMock.requests[1]!.body["commands"]).toEqual([]);
173
+
174
+ // "b" coalesced into the resume run and must not starve in the queue.
175
+ act(() => fetchMock.servers[1]!.close());
176
+ await waitFor(() => expect(fetchMock.requests).toHaveLength(3));
177
+ expect(fetchMock.requests[2]!.url).toBe("https://example.com/api");
178
+ expect(fetchMock.requests[2]!.body["commands"]).toEqual([
179
+ createMessageCommand("b"),
180
+ ]);
181
+
182
+ act(() => fetchMock.servers[2]!.close());
183
+ await waitFor(() => expect(aui().thread.getState().isRunning).toBe(false));
184
+ });
185
+ });
@@ -96,7 +96,7 @@ export const useAssistantTransportSendCommand = () => {
96
96
  const aui = useAui();
97
97
 
98
98
  return (command: AssistantTransportCommand) => {
99
- const extras = aui.thread().getState().extras;
99
+ const extras = aui.thread.getState().extras;
100
100
  const transportExtras = asAssistantTransportExtras(extras);
101
101
  transportExtras.sendCommand(command);
102
102
  };
@@ -135,8 +135,13 @@ const useAssistantTransportThreadRuntime = <T>(
135
135
  resumeFlagRef.current = false;
136
136
  setIsReplaying(false);
137
137
  const commands: QueuedCommand[] = isResume ? [] : commandQueue.flush();
138
- if (commands.length === 0 && !isResume)
139
- throw new Error("No commands to send");
138
+ if (commands.length === 0 && !isResume) return;
139
+
140
+ // The flushed batch consumes the parentId; read it alongside the flush
141
+ // (before any awaits) so a mid-run append keeps its own value. Resume
142
+ // runs send no commands, so they neither send nor consume it.
143
+ const parentId = isResume ? undefined : parentIdRef.current;
144
+ if (!isResume) parentIdRef.current = undefined;
140
145
 
141
146
  const headers = await createRequestHeaders(options.headers);
142
147
  const bodyValue =
@@ -151,8 +156,8 @@ const useAssistantTransportThreadRuntime = <T>(
151
156
  system: context.system,
152
157
  tools: context.tools ? toToolsJSONSchema(context.tools) : undefined,
153
158
  threadId,
154
- ...(parentIdRef.current !== undefined && {
155
- parentId: parentIdRef.current,
159
+ ...(parentId !== undefined && {
160
+ parentId,
156
161
  }),
157
162
  // nested (new format, aligned with AssistantChatTransport)
158
163
  callSettings: context.callSettings,
@@ -232,6 +237,17 @@ const useAssistantTransportThreadRuntime = <T>(
232
237
  if (err) {
233
238
  throw new Error(err);
234
239
  }
240
+
241
+ // A successful run confirms delivery even when no state-changing
242
+ // chunk was observed.
243
+ if (!markedDelivered) {
244
+ commandQueue.markDelivered();
245
+ }
246
+
247
+ // commands that coalesced into this resume run must not starve
248
+ if (isResume && commandQueue.state.queued.length > 0) {
249
+ runManager.schedule();
250
+ }
235
251
  },
236
252
  onFinish: options.onFinish,
237
253
  onCancel: () => {
@@ -162,7 +162,7 @@ function InlineRenderer({
162
162
  sendMessage: (params) => {
163
163
  const text = extractSendMessageText(params);
164
164
  if (!text) return { ok: false, reason: "unrecognised params shape" };
165
- aui.thread().append({ content: [{ type: "text", text }] });
165
+ aui.thread.append({ content: [{ type: "text", text }] });
166
166
  return { ok: true };
167
167
  },
168
168
  callTool: (params) =>
@@ -14,7 +14,7 @@ const { registerMock, auiMock } = vi.hoisted(() => {
14
14
  return {
15
15
  registerMock: register,
16
16
  auiMock: {
17
- modelContext: () => ({ register }),
17
+ modelContext: { register },
18
18
  },
19
19
  };
20
20
  });
@@ -104,7 +104,7 @@ export const makeAssistantVisible = <T extends ComponentType<any>>(
104
104
 
105
105
  const { clickable, editable } = config ?? {};
106
106
  useEffect(() => {
107
- return aui.modelContext().register({
107
+ return aui.modelContext.register({
108
108
  getModelContext: () => {
109
109
  return {
110
110
  tools: {
@@ -23,7 +23,7 @@ const useActionBarExportMarkdown = ({
23
23
  });
24
24
 
25
25
  const callback = useCallback(async () => {
26
- const content = aui.message().getCopyText();
26
+ const content = aui.message.getCopyText();
27
27
  if (!content) return;
28
28
 
29
29
  if (onExport) {
@@ -2,11 +2,13 @@
2
2
 
3
3
  import { useAuiState } from "@assistant-ui/store";
4
4
 
5
- export enum HideAndFloatStatus {
6
- Hidden = "hidden",
7
- Floating = "floating",
8
- Normal = "normal",
9
- }
5
+ export const HideAndFloatStatus = {
6
+ Hidden: "hidden",
7
+ Floating: "floating",
8
+ Normal: "normal",
9
+ } as const;
10
+ export type HideAndFloatStatus =
11
+ (typeof HideAndFloatStatus)[keyof typeof HideAndFloatStatus];
10
12
 
11
13
  export type UseActionBarFloatStatusProps = {
12
14
  hideWhenRunning?: boolean | undefined;
@@ -12,7 +12,7 @@ const useAttachmentRemove = () => {
12
12
  const aui = useAui();
13
13
 
14
14
  const handleRemoveAttachment = useCallback(() => {
15
- aui.attachment().remove();
15
+ aui.attachment.remove();
16
16
  }, [aui]);
17
17
 
18
18
  return handleRemoveAttachment;
@@ -13,7 +13,7 @@ const useChainOfThoughtAccordionTrigger = () => {
13
13
  const collapsed = useAuiState((s) => s.chainOfThought.collapsed);
14
14
 
15
15
  const callback = useCallback(() => {
16
- aui.chainOfThought().setCollapsed(!collapsed);
16
+ aui.chainOfThought.setCollapsed(!collapsed);
17
17
  }, [aui, collapsed]);
18
18
 
19
19
  return callback;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { act } from "react";
5
+ import { createRoot, type Root } from "react-dom/client";
6
+ import type * as CoreReact from "@assistant-ui/core/react";
7
+ import type * as AssistantStore from "@assistant-ui/store";
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9
+ import { ComposerPrimitiveAddAttachment } from "./ComposerAddAttachment";
10
+
11
+ const { addAttachment } = vi.hoisted(() => ({
12
+ addAttachment: vi.fn<(file: File) => Promise<void>>(),
13
+ }));
14
+
15
+ (globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
16
+
17
+ vi.mock("@assistant-ui/core/react", async (importOriginal) => {
18
+ const actual = await importOriginal<typeof CoreReact>();
19
+ return {
20
+ ...actual,
21
+ useComposerAddAttachment: () => ({
22
+ disabled: false,
23
+ addAttachment,
24
+ }),
25
+ };
26
+ });
27
+
28
+ vi.mock("@assistant-ui/store", async (importOriginal) => {
29
+ const actual = await importOriginal<typeof AssistantStore>();
30
+ return {
31
+ ...actual,
32
+ useAui: () => ({
33
+ composer: {
34
+ getState: () => ({ attachmentAccept: "*" }),
35
+ },
36
+ }),
37
+ };
38
+ });
39
+
40
+ describe("ComposerPrimitiveAddAttachment", () => {
41
+ let container: HTMLDivElement;
42
+ let root: Root;
43
+
44
+ beforeEach(() => {
45
+ addAttachment.mockReset();
46
+ vi.spyOn(HTMLInputElement.prototype, "click").mockImplementation(() => {});
47
+
48
+ container = document.createElement("div");
49
+ document.body.appendChild(container);
50
+ root = createRoot(container);
51
+ });
52
+
53
+ afterEach(async () => {
54
+ await act(async () => {
55
+ root.unmount();
56
+ });
57
+ document.querySelectorAll('input[type="file"]').forEach((input) => {
58
+ input.remove();
59
+ });
60
+ container.remove();
61
+ vi.restoreAllMocks();
62
+ });
63
+
64
+ it("handles rejected file picker attachments and continues processing", async () => {
65
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
66
+
67
+ addAttachment
68
+ .mockRejectedValueOnce(new Error("unsupported file"))
69
+ .mockResolvedValueOnce(undefined);
70
+
71
+ await act(async () => {
72
+ root.render(
73
+ <ComposerPrimitiveAddAttachment>
74
+ Add attachment
75
+ </ComposerPrimitiveAddAttachment>,
76
+ );
77
+ });
78
+
79
+ const button = container.querySelector("button");
80
+ expect(button).not.toBeNull();
81
+
82
+ await act(async () => {
83
+ button!.click();
84
+ });
85
+
86
+ const input =
87
+ document.querySelector<HTMLInputElement>('input[type="file"]');
88
+ expect(input).not.toBeNull();
89
+
90
+ const files = [
91
+ new File(["a"], "unsupported.bin"),
92
+ new File(["b"], "supported.txt", { type: "text/plain" }),
93
+ ];
94
+ Object.defineProperty(input, "files", {
95
+ configurable: true,
96
+ value: files,
97
+ });
98
+
99
+ const result = input!.onchange?.call(input, {
100
+ target: input,
101
+ } as unknown as Event);
102
+
103
+ await expect(Promise.resolve(result)).resolves.toBeUndefined();
104
+ expect(addAttachment).toHaveBeenCalledTimes(2);
105
+ expect(errorSpy).not.toHaveBeenCalled();
106
+ expect(input!.isConnected).toBe(false);
107
+ });
108
+ });
@@ -24,21 +24,27 @@ const useComposerAddAttachment = ({
24
24
  input.multiple = multiple;
25
25
  input.hidden = true;
26
26
 
27
- const attachmentAccept = aui.composer().getState().attachmentAccept;
27
+ const attachmentAccept = aui.composer.getState().attachmentAccept;
28
28
  if (attachmentAccept !== "*") {
29
29
  input.accept = attachmentAccept;
30
30
  }
31
31
 
32
32
  document.body.appendChild(input);
33
33
 
34
- input.onchange = (e) => {
34
+ input.onchange = async (e) => {
35
35
  const fileList = (e.target as HTMLInputElement).files;
36
36
  if (!fileList) return;
37
- for (const file of fileList) {
38
- addAttachment(file);
39
- }
37
+
38
+ const attachmentPromises = Array.from(fileList, async (file) => {
39
+ try {
40
+ await addAttachment(file);
41
+ } catch {
42
+ // The composer runtime emits composer.attachmentAddError before rejecting.
43
+ }
44
+ });
40
45
 
41
46
  document.body.removeChild(input);
47
+ await Promise.all(attachmentPromises);
42
48
  };
43
49
 
44
50
  input.oncancel = () => {
@@ -15,9 +15,9 @@ vi.mock("@assistant-ui/store", async (importOriginal) => {
15
15
  return {
16
16
  ...actual,
17
17
  useAui: () => ({
18
- composer: () => ({
18
+ composer: {
19
19
  addAttachment,
20
- }),
20
+ },
21
21
  }),
22
22
  };
23
23
  });
@@ -95,7 +95,7 @@ describe("ComposerPrimitiveAttachmentDropzone", () => {
95
95
  });
96
96
  });
97
97
 
98
- it("continues processing other files when one attachment fails", async () => {
98
+ it("continues processing other files when one attachment fails without console.error", async () => {
99
99
  const error = new Error("upload failed");
100
100
  const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
101
101
 
@@ -120,6 +120,7 @@ describe("ComposerPrimitiveAttachmentDropzone", () => {
120
120
  });
121
121
 
122
122
  expect(addAttachment).toHaveBeenCalledTimes(3);
123
- expect(errorSpy).toHaveBeenCalledWith("Failed to add attachment:", error);
123
+ expect(errorSpy).not.toHaveBeenCalled();
124
+ errorSpy.mockRestore();
124
125
  });
125
126
  });
@@ -70,9 +70,10 @@ export const ComposerPrimitiveAttachmentDropzone = forwardRef<
70
70
  await Promise.all(
71
71
  files.map(async (file) => {
72
72
  try {
73
- await aui.composer().addAttachment(file);
74
- } catch (error) {
75
- console.error("Failed to add attachment:", error);
73
+ await aui.composer.addAttachment(file);
74
+ } catch {
75
+ // The composer runtime emits composer.attachmentAddError before rejecting;
76
+ // the readonly and empty-thread stubs only throw.
76
77
  }
77
78
  }),
78
79
  );
@@ -9,6 +9,7 @@ import { ComposerPrimitiveInput } from "./ComposerInput";
9
9
  const setText = vi.fn<(text: string) => void>();
10
10
  const setCursorPosition = vi.fn<(pos: number) => void>();
11
11
  const sendSpy = vi.fn<(options?: { steer?: boolean }) => void>();
12
+ const addAttachment = vi.fn<(file: File) => Promise<void>>();
12
13
 
13
14
  const composerState = {
14
15
  isEditing: true,
@@ -37,16 +38,16 @@ let pluginRegistry: { getPlugins: () => (typeof plugin)[] } | null = null;
37
38
 
38
39
  vi.mock("@assistant-ui/store", () => {
39
40
  const aui = {
40
- composer: () => ({
41
- setText,
41
+ composer: {
42
+ setText: (text: string) => setText(text),
42
43
  getState: () => composerState,
43
44
  cancel: () => {},
44
- send: sendSpy,
45
- addAttachment: async () => {},
46
- }),
47
- thread: () => ({
45
+ send: (options?: { steer?: boolean }) => sendSpy(options),
46
+ addAttachment: (file: File) => addAttachment(file),
47
+ },
48
+ thread: {
48
49
  getState: () => threadState,
49
- }),
50
+ },
50
51
  on: () => () => {},
51
52
  };
52
53
  type Selector<T> = (s: {
@@ -140,6 +141,16 @@ const fireKeyDown = (
140
141
  return event;
141
142
  };
142
143
 
144
+ const firePaste = (textarea: HTMLTextAreaElement, files: File[]) => {
145
+ const event = new Event("paste", { bubbles: true, cancelable: true });
146
+ Object.defineProperty(event, "clipboardData", {
147
+ value: { files },
148
+ configurable: true,
149
+ });
150
+ textarea.dispatchEvent(event);
151
+ return event;
152
+ };
153
+
143
154
  const setMatchMedia = (matches: boolean) => {
144
155
  Object.defineProperty(window, "matchMedia", {
145
156
  writable: true,
@@ -169,6 +180,7 @@ describe("ComposerPrimitiveInput", () => {
169
180
  setText.mockReset();
170
181
  setCursorPosition.mockReset();
171
182
  sendSpy.mockReset();
183
+ addAttachment.mockReset();
172
184
  composerState.isEditing = true;
173
185
  composerState.text = "";
174
186
  composerState.isEmpty = true;
@@ -501,4 +513,89 @@ describe("ComposerPrimitiveInput", () => {
501
513
  expect(event.defaultPrevented).toBe(true);
502
514
  });
503
515
  });
516
+
517
+ describe("paste attachments", () => {
518
+ it("attempts every pasted file and prevents default when attachments are supported", async () => {
519
+ threadState.capabilities = { queue: false, attachments: true };
520
+
521
+ const textarea = await mount();
522
+
523
+ let event!: Event;
524
+ await act(async () => {
525
+ event = firePaste(textarea, [
526
+ new File(["a"], "first.txt", { type: "text/plain" }),
527
+ new File(["b"], "second.txt", { type: "text/plain" }),
528
+ new File(["c"], "third.txt", { type: "text/plain" }),
529
+ ]);
530
+ await Promise.resolve();
531
+ await Promise.resolve();
532
+ });
533
+
534
+ expect(addAttachment).toHaveBeenCalledTimes(3);
535
+ expect(addAttachment.mock.calls.map(([file]) => file.name)).toEqual([
536
+ "first.txt",
537
+ "second.txt",
538
+ "third.txt",
539
+ ]);
540
+ expect(event.defaultPrevented).toBe(true);
541
+ });
542
+
543
+ it("continues processing other files when one attachment fails without console.error", async () => {
544
+ threadState.capabilities = { queue: false, attachments: true };
545
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
546
+
547
+ addAttachment
548
+ .mockRejectedValueOnce(new Error("upload failed"))
549
+ .mockResolvedValueOnce(undefined)
550
+ .mockResolvedValueOnce(undefined);
551
+
552
+ const textarea = await mount();
553
+
554
+ await act(async () => {
555
+ firePaste(textarea, [
556
+ new File(["a"], "first.txt", { type: "text/plain" }),
557
+ new File(["b"], "second.txt", { type: "text/plain" }),
558
+ new File(["c"], "third.txt", { type: "text/plain" }),
559
+ ]);
560
+ await Promise.resolve();
561
+ await Promise.resolve();
562
+ });
563
+
564
+ expect(addAttachment).toHaveBeenCalledTimes(3);
565
+ expect(errorSpy).not.toHaveBeenCalled();
566
+ errorSpy.mockRestore();
567
+ });
568
+
569
+ it("does not add attachments on paste when attachments are unsupported", async () => {
570
+ threadState.capabilities = { queue: false, attachments: false };
571
+
572
+ const textarea = await mount();
573
+
574
+ let event!: Event;
575
+ await act(async () => {
576
+ event = firePaste(textarea, [
577
+ new File(["a"], "first.txt", { type: "text/plain" }),
578
+ ]);
579
+ await Promise.resolve();
580
+ });
581
+
582
+ expect(addAttachment).not.toHaveBeenCalled();
583
+ expect(event.defaultPrevented).toBe(false);
584
+ });
585
+
586
+ it("skips attachment handling when addAttachmentOnPaste is false", async () => {
587
+ threadState.capabilities = { queue: false, attachments: true };
588
+
589
+ const textarea = await mount({ addAttachmentOnPaste: false });
590
+
591
+ await act(async () => {
592
+ firePaste(textarea, [
593
+ new File(["a"], "first.txt", { type: "text/plain" }),
594
+ ]);
595
+ await Promise.resolve();
596
+ });
597
+
598
+ expect(addAttachment).not.toHaveBeenCalled();
599
+ });
600
+ });
504
601
  });