@assistant-ui/core 0.1.15 → 0.1.16

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 (48) hide show
  1. package/dist/react/adapters/LocalStorageThreadListAdapter.d.ts.map +1 -1
  2. package/dist/react/adapters/LocalStorageThreadListAdapter.js.map +1 -1
  3. package/dist/react/primitives/message/MessageParts.d.ts +6 -0
  4. package/dist/react/primitives/message/MessageParts.d.ts.map +1 -1
  5. package/dist/react/primitives/message/MessageParts.js +47 -30
  6. package/dist/react/primitives/message/MessageParts.js.map +1 -1
  7. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts +1 -1
  8. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
  9. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js +26 -5
  10. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js.map +1 -1
  11. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  12. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +6 -5
  13. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
  14. package/dist/runtime/api/bindings.d.ts +1 -0
  15. package/dist/runtime/api/bindings.d.ts.map +1 -1
  16. package/dist/runtime/api/composer-runtime.d.ts +4 -2
  17. package/dist/runtime/api/composer-runtime.d.ts.map +1 -1
  18. package/dist/runtime/api/composer-runtime.js.map +1 -1
  19. package/dist/runtime/api/thread-list-runtime.d.ts.map +1 -1
  20. package/dist/runtime/api/thread-list-runtime.js +1 -0
  21. package/dist/runtime/api/thread-list-runtime.js.map +1 -1
  22. package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
  23. package/dist/runtime/base/base-composer-runtime-core.js +22 -8
  24. package/dist/runtime/base/base-composer-runtime-core.js.map +1 -1
  25. package/dist/runtime/interfaces/thread-list-runtime-core.d.ts +1 -0
  26. package/dist/runtime/interfaces/thread-list-runtime-core.d.ts.map +1 -1
  27. package/dist/runtimes/remote-thread-list/remote-thread-state.d.ts +3 -0
  28. package/dist/runtimes/remote-thread-list/remote-thread-state.d.ts.map +1 -1
  29. package/dist/runtimes/remote-thread-list/remote-thread-state.js.map +1 -1
  30. package/dist/runtimes/remote-thread-list/types.d.ts +13 -1
  31. package/dist/runtimes/remote-thread-list/types.d.ts.map +1 -1
  32. package/dist/store/scopes/thread-list-item.d.ts +1 -0
  33. package/dist/store/scopes/thread-list-item.d.ts.map +1 -1
  34. package/package.json +5 -5
  35. package/src/react/adapters/LocalStorageThreadListAdapter.tsx +2 -0
  36. package/src/react/primitives/message/MessageParts.tsx +71 -44
  37. package/src/react/runtimes/RemoteThreadListHookInstanceManager.tsx +40 -7
  38. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +6 -12
  39. package/src/runtime/api/bindings.ts +1 -0
  40. package/src/runtime/api/composer-runtime.ts +4 -2
  41. package/src/runtime/api/thread-list-runtime.ts +1 -0
  42. package/src/runtime/base/base-composer-runtime-core.ts +37 -16
  43. package/src/runtime/interfaces/thread-list-runtime-core.ts +1 -0
  44. package/src/runtimes/remote-thread-list/remote-thread-state.ts +3 -0
  45. package/src/runtimes/remote-thread-list/types.ts +13 -1
  46. package/src/store/scopes/thread-list-item.ts +1 -0
  47. package/src/tests/RemoteThreadListThreadListRuntimeCore-custom-metadata.test.ts +123 -0
  48. package/src/tests/base-composer-runtime-core-addAttachment.test.ts +154 -0
@@ -15,14 +15,7 @@ import {
15
15
  } from "../../runtimes/remote-thread-list/remote-thread-state";
16
16
  import type { RemoteThreadListOptions } from "../../runtimes/remote-thread-list/types";
17
17
  import { RemoteThreadListHookInstanceManager } from "./RemoteThreadListHookInstanceManager";
18
- import {
19
- type ComponentType,
20
- type FC,
21
- Fragment,
22
- type PropsWithChildren,
23
- useEffect,
24
- useId,
25
- } from "react";
18
+ import { type FC, Fragment, useEffect, useId } from "react";
26
19
  import { create } from "zustand";
27
20
  import { AssistantMessageStream } from "assistant-stream";
28
21
  import type { ModelContextProvider } from "../../model-context/types";
@@ -98,6 +91,7 @@ export class RemoteThreadListThreadListRuntimeCore
98
91
  externalId: thread.externalId,
99
92
  status: thread.status,
100
93
  title: thread.title,
94
+ custom: thread.custom,
101
95
  initializeTask: Promise.resolve({
102
96
  remoteId: thread.remoteId,
103
97
  externalId: thread.externalId,
@@ -150,8 +144,7 @@ export class RemoteThreadListThreadListRuntimeCore
150
144
  this,
151
145
  );
152
146
  this.useProvider = create(() => ({
153
- Provider: (options.adapter.unstable_Provider ??
154
- Fragment) as ComponentType<PropsWithChildren>,
147
+ Provider: options.adapter.unstable_Provider ?? Fragment,
155
148
  }));
156
149
  this.__internal_setOptions(options);
157
150
  this.switchToNewThread();
@@ -165,8 +158,7 @@ export class RemoteThreadListThreadListRuntimeCore
165
158
 
166
159
  this._options = options;
167
160
 
168
- const Provider = (options.adapter.unstable_Provider ??
169
- Fragment) as ComponentType<PropsWithChildren>;
161
+ const Provider = options.adapter.unstable_Provider ?? Fragment;
170
162
  if (Provider !== this.useProvider.getState().Provider) {
171
163
  this.useProvider.setState({ Provider }, true);
172
164
  }
@@ -250,6 +242,7 @@ export class RemoteThreadListThreadListRuntimeCore
250
242
  externalId: remoteMetadata.externalId,
251
243
  status: remoteMetadata.status,
252
244
  title: remoteMetadata.title,
245
+ custom: remoteMetadata.custom,
253
246
  } as RemoteThreadData,
254
247
  };
255
248
 
@@ -335,6 +328,7 @@ export class RemoteThreadListThreadListRuntimeCore
335
328
  remoteId: undefined,
336
329
  externalId: undefined,
337
330
  title: undefined,
331
+ custom: undefined,
338
332
  } satisfies RemoteThreadData,
339
333
  },
340
334
  });
@@ -47,4 +47,5 @@ export type ThreadListItemState = {
47
47
  readonly externalId: string | undefined;
48
48
  readonly status: import("../interfaces/thread-list-runtime-core").ThreadListItemStatus;
49
49
  readonly title?: string | undefined;
50
+ readonly custom?: Record<string, unknown> | undefined;
50
51
  };
@@ -136,8 +136,10 @@ export type ComposerRuntime = {
136
136
  /**
137
137
  * Add an attachment to the composer. Accepts either a standard File object
138
138
  * (processed through the AttachmentAdapter) or a CreateAttachment descriptor
139
- * for external-source attachments (URLs, API data, CMS references) that
140
- * bypasses the adapter entirely.
139
+ * for external-source attachments (URLs, API data, CMS references). External
140
+ * descriptors bypass the adapter's `add()` step but still respect
141
+ * `adapter.accept` when an adapter is configured; without an adapter they
142
+ * are added as-is.
141
143
  * @param fileOrAttachment The file or attachment descriptor to add.
142
144
  */
143
145
  addAttachment(fileOrAttachment: File | CreateAttachment): Promise<void>;
@@ -80,6 +80,7 @@ const getThreadListItemState = (
80
80
  externalId: threadData.externalId,
81
81
  title: threadData.title,
82
82
  status: threadData.status,
83
+ custom: threadData.custom,
83
84
  isMain: threadData.id === threadList.mainThreadId,
84
85
  };
85
86
  };
@@ -204,6 +204,27 @@ export abstract class BaseComposerRuntimeCore
204
204
 
205
205
  async addAttachment(fileOrAttachment: File | CreateAttachment) {
206
206
  if (!(fileOrAttachment instanceof File)) {
207
+ const adapter = this.getAttachmentAdapter();
208
+ if (
209
+ adapter &&
210
+ !fileMatchesAccept(
211
+ {
212
+ name: fileOrAttachment.name,
213
+ type: fileOrAttachment.contentType ?? "",
214
+ },
215
+ adapter.accept,
216
+ )
217
+ ) {
218
+ try {
219
+ this._notifyEventSubscribers("attachmentAddError");
220
+ } catch {
221
+ // prevent subscriber errors from masking the original error
222
+ }
223
+ throw new Error(
224
+ `File type ${fileOrAttachment.contentType || "unknown"} is not accepted. Accepted types: ${adapter.accept}`,
225
+ );
226
+ }
227
+
207
228
  const a: CompleteAttachment = {
208
229
  id: fileOrAttachment.id ?? generateId(),
209
230
  type: fileOrAttachment.type ?? "document",
@@ -213,25 +234,11 @@ export abstract class BaseComposerRuntimeCore
213
234
  status: { type: "complete" },
214
235
  };
215
236
  this._attachments = [...this._attachments, a];
216
- this._notifyEventSubscribers("attachmentAdd");
217
237
  this._notifySubscribers();
238
+ this._notifyEventSubscribers("attachmentAdd");
218
239
  return;
219
240
  }
220
241
 
221
- const adapter = this.getAttachmentAdapter();
222
- if (!adapter) throw new Error("Attachments are not supported");
223
-
224
- if (
225
- !fileMatchesAccept(
226
- { name: fileOrAttachment.name, type: fileOrAttachment.type },
227
- adapter.accept,
228
- )
229
- ) {
230
- throw new Error(
231
- `File type ${fileOrAttachment.type || "unknown"} is not accepted. Accepted types: ${adapter.accept}`,
232
- );
233
- }
234
-
235
242
  const upsertAttachment = (a: PendingAttachment) => {
236
243
  const idx = this._attachments.findIndex(
237
244
  (attachment) => attachment.id === a.id,
@@ -251,6 +258,20 @@ export abstract class BaseComposerRuntimeCore
251
258
 
252
259
  let lastAttachment: PendingAttachment | undefined;
253
260
  try {
261
+ const adapter = this.getAttachmentAdapter();
262
+ if (!adapter) throw new Error("Attachments are not supported");
263
+
264
+ if (
265
+ !fileMatchesAccept(
266
+ { name: fileOrAttachment.name, type: fileOrAttachment.type },
267
+ adapter.accept,
268
+ )
269
+ ) {
270
+ throw new Error(
271
+ `File type ${fileOrAttachment.type || "unknown"} is not accepted. Accepted types: ${adapter.accept}`,
272
+ );
273
+ }
274
+
254
275
  const promiseOrGenerator = adapter.add({ file: fileOrAttachment });
255
276
  if (Symbol.asyncIterator in promiseOrGenerator) {
256
277
  for await (const r of promiseOrGenerator) {
@@ -271,7 +292,7 @@ export abstract class BaseComposerRuntimeCore
271
292
  try {
272
293
  this._notifyEventSubscribers("attachmentAddError");
273
294
  } catch {
274
- // prevent subscriber errors from masking the adapter error
295
+ // prevent subscriber errors from masking the original error
275
296
  }
276
297
  throw e;
277
298
  }
@@ -10,6 +10,7 @@ export type ThreadListItemCoreState = {
10
10
 
11
11
  readonly status: ThreadListItemStatus;
12
12
  readonly title?: string | undefined;
13
+ readonly custom?: Record<string, unknown> | undefined;
13
14
 
14
15
  readonly runtime?: ThreadRuntimeCore | undefined;
15
16
  };
@@ -7,6 +7,7 @@ export type RemoteThreadData =
7
7
  readonly externalId: undefined;
8
8
  readonly status: "new";
9
9
  readonly title: undefined;
10
+ readonly custom: undefined;
10
11
  }
11
12
  | {
12
13
  readonly id: string;
@@ -15,6 +16,7 @@ export type RemoteThreadData =
15
16
  readonly externalId: undefined;
16
17
  readonly status: "regular" | "archived";
17
18
  readonly title?: string | undefined;
19
+ readonly custom: undefined;
18
20
  }
19
21
  | {
20
22
  readonly id: string;
@@ -23,6 +25,7 @@ export type RemoteThreadData =
23
25
  readonly externalId: string | undefined;
24
26
  readonly status: "regular" | "archived";
25
27
  readonly title?: string | undefined;
28
+ readonly custom?: Record<string, unknown> | undefined;
26
29
  };
27
30
 
28
31
  export type THREAD_MAPPING_ID = string & { __brand: "THREAD_MAPPING_ID" };
@@ -1,3 +1,4 @@
1
+ import type { ComponentType, PropsWithChildren } from "react";
1
2
  import type { ThreadMessage } from "../../types/message";
2
3
  import type { AssistantRuntime } from "../../runtime/api/assistant-runtime";
3
4
  import type { AssistantStream } from "assistant-stream";
@@ -12,6 +13,7 @@ export type RemoteThreadMetadata = {
12
13
  readonly remoteId: string;
13
14
  readonly externalId?: string | undefined;
14
15
  readonly title?: string | undefined;
16
+ readonly custom?: Record<string, unknown> | undefined;
15
17
  };
16
18
 
17
19
  export type RemoteThreadListResponse = {
@@ -32,7 +34,17 @@ export type RemoteThreadListAdapter = {
32
34
  ): Promise<AssistantStream>;
33
35
  fetch(threadId: string): Promise<RemoteThreadMetadata>;
34
36
 
35
- unstable_Provider?: ((...args: any[]) => unknown) | undefined;
37
+ /**
38
+ * Optional React component wrapped around each active thread. Use it to
39
+ * inject per-thread context such as a history or attachments adapter (see
40
+ * `useCloudThreadListAdapter` for the canonical shape).
41
+ *
42
+ * The Provider must render `children` on its first commit; deferring them
43
+ * behind a loading state, a Suspense boundary, or a `useEffect`-gated render
44
+ * is unsupported and leaves thread context unavailable to downstream
45
+ * consumers. Load data inside an always-mounted child instead.
46
+ */
47
+ unstable_Provider?: ComponentType<PropsWithChildren> | undefined;
36
48
  };
37
49
 
38
50
  export type RemoteThreadListOptions = {
@@ -7,6 +7,7 @@ export type ThreadListItemState = {
7
7
  readonly externalId: string | undefined;
8
8
  readonly title?: string | undefined;
9
9
  readonly status: ThreadListItemStatus;
10
+ readonly custom?: Record<string, unknown> | undefined;
10
11
  };
11
12
 
12
13
  export type ThreadListItemMethods = {
@@ -0,0 +1,123 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { createCore, makeAdapter } from "./remote-thread-list-test-helpers";
3
+
4
+ describe("RemoteThreadListThreadListRuntimeCore custom metadata", () => {
5
+ it("preserves custom field from list() through to threadItems", async () => {
6
+ const adapter = makeAdapter({
7
+ list: vi.fn(async () => ({
8
+ threads: [
9
+ {
10
+ status: "regular" as const,
11
+ remoteId: "thread-1",
12
+ externalId: "ext-1",
13
+ title: "Test",
14
+ custom: { workspaceId: "ws-1", createdAt: "2026-04-26" },
15
+ },
16
+ ],
17
+ })),
18
+ });
19
+
20
+ const core = createCore(adapter);
21
+ await core.getLoadThreadsPromise();
22
+
23
+ const item = core.getItemById("thread-1");
24
+ expect(item?.custom).toEqual({
25
+ workspaceId: "ws-1",
26
+ createdAt: "2026-04-26",
27
+ });
28
+ });
29
+
30
+ it("preserves custom field from fetch() through switchToThread", async () => {
31
+ const adapter = makeAdapter({
32
+ fetch: vi.fn(async (id: string) => ({
33
+ status: "regular" as const,
34
+ remoteId: id,
35
+ externalId: id,
36
+ title: "Test",
37
+ custom: { ownerId: "user-42" },
38
+ })),
39
+ });
40
+
41
+ const core = createCore(adapter);
42
+ await core.switchToThread("thread-2");
43
+
44
+ const item = core.getItemById("thread-2");
45
+ expect(item?.custom).toEqual({ ownerId: "user-42" });
46
+ });
47
+
48
+ it("leaves custom undefined when adapter omits it", async () => {
49
+ const adapter = makeAdapter({
50
+ list: vi.fn(async () => ({
51
+ threads: [
52
+ {
53
+ status: "regular" as const,
54
+ remoteId: "thread-3",
55
+ externalId: "ext-3",
56
+ title: "Test",
57
+ },
58
+ ],
59
+ })),
60
+ });
61
+
62
+ const core = createCore(adapter);
63
+ await core.getLoadThreadsPromise();
64
+
65
+ const item = core.getItemById("thread-3");
66
+ expect(item?.custom).toBeUndefined();
67
+ });
68
+
69
+ it("preserves custom across rename optimistic update", async () => {
70
+ const adapter = makeAdapter({
71
+ list: vi.fn(async () => ({
72
+ threads: [
73
+ {
74
+ status: "regular" as const,
75
+ remoteId: "thread-4",
76
+ externalId: "ext-4",
77
+ title: "Old",
78
+ custom: { tag: "important" },
79
+ },
80
+ ],
81
+ })),
82
+ });
83
+
84
+ const core = createCore(adapter);
85
+ await core.getLoadThreadsPromise();
86
+ await core.rename("thread-4", "New");
87
+
88
+ const item = core.getItemById("thread-4");
89
+ expect(item?.title).toBe("New");
90
+ expect(item?.custom).toEqual({ tag: "important" });
91
+ });
92
+
93
+ it("preserves custom across archive and unarchive optimistic updates", async () => {
94
+ const adapter = makeAdapter({
95
+ list: vi.fn(async () => ({
96
+ threads: [
97
+ {
98
+ status: "regular" as const,
99
+ remoteId: "thread-5",
100
+ externalId: "ext-5",
101
+ title: "Test",
102
+ custom: { workspaceId: "ws-1" },
103
+ },
104
+ ],
105
+ })),
106
+ });
107
+
108
+ const core = createCore(adapter);
109
+ await core.getLoadThreadsPromise();
110
+
111
+ await core.archive("thread-5");
112
+ expect(core.getItemById("thread-5")?.status).toBe("archived");
113
+ expect(core.getItemById("thread-5")?.custom).toEqual({
114
+ workspaceId: "ws-1",
115
+ });
116
+
117
+ await core.unarchive("thread-5");
118
+ expect(core.getItemById("thread-5")?.status).toBe("regular");
119
+ expect(core.getItemById("thread-5")?.custom).toEqual({
120
+ workspaceId: "ws-1",
121
+ });
122
+ });
123
+ });
@@ -0,0 +1,154 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { DefaultThreadComposerRuntimeCore } from "../runtime/base/default-thread-composer-runtime-core";
3
+ import type { AttachmentAdapter } from "../adapters/attachment";
4
+ import type { ThreadRuntimeCore } from "../runtime/interfaces/thread-runtime-core";
5
+ import type { PendingAttachment } from "../types/attachment";
6
+
7
+ const makeAdapter = (
8
+ overrides: Partial<AttachmentAdapter> = {},
9
+ ): AttachmentAdapter => ({
10
+ accept: "image/*",
11
+ add: async ({ file }: { file: File }): Promise<PendingAttachment> => ({
12
+ id: "att-1",
13
+ type: "image",
14
+ name: file.name,
15
+ contentType: file.type,
16
+ file,
17
+ status: { type: "running", reason: "uploading", progress: 0 },
18
+ }),
19
+ remove: async () => {},
20
+ send: async (a) => ({ ...a, status: { type: "complete" }, content: [] }),
21
+ ...overrides,
22
+ });
23
+
24
+ const makeComposer = (adapter?: AttachmentAdapter) => {
25
+ const runtime = {
26
+ append: vi.fn(),
27
+ cancelRun: vi.fn(),
28
+ subscribe: vi.fn(() => () => {}),
29
+ capabilities: { cancel: false },
30
+ messages: [],
31
+ adapters: adapter ? { attachments: adapter } : undefined,
32
+ } as unknown as Omit<ThreadRuntimeCore, "composer"> & {
33
+ adapters?: { attachments?: AttachmentAdapter };
34
+ };
35
+ return new DefaultThreadComposerRuntimeCore(runtime);
36
+ };
37
+
38
+ describe("BaseComposerRuntimeCore.addAttachment error events", () => {
39
+ it("emits attachmentAddError when no adapter is configured", async () => {
40
+ const composer = makeComposer();
41
+ const onError = vi.fn();
42
+ const onAdd = vi.fn();
43
+ composer.unstable_on("attachmentAddError", onError);
44
+ composer.unstable_on("attachmentAdd", onAdd);
45
+
46
+ await expect(
47
+ composer.addAttachment(new File(["x"], "f.txt", { type: "text/plain" })),
48
+ ).rejects.toThrow("Attachments are not supported");
49
+
50
+ expect(onError).toHaveBeenCalledTimes(1);
51
+ expect(onAdd).not.toHaveBeenCalled();
52
+ });
53
+
54
+ it("emits attachmentAddError when file type is rejected by adapter.accept", async () => {
55
+ const composer = makeComposer(makeAdapter());
56
+ const onError = vi.fn();
57
+ const onAdd = vi.fn();
58
+ composer.unstable_on("attachmentAddError", onError);
59
+ composer.unstable_on("attachmentAdd", onAdd);
60
+
61
+ await expect(
62
+ composer.addAttachment(new File(["x"], "f.txt", { type: "text/plain" })),
63
+ ).rejects.toThrow(/File type text\/plain is not accepted/);
64
+
65
+ expect(onError).toHaveBeenCalledTimes(1);
66
+ expect(onAdd).not.toHaveBeenCalled();
67
+ });
68
+
69
+ it("emits attachmentAddError when adapter.add throws", async () => {
70
+ const composer = makeComposer(
71
+ makeAdapter({
72
+ add: async () => {
73
+ throw new Error("upload failed");
74
+ },
75
+ }),
76
+ );
77
+ const onError = vi.fn();
78
+ const onAdd = vi.fn();
79
+ composer.unstable_on("attachmentAddError", onError);
80
+ composer.unstable_on("attachmentAdd", onAdd);
81
+
82
+ await expect(
83
+ composer.addAttachment(new File(["x"], "f.png", { type: "image/png" })),
84
+ ).rejects.toThrow("upload failed");
85
+
86
+ expect(onError).toHaveBeenCalledTimes(1);
87
+ expect(onAdd).not.toHaveBeenCalled();
88
+ });
89
+
90
+ it("emits attachmentAdd on successful add", async () => {
91
+ const composer = makeComposer(makeAdapter());
92
+ const onError = vi.fn();
93
+ const onAdd = vi.fn();
94
+ composer.unstable_on("attachmentAddError", onError);
95
+ composer.unstable_on("attachmentAdd", onAdd);
96
+
97
+ await composer.addAttachment(
98
+ new File(["x"], "f.png", { type: "image/png" }),
99
+ );
100
+
101
+ expect(onAdd).toHaveBeenCalledTimes(1);
102
+ expect(onError).not.toHaveBeenCalled();
103
+ });
104
+
105
+ it("does not let subscriber errors mask the original throw", async () => {
106
+ const composer = makeComposer(makeAdapter());
107
+ composer.unstable_on("attachmentAddError", () => {
108
+ throw new Error("subscriber boom");
109
+ });
110
+
111
+ await expect(
112
+ composer.addAttachment(new File(["x"], "f.txt", { type: "text/plain" })),
113
+ ).rejects.toThrow(/File type text\/plain is not accepted/);
114
+ });
115
+
116
+ it("emits attachmentAddError when async generator adapter throws mid-iteration", async () => {
117
+ const composer = makeComposer(
118
+ makeAdapter({
119
+ async *add({ file }: { file: File }) {
120
+ yield {
121
+ id: "att-1",
122
+ type: "image" as const,
123
+ name: file.name,
124
+ contentType: file.type,
125
+ file,
126
+ status: {
127
+ type: "running" as const,
128
+ reason: "uploading" as const,
129
+ progress: 0,
130
+ },
131
+ };
132
+ throw new Error("network error");
133
+ },
134
+ }),
135
+ );
136
+ const onError = vi.fn();
137
+ const onAdd = vi.fn();
138
+ composer.unstable_on("attachmentAddError", onError);
139
+ composer.unstable_on("attachmentAdd", onAdd);
140
+
141
+ await expect(
142
+ composer.addAttachment(new File(["x"], "f.png", { type: "image/png" })),
143
+ ).rejects.toThrow("network error");
144
+
145
+ expect(onError).toHaveBeenCalledTimes(1);
146
+ expect(onAdd).not.toHaveBeenCalled();
147
+ expect(composer.attachments).toHaveLength(1);
148
+ const att = composer.attachments[0]!;
149
+ expect(att.status.type).toBe("incomplete");
150
+ if (att.status.type === "incomplete") {
151
+ expect(att.status.reason).toBe("error");
152
+ }
153
+ });
154
+ });