@assistant-ui/core 0.1.16 → 0.1.17

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 (84) hide show
  1. package/dist/index.d.ts +4 -4
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js.map +1 -1
  4. package/dist/react/index.d.ts +2 -1
  5. package/dist/react/index.d.ts.map +1 -1
  6. package/dist/react/index.js +1 -0
  7. package/dist/react/index.js.map +1 -1
  8. package/dist/react/primitives/message/MessageGroupedParts.d.ts +104 -0
  9. package/dist/react/primitives/message/MessageGroupedParts.d.ts.map +1 -0
  10. package/dist/react/primitives/message/MessageGroupedParts.js +74 -0
  11. package/dist/react/primitives/message/MessageGroupedParts.js.map +1 -0
  12. package/dist/react/primitives/message/MessageParts.d.ts +8 -1
  13. package/dist/react/primitives/message/MessageParts.d.ts.map +1 -1
  14. package/dist/react/primitives/message/MessageParts.js +45 -42
  15. package/dist/react/primitives/message/MessageParts.js.map +1 -1
  16. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts +2 -2
  17. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +2 -2
  18. package/dist/react/utils/groupParts.d.ts +49 -0
  19. package/dist/react/utils/groupParts.d.ts.map +1 -0
  20. package/dist/react/utils/groupParts.js +97 -0
  21. package/dist/react/utils/groupParts.js.map +1 -0
  22. package/dist/runtime/api/composer-runtime.d.ts +3 -3
  23. package/dist/runtime/api/composer-runtime.d.ts.map +1 -1
  24. package/dist/runtime/api/composer-runtime.js +1 -1
  25. package/dist/runtime/api/composer-runtime.js.map +1 -1
  26. package/dist/runtime/api/thread-list-item-runtime.d.ts +18 -3
  27. package/dist/runtime/api/thread-list-item-runtime.d.ts.map +1 -1
  28. package/dist/runtime/api/thread-list-item-runtime.js +1 -1
  29. package/dist/runtime/api/thread-list-item-runtime.js.map +1 -1
  30. package/dist/runtime/api/thread-runtime.d.ts +5 -5
  31. package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
  32. package/dist/runtime/api/thread-runtime.js +1 -1
  33. package/dist/runtime/api/thread-runtime.js.map +1 -1
  34. package/dist/runtime/base/base-composer-runtime-core.d.ts +4 -3
  35. package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
  36. package/dist/runtime/base/base-composer-runtime-core.js +47 -33
  37. package/dist/runtime/base/base-composer-runtime-core.js.map +1 -1
  38. package/dist/runtime/base/base-thread-runtime-core.d.ts +3 -3
  39. package/dist/runtime/base/base-thread-runtime-core.d.ts.map +1 -1
  40. package/dist/runtime/base/base-thread-runtime-core.js +11 -11
  41. package/dist/runtime/base/base-thread-runtime-core.js.map +1 -1
  42. package/dist/runtime/interfaces/composer-runtime-core.d.ts +28 -2
  43. package/dist/runtime/interfaces/composer-runtime-core.d.ts.map +1 -1
  44. package/dist/runtime/interfaces/thread-runtime-core.d.ts +37 -2
  45. package/dist/runtime/interfaces/thread-runtime-core.d.ts.map +1 -1
  46. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +2 -2
  47. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  48. package/dist/runtimes/local/local-thread-runtime-core.js +2 -2
  49. package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
  50. package/dist/store/runtime-clients/composer-runtime-client.d.ts.map +1 -1
  51. package/dist/store/runtime-clients/composer-runtime-client.js +5 -6
  52. package/dist/store/runtime-clients/composer-runtime-client.js.map +1 -1
  53. package/dist/store/scopes/composer.d.ts +11 -1
  54. package/dist/store/scopes/composer.d.ts.map +1 -1
  55. package/dist/store/scopes/thread-list-item.d.ts +10 -0
  56. package/dist/store/scopes/thread-list-item.d.ts.map +1 -1
  57. package/dist/store/scopes/thread.d.ts +17 -0
  58. package/dist/store/scopes/thread.d.ts.map +1 -1
  59. package/dist/subscribable/subscribable.d.ts +4 -4
  60. package/dist/subscribable/subscribable.d.ts.map +1 -1
  61. package/dist/subscribable/subscribable.js +4 -4
  62. package/dist/subscribable/subscribable.js.map +1 -1
  63. package/package.json +17 -5
  64. package/src/index.ts +10 -0
  65. package/src/react/index.ts +2 -0
  66. package/src/react/primitives/message/MessageGroupedParts.tsx +186 -0
  67. package/src/react/primitives/message/MessageParts.tsx +80 -55
  68. package/src/react/utils/groupParts.ts +152 -0
  69. package/src/runtime/api/composer-runtime.ts +10 -9
  70. package/src/runtime/api/thread-list-item-runtime.ts +28 -6
  71. package/src/runtime/api/thread-runtime.ts +11 -7
  72. package/src/runtime/base/base-composer-runtime-core.ts +85 -42
  73. package/src/runtime/base/base-thread-runtime-core.ts +21 -12
  74. package/src/runtime/interfaces/composer-runtime-core.ts +39 -7
  75. package/src/runtime/interfaces/thread-runtime-core.ts +44 -6
  76. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +2 -2
  77. package/src/runtimes/local/local-thread-runtime-core.ts +2 -2
  78. package/src/store/runtime-clients/composer-runtime-client.ts +5 -9
  79. package/src/store/scopes/composer.ts +11 -0
  80. package/src/store/scopes/thread-list-item.ts +10 -0
  81. package/src/store/scopes/thread.ts +17 -0
  82. package/src/subscribable/subscribable.ts +10 -7
  83. package/src/tests/base-composer-runtime-core-addAttachment.test.ts +63 -0
  84. package/src/tests/groupParts.test.ts +114 -0
@@ -21,6 +21,8 @@ import type {
21
21
  SpeechState,
22
22
  VoiceSessionState,
23
23
  RuntimeCapabilities,
24
+ ThreadRuntimeEventCallback,
25
+ ThreadRuntimeEventPayload,
24
26
  ThreadRuntimeEventType,
25
27
  StartRunConfig,
26
28
  ResumeRunConfig,
@@ -168,11 +170,14 @@ export abstract class BaseThreadRuntimeCore implements ThreadRuntimeCore {
168
170
  for (const callback of this._subscriptions) callback();
169
171
  }
170
172
 
171
- public _notifyEventSubscribers(event: ThreadRuntimeEventType) {
173
+ public _notifyEventSubscribers<E extends ThreadRuntimeEventType>(
174
+ event: E,
175
+ payload: ThreadRuntimeEventPayload[E],
176
+ ) {
172
177
  const subscribers = this._eventSubscribers.get(event);
173
178
  if (!subscribers) return;
174
179
 
175
- for (const callback of subscribers) callback();
180
+ for (const callback of subscribers) callback(payload);
176
181
  }
177
182
 
178
183
  public subscribe(callback: () => void): Unsubscribe {
@@ -432,7 +437,7 @@ export abstract class BaseThreadRuntimeCore implements ThreadRuntimeCore {
432
437
  protected ensureInitialized() {
433
438
  if (!this._isInitialized) {
434
439
  this._isInitialized = true;
435
- this._notifyEventSubscribers("initialize");
440
+ this._notifyEventSubscribers("initialize", {});
436
441
  }
437
442
  }
438
443
 
@@ -453,24 +458,28 @@ export abstract class BaseThreadRuntimeCore implements ThreadRuntimeCore {
453
458
 
454
459
  private _eventSubscribers = new Map<
455
460
  ThreadRuntimeEventType,
456
- Set<() => void>
461
+ Set<(payload?: unknown) => void>
457
462
  >();
458
463
 
459
- public unstable_on(event: ThreadRuntimeEventType, callback: () => void) {
464
+ public unstable_on<E extends ThreadRuntimeEventType>(
465
+ event: E,
466
+ callback: ThreadRuntimeEventCallback<E>,
467
+ ) {
468
+ const wrapped = callback as (payload?: unknown) => void;
460
469
  if (event === "modelContextUpdate") {
461
- return this._contextProvider.subscribe?.(callback) ?? (() => {});
470
+ // provider.subscribe is `() => void`; pump the typed empty payload to the user callback.
471
+ return this._contextProvider.subscribe?.(() => wrapped({})) ?? (() => {});
462
472
  }
463
473
 
464
- const subscribers = this._eventSubscribers.get(event);
474
+ let subscribers = this._eventSubscribers.get(event);
465
475
  if (!subscribers) {
466
- this._eventSubscribers.set(event, new Set([callback]));
467
- } else {
468
- subscribers.add(callback);
476
+ subscribers = new Set();
477
+ this._eventSubscribers.set(event, subscribers);
469
478
  }
479
+ subscribers.add(wrapped);
470
480
 
471
481
  return () => {
472
- const subscribers = this._eventSubscribers.get(event)!;
473
- subscribers.delete(callback);
482
+ this._eventSubscribers.get(event)?.delete(wrapped);
474
483
  };
475
484
  }
476
485
  }
@@ -5,10 +5,37 @@ import type { Unsubscribe } from "../../types/unsubscribe";
5
5
  import type { RunConfig } from "../../types/message";
6
6
  import type { DictationAdapter } from "../../adapters/speech";
7
7
 
8
- export type ComposerRuntimeEventType =
9
- | "send"
10
- | "attachmentAdd"
11
- | "attachmentAddError";
8
+ export type AttachmentAddErrorReason =
9
+ | "no-adapter"
10
+ | "not-accepted"
11
+ | "adapter-error";
12
+
13
+ export type AttachmentAddErrorEvent = {
14
+ readonly reason: AttachmentAddErrorReason;
15
+ readonly message: string;
16
+ readonly attachmentId?: string;
17
+ readonly error?: Error;
18
+ };
19
+
20
+ export type ComposerRuntimeEventPayload = {
21
+ /**
22
+ * @deprecated State-derivable. Observe `state.text` clearing via
23
+ * `subscribe` + `getState` instead. Kept for backward compatibility.
24
+ */
25
+ send: Record<string, never>;
26
+ /**
27
+ * @deprecated State-derivable. Observe `state.attachments` via `subscribe` +
28
+ * `getState` instead. Kept for backward compatibility.
29
+ */
30
+ attachmentAdd: Record<string, never>;
31
+ attachmentAddError: AttachmentAddErrorEvent;
32
+ };
33
+
34
+ export type ComposerRuntimeEventType = keyof ComposerRuntimeEventPayload;
35
+
36
+ export type ComposerRuntimeEventCallback<E extends ComposerRuntimeEventType> = (
37
+ payload: ComposerRuntimeEventPayload[E],
38
+ ) => void;
12
39
 
13
40
  export type DictationState = {
14
41
  readonly status: DictationAdapter.Status;
@@ -56,9 +83,14 @@ export type ComposerRuntimeCore = Readonly<{
56
83
 
57
84
  subscribe: (callback: () => void) => Unsubscribe;
58
85
 
59
- unstable_on: (
60
- event: ComposerRuntimeEventType,
61
- callback: () => void,
86
+ /**
87
+ * @deprecated This API is still under active development and might change without notice.
88
+ * For state-derivable transitions, prefer `subscribe` + `getState`. This channel is the
89
+ * escape hatch for transient occurrences not represented in state.
90
+ */
91
+ unstable_on: <E extends ComposerRuntimeEventType>(
92
+ event: E,
93
+ callback: ComposerRuntimeEventCallback<E>,
62
94
  ) => Unsubscribe;
63
95
  }>;
64
96
 
@@ -69,11 +69,41 @@ export type SubmittedFeedback = {
69
69
  readonly type: "negative" | "positive";
70
70
  };
71
71
 
72
- export type ThreadRuntimeEventType =
73
- | "runStart"
74
- | "runEnd"
75
- | "initialize"
76
- | "modelContextUpdate";
72
+ export type ThreadRuntimeEventPayload = {
73
+ /**
74
+ * @deprecated State-derivable. Observe `state.isRunning` flipping to `true`
75
+ * via `subscribe` + `getState` instead. Note: this event fires at the
76
+ * transition point and may run before the next subscriber notification.
77
+ * Kept for backward compatibility.
78
+ */
79
+ runStart: Record<string, never>;
80
+ /**
81
+ * @deprecated State-derivable. Observe `state.isRunning` flipping to `false`
82
+ * via `subscribe` + `getState` instead. Note: this event fires at the
83
+ * transition point and may run before the next subscriber notification.
84
+ * Kept for backward compatibility.
85
+ */
86
+ runEnd: Record<string, never>;
87
+ /**
88
+ * @deprecated State-derivable. This event fires at the initialization
89
+ * transition immediately BEFORE the first message is added, so reading state
90
+ * inside the handler still sees an empty thread; observe `state.messages`
91
+ * becoming non-empty via a regular `subscribe` callback instead. Kept for
92
+ * backward compatibility.
93
+ */
94
+ initialize: Record<string, never>;
95
+ /**
96
+ * Truly transient. The model context lives in a provider, not in thread
97
+ * state, so this event has no state-derivable equivalent.
98
+ */
99
+ modelContextUpdate: Record<string, never>;
100
+ };
101
+
102
+ export type ThreadRuntimeEventType = keyof ThreadRuntimeEventPayload;
103
+
104
+ export type ThreadRuntimeEventCallback<E extends ThreadRuntimeEventType> = (
105
+ payload: ThreadRuntimeEventPayload[E],
106
+ ) => void;
77
107
 
78
108
  export type StartRunConfig = {
79
109
  parentId: string | null;
@@ -155,7 +185,15 @@ export type ThreadRuntimeCore = Readonly<{
155
185
 
156
186
  reset(initialMessages?: readonly ThreadMessageLike[]): void;
157
187
 
158
- unstable_on(event: ThreadRuntimeEventType, callback: () => void): Unsubscribe;
188
+ /**
189
+ * @deprecated This API is still under active development and might change without notice.
190
+ * For state-derivable transitions, prefer `subscribe` + `getState`. This channel is the
191
+ * escape hatch for transient occurrences not represented in state.
192
+ */
193
+ unstable_on<E extends ThreadRuntimeEventType>(
194
+ event: E,
195
+ callback: ThreadRuntimeEventCallback<E>,
196
+ ): Unsubscribe;
159
197
 
160
198
  /**
161
199
  * @deprecated Use importExternalState instead. This method will be removed in 0.12.0.
@@ -236,9 +236,9 @@ export class ExternalStoreThreadRuntimeCore
236
236
 
237
237
  if ((oldStore?.isRunning ?? false) !== (store.isRunning ?? false)) {
238
238
  if (store.isRunning) {
239
- this._notifyEventSubscribers("runStart");
239
+ this._notifyEventSubscribers("runStart", {});
240
240
  } else {
241
- this._notifyEventSubscribers("runEnd");
241
+ this._notifyEventSubscribers("runEnd", {});
242
242
  }
243
243
  }
244
244
 
@@ -253,7 +253,7 @@ export class LocalThreadRuntimeCore
253
253
  createdAt: new Date(),
254
254
  };
255
255
 
256
- this._notifyEventSubscribers("runStart");
256
+ this._notifyEventSubscribers("runStart", {});
257
257
 
258
258
  try {
259
259
  this._suggestions = [];
@@ -271,7 +271,7 @@ export class LocalThreadRuntimeCore
271
271
  runCallback = undefined;
272
272
  } while (shouldContinue(message, this._options.unstable_humanToolNames));
273
273
  } finally {
274
- this._notifyEventSubscribers("runEnd");
274
+ this._notifyEventSubscribers("runEnd", {});
275
275
  }
276
276
 
277
277
  this._suggestionsController = new AbortController();
@@ -63,19 +63,15 @@ export const ComposerClient = resource(
63
63
  unsubscribers.push(unsubscribe);
64
64
  }
65
65
 
66
- // attachmentAddError carries the failed attachment ID
67
66
  unsubscribers.push(
68
- runtime.unstable_on("attachmentAddError", () => {
69
- const errorAttachment = runtime
70
- .getState()
71
- .attachments.findLast(
72
- (a) =>
73
- a.status.type === "incomplete" && a.status.reason === "error",
74
- );
67
+ runtime.unstable_on("attachmentAddError", (payload) => {
68
+ // payload.error omitted: raw Error is not store-serializable; use runtime.unstable_on for it.
75
69
  emit("composer.attachmentAddError", {
76
70
  threadId: threadIdRef.current,
77
71
  ...(messageIdRef && { messageId: messageIdRef.current }),
78
- ...(errorAttachment && { attachmentId: errorAttachment.id }),
72
+ ...(payload.attachmentId && { attachmentId: payload.attachmentId }),
73
+ reason: payload.reason,
74
+ message: payload.message,
79
75
  });
80
76
  }),
81
77
  );
@@ -4,6 +4,7 @@ import type { QuoteInfo } from "../../types/quote";
4
4
  import type { RunConfig } from "../../types/message";
5
5
  import type { ComposerRuntime } from "../../runtime/api/composer-runtime";
6
6
  import type {
7
+ AttachmentAddErrorReason,
7
8
  DictationState,
8
9
  SendOptions,
9
10
  } from "../../runtime/interfaces/composer-runtime-core";
@@ -91,12 +92,22 @@ export type ComposerMeta = {
91
92
  };
92
93
 
93
94
  export type ComposerEvents = {
95
+ /**
96
+ * @deprecated State-derivable. Observe composer `text` clearing via
97
+ * `useAuiState` instead. Kept for backward compatibility.
98
+ */
94
99
  "composer.send": { threadId: string; messageId?: string };
100
+ /**
101
+ * @deprecated State-derivable. Observe composer `attachments` via
102
+ * `useAuiState` instead. Kept for backward compatibility.
103
+ */
95
104
  "composer.attachmentAdd": { threadId: string; messageId?: string };
96
105
  "composer.attachmentAddError": {
97
106
  threadId: string;
98
107
  messageId?: string;
99
108
  attachmentId?: string;
109
+ reason: AttachmentAddErrorReason;
110
+ message: string;
100
111
  };
101
112
  };
102
113
 
@@ -32,7 +32,17 @@ export type ThreadListItemMeta = {
32
32
  };
33
33
 
34
34
  export type ThreadListItemEvents = {
35
+ /**
36
+ * @deprecated State-derivable. Compare `s.threads.mainThreadId` against the
37
+ * item's `s.threadListItem.id` via `useAuiState` instead. Kept for backward
38
+ * compatibility.
39
+ */
35
40
  "threadListItem.switchedTo": { threadId: string };
41
+ /**
42
+ * @deprecated State-derivable. Compare `s.threads.mainThreadId` against the
43
+ * item's `s.threadListItem.id` via `useAuiState` instead. Kept for backward
44
+ * compatibility.
45
+ */
36
46
  "threadListItem.switchedAway": { threadId: string };
37
47
  };
38
48
 
@@ -130,9 +130,26 @@ export type ThreadMeta = {
130
130
  };
131
131
 
132
132
  export type ThreadEvents = {
133
+ /**
134
+ * @deprecated State-derivable. Observe `isRunning` flipping to `true` via
135
+ * `useAuiState` instead. Kept for backward compatibility.
136
+ */
133
137
  "thread.runStart": { threadId: string };
138
+ /**
139
+ * @deprecated State-derivable. Observe `isRunning` flipping to `false` via
140
+ * `useAuiState` instead. Kept for backward compatibility.
141
+ */
134
142
  "thread.runEnd": { threadId: string };
143
+ /**
144
+ * @deprecated State-derivable. This event fires before the first message is
145
+ * added; observe `messages` becoming non-empty via `useAuiState` instead of
146
+ * reading state inside this event handler. Kept for backward compatibility.
147
+ */
135
148
  "thread.initialize": { threadId: string };
149
+ /**
150
+ * Truly transient. Model context lives in a provider, not in thread state,
151
+ * so this event has no state-derivable equivalent.
152
+ */
136
153
  "thread.modelContextUpdate": { threadId: string };
137
154
  };
138
155
 
@@ -21,7 +21,10 @@ export type EventSubscribable<TEvent extends string> = {
21
21
  event: TEvent;
22
22
  binding: SubscribableWithState<
23
23
  | {
24
- unstable_on: (event: TEvent, callback: () => void) => Unsubscribe;
24
+ unstable_on: (
25
+ event: TEvent,
26
+ callback: (payload?: unknown) => void,
27
+ ) => Unsubscribe;
25
28
  }
26
29
  | undefined,
27
30
  unknown
@@ -87,7 +90,7 @@ export class BaseSubscribable {
87
90
 
88
91
  // lazy connect/disconnect: only opens upstream subscription while it has subscribers
89
92
  export abstract class BaseSubject {
90
- private _subscriptions = new Set<() => void>();
93
+ private _subscriptions = new Set<(payload?: unknown) => void>();
91
94
  private _connection: Unsubscribe | undefined;
92
95
 
93
96
  protected get isConnected() {
@@ -96,8 +99,8 @@ export abstract class BaseSubject {
96
99
 
97
100
  protected abstract _connect(): Unsubscribe;
98
101
 
99
- protected notifySubscribers() {
100
- for (const callback of this._subscriptions) callback();
102
+ protected notifySubscribers(payload?: unknown) {
103
+ for (const callback of this._subscriptions) callback(payload);
101
104
  }
102
105
 
103
106
  private _updateConnection() {
@@ -110,7 +113,7 @@ export abstract class BaseSubject {
110
113
  }
111
114
  }
112
115
 
113
- public subscribe(callback: () => void) {
116
+ public subscribe(callback: (payload?: unknown) => void) {
114
117
  this._subscriptions.add(callback);
115
118
  this._updateConnection();
116
119
 
@@ -270,8 +273,8 @@ export class EventSubscriptionSubject<
270
273
  }
271
274
 
272
275
  protected _connect(): Unsubscribe {
273
- const callback = () => {
274
- this.notifySubscribers();
276
+ const callback = (payload?: unknown) => {
277
+ this.notifySubscribers(payload);
275
278
  };
276
279
 
277
280
  let lastState = this.config.binding.getState();
@@ -48,6 +48,13 @@ describe("BaseComposerRuntimeCore.addAttachment error events", () => {
48
48
  ).rejects.toThrow("Attachments are not supported");
49
49
 
50
50
  expect(onError).toHaveBeenCalledTimes(1);
51
+ expect(onError).toHaveBeenCalledWith(
52
+ expect.objectContaining({
53
+ reason: "no-adapter",
54
+ message: "Attachments are not supported",
55
+ error: expect.any(Error),
56
+ }),
57
+ );
51
58
  expect(onAdd).not.toHaveBeenCalled();
52
59
  });
53
60
 
@@ -63,6 +70,15 @@ describe("BaseComposerRuntimeCore.addAttachment error events", () => {
63
70
  ).rejects.toThrow(/File type text\/plain is not accepted/);
64
71
 
65
72
  expect(onError).toHaveBeenCalledTimes(1);
73
+ expect(onError).toHaveBeenCalledWith(
74
+ expect.objectContaining({
75
+ reason: "not-accepted",
76
+ message: expect.stringContaining(
77
+ "File type text/plain is not accepted",
78
+ ),
79
+ error: expect.any(Error),
80
+ }),
81
+ );
66
82
  expect(onAdd).not.toHaveBeenCalled();
67
83
  });
68
84
 
@@ -84,6 +100,13 @@ describe("BaseComposerRuntimeCore.addAttachment error events", () => {
84
100
  ).rejects.toThrow("upload failed");
85
101
 
86
102
  expect(onError).toHaveBeenCalledTimes(1);
103
+ expect(onError).toHaveBeenCalledWith(
104
+ expect.objectContaining({
105
+ reason: "adapter-error",
106
+ message: "upload failed",
107
+ error: expect.any(Error),
108
+ }),
109
+ );
87
110
  expect(onAdd).not.toHaveBeenCalled();
88
111
  });
89
112
 
@@ -143,6 +166,14 @@ describe("BaseComposerRuntimeCore.addAttachment error events", () => {
143
166
  ).rejects.toThrow("network error");
144
167
 
145
168
  expect(onError).toHaveBeenCalledTimes(1);
169
+ expect(onError).toHaveBeenCalledWith(
170
+ expect.objectContaining({
171
+ reason: "adapter-error",
172
+ message: "network error",
173
+ attachmentId: "att-1",
174
+ error: expect.any(Error),
175
+ }),
176
+ );
146
177
  expect(onAdd).not.toHaveBeenCalled();
147
178
  expect(composer.attachments).toHaveLength(1);
148
179
  const att = composer.attachments[0]!;
@@ -151,4 +182,36 @@ describe("BaseComposerRuntimeCore.addAttachment error events", () => {
151
182
  expect(att.status.reason).toBe("error");
152
183
  }
153
184
  });
185
+
186
+ it("emits attachmentAddError with attachment id when adapter yields an errored attachment", async () => {
187
+ const composer = makeComposer(
188
+ makeAdapter({
189
+ add: async ({ file }) => ({
190
+ id: "att-2",
191
+ type: "image",
192
+ name: file.name,
193
+ contentType: file.type,
194
+ file,
195
+ status: { type: "incomplete", reason: "error" },
196
+ }),
197
+ }),
198
+ );
199
+ const onError = vi.fn();
200
+ const onAdd = vi.fn();
201
+ composer.unstable_on("attachmentAddError", onError);
202
+ composer.unstable_on("attachmentAdd", onAdd);
203
+
204
+ await composer.addAttachment(
205
+ new File(["x"], "f.png", { type: "image/png" }),
206
+ );
207
+
208
+ expect(onError).toHaveBeenCalledTimes(1);
209
+ expect(onError).toHaveBeenCalledWith(
210
+ expect.objectContaining({
211
+ reason: "adapter-error",
212
+ attachmentId: "att-2",
213
+ }),
214
+ );
215
+ expect(onAdd).not.toHaveBeenCalled();
216
+ });
154
217
  });
@@ -0,0 +1,114 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildGroupTree,
4
+ normalizeGroupKey,
5
+ type GroupNode,
6
+ } from "../react/utils/groupParts";
7
+
8
+ const asPaths = (keys: readonly (string | readonly string[] | null)[]) =>
9
+ keys.map((k) => normalizeGroupKey(k));
10
+
11
+ // Compact tree dump: "G:key#nodeKey[i,j]{...}" | "P:#nodeKey(i)"
12
+ const dump = (nodes: readonly GroupNode[]): string =>
13
+ nodes
14
+ .map((n) => {
15
+ if (n.type === "part") {
16
+ return `P:#${n.nodeKey}(${n.index})`;
17
+ }
18
+ const inner = dump(n.children);
19
+ return `G:${n.key}#${n.nodeKey}[${n.indices.join(",")}]{${inner}}`;
20
+ })
21
+ .join(",");
22
+
23
+ describe("normalizeGroupKey", () => {
24
+ it("maps null/undefined/[] to []", () => {
25
+ expect(normalizeGroupKey(null)).toEqual([]);
26
+ expect(normalizeGroupKey(undefined)).toEqual([]);
27
+ expect(normalizeGroupKey([])).toEqual([]);
28
+ });
29
+
30
+ it("wraps a string into a single-element array", () => {
31
+ expect(normalizeGroupKey("foo")).toEqual(["foo"]);
32
+ });
33
+
34
+ it("passes arrays through", () => {
35
+ expect(normalizeGroupKey(["a", "b"])).toEqual(["a", "b"]);
36
+ });
37
+ });
38
+
39
+ describe("buildGroupTree", () => {
40
+ it("returns an empty list for no parts", () => {
41
+ expect(buildGroupTree([])).toEqual([]);
42
+ });
43
+
44
+ it("emits one part leaf per ungrouped part (no coalescing)", () => {
45
+ const tree = buildGroupTree(asPaths([null, null, null]));
46
+ expect(dump(tree)).toBe("P:#0(0),P:#1(1),P:#2(2)");
47
+ });
48
+
49
+ it("wraps adjacent same-key parts in one group with one part child each", () => {
50
+ const tree = buildGroupTree(asPaths(["a", "a", "a"]));
51
+ expect(dump(tree)).toBe("G:a#0[0,1,2]{P:#0.0(0),P:#0.1(1),P:#0.2(2)}");
52
+ });
53
+
54
+ it("splits non-adjacent runs of the same key into separate groups", () => {
55
+ const tree = buildGroupTree(asPaths(["a", null, "a"]));
56
+ expect(dump(tree)).toBe("G:a#0[0]{P:#0.0(0)},P:#1(1),G:a#2[2]{P:#2.0(2)}");
57
+ });
58
+
59
+ it("nests groups: parts at depth 1 sit alongside depth-2 subgroups", () => {
60
+ // ["A","B"], ["A","B"], ["A"], ["A"], ["A","C"]:
61
+ // Outer A spans 0..4. Inside A: a B subgroup (0,1), two depth-1 parts
62
+ // (2,3), then a C subgroup (4).
63
+ const tree = buildGroupTree(
64
+ asPaths([["A", "B"], ["A", "B"], ["A"], ["A"], ["A", "C"]]),
65
+ );
66
+ expect(dump(tree)).toBe(
67
+ "G:A#0[0,1,2,3,4]{G:B#0.0[0,1]{P:#0.0.0(0),P:#0.0.1(1)},P:#0.1(2),P:#0.2(3),G:C#0.3[4]{P:#0.3.0(4)}}",
68
+ );
69
+ });
70
+
71
+ it("treats longer prefix changes as group close+open", () => {
72
+ // ["A","B"], ["A","B","C"], ["A","B"]: opens C under B, closes back.
73
+ const tree = buildGroupTree(
74
+ asPaths([
75
+ ["A", "B"],
76
+ ["A", "B", "C"],
77
+ ["A", "B"],
78
+ ]),
79
+ );
80
+ expect(dump(tree)).toBe(
81
+ "G:A#0[0,1,2]{G:B#0.0[0,1,2]{P:#0.0.0(0),G:C#0.0.1[1]{P:#0.0.1.0(1)},P:#0.0.2(2)}}",
82
+ );
83
+ });
84
+
85
+ it("does not coalesce same-keyed groups separated by a divergent sibling", () => {
86
+ const tree = buildGroupTree(
87
+ asPaths([
88
+ ["A", "B"],
89
+ ["A", "C"],
90
+ ["A", "B"],
91
+ ]),
92
+ );
93
+ expect(dump(tree)).toBe(
94
+ "G:A#0[0,1,2]{G:B#0.0[0]{P:#0.0.0(0)},G:C#0.1[1]{P:#0.1.0(1)},G:B#0.2[2]{P:#0.2.0(2)}}",
95
+ );
96
+ });
97
+
98
+ it("accepts strings and arrays interchangeably via normalizeGroupKey", () => {
99
+ const tree = buildGroupTree([
100
+ normalizeGroupKey("A"),
101
+ normalizeGroupKey(["A"]),
102
+ ]);
103
+ expect(dump(tree)).toBe("G:A#0[0,1]{P:#0.0(0),P:#0.1(1)}");
104
+ });
105
+
106
+ it("assigns stable nodeKeys under append (existing keys do not shift)", () => {
107
+ const before = buildGroupTree(asPaths([["A"], null]));
108
+ const after = buildGroupTree(asPaths([["A"], null, ["B"]]));
109
+
110
+ expect(before[0]!.nodeKey).toBe(after[0]!.nodeKey);
111
+ expect(before[1]!.nodeKey).toBe(after[1]!.nodeKey);
112
+ expect(after[2]!.nodeKey).toBe("2");
113
+ });
114
+ });