@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
@@ -208,7 +208,7 @@ export namespace MessagePrimitiveParts {
208
208
  * @param endIndex - Index of the last tool call in the group
209
209
  * @param children - Rendered tool call components to display within the group
210
210
  *
211
- * @deprecated This feature is still experimental and subject to change.
211
+ * @deprecated Use `<MessagePrimitive.GroupedParts>` with a custom `groupBy` instead.
212
212
  */
213
213
  ToolGroup?: ComponentType<
214
214
  PropsWithChildren<{ startIndex: number; endIndex: number }>
@@ -220,6 +220,8 @@ export namespace MessagePrimitiveParts {
220
220
  * @param startIndex - Index of the first reasoning part in the group
221
221
  * @param endIndex - Index of the last reasoning part in the group
222
222
  * @param children - Rendered reasoning part components
223
+ *
224
+ * @deprecated Use `<MessagePrimitive.GroupedParts>` with a custom `groupBy` instead.
223
225
  */
224
226
  ReasoningGroup?: ReasoningGroupComponent;
225
227
 
@@ -234,6 +236,11 @@ export namespace MessagePrimitiveParts {
234
236
  * `ToolGroup` components cannot be used alongside it.
235
237
  */
236
238
  type ChainOfThoughtComponents = BaseComponents & {
239
+ /**
240
+ * @deprecated Use `<MessagePrimitive.GroupedParts>` with a `groupBy`
241
+ * that returns `["group-thought", ...]` for reasoning and tool-call
242
+ * parts. See `@assistant-ui/ui` for a worked example.
243
+ */
237
244
  ChainOfThought: ComponentType;
238
245
 
239
246
  Reasoning?: never;
@@ -604,70 +611,88 @@ const EMPTY_RUNNING_TEXT_PART: Extract<EnrichedPartState, { type: "text" }> =
604
611
  status: RUNNING_STATUS,
605
612
  });
606
613
 
614
+ /**
615
+ * @internal
616
+ * Renders a single part by index, calling `children` with the
617
+ * {@link EnrichedPartState} (tool/data UI enrichments + addResult/resume
618
+ * for tool calls). Shared between `<MessagePrimitive.Parts>` and
619
+ * `<MessagePrimitive.GroupedParts>`. Returns whatever `children`
620
+ * returns — callers decide how to handle a `null` return.
621
+ */
622
+ export const MessagePartChildren: FC<{
623
+ index: number;
624
+ children: (value: { part: EnrichedPartState }) => ReactNode;
625
+ }> = ({ index, children }) => {
626
+ const aui = useAui();
627
+ // Subscribed (not snapshotted like `tools`) so fallbacks registered
628
+ // after the first render trigger a re-render and `hasUI` re-evaluates.
629
+ const dataRenderers = useAuiState((s) => s.dataRenderers);
630
+
631
+ return (
632
+ <PartByIndexProvider index={index}>
633
+ <RenderChildrenWithAccessor
634
+ getItemState={(aui) => aui.message().part({ index }).getState()}
635
+ >
636
+ {(getItem) =>
637
+ children({
638
+ get part() {
639
+ const state = getItem();
640
+ if (state.type === "tool-call") {
641
+ const entry = aui.tools().getState().tools[state.toolName];
642
+ const hasUI = Array.isArray(entry) ? !!entry[0] : !!entry;
643
+ const partMethods = aui.message().part({ index });
644
+ return {
645
+ ...state,
646
+ toolUI: hasUI ? <RegisteredToolUI /> : null,
647
+ addResult: partMethods.addToolResult,
648
+ resume: partMethods.resumeToolCall,
649
+ };
650
+ }
651
+ if (state.type === "data") {
652
+ const hasUI =
653
+ getDataRenderer(dataRenderers, state.name, undefined) !==
654
+ undefined;
655
+ return {
656
+ ...state,
657
+ dataRendererUI: hasUI ? <RegisteredDataRendererUI /> : null,
658
+ };
659
+ }
660
+ return state;
661
+ },
662
+ })
663
+ }
664
+ </RenderChildrenWithAccessor>
665
+ </PartByIndexProvider>
666
+ );
667
+ };
668
+
607
669
  const MessagePrimitivePartsInner: FC<{
608
670
  children: (value: { part: EnrichedPartState }) => ReactNode;
609
671
  }> = ({ children }) => {
610
- const aui = useAui();
611
672
  const contentLength = useAuiState((s) => s.message.parts.length);
612
673
  const isRunning = useAuiState(
613
674
  (s) => (s.message.status?.type ?? "complete") === "running",
614
675
  );
615
676
  const isEmptyRunning = contentLength === 0 && isRunning;
616
- // Subscribed (not snapshotted like `tools`) so fallbacks registered after
617
- // the first render trigger a re-render and `hasUI` re-evaluates.
618
- const dataRenderers = useAuiState((s) => s.dataRenderers);
619
-
620
- // biome-ignore lint/correctness/useExhaustiveDependencies: aui accessors are stable refs
621
- return useMemo(() => {
622
- if (contentLength === 0) {
623
- if (!isEmptyRunning) return null;
624
677
 
625
- return (
626
- <TextMessagePartProvider text="" isRunning>
627
- {children({ part: EMPTY_RUNNING_TEXT_PART })}
628
- </TextMessagePartProvider>
629
- );
630
- }
678
+ if (contentLength === 0) {
679
+ if (!isEmptyRunning) return null;
680
+ return (
681
+ <TextMessagePartProvider text="" isRunning>
682
+ {children({ part: EMPTY_RUNNING_TEXT_PART })}
683
+ </TextMessagePartProvider>
684
+ );
685
+ }
631
686
 
632
- return Array.from({ length: contentLength }, (_, index) => (
633
- <PartByIndexProvider key={index} index={index}>
634
- <RenderChildrenWithAccessor
635
- getItemState={(aui) => aui.message().part({ index }).getState()}
636
- >
637
- {(getItem) => {
638
- const result = children({
639
- get part() {
640
- const state = getItem();
641
- if (state.type === "tool-call") {
642
- const entry = aui.tools().getState().tools[state.toolName];
643
- const hasUI = Array.isArray(entry) ? !!entry[0] : !!entry;
644
- const partMethods = aui.message().part({ index });
645
- return {
646
- ...state,
647
- toolUI: hasUI ? <RegisteredToolUI /> : null,
648
- addResult: partMethods.addToolResult,
649
- resume: partMethods.resumeToolCall,
650
- };
651
- }
652
- if (state.type === "data") {
653
- const hasUI =
654
- getDataRenderer(dataRenderers, state.name, undefined) !==
655
- undefined;
656
- return {
657
- ...state,
658
- dataRendererUI: hasUI ? <RegisteredDataRendererUI /> : null,
659
- };
660
- }
661
- return state;
662
- },
663
- });
664
- if (result !== null) return result;
665
- return <DefaultPartFallback />;
666
- }}
667
- </RenderChildrenWithAccessor>
668
- </PartByIndexProvider>
669
- ));
670
- }, [contentLength, children, isEmptyRunning, dataRenderers]);
687
+ return (
688
+ <>
689
+ {Array.from({ length: contentLength }, (_, index) => (
690
+ <MessagePartChildren key={index} index={index}>
691
+ {(value) => children(value) ?? <DefaultPartFallback />}
692
+ </MessagePartChildren>
693
+ ))}
694
+ </>
695
+ );
671
696
  };
672
697
 
673
698
  /**
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Hierarchical adjacent-coalescing grouping for message parts.
3
+ *
4
+ * Given a group path per part (from `groupBy`), builds a tree of group
5
+ * nodes wrapping individual parts. Adjacent parts sharing a path prefix
6
+ * coalesce into the same group; ungrouped parts are direct children of
7
+ * the root.
8
+ *
9
+ * Each node gets a structural `nodeKey` built from sibling indices
10
+ * (`"0.1.0"`), stable under append-only streaming.
11
+ */
12
+
13
+ /**
14
+ * Public group key type. Group keys must be prefixed with `group-` so
15
+ * that a unified `switch (part.type)` in the renderer can distinguish
16
+ * a group key (e.g. `"group-thought"`) from a real part type
17
+ * (`"text"`, `"tool-call"`).
18
+ */
19
+ export type GroupKey<TKey extends `group-${string}` = `group-${string}`> =
20
+ | TKey
21
+ | readonly TKey[]
22
+ | null
23
+ | undefined;
24
+
25
+ export type GroupNode = GroupNodeGroup | GroupNodePart;
26
+
27
+ export interface GroupNodeGroup {
28
+ readonly type: "group";
29
+ /** Current-level group key (last segment of the path). */
30
+ readonly key: string;
31
+ /** Structural React key: sibling-index path, e.g. `"0.1.0"`. */
32
+ readonly nodeKey: string;
33
+ /** Indices of parts in this subtree, in order. */
34
+ readonly indices: readonly number[];
35
+ readonly children: readonly GroupNode[];
36
+ }
37
+
38
+ export interface GroupNodePart {
39
+ readonly type: "part";
40
+ /** Index of the part in the message. */
41
+ readonly index: number;
42
+ /** Structural React key: sibling-index path within parent. */
43
+ readonly nodeKey: string;
44
+ }
45
+
46
+ const EMPTY_PATH: readonly string[] = Object.freeze([]);
47
+
48
+ /**
49
+ * Normalize a `groupBy` return value to a path array.
50
+ * `null`/`undefined`/`[]` → `[]` (ungrouped).
51
+ * `"foo"` → `["foo"]`. Arrays pass through.
52
+ */
53
+ export const normalizeGroupKey = (key: GroupKey): readonly string[] => {
54
+ if (key == null) return EMPTY_PATH;
55
+ if (typeof key === "string") return [key];
56
+ return key;
57
+ };
58
+
59
+ interface BuildFrame {
60
+ key: string;
61
+ nodeKey: string;
62
+ indices: number[];
63
+ children: GroupNode[];
64
+ nextChildIdx: number;
65
+ }
66
+
67
+ const makeChildNodeKey = (parent: BuildFrame): string => {
68
+ const idx = parent.nextChildIdx++;
69
+ return parent.nodeKey === "" ? String(idx) : `${parent.nodeKey}.${idx}`;
70
+ };
71
+
72
+ /**
73
+ * Build the group tree from an array of normalized group paths.
74
+ * `paths[i]` is the path for part `i`. The output tree contains one
75
+ * `part` node per part and one `group` node per coalesced run.
76
+ */
77
+ export const buildGroupTree = (
78
+ paths: readonly (readonly string[])[],
79
+ ): readonly GroupNode[] => {
80
+ const root: BuildFrame = {
81
+ key: "",
82
+ nodeKey: "",
83
+ indices: [],
84
+ children: [],
85
+ nextChildIdx: 0,
86
+ };
87
+ const stack: BuildFrame[] = [root];
88
+
89
+ const closeTop = (): void => {
90
+ const closing = stack.pop()!;
91
+ const parent = stack[stack.length - 1]!;
92
+ parent.children.push({
93
+ type: "group",
94
+ key: closing.key,
95
+ nodeKey: closing.nodeKey,
96
+ indices: closing.indices,
97
+ children: closing.children,
98
+ });
99
+ };
100
+
101
+ for (let i = 0; i < paths.length; i++) {
102
+ const path = paths[i]!;
103
+
104
+ // Find the longest prefix shared between currently-open groups
105
+ // (excluding root) and this part's path.
106
+ let common = 0;
107
+ while (
108
+ common < stack.length - 1 &&
109
+ common < path.length &&
110
+ stack[common + 1]!.key === path[common]
111
+ ) {
112
+ common++;
113
+ }
114
+
115
+ // Close groups not on this path.
116
+ while (stack.length - 1 > common) {
117
+ closeTop();
118
+ }
119
+
120
+ // Open new groups down to the part's depth.
121
+ while (stack.length - 1 < path.length) {
122
+ const parent = stack[stack.length - 1]!;
123
+ stack.push({
124
+ key: path[stack.length - 1]!,
125
+ nodeKey: makeChildNodeKey(parent),
126
+ indices: [],
127
+ children: [],
128
+ nextChildIdx: 0,
129
+ });
130
+ }
131
+
132
+ // Push this part as a leaf in the deepest open group (or root).
133
+ const top = stack[stack.length - 1]!;
134
+ top.children.push({
135
+ type: "part",
136
+ index: i,
137
+ nodeKey: makeChildNodeKey(top),
138
+ });
139
+
140
+ // Record the part index in every open ancestor group.
141
+ for (let s = 1; s < stack.length; s++) {
142
+ stack[s]!.indices.push(i);
143
+ }
144
+ }
145
+
146
+ // Close any still-open groups.
147
+ while (stack.length > 1) {
148
+ closeTop();
149
+ }
150
+
151
+ return root.children;
152
+ };
@@ -12,6 +12,7 @@ import {
12
12
  SKIP_UPDATE,
13
13
  } from "../../subscribable/subscribable";
14
14
  import type {
15
+ ComposerRuntimeEventCallback,
15
16
  ComposerRuntimeEventType,
16
17
  DictationState,
17
18
  EditComposerRuntimeCore,
@@ -222,9 +223,9 @@ export type ComposerRuntime = {
222
223
  /**
223
224
  * @deprecated This API is still under active development and might change without notice.
224
225
  */
225
- unstable_on(
226
- event: ComposerRuntimeEventType,
227
- callback: () => void,
226
+ unstable_on<E extends ComposerRuntimeEventType>(
227
+ event: E,
228
+ callback: ComposerRuntimeEventCallback<E>,
228
229
  ): Unsubscribe;
229
230
  };
230
231
 
@@ -332,19 +333,19 @@ export abstract class ComposerRuntimeImpl implements ComposerRuntime {
332
333
  EventSubscriptionSubject<ComposerRuntimeEventType>
333
334
  >();
334
335
 
335
- public unstable_on(
336
- event: ComposerRuntimeEventType,
337
- callback: () => void,
336
+ public unstable_on<E extends ComposerRuntimeEventType>(
337
+ event: E,
338
+ callback: ComposerRuntimeEventCallback<E>,
338
339
  ): Unsubscribe {
339
340
  let subject = this._eventSubscriptionSubjects.get(event);
340
341
  if (!subject) {
341
- subject = new EventSubscriptionSubject({
342
- event: event,
342
+ subject = new EventSubscriptionSubject<ComposerRuntimeEventType>({
343
+ event,
343
344
  binding: this._core,
344
345
  });
345
346
  this._eventSubscriptionSubjects.set(event, subject);
346
347
  }
347
- return subject.subscribe(callback);
348
+ return subject.subscribe(callback as (payload?: unknown) => void);
348
349
  }
349
350
 
350
351
  public abstract getAttachmentByIndex(idx: number): AttachmentRuntime;
@@ -3,7 +3,26 @@ import type { SubscribableWithState } from "../../subscribable/subscribable";
3
3
  import type { ThreadListItemRuntimePath } from "./paths";
4
4
  import type { ThreadListRuntimeCoreBinding } from "./thread-list-runtime";
5
5
 
6
- export type ThreadListItemEventType = "switchedTo" | "switchedAway";
6
+ export type ThreadListItemEventPayload = {
7
+ /**
8
+ * @deprecated State-derivable. Compare `s.threads.mainThreadId` against the
9
+ * item's `s.threadListItem.id` via `useAuiState` instead. Kept for backward
10
+ * compatibility.
11
+ */
12
+ switchedTo: Record<string, never>;
13
+ /**
14
+ * @deprecated State-derivable. Compare `s.threads.mainThreadId` against the
15
+ * item's `s.threadListItem.id` via `useAuiState` instead. Kept for backward
16
+ * compatibility.
17
+ */
18
+ switchedAway: Record<string, never>;
19
+ };
20
+
21
+ export type ThreadListItemEventType = keyof ThreadListItemEventPayload;
22
+
23
+ export type ThreadListItemEventCallback<E extends ThreadListItemEventType> = (
24
+ payload: ThreadListItemEventPayload[E],
25
+ ) => void;
7
26
 
8
27
  import type { ThreadListItemState } from "./bindings";
9
28
  import type { ThreadListItemStatus } from "../interfaces/thread-list-runtime-core";
@@ -27,9 +46,9 @@ export type ThreadListItemRuntime = {
27
46
 
28
47
  subscribe(callback: () => void): Unsubscribe;
29
48
 
30
- unstable_on(
31
- event: ThreadListItemEventType,
32
- callback: () => void,
49
+ unstable_on<E extends ThreadListItemEventType>(
50
+ event: E,
51
+ callback: ThreadListItemEventCallback<E>,
33
52
  ): Unsubscribe;
34
53
 
35
54
  __internal_getRuntime(): ThreadListItemRuntime;
@@ -112,7 +131,10 @@ export class ThreadListItemRuntimeImpl implements ThreadListItemRuntime {
112
131
  return this._threadListBinding.generateTitle(state.id);
113
132
  }
114
133
 
115
- public unstable_on(event: ThreadListItemEventType, callback: () => void) {
134
+ public unstable_on<E extends ThreadListItemEventType>(
135
+ event: E,
136
+ callback: ThreadListItemEventCallback<E>,
137
+ ) {
116
138
  let prevIsMain = this._core.getState().isMain;
117
139
  let prevThreadId = this._core.getState().id;
118
140
  return this.subscribe(() => {
@@ -125,7 +147,7 @@ export class ThreadListItemRuntimeImpl implements ThreadListItemRuntime {
125
147
 
126
148
  if (event === "switchedTo" && !newIsMain) return;
127
149
  if (event === "switchedAway" && newIsMain) return;
128
- callback();
150
+ (callback as (payload?: unknown) => void)({});
129
151
  });
130
152
  }
131
153
 
@@ -4,6 +4,7 @@ import type {
4
4
  ThreadRuntimeCore,
5
5
  SpeechState,
6
6
  VoiceSessionState,
7
+ ThreadRuntimeEventCallback,
7
8
  ThreadRuntimeEventType,
8
9
  StartRunConfig,
9
10
  ResumeRunConfig,
@@ -329,7 +330,10 @@ export type ThreadRuntime = {
329
330
  muteVoice(): void;
330
331
  unmuteVoice(): void;
331
332
 
332
- unstable_on(event: ThreadRuntimeEventType, callback: () => void): Unsubscribe;
333
+ unstable_on<E extends ThreadRuntimeEventType>(
334
+ event: E,
335
+ callback: ThreadRuntimeEventCallback<E>,
336
+ ): Unsubscribe;
333
337
  };
334
338
 
335
339
  export class ThreadRuntimeImpl implements ThreadRuntime {
@@ -601,18 +605,18 @@ export class ThreadRuntimeImpl implements ThreadRuntime {
601
605
  EventSubscriptionSubject<ThreadRuntimeEventType>
602
606
  >();
603
607
 
604
- public unstable_on(
605
- event: ThreadRuntimeEventType,
606
- callback: () => void,
608
+ public unstable_on<E extends ThreadRuntimeEventType>(
609
+ event: E,
610
+ callback: ThreadRuntimeEventCallback<E>,
607
611
  ): Unsubscribe {
608
612
  let subject = this._eventSubscriptionSubjects.get(event);
609
613
  if (!subject) {
610
- subject = new EventSubscriptionSubject({
611
- event: event,
614
+ subject = new EventSubscriptionSubject<ThreadRuntimeEventType>({
615
+ event,
612
616
  binding: this._threadBinding,
613
617
  });
614
618
  this._eventSubscriptionSubjects.set(event, subject);
615
619
  }
616
- return subject.subscribe(callback);
620
+ return subject.subscribe(callback as (payload?: unknown) => void);
617
621
  }
618
622
  }
@@ -14,7 +14,10 @@ import {
14
14
  fileMatchesAccept,
15
15
  } from "../../adapters/attachment";
16
16
  import type {
17
+ AttachmentAddErrorReason,
17
18
  ComposerRuntimeCore,
19
+ ComposerRuntimeEventCallback,
20
+ ComposerRuntimeEventPayload,
18
21
  ComposerRuntimeEventType,
19
22
  DictationState,
20
23
  SendOptions,
@@ -189,7 +192,7 @@ export abstract class BaseComposerRuntimeCore
189
192
  };
190
193
 
191
194
  this.handleSend(message, options);
192
- this._notifyEventSubscribers("send");
195
+ this._notifyEventSubscribers("send", {});
193
196
  }
194
197
 
195
198
  public cancel() {
@@ -215,14 +218,15 @@ export abstract class BaseComposerRuntimeCore
215
218
  adapter.accept,
216
219
  )
217
220
  ) {
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}`,
221
+ const message = `File type ${fileOrAttachment.contentType || "unknown"} is not accepted. Accepted types: ${adapter.accept}`;
222
+ const err = new Error(message);
223
+ this._safeEmitAttachmentAddError(
224
+ "not-accepted",
225
+ message,
226
+ undefined,
227
+ err,
225
228
  );
229
+ throw err;
226
230
  }
227
231
 
228
232
  const a: CompleteAttachment = {
@@ -235,7 +239,7 @@ export abstract class BaseComposerRuntimeCore
235
239
  };
236
240
  this._attachments = [...this._attachments, a];
237
241
  this._notifySubscribers();
238
- this._notifyEventSubscribers("attachmentAdd");
242
+ this._notifyEventSubscribers("attachmentAdd", {});
239
243
  return;
240
244
  }
241
245
 
@@ -256,22 +260,28 @@ export abstract class BaseComposerRuntimeCore
256
260
  this._notifySubscribers();
257
261
  };
258
262
 
259
- let lastAttachment: PendingAttachment | undefined;
260
- try {
261
- const adapter = this.getAttachmentAdapter();
262
- if (!adapter) throw new Error("Attachments are not supported");
263
+ const adapter = this.getAttachmentAdapter();
264
+ if (!adapter) {
265
+ const message = "Attachments are not supported";
266
+ const err = new Error(message);
267
+ this._safeEmitAttachmentAddError("no-adapter", message, undefined, err);
268
+ throw err;
269
+ }
263
270
 
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
- }
271
+ if (
272
+ !fileMatchesAccept(
273
+ { name: fileOrAttachment.name, type: fileOrAttachment.type },
274
+ adapter.accept,
275
+ )
276
+ ) {
277
+ const message = `File type ${fileOrAttachment.type || "unknown"} is not accepted. Accepted types: ${adapter.accept}`;
278
+ const err = new Error(message);
279
+ this._safeEmitAttachmentAddError("not-accepted", message, undefined, err);
280
+ throw err;
281
+ }
274
282
 
283
+ let lastAttachment: PendingAttachment | undefined;
284
+ try {
275
285
  const promiseOrGenerator = adapter.add({ file: fileOrAttachment });
276
286
  if (Symbol.asyncIterator in promiseOrGenerator) {
277
287
  for await (const r of promiseOrGenerator) {
@@ -289,20 +299,48 @@ export abstract class BaseComposerRuntimeCore
289
299
  status: { type: "incomplete", reason: "error" },
290
300
  });
291
301
  }
292
- try {
293
- this._notifyEventSubscribers("attachmentAddError");
294
- } catch {
295
- // prevent subscriber errors from masking the original error
296
- }
302
+ this._safeEmitAttachmentAddError(
303
+ "adapter-error",
304
+ e instanceof Error ? e.message : String(e),
305
+ lastAttachment?.id,
306
+ e instanceof Error ? e : undefined,
307
+ );
297
308
  throw e;
298
309
  }
299
310
 
300
311
  const hasError =
301
312
  lastAttachment?.status.type === "incomplete" &&
302
313
  lastAttachment.status.reason === "error";
303
- this._notifyEventSubscribers(
304
- hasError ? "attachmentAddError" : "attachmentAdd",
305
- );
314
+ if (hasError) {
315
+ this._safeEmitAttachmentAddError(
316
+ "adapter-error",
317
+ "Attachment upload did not complete successfully.",
318
+ lastAttachment?.id,
319
+ );
320
+ } else {
321
+ this._notifyEventSubscribers("attachmentAdd", {});
322
+ }
323
+ }
324
+
325
+ private _safeEmitAttachmentAddError(
326
+ reason: AttachmentAddErrorReason,
327
+ message: string,
328
+ attachmentId?: string,
329
+ error?: Error,
330
+ ) {
331
+ try {
332
+ this._notifyEventSubscribers("attachmentAddError", {
333
+ reason,
334
+ message,
335
+ ...(attachmentId !== undefined && { attachmentId }),
336
+ ...(error !== undefined && { error }),
337
+ });
338
+ } catch (subscriberError) {
339
+ console.error(
340
+ "[assistant-ui] attachmentAddError subscriber threw:",
341
+ subscriberError,
342
+ );
343
+ }
306
344
  }
307
345
 
308
346
  async removeAttachment(attachmentId: string) {
@@ -471,28 +509,33 @@ export abstract class BaseComposerRuntimeCore
471
509
 
472
510
  private _eventSubscribers = new Map<
473
511
  ComposerRuntimeEventType,
474
- Set<() => void>
512
+ Set<(payload?: unknown) => void>
475
513
  >();
476
514
 
477
- protected _notifyEventSubscribers(event: ComposerRuntimeEventType) {
515
+ protected _notifyEventSubscribers<E extends ComposerRuntimeEventType>(
516
+ event: E,
517
+ payload: ComposerRuntimeEventPayload[E],
518
+ ) {
478
519
  const subscribers = this._eventSubscribers.get(event);
479
520
  if (!subscribers) return;
480
521
 
481
- for (const callback of subscribers) callback();
522
+ for (const callback of subscribers) callback(payload);
482
523
  }
483
524
 
484
- public unstable_on(event: ComposerRuntimeEventType, callback: () => void) {
485
- const subscribers = this._eventSubscribers.get(event);
525
+ public unstable_on<E extends ComposerRuntimeEventType>(
526
+ event: E,
527
+ callback: ComposerRuntimeEventCallback<E>,
528
+ ) {
529
+ const wrapped = callback as (payload?: unknown) => void;
530
+ let subscribers = this._eventSubscribers.get(event);
486
531
  if (!subscribers) {
487
- this._eventSubscribers.set(event, new Set([callback]));
488
- } else {
489
- subscribers.add(callback);
532
+ subscribers = new Set();
533
+ this._eventSubscribers.set(event, subscribers);
490
534
  }
535
+ subscribers.add(wrapped);
491
536
 
492
537
  return () => {
493
- const subscribers = this._eventSubscribers.get(event);
494
- if (!subscribers) return;
495
- subscribers.delete(callback);
538
+ this._eventSubscribers.get(event)?.delete(wrapped);
496
539
  };
497
540
  }
498
541
  }