@assistant-ui/react 0.15.12 → 0.15.13

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.
@@ -1,118 +0,0 @@
1
- import { useMemo, useState } from "react";
2
- import { resource } from "@assistant-ui/tap";
3
- import {
4
- type ClientElement,
5
- type ClientOutput,
6
- useClientResource,
7
- } from "@assistant-ui/store";
8
-
9
- const RESOLVED_PROMISE = Promise.resolve();
10
- const THREAD_ID = "default";
11
-
12
- const useSingleThreadListItem = ({
13
- isRunning,
14
- }: {
15
- isRunning: boolean;
16
- }): ClientOutput<"threadListItem"> => {
17
- const [custom, setCustom] = useState<Record<string, unknown> | undefined>();
18
-
19
- return {
20
- getState: () => ({
21
- id: THREAD_ID,
22
- remoteId: undefined,
23
- externalId: undefined,
24
- title: undefined,
25
- status: "regular",
26
- custom,
27
- isRunning,
28
- }),
29
- switchTo: () => {},
30
- rename: () => {},
31
- updateCustom: setCustom,
32
- archive: () => {},
33
- unarchive: () => {},
34
- delete: () => {},
35
- generateTitle: () => {},
36
- initialize: async () => ({ remoteId: THREAD_ID, externalId: undefined }),
37
- detach: () => {},
38
- };
39
- };
40
-
41
- const SingleThreadListItem = resource(useSingleThreadListItem);
42
-
43
- type SingleThreadListProps = {
44
- thread: ClientElement<"thread">;
45
- };
46
-
47
- /**
48
- * A minimal threads scope that wraps a single thread.
49
- * Automatically provided by ExternalThread when no threads scope exists.
50
- * Mounts the provided thread resource element.
51
- */
52
- const useSingleThreadList = ({
53
- thread,
54
- }: SingleThreadListProps): ClientOutput<"threads"> => {
55
- const threadClient = useClientResource(thread);
56
- const itemClient = useClientResource(
57
- SingleThreadListItem({ isRunning: threadClient.state.isRunning }),
58
- );
59
-
60
- const state = useMemo(
61
- () => ({
62
- mainThreadId: THREAD_ID,
63
- newThreadId: null,
64
- isLoading: false,
65
- isLoadingMore: false,
66
- hasMore: false,
67
- threadIds: [THREAD_ID],
68
- archivedThreadIds: [],
69
- threadItems: [itemClient.state],
70
- main: threadClient.state,
71
- }),
72
- [itemClient.state, threadClient.state],
73
- );
74
-
75
- return {
76
- getState: () => state,
77
- switchToThread: () => {
78
- throw new Error("SingleThreadList does not support switchToThread");
79
- },
80
- switchToNewThread: () => {
81
- throw new Error("SingleThreadList does not support switchToNewThread");
82
- },
83
- getLoadThreadsPromise: () => RESOLVED_PROMISE,
84
- reload: () => RESOLVED_PROMISE,
85
- reloadMainThread: () => RESOLVED_PROMISE,
86
- loadMore: () => RESOLVED_PROMISE,
87
- item: (selector) => {
88
- if (
89
- selector !== "main" &&
90
- !(
91
- typeof selector === "object" &&
92
- "id" in selector &&
93
- selector.id === THREAD_ID
94
- ) &&
95
- !(
96
- typeof selector === "object" &&
97
- "index" in selector &&
98
- selector.index === 0
99
- )
100
- ) {
101
- throw new Error(
102
- `SingleThreadList: unknown item selector ${JSON.stringify(selector)}`,
103
- );
104
- }
105
- return itemClient.methods;
106
- },
107
- thread: (selector) => {
108
- if (selector !== "main" && selector !== THREAD_ID) {
109
- throw new Error(
110
- `SingleThreadList: unknown thread selector ${JSON.stringify(selector)}`,
111
- );
112
- }
113
- return threadClient.methods;
114
- },
115
- };
116
- };
117
-
118
- export const SingleThreadList = resource(useSingleThreadList);
@@ -1,370 +0,0 @@
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 { AuiProvider, useAui } from "@assistant-ui/store";
7
- import type {
8
- ExternalThreadMessage,
9
- ExternalThreadProps,
10
- } from "../client/ExternalThread";
11
- import { ExternalThread } from "../client/ExternalThread";
12
-
13
- const renderThreadWithProps = (props: Partial<ExternalThreadProps>) => {
14
- const captured: { aui?: ReturnType<typeof useAui> } = {};
15
- const Capture: FC = () => {
16
- captured.aui = useAui();
17
- return null;
18
- };
19
- const App: FC = () => {
20
- const aui = useAui({
21
- thread: ExternalThread({ messages: [], isRunning: false, ...props }),
22
- });
23
- return (
24
- <AuiProvider value={aui}>
25
- <Capture />
26
- </AuiProvider>
27
- );
28
- };
29
-
30
- render(<App />);
31
- return () => captured.aui!;
32
- };
33
-
34
- const renderThread = () => {
35
- const captured: { aui?: ReturnType<typeof useAui> } = {};
36
- const Capture: FC = () => {
37
- captured.aui = useAui();
38
- return null;
39
- };
40
- const App: FC = () => {
41
- const aui = useAui({
42
- thread: ExternalThread({ messages: [], isRunning: false }),
43
- });
44
- return (
45
- <AuiProvider value={aui}>
46
- <Capture />
47
- </AuiProvider>
48
- );
49
- };
50
-
51
- render(<App />);
52
- return () => captured.aui!;
53
- };
54
-
55
- describe("ExternalThread attachments", () => {
56
- it("adds prepared attachments when File is unavailable", async () => {
57
- const aui = renderThread();
58
- const fileConstructor = globalThis.File;
59
- vi.stubGlobal("File", undefined);
60
-
61
- try {
62
- await act(async () => {
63
- await aui()
64
- .thread()
65
- .composer()
66
- .addAttachment({
67
- name: "notes.txt",
68
- contentType: "text/plain",
69
- content: [{ type: "text", text: "hello" }],
70
- });
71
- });
72
- } finally {
73
- vi.stubGlobal("File", fileConstructor);
74
- }
75
-
76
- await waitFor(() => {
77
- expect(aui().thread.composer().getState().attachments[0]).toMatchObject({
78
- type: "document",
79
- name: "notes.txt",
80
- contentType: "text/plain",
81
- content: [{ type: "text", text: "hello" }],
82
- });
83
- });
84
- });
85
-
86
- it("preserves foreign files that expose content", async () => {
87
- const aui = renderThread();
88
- const foreignFile = {
89
- name: "photo.png",
90
- type: "image/png",
91
- lastModified: 0,
92
- content: [{ type: "text", text: "implementation detail" }],
93
- } as File;
94
-
95
- await act(() => aui().thread.composer().addAttachment(foreignFile));
96
-
97
- expect(aui().thread.composer().getState().attachments[0]).toMatchObject({
98
- type: "file",
99
- name: "photo.png",
100
- contentType: "image/png",
101
- file: foreignFile,
102
- });
103
- });
104
-
105
- it("rejects files that do not match the adapter accept string", async () => {
106
- const add = vi.fn(async () => ({}) as never);
107
- const aui = renderThreadWithProps({
108
- attachmentAdapter: {
109
- accept: "image/*",
110
- add,
111
- send: async () => ({}) as never,
112
- remove: async () => {},
113
- },
114
- });
115
-
116
- await expect(
117
- aui()
118
- .thread()
119
- .composer()
120
- .addAttachment(
121
- new File(["data"], "archive.zip", { type: "application/zip" }),
122
- ),
123
- ).rejects.toThrow(
124
- "File type application/zip is not accepted. Accepted types: image/*",
125
- );
126
- expect(add).not.toHaveBeenCalled();
127
- expect(aui().thread.composer().getState().attachments).toHaveLength(0);
128
- });
129
-
130
- it("rejects prepared attachments that do not match the adapter accept string", async () => {
131
- const aui = renderThreadWithProps({
132
- attachmentAdapter: {
133
- accept: ".pdf",
134
- add: async () => ({}) as never,
135
- send: async () => ({}) as never,
136
- remove: async () => {},
137
- },
138
- });
139
-
140
- await expect(
141
- aui()
142
- .thread()
143
- .composer()
144
- .addAttachment({
145
- name: "notes.txt",
146
- contentType: "text/plain",
147
- content: [{ type: "text", text: "hello" }],
148
- }),
149
- ).rejects.toThrow(
150
- "File type text/plain is not accepted. Accepted types: .pdf",
151
- );
152
- expect(aui().thread.composer().getState().attachments).toHaveLength(0);
153
- });
154
-
155
- it("restores the draft when an attachment upload fails on send", async () => {
156
- const consoleError = vi
157
- .spyOn(console, "error")
158
- .mockImplementation(() => {});
159
- const sent: unknown[] = [];
160
- let rejectSend!: (reason: unknown) => void;
161
- const adapter = {
162
- accept: "*",
163
- add: async ({ file }: { file: File }) => ({
164
- id: "att-1",
165
- type: "file" as const,
166
- name: file.name,
167
- contentType: file.type,
168
- file,
169
- status: {
170
- type: "requires-action" as const,
171
- reason: "composer-send" as const,
172
- },
173
- }),
174
- send: () =>
175
- new Promise((_resolve, reject) => {
176
- rejectSend = reject;
177
- }) as never,
178
- remove: async () => {},
179
- };
180
-
181
- const aui = renderThreadWithProps({
182
- attachmentAdapter: adapter,
183
- onNew: (message) => sent.push(message),
184
- });
185
-
186
- await act(() =>
187
- aui()
188
- .thread()
189
- .composer()
190
- .addAttachment(new File(["data"], "notes.txt", { type: "text/plain" })),
191
- );
192
- await act(async () => {
193
- aui().thread.composer().setText("hello");
194
- });
195
- await act(async () => {
196
- aui().thread.composer().send();
197
- });
198
-
199
- // The draft is cleared optimistically while the upload runs.
200
- expect(aui().thread.composer().getState().text).toBe("");
201
-
202
- await act(async () => {
203
- rejectSend(new Error("upload failed"));
204
- });
205
-
206
- await waitFor(() => {
207
- const state = aui().thread.composer().getState();
208
- expect(state.text).toBe("hello");
209
- expect(state.attachments).toHaveLength(1);
210
- });
211
- expect(sent).toHaveLength(0);
212
- expect(consoleError).toHaveBeenCalledWith(
213
- "Failed to send attachments",
214
- expect.any(Error),
215
- );
216
- });
217
-
218
- it.each([
219
- [
220
- "clearAttachments",
221
- (c: { clearAttachments(): Promise<void> }) => c.clearAttachments(),
222
- ],
223
- ["reset", (c: { reset(): Promise<void> }) => c.reset()],
224
- ] as const)(
225
- "calls adapter.remove for pending attachments on %s",
226
- async (_name, invoke) => {
227
- const remove = vi.fn(async () => {});
228
- const adapter = {
229
- accept: "*",
230
- add: async ({ file }: { file: File }) => ({
231
- id: "att-1",
232
- type: "file" as const,
233
- name: file.name,
234
- contentType: file.type,
235
- file,
236
- status: {
237
- type: "requires-action" as const,
238
- reason: "composer-send" as const,
239
- },
240
- }),
241
- send: async () => ({}) as never,
242
- remove,
243
- };
244
-
245
- const aui = renderThreadWithProps({ attachmentAdapter: adapter });
246
-
247
- await act(() =>
248
- aui()
249
- .thread()
250
- .composer()
251
- .addAttachment(
252
- new File(["data"], "notes.txt", { type: "text/plain" }),
253
- ),
254
- );
255
-
256
- await act(() => invoke(aui().thread.composer()));
257
-
258
- expect(remove).toHaveBeenCalledTimes(1);
259
- expect(remove).toHaveBeenCalledWith(
260
- expect.objectContaining({ id: "att-1" }),
261
- );
262
- expect(aui().thread.composer().getState().attachments).toHaveLength(0);
263
- },
264
- );
265
-
266
- it("routes edit-composer attachments through the adapter", async () => {
267
- const add = vi.fn(async ({ file }: { file: File }) => ({
268
- id: "att-edit",
269
- type: "file" as const,
270
- name: file.name,
271
- contentType: file.type,
272
- file,
273
- status: {
274
- type: "requires-action" as const,
275
- reason: "composer-send" as const,
276
- },
277
- }));
278
- const aui = renderThreadWithProps({
279
- messages: [
280
- {
281
- id: "u1",
282
- role: "user",
283
- content: [{ type: "text", text: "hi" }],
284
- createdAt: new Date(0),
285
- attachments: [],
286
- metadata: { custom: {} },
287
- } as unknown as ExternalThreadMessage,
288
- ],
289
- onEdit: () => {},
290
- attachmentAdapter: {
291
- accept: "*",
292
- add,
293
- send: async () => ({}) as never,
294
- remove: async () => {},
295
- },
296
- });
297
-
298
- const composer = () => aui().thread.message({ id: "u1" }).composer();
299
- await act(async () => {
300
- composer().beginEdit();
301
- });
302
- await act(() =>
303
- composer().addAttachment(
304
- new File(["data"], "notes.txt", { type: "text/plain" }),
305
- ),
306
- );
307
-
308
- expect(add).toHaveBeenCalledTimes(1);
309
- expect(composer().getState().attachments[0]).toMatchObject({
310
- id: "att-edit",
311
- name: "notes.txt",
312
- });
313
- });
314
-
315
- it("merges the failed send into a draft the user already modified", async () => {
316
- vi.spyOn(console, "error").mockImplementation(() => {});
317
- let rejectSend!: (reason: unknown) => void;
318
- const adapter = {
319
- accept: "*",
320
- add: async ({ file }: { file: File }) => ({
321
- id: "att-1",
322
- type: "file" as const,
323
- name: file.name,
324
- contentType: file.type,
325
- file,
326
- status: {
327
- type: "requires-action" as const,
328
- reason: "composer-send" as const,
329
- },
330
- }),
331
- send: () =>
332
- new Promise((_resolve, reject) => {
333
- rejectSend = reject;
334
- }) as never,
335
- remove: async () => {},
336
- };
337
-
338
- const aui = renderThreadWithProps({
339
- attachmentAdapter: adapter,
340
- onNew: () => {},
341
- });
342
-
343
- await act(() =>
344
- aui()
345
- .thread()
346
- .composer()
347
- .addAttachment(new File(["data"], "notes.txt", { type: "text/plain" })),
348
- );
349
- await act(async () => {
350
- aui().thread.composer().setText("hello");
351
- aui().thread.composer().send();
352
- });
353
- await act(async () => {
354
- aui().thread.composer().setText("meanwhile");
355
- });
356
-
357
- await act(async () => {
358
- rejectSend(new Error("upload failed"));
359
- });
360
-
361
- const state = aui().thread.composer().getState();
362
- expect(state.text).toBe("hello\nmeanwhile");
363
- expect(state.attachments).toHaveLength(1);
364
- expect(state.attachments[0]).toMatchObject({ id: "att-1" });
365
- });
366
-
367
- afterEach(() => {
368
- vi.restoreAllMocks();
369
- });
370
- });
@@ -1,160 +0,0 @@
1
- // @vitest-environment jsdom
2
-
3
- import { render } from "@testing-library/react";
4
- import type { FC } from "react";
5
- import { describe, it, expect, vi } from "vitest";
6
- import { useAui, AuiProvider } from "@assistant-ui/store";
7
- import type {
8
- ExternalThreadBranchAdapter,
9
- ThreadMessage,
10
- } from "@assistant-ui/core";
11
- import {
12
- ExternalThread,
13
- type ExternalThreadProps,
14
- } from "../client/ExternalThread";
15
-
16
- const message = (id: string, role: "user" | "assistant"): ThreadMessage =>
17
- ({
18
- id,
19
- role,
20
- content: [{ type: "text", text: `text of ${id}` }],
21
- createdAt: new Date(1718000000000),
22
- ...(role === "assistant"
23
- ? { status: { type: "complete", reason: "stop" } }
24
- : { attachments: [] }),
25
- metadata: { custom: {} },
26
- }) as ThreadMessage;
27
-
28
- const renderThread = (props: ExternalThreadProps) => {
29
- const captured: { aui?: ReturnType<typeof useAui> } = {};
30
- const Capture: FC = () => {
31
- captured.aui = useAui();
32
- return null;
33
- };
34
- const App: FC<{ threadProps: ExternalThreadProps }> = ({ threadProps }) => {
35
- const aui = useAui({ thread: ExternalThread(threadProps) });
36
- return (
37
- <AuiProvider value={aui}>
38
- <Capture />
39
- </AuiProvider>
40
- );
41
- };
42
- const utils = render(<App threadProps={props} />);
43
- return {
44
- aui: () => captured.aui!,
45
- rerender: (next: ExternalThreadProps) =>
46
- utils.rerender(<App threadProps={next} />),
47
- };
48
- };
49
-
50
- const baseProps = (
51
- branches?: ExternalThreadBranchAdapter,
52
- ): ExternalThreadProps => ({
53
- messages: [message("u1", "user"), message("a2", "assistant")],
54
- isRunning: false,
55
- ...(branches ? { branches } : {}),
56
- });
57
-
58
- const adapterFor = (
59
- ids: readonly string[],
60
- switchToBranch = vi.fn(),
61
- ): ExternalThreadBranchAdapter => ({
62
- getBranches: (messageId) => (ids.includes(messageId) ? ids : []),
63
- switchToBranch,
64
- });
65
-
66
- describe("ExternalThread branches", () => {
67
- it("defaults to single-branch state without an adapter", () => {
68
- const { aui } = renderThread(baseProps());
69
- const state = aui().thread.getState();
70
- expect(state.messages[1]!.branchNumber).toBe(1);
71
- expect(state.messages[1]!.branchCount).toBe(1);
72
- expect(state.capabilities.switchToBranch).toBe(false);
73
- expect(() =>
74
- aui().thread.message({ index: 1 }).switchToBranch({ position: "next" }),
75
- ).not.toThrow();
76
- });
77
-
78
- it("derives branchNumber and branchCount from the adapter", () => {
79
- const { aui } = renderThread(baseProps(adapterFor(["a1", "a2", "a3"])));
80
- const state = aui().thread.getState();
81
- expect(state.messages[1]!.branchNumber).toBe(2);
82
- expect(state.messages[1]!.branchCount).toBe(3);
83
- expect(state.capabilities.switchToBranch).toBe(true);
84
- });
85
-
86
- it("resolves previous and next to sibling ids and no-ops at the edges", () => {
87
- const switchToBranch = vi.fn();
88
- const { aui } = renderThread(
89
- baseProps(adapterFor(["a1", "a2", "a3"], switchToBranch)),
90
- );
91
- const msg = () => aui().thread.message({ index: 1 });
92
-
93
- msg().switchToBranch({ position: "previous" });
94
- expect(switchToBranch).toHaveBeenLastCalledWith("a1");
95
- msg().switchToBranch({ position: "next" });
96
- expect(switchToBranch).toHaveBeenLastCalledWith("a3");
97
-
98
- switchToBranch.mockClear();
99
- const edge = renderThread({
100
- messages: [message("u1", "user"), message("a1", "assistant")],
101
- isRunning: false,
102
- branches: adapterFor(["a1", "a2"], switchToBranch),
103
- });
104
- edge.aui().thread.message({ index: 1 }).switchToBranch({
105
- position: "previous",
106
- });
107
- expect(switchToBranch).not.toHaveBeenCalled();
108
- });
109
-
110
- it("forwards an explicit branchId unvalidated but ignores self-switches", () => {
111
- const switchToBranch = vi.fn();
112
- const { aui } = renderThread(
113
- baseProps(adapterFor(["a1", "a2"], switchToBranch)),
114
- );
115
- const msg = () => aui().thread.message({ index: 1 });
116
-
117
- msg().switchToBranch({ branchId: "not-in-the-list" });
118
- expect(switchToBranch).toHaveBeenLastCalledWith("not-in-the-list");
119
-
120
- switchToBranch.mockClear();
121
- msg().switchToBranch({ branchId: "a2" });
122
- expect(switchToBranch).not.toHaveBeenCalled();
123
- });
124
-
125
- it("falls back to single-branch state when getBranches omits the own id", () => {
126
- const switchToBranch = vi.fn();
127
- const { aui } = renderThread(
128
- baseProps({
129
- getBranches: () => ["b1", "b2"],
130
- switchToBranch,
131
- }),
132
- );
133
- const state = aui().thread.getState();
134
- expect(state.messages[1]!.branchNumber).toBe(1);
135
- expect(state.messages[1]!.branchCount).toBe(1);
136
-
137
- aui().thread.message({ index: 1 }).switchToBranch({ position: "next" });
138
- expect(switchToBranch).not.toHaveBeenCalled();
139
- });
140
-
141
- it("keeps message state identity across adapter recreation with equal values", () => {
142
- const messages = [message("u1", "user"), message("a2", "assistant")];
143
- const { aui, rerender } = renderThread({
144
- messages,
145
- isRunning: false,
146
- branches: adapterFor(["a1", "a2", "a3"]),
147
- });
148
- const before = aui().thread.getState().messages[1];
149
-
150
- rerender({
151
- messages,
152
- isRunning: false,
153
- branches: adapterFor(["a1", "a2", "a3"]),
154
- });
155
- const after = aui().thread.getState().messages[1];
156
-
157
- expect(after!.branchNumber).toBe(2);
158
- expect(before!.parts[0]).toBe(after!.parts[0]);
159
- });
160
- });