@assistant-ui/core 0.2.18 → 0.2.19

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 (228) hide show
  1. package/dist/index.d.ts +4 -2
  2. package/dist/index.js +3 -1
  3. package/dist/index.js.map +1 -1
  4. package/dist/internal.d.ts +2 -1
  5. package/dist/internal.js +2 -1
  6. package/dist/model-context/interactable-composer-metadata.d.ts +105 -0
  7. package/dist/model-context/interactable-composer-metadata.d.ts.map +1 -0
  8. package/dist/model-context/interactable-composer-metadata.js +259 -0
  9. package/dist/model-context/interactable-composer-metadata.js.map +1 -0
  10. package/dist/model-context/registry.d.ts.map +1 -1
  11. package/dist/model-context/registry.js +1 -0
  12. package/dist/model-context/registry.js.map +1 -1
  13. package/dist/model-context/types.d.ts +6 -0
  14. package/dist/model-context/types.d.ts.map +1 -1
  15. package/dist/model-context/types.js +4 -0
  16. package/dist/model-context/types.js.map +1 -1
  17. package/dist/react/AssistantProvider.d.ts +1 -1
  18. package/dist/react/AssistantProvider.js +1 -1
  19. package/dist/react/AssistantRuntimeProvider.d.ts +1 -1
  20. package/dist/react/RuntimeAdapter.js +1 -1
  21. package/dist/react/client/Interactables.d.ts +8 -2
  22. package/dist/react/client/Interactables.d.ts.map +1 -1
  23. package/dist/react/client/Interactables.js +190 -94
  24. package/dist/react/client/Interactables.js.map +1 -1
  25. package/dist/react/client/Tools.js +1 -1
  26. package/dist/react/client/interactable-model-context.d.ts +9 -6
  27. package/dist/react/client/interactable-model-context.d.ts.map +1 -1
  28. package/dist/react/client/interactable-model-context.js +154 -32
  29. package/dist/react/client/interactable-model-context.js.map +1 -1
  30. package/dist/react/index.d.ts +15 -6
  31. package/dist/react/index.js +12 -5
  32. package/dist/react/interactables-legacy/Interactables.d.ts +12 -0
  33. package/dist/react/interactables-legacy/Interactables.d.ts.map +1 -0
  34. package/dist/react/interactables-legacy/Interactables.js +264 -0
  35. package/dist/react/interactables-legacy/Interactables.js.map +1 -0
  36. package/dist/react/interactables-legacy/interactable-model-context.d.ts +12 -0
  37. package/dist/react/interactables-legacy/interactable-model-context.d.ts.map +1 -0
  38. package/dist/react/interactables-legacy/interactable-model-context.js +54 -0
  39. package/dist/react/interactables-legacy/interactable-model-context.js.map +1 -0
  40. package/dist/react/interactables-legacy/scopes.d.ts +58 -0
  41. package/dist/react/interactables-legacy/scopes.d.ts.map +1 -0
  42. package/dist/react/interactables-legacy/scopes.js +0 -0
  43. package/dist/react/interactables-legacy/useAssistantInteractable.d.ts +31 -0
  44. package/dist/react/interactables-legacy/useAssistantInteractable.d.ts.map +1 -0
  45. package/dist/react/{model-context → interactables-legacy}/useAssistantInteractable.js +6 -2
  46. package/dist/react/interactables-legacy/useAssistantInteractable.js.map +1 -0
  47. package/dist/react/interactables-legacy/useInteractableState.d.ts +21 -0
  48. package/dist/react/interactables-legacy/useInteractableState.d.ts.map +1 -0
  49. package/dist/react/interactables-legacy/useInteractableState.js +84 -0
  50. package/dist/react/interactables-legacy/useInteractableState.js.map +1 -0
  51. package/dist/react/model-context/interactableTool.d.ts +53 -0
  52. package/dist/react/model-context/interactableTool.d.ts.map +1 -0
  53. package/dist/react/model-context/interactableTool.js +188 -0
  54. package/dist/react/model-context/interactableTool.js.map +1 -0
  55. package/dist/react/model-context/useAssistantContext.js +1 -1
  56. package/dist/react/model-context/useAssistantDataUI.js +1 -1
  57. package/dist/react/model-context/useAssistantInstructions.js +1 -1
  58. package/dist/react/model-context/useAssistantTool.js +1 -1
  59. package/dist/react/model-context/useAssistantToolUI.js +1 -1
  60. package/dist/react/model-context/useAuiToolOverrides.js +1 -1
  61. package/dist/react/model-context/useInteractable.d.ts +73 -0
  62. package/dist/react/model-context/useInteractable.d.ts.map +1 -0
  63. package/dist/react/model-context/useInteractable.js +100 -0
  64. package/dist/react/model-context/useInteractable.js.map +1 -0
  65. package/dist/react/model-context/useInteractableState.d.ts +8 -5
  66. package/dist/react/model-context/useInteractableState.d.ts.map +1 -1
  67. package/dist/react/model-context/useInteractableState.js +48 -48
  68. package/dist/react/model-context/useInteractableState.js.map +1 -1
  69. package/dist/react/model-context/useInteractableVersions.d.ts +19 -0
  70. package/dist/react/model-context/useInteractableVersions.d.ts.map +1 -0
  71. package/dist/react/model-context/useInteractableVersions.js +51 -0
  72. package/dist/react/model-context/useInteractableVersions.js.map +1 -0
  73. package/dist/react/primitive-hooks/useThreadMessageIds.d.ts +15 -0
  74. package/dist/react/primitive-hooks/useThreadMessageIds.d.ts.map +1 -0
  75. package/dist/react/primitive-hooks/useThreadMessageIds.js +26 -0
  76. package/dist/react/primitive-hooks/useThreadMessageIds.js.map +1 -0
  77. package/dist/react/primitive-hooks/useVoice.js +1 -1
  78. package/dist/react/primitives/chainOfThought/ChainOfThoughtParts.js +1 -1
  79. package/dist/react/primitives/composer/ComposerAttachments.js +1 -1
  80. package/dist/react/primitives/composer/ComposerQueue.js +1 -1
  81. package/dist/react/primitives/generativeUI/GenerativeUI.js +1 -1
  82. package/dist/react/primitives/message/MessageAttachments.js +1 -1
  83. package/dist/react/primitives/message/MessageGroupedParts.js +1 -1
  84. package/dist/react/primitives/message/MessageParts.js +1 -1
  85. package/dist/react/primitives/message/MessageQuote.js +1 -1
  86. package/dist/react/primitives/part/PartMessages.d.ts +5 -2
  87. package/dist/react/primitives/part/PartMessages.d.ts.map +1 -1
  88. package/dist/react/primitives/part/PartMessages.js +8 -8
  89. package/dist/react/primitives/part/PartMessages.js.map +1 -1
  90. package/dist/react/primitives/thread/ThreadMessages.d.ts +31 -1
  91. package/dist/react/primitives/thread/ThreadMessages.d.ts.map +1 -1
  92. package/dist/react/primitives/thread/ThreadMessages.js +64 -2
  93. package/dist/react/primitives/thread/ThreadMessages.js.map +1 -1
  94. package/dist/react/primitives/thread/ThreadSuggestions.js +1 -1
  95. package/dist/react/primitives/threadList/ThreadListItems.js +1 -1
  96. package/dist/react/providers/MessageByIdProvider.d.ts +9 -0
  97. package/dist/react/providers/MessageByIdProvider.d.ts.map +1 -0
  98. package/dist/react/providers/MessageByIdProvider.js +57 -0
  99. package/dist/react/providers/MessageByIdProvider.js.map +1 -0
  100. package/dist/react/providers/ReadonlyThreadProvider.js +1 -1
  101. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js +1 -1
  102. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +3 -0
  103. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  104. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +16 -1
  105. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
  106. package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.js +1 -1
  107. package/dist/react/runtimes/cloud/auiV0.js +1 -1
  108. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.js +1 -1
  109. package/dist/react/runtimes/createRuntimeExtras.d.ts +20 -0
  110. package/dist/react/runtimes/createRuntimeExtras.d.ts.map +1 -0
  111. package/dist/react/runtimes/createRuntimeExtras.js +43 -0
  112. package/dist/react/runtimes/createRuntimeExtras.js.map +1 -0
  113. package/dist/react/runtimes/useLocalRuntime.js +1 -1
  114. package/dist/react/runtimes/useRemoteThreadListRuntime.d.ts.map +1 -1
  115. package/dist/react/runtimes/useRemoteThreadListRuntime.js +6 -2
  116. package/dist/react/runtimes/useRemoteThreadListRuntime.js.map +1 -1
  117. package/dist/react/runtimes/useStreamingTiming.d.ts +26 -0
  118. package/dist/react/runtimes/useStreamingTiming.d.ts.map +1 -0
  119. package/dist/react/runtimes/useStreamingTiming.js +43 -0
  120. package/dist/react/runtimes/useStreamingTiming.js.map +1 -0
  121. package/dist/react/types/scopes/interactables.d.ts +40 -24
  122. package/dist/react/types/scopes/interactables.d.ts.map +1 -1
  123. package/dist/react/types/store-augmentation.d.ts +3 -1
  124. package/dist/react/types/store-augmentation.d.ts.map +1 -1
  125. package/dist/react/utils/useJSONEqual.d.ts +11 -0
  126. package/dist/react/utils/useJSONEqual.d.ts.map +1 -0
  127. package/dist/react/utils/useJSONEqual.js +30 -0
  128. package/dist/react/utils/useJSONEqual.js.map +1 -0
  129. package/dist/runtime/base/base-composer-runtime-core.d.ts +5 -0
  130. package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
  131. package/dist/runtime/base/base-composer-runtime-core.js +13 -0
  132. package/dist/runtime/base/base-composer-runtime-core.js.map +1 -1
  133. package/dist/runtime/base/default-edit-composer-runtime-core.d.ts.map +1 -1
  134. package/dist/runtime/base/default-edit-composer-runtime-core.js +6 -1
  135. package/dist/runtime/base/default-edit-composer-runtime-core.js.map +1 -1
  136. package/dist/runtime/base/default-thread-composer-runtime-core.d.ts.map +1 -1
  137. package/dist/runtime/base/default-thread-composer-runtime-core.js +4 -1
  138. package/dist/runtime/base/default-thread-composer-runtime-core.js.map +1 -1
  139. package/dist/runtime/utils/message-repository.d.ts +1 -0
  140. package/dist/runtime/utils/message-repository.d.ts.map +1 -1
  141. package/dist/runtime/utils/message-repository.js +6 -3
  142. package/dist/runtime/utils/message-repository.js.map +1 -1
  143. package/dist/runtime/utils/streaming-timing.d.ts +59 -0
  144. package/dist/runtime/utils/streaming-timing.d.ts.map +1 -0
  145. package/dist/runtime/utils/streaming-timing.js +76 -0
  146. package/dist/runtime/utils/streaming-timing.js.map +1 -0
  147. package/dist/runtime/utils/thread-message-like.js +2 -1
  148. package/dist/runtime/utils/thread-message-like.js.map +1 -1
  149. package/dist/runtimes/external-store/external-store-adapter.d.ts +26 -1
  150. package/dist/runtimes/external-store/external-store-adapter.d.ts.map +1 -1
  151. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +9 -0
  152. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  153. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +19 -0
  154. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  155. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  156. package/dist/runtimes/local/local-thread-runtime-core.js +1 -1
  157. package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
  158. package/dist/runtimes/remote-thread-list/types.d.ts +12 -0
  159. package/dist/runtimes/remote-thread-list/types.d.ts.map +1 -1
  160. package/dist/store/clients/suggestions.js +1 -1
  161. package/dist/store/clients/thread-message-client.js +1 -1
  162. package/dist/store/runtime-clients/composer-runtime-client.js +1 -1
  163. package/dist/store/runtime-clients/message-runtime-client.js +1 -1
  164. package/dist/store/runtime-clients/thread-list-item-runtime-client.js +1 -1
  165. package/dist/store/runtime-clients/thread-runtime-client.js +1 -1
  166. package/dist/tests/remote-thread-list-test-helpers.d.ts +1 -1
  167. package/dist/tests/remote-thread-list-test-helpers.d.ts.map +1 -1
  168. package/dist/tests/remote-thread-list-test-helpers.js +3 -2
  169. package/dist/tests/remote-thread-list-test-helpers.js.map +1 -1
  170. package/package.json +9 -9
  171. package/src/index.ts +17 -0
  172. package/src/internal.ts +7 -0
  173. package/src/model-context/interactable-composer-metadata.test.ts +550 -0
  174. package/src/model-context/interactable-composer-metadata.ts +395 -0
  175. package/src/model-context/registry.ts +5 -0
  176. package/src/model-context/types.ts +12 -0
  177. package/src/react/client/Interactables.test.ts +358 -0
  178. package/src/react/client/Interactables.ts +339 -86
  179. package/src/react/client/interactable-model-context.test.ts +418 -0
  180. package/src/react/client/interactable-model-context.ts +252 -58
  181. package/src/react/index.ts +72 -3
  182. package/src/react/interactables-legacy/Interactables.ts +332 -0
  183. package/src/react/interactables-legacy/interactable-model-context.test.ts +54 -0
  184. package/src/react/interactables-legacy/interactable-model-context.ts +80 -0
  185. package/src/react/interactables-legacy/scopes.ts +66 -0
  186. package/src/react/{model-context → interactables-legacy}/useAssistantInteractable.ts +10 -1
  187. package/src/react/interactables-legacy/useInteractableState.ts +67 -0
  188. package/src/react/model-context/interactableTool.tsx +175 -0
  189. package/src/react/model-context/useInteractable.ts +233 -0
  190. package/src/react/model-context/useInteractableState.ts +31 -23
  191. package/src/react/model-context/useInteractableVersions.ts +48 -0
  192. package/src/react/primitive-hooks/useThreadMessageIds.ts +27 -0
  193. package/src/react/primitives/part/PartMessages.test.tsx +17 -0
  194. package/src/react/primitives/part/PartMessages.tsx +13 -8
  195. package/src/react/primitives/thread/ThreadMessages.tsx +67 -0
  196. package/src/react/providers/MessageByIdProvider.tsx +23 -0
  197. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +21 -1
  198. package/src/react/runtimes/createRuntimeExtras.test.ts +58 -0
  199. package/src/react/runtimes/createRuntimeExtras.ts +68 -0
  200. package/src/react/runtimes/createRuntimeExtras.use.test.ts +50 -0
  201. package/src/react/runtimes/useRemoteThreadListRuntime.ts +13 -1
  202. package/src/react/runtimes/useStreamingTiming.ts +68 -0
  203. package/src/react/types/scopes/interactables.ts +45 -23
  204. package/src/react/types/store-augmentation.ts +4 -2
  205. package/src/react/utils/useJSONEqual.ts +20 -0
  206. package/src/runtime/base/base-composer-runtime-core.ts +13 -0
  207. package/src/runtime/base/default-edit-composer-runtime-core.ts +18 -1
  208. package/src/runtime/base/default-thread-composer-runtime-core.ts +11 -1
  209. package/src/runtime/utils/message-repository.ts +11 -7
  210. package/src/runtime/utils/streaming-timing.test.ts +301 -0
  211. package/src/runtime/utils/streaming-timing.ts +161 -0
  212. package/src/runtime/utils/thread-message-like.ts +2 -1
  213. package/src/runtimes/external-store/external-store-adapter.ts +28 -0
  214. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +33 -1
  215. package/src/runtimes/local/local-thread-runtime-core.ts +4 -1
  216. package/src/runtimes/remote-thread-list/types.ts +13 -0
  217. package/src/tests/MessageRepository.test.ts +2 -0
  218. package/src/tests/composer-can-send.test.ts +1 -0
  219. package/src/tests/default-edit-composer-runtime-core.test.ts +78 -1
  220. package/src/tests/default-thread-composer-runtime-core.test.ts +82 -0
  221. package/src/tests/external-store-thread-runtime-core.test.ts +337 -0
  222. package/src/tests/groupParts.test.ts +32 -0
  223. package/src/tests/remote-thread-list-reactive-threadId.test.ts +90 -0
  224. package/src/tests/remote-thread-list-test-helpers.ts +2 -1
  225. package/src/tests/thread-message-like.test.ts +50 -0
  226. package/dist/react/model-context/useAssistantInteractable.d.ts +0 -22
  227. package/dist/react/model-context/useAssistantInteractable.d.ts.map +0 -1
  228. package/dist/react/model-context/useAssistantInteractable.js.map +0 -1
@@ -7,6 +7,7 @@ import {
7
7
  } from "react";
8
8
  import { RenderChildrenWithAccessor, useAuiState } from "@assistant-ui/store";
9
9
  import { MessageByIndexProvider } from "../../providers/MessageByIndexProvider";
10
+ import { MessageByIdProvider } from "../../providers/MessageByIdProvider";
10
11
  import type { MessageState } from "../../../store";
11
12
 
12
13
  type MessagesComponentConfig =
@@ -79,6 +80,20 @@ const isComponentsSame = (
79
80
 
80
81
  const DEFAULT_SYSTEM_MESSAGE = () => null;
81
82
 
83
+ const messageIdSetCache = new WeakMap<
84
+ readonly MessageState[],
85
+ ReadonlySet<string>
86
+ >();
87
+
88
+ const hasMessageId = (messages: readonly MessageState[], messageId: string) => {
89
+ let ids = messageIdSetCache.get(messages);
90
+ if (!ids) {
91
+ ids = new Set(messages.map((m) => m.id));
92
+ messageIdSetCache.set(messages, ids);
93
+ }
94
+ return ids.has(messageId);
95
+ };
96
+
82
97
  const getComponent = (
83
98
  components: MessagesComponentConfig,
84
99
  role: MessageState["role"],
@@ -170,6 +185,58 @@ export const ThreadPrimitiveMessageByIndex: FC<ThreadPrimitiveMessageByIndex.Pro
170
185
 
171
186
  ThreadPrimitiveMessageByIndex.displayName = "ThreadPrimitive.MessageByIndex";
172
187
 
188
+ export namespace ThreadPrimitiveUnstable_MessageById {
189
+ export type Props = {
190
+ messageId: string;
191
+ components: MessagesComponentConfig;
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Renders the message with the given id in the current thread.
197
+ *
198
+ * Unlike {@link ThreadPrimitiveMessageByIndex}, this keys off the message id,
199
+ * so it stays attached to the same message across reordering and windowing -
200
+ * the shape needed to drive a virtualized or custom message list together with
201
+ * `unstable_useThreadMessageIds`. A missing or removed id renders `null` rather
202
+ * than throwing.
203
+ *
204
+ * @deprecated Unstable / Experimental - may change in any release.
205
+ *
206
+ * @example
207
+ * ```tsx
208
+ * const messageIds = unstable_useThreadMessageIds();
209
+ * return messageIds.map((messageId) => (
210
+ * <ThreadPrimitive.Unstable_MessageById
211
+ * key={messageId}
212
+ * messageId={messageId}
213
+ * components={MESSAGE_COMPONENTS}
214
+ * />
215
+ * ));
216
+ * ```
217
+ */
218
+ export const ThreadPrimitiveUnstable_MessageById: FC<ThreadPrimitiveUnstable_MessageById.Props> =
219
+ memo(
220
+ ({ messageId, components }) => {
221
+ const exists = useAuiState((s) =>
222
+ hasMessageId(s.thread.messages, messageId),
223
+ );
224
+ if (!exists) return null;
225
+
226
+ return (
227
+ <MessageByIdProvider id={messageId}>
228
+ <ThreadMessageComponent components={components} />
229
+ </MessageByIdProvider>
230
+ );
231
+ },
232
+ (prev, next) =>
233
+ prev.messageId === next.messageId &&
234
+ isComponentsSame(prev.components, next.components),
235
+ );
236
+
237
+ ThreadPrimitiveUnstable_MessageById.displayName =
238
+ "ThreadPrimitive.Unstable_MessageById";
239
+
173
240
  const ThreadPrimitiveMessagesInner: FC<{
174
241
  children: (value: { message: MessageState }) => ReactNode;
175
242
  }> = ({ children }) => {
@@ -0,0 +1,23 @@
1
+ import type { FC, PropsWithChildren } from "react";
2
+ import { useAui, AuiProvider, Derived } from "@assistant-ui/store";
3
+
4
+ export const MessageByIdProvider: FC<
5
+ PropsWithChildren<{
6
+ id: string;
7
+ }>
8
+ > = ({ id, children }) => {
9
+ const aui = useAui({
10
+ message: Derived({
11
+ source: "thread",
12
+ query: { type: "id", id },
13
+ get: (aui) => aui.thread().message({ id }),
14
+ }),
15
+ composer: Derived({
16
+ source: "message",
17
+ query: {},
18
+ get: (aui) => aui.thread().message({ id }).composer(),
19
+ }),
20
+ });
21
+
22
+ return <AuiProvider value={aui}>{children}</AuiProvider>;
23
+ };
@@ -163,7 +163,10 @@ export class RemoteThreadListThreadListRuntimeCore
163
163
  super();
164
164
  this.contextProvider = contextProvider;
165
165
 
166
- this._state.subscribe(() => this._notifySubscribers());
166
+ this._state.subscribe(() => {
167
+ this._notifySubscribers();
168
+ this._notifyThreadIdChange();
169
+ });
167
170
  this._hookManager = new RemoteThreadListHookInstanceManager(
168
171
  options.runtimeHook,
169
172
  this,
@@ -253,6 +256,22 @@ export class RemoteThreadListThreadListRuntimeCore
253
256
  return this._mainThreadId;
254
257
  }
255
258
 
259
+ // The settled remote ID of the active thread, or undefined while it is still
260
+ // a new/optimistic thread. This is the value surfaced to `onThreadIdChange`.
261
+ private get _mainThreadRemoteId(): string | undefined {
262
+ if (this._mainThreadId === undefined) return undefined;
263
+ return getThreadData(this._state.value, this._mainThreadId)?.remoteId;
264
+ }
265
+
266
+ private _lastNotifiedThreadId: string | undefined = undefined;
267
+
268
+ private _notifyThreadIdChange() {
269
+ const threadId = this._mainThreadRemoteId;
270
+ if (this._lastNotifiedThreadId === threadId) return;
271
+ this._lastNotifiedThreadId = threadId;
272
+ this._options.onThreadIdChange?.(threadId);
273
+ }
274
+
256
275
  public getMainThreadRuntimeCore() {
257
276
  const result = this._hookManager.getThreadRuntimeCore(this._mainThreadId);
258
277
  if (!result) return EMPTY_THREAD_CORE;
@@ -351,6 +370,7 @@ export class RemoteThreadListThreadListRuntimeCore
351
370
  this._mainThreadId = data.id;
352
371
 
353
372
  this._notifySubscribers();
373
+ this._notifyThreadIdChange();
354
374
  }
355
375
 
356
376
  public async switchToNewThread(): Promise<void> {
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import type { AssistantClient } from "@assistant-ui/store";
3
+ import { createRuntimeExtras } from "./createRuntimeExtras";
4
+
5
+ type Extras = { value: number; greet: () => string };
6
+
7
+ const clientWith = (extras: unknown) =>
8
+ ({
9
+ thread: () => ({ getState: () => ({ extras }) }),
10
+ }) as unknown as AssistantClient;
11
+
12
+ describe("createRuntimeExtras", () => {
13
+ it("brands a value so its own guards recognize it", () => {
14
+ const channel = createRuntimeExtras<Extras>("useTestRuntime");
15
+ const branded = channel.provide({ value: 1, greet: () => "hi" });
16
+
17
+ expect(channel.is(branded)).toBe(true);
18
+ expect(channel.tryGet(branded)).toBe(branded);
19
+ });
20
+
21
+ it("returns the same object reference from provide (stable identity)", () => {
22
+ const channel = createRuntimeExtras<Extras>("useTestRuntime");
23
+ const value = { value: 1, greet: () => "hi" };
24
+ expect(channel.provide(value)).toBe(value);
25
+ });
26
+
27
+ it("keeps the brand non-enumerable so it stays out of serialization", () => {
28
+ const channel = createRuntimeExtras<Extras>("useTestRuntime");
29
+ const branded = channel.provide({ value: 1, greet: () => "hi" });
30
+
31
+ expect(Object.keys(branded)).toEqual(["value", "greet"]);
32
+ expect(JSON.parse(JSON.stringify(branded))).toEqual({ value: 1 });
33
+ });
34
+
35
+ it("rejects unbranded and foreign values", () => {
36
+ const channel = createRuntimeExtras<Extras>("useTestRuntime");
37
+ const other = createRuntimeExtras<Extras>("useOtherRuntime");
38
+ const foreign = other.provide({ value: 2, greet: () => "yo" });
39
+
40
+ expect(channel.is(undefined)).toBe(false);
41
+ expect(channel.is({ value: 1 })).toBe(false);
42
+ expect(channel.is(foreign)).toBe(false);
43
+ expect(channel.tryGet(foreign)).toBeUndefined();
44
+ });
45
+
46
+ it("get reads the current snapshot off an AssistantClient", () => {
47
+ const channel = createRuntimeExtras<Extras>("useTestRuntime");
48
+ const branded = channel.provide({ value: 7, greet: () => "hi" });
49
+
50
+ expect(channel.get(clientWith(branded)).value).toBe(7);
51
+ });
52
+
53
+ it("get throws with the runtime name when the thread is not backed by it", () => {
54
+ const channel = createRuntimeExtras<Extras>("useTestRuntime");
55
+
56
+ expect(() => channel.get(clientWith(undefined))).toThrow("useTestRuntime");
57
+ });
58
+ });
@@ -0,0 +1,68 @@
1
+ "use client";
2
+
3
+ import { useAuiState } from "@assistant-ui/store";
4
+ import type { AssistantClient } from "@assistant-ui/store";
5
+
6
+ /** @deprecated Internal API for external-store adapter authors. Not part of the public API; may change or be removed without notice. */
7
+ export type RuntimeExtras<T extends object> = {
8
+ provide: (value: T) => T;
9
+ is: (extras: unknown) => extras is T;
10
+ tryGet: (extras: unknown) => T | undefined;
11
+ get: (client: AssistantClient) => T;
12
+ use: {
13
+ (): T;
14
+ <S>(select: (extras: T) => S): S;
15
+ <S>(select: (extras: T) => S, fallback: S): S;
16
+ };
17
+ };
18
+
19
+ /** @deprecated Internal API for external-store adapter authors. Not part of the public API; may change or be removed without notice. */
20
+ export const createRuntimeExtras = <T extends object>(
21
+ runtimeName: string,
22
+ ): RuntimeExtras<T> => {
23
+ const brand = Symbol(`${runtimeName} extras`);
24
+
25
+ const is = (extras: unknown): extras is T =>
26
+ typeof extras === "object" && extras !== null && brand in extras;
27
+
28
+ const assert = (extras: unknown): T => {
29
+ if (!is(extras))
30
+ throw new Error(
31
+ `The current thread is not backed by the ${runtimeName} runtime.`,
32
+ );
33
+ return extras;
34
+ };
35
+
36
+ const provide = (value: T): T => {
37
+ Object.defineProperty(value, brand, {
38
+ value: true,
39
+ enumerable: false,
40
+ configurable: true,
41
+ });
42
+ return value;
43
+ };
44
+
45
+ const tryGet = (extras: unknown): T | undefined =>
46
+ is(extras) ? extras : undefined;
47
+
48
+ const get = (client: AssistantClient): T =>
49
+ assert(client.thread().getState().extras);
50
+
51
+ function use<S>(
52
+ select?: (extras: T) => S,
53
+ ...rest: [fallback: S] | []
54
+ ): S | T {
55
+ // Detect a provided fallback by arity, not value: callers pass an explicit
56
+ // `undefined` fallback, which must return `undefined` rather than throw.
57
+ const hasFallback = rest.length > 0;
58
+ const fallback = rest[0] as S;
59
+ return useAuiState((s) => {
60
+ const extras = s.thread.extras;
61
+ if (is(extras)) return select ? select(extras) : extras;
62
+ if (hasFallback) return fallback;
63
+ return assert(extras);
64
+ });
65
+ }
66
+
67
+ return { provide, is, tryGet, get, use: use as RuntimeExtras<T>["use"] };
68
+ };
@@ -0,0 +1,50 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+
3
+ const { mockUseAuiState } = vi.hoisted(() => ({ mockUseAuiState: vi.fn() }));
4
+
5
+ vi.mock("@assistant-ui/store", async (importOriginal) => ({
6
+ ...(await importOriginal<typeof import("@assistant-ui/store")>()),
7
+ useAuiState: ((selector: (s: unknown) => unknown) =>
8
+ mockUseAuiState(
9
+ selector,
10
+ )) as typeof import("@assistant-ui/store").useAuiState,
11
+ }));
12
+
13
+ import { createRuntimeExtras } from "./createRuntimeExtras";
14
+
15
+ type Extras = { value: number };
16
+
17
+ const against = (extras: unknown) =>
18
+ mockUseAuiState.mockImplementationOnce((selector: (s: unknown) => unknown) =>
19
+ selector({ thread: { extras } }),
20
+ );
21
+
22
+ describe("createRuntimeExtras.use", () => {
23
+ const channel = createRuntimeExtras<Extras>("useTestRuntime");
24
+
25
+ it("projects the extras when the thread is backed by the runtime", () => {
26
+ against(channel.provide({ value: 5 }));
27
+ expect(channel.use((e) => e.value, 0)).toBe(5);
28
+ });
29
+
30
+ it("returns the whole extras when called without a selector", () => {
31
+ const extras = channel.provide({ value: 9 });
32
+ against(extras);
33
+ expect(channel.use()).toBe(extras);
34
+ });
35
+
36
+ it("returns the fallback when the thread is not backed by the runtime", () => {
37
+ against(undefined);
38
+ expect(channel.use((e) => e.value, 0)).toBe(0);
39
+ });
40
+
41
+ it("returns an explicit undefined fallback instead of throwing", () => {
42
+ against(undefined);
43
+ expect(channel.use((e) => e.value, undefined)).toBeUndefined();
44
+ });
45
+
46
+ it("throws when no fallback is given and the runtime is absent", () => {
47
+ against(undefined);
48
+ expect(() => channel.use((e) => e.value)).toThrow("useTestRuntime");
49
+ });
50
+ });
@@ -1,4 +1,11 @@
1
- import { useState, useEffect, useMemo, useRef, useCallback } from "react";
1
+ import {
2
+ useState,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useCallback,
7
+ useEffectEvent,
8
+ } from "react";
2
9
  import { BaseAssistantRuntimeCore } from "../../runtime/base/base-assistant-runtime-core";
3
10
  import { AssistantRuntimeImpl } from "../../runtime/api/assistant-runtime";
4
11
  import type { RemoteThreadListOptions } from "../../runtimes/remote-thread-list/types";
@@ -51,12 +58,17 @@ export const useRemoteThreadListRuntime = (
51
58
  return runtimeHookRef.current();
52
59
  }, []);
53
60
 
61
+ const onThreadIdChange = useEffectEvent((threadId: string | undefined) => {
62
+ options.onThreadIdChange?.(threadId);
63
+ });
64
+
54
65
  const stableOptions = useMemo<RemoteThreadListOptions>(
55
66
  () => ({
56
67
  adapter: options.adapter,
57
68
  allowNesting: options.allowNesting,
58
69
  initialThreadId: startThreadIdRef.current,
59
70
  runtimeHook: stableRuntimeHook,
71
+ onThreadIdChange,
60
72
  }),
61
73
  [options.adapter, options.allowNesting, stableRuntimeHook],
62
74
  );
@@ -0,0 +1,68 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import type { MessageTiming } from "../../types/message";
5
+ import {
6
+ stepStreamingTiming,
7
+ type StreamingTimingAccessors,
8
+ type StreamingTimingOptions,
9
+ type StreamingTimingState,
10
+ } from "../../runtime/utils/streaming-timing";
11
+
12
+ export type {
13
+ StreamingTimingAccessors,
14
+ StreamingTimingOptions,
15
+ StreamingTimingState,
16
+ };
17
+
18
+ /**
19
+ * Tracks per-message streaming timing client-side and returns finalized
20
+ * `MessageTiming` keyed by message id.
21
+ *
22
+ * Observes `isRunning` transitions and content growth through the provided
23
+ * `accessors`, which adapt the hook to a runtime's message shape. Timing is
24
+ * finalized when streaming ends; adapters thread the result into
25
+ * `useExternalMessageConverter` metadata as `messageTiming`.
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * const messageTiming = useStreamingTiming(messages, isRunning, {
30
+ * getAssistantMessageId: (msgs) => msgs.findLast((m) => m.role === "assistant")?.id,
31
+ * getTextLength: (msgs, id) => msgs.find((m) => m.id === id)?.content?.length ?? 0,
32
+ * getToolCallCount: (msgs, id) => msgs.find((m) => m.id === id)?.tool_calls?.length ?? 0,
33
+ * });
34
+ * ```
35
+ */
36
+ export const useStreamingTiming = <TMessage>(
37
+ messages: readonly TMessage[],
38
+ isRunning: boolean,
39
+ accessors: StreamingTimingAccessors<TMessage>,
40
+ options?: StreamingTimingOptions,
41
+ ): Record<string, MessageTiming> => {
42
+ const [timings, setTimings] = useState<Record<string, MessageTiming>>({});
43
+ const stateRef = useRef<StreamingTimingState | null>(null);
44
+ // Read the latest accessors/options from refs so the effect keeps the
45
+ // original `[messages, isRunning]` reactivity of the adapter hooks this
46
+ // replaces (a stable timings object while streaming, a single new reference
47
+ // at finalize).
48
+ const accessorsRef = useRef(accessors);
49
+ accessorsRef.current = accessors;
50
+ const optionsRef = useRef(options);
51
+ optionsRef.current = options;
52
+
53
+ useEffect(() => {
54
+ const result = stepStreamingTiming(
55
+ stateRef.current,
56
+ messages,
57
+ isRunning,
58
+ accessorsRef.current,
59
+ optionsRef.current,
60
+ );
61
+ stateRef.current = result.state;
62
+ if (Object.keys(result.timings).length > 0) {
63
+ setTimings((prev) => ({ ...prev, ...result.timings }));
64
+ }
65
+ }, [messages, isRunning]);
66
+
67
+ return timings;
68
+ };
@@ -1,66 +1,88 @@
1
1
  import type { Tool } from "assistant-stream";
2
2
  import type { Unsubscribe } from "../../..";
3
+ import type { ToolCallMessagePartComponent } from "../MessagePartComponentTypes";
3
4
 
4
5
  /**
5
6
  * Schema type matching Tool["parameters"] from assistant-stream.
6
7
  * Accepts both StandardSchemaV1 and JSONSchema7.
7
8
  */
8
- export type InteractableStateSchema = NonNullable<
9
+ export type Unstable_InteractableStateSchema = NonNullable<
9
10
  Extract<Tool, { parameters: unknown }>["parameters"]
10
11
  >;
11
12
 
12
- export type InteractableDefinition = {
13
+ type InteractableScope = "app" | "thread";
14
+
15
+ export type Unstable_InteractableDefinition = {
13
16
  id: string;
14
17
  name: string;
15
18
  description: string;
16
- stateSchema: InteractableStateSchema;
19
+ stateSchema: Unstable_InteractableStateSchema;
17
20
  state: unknown;
18
- selected?: boolean | undefined;
21
+ initialState: unknown;
22
+ scope?: InteractableScope | undefined;
19
23
  };
20
24
 
21
- export type InteractableRegistration = {
25
+ export type Unstable_InteractableRegistration = {
22
26
  id: string;
23
27
  name: string;
24
28
  description: string;
25
- stateSchema: InteractableStateSchema;
29
+ stateSchema: Unstable_InteractableStateSchema;
26
30
  initialState: unknown;
27
- selected?: boolean | undefined;
31
+ /**
32
+ * Component installed as the tool UI for this interactable's `update_{name}`
33
+ * tool calls, so a model edit re-renders the interactable at the message
34
+ * that made it. Registered once per name; requires the `tools` scope.
35
+ */
36
+ updateRender?: ToolCallMessagePartComponent | undefined;
28
37
  };
29
38
 
30
- export type InteractablePersistenceStatus = {
39
+ export type Unstable_InteractablePersistenceStatus = {
31
40
  isPending: boolean;
32
41
  error: unknown;
33
42
  };
34
43
 
35
- export type InteractablesState = {
44
+ export type Unstable_InteractablesState = {
36
45
  /** Keyed by instance id */
37
- definitions: Record<string, InteractableDefinition>;
46
+ definitions: Record<string, Unstable_InteractableDefinition>;
38
47
  /** Per-id persistence sync status */
39
- persistence: Record<string, InteractablePersistenceStatus>;
48
+ persistence: Record<string, Unstable_InteractablePersistenceStatus>;
40
49
  };
41
50
 
42
- export type InteractablePersistedState = Record<
51
+ export type Unstable_InteractablePersistedState = Record<
43
52
  string,
44
53
  { name: string; state: unknown }
45
54
  >;
46
55
 
47
- export type InteractablePersistenceAdapter = {
48
- save(state: InteractablePersistedState): void | Promise<void>;
56
+ export type Unstable_InteractablePersistenceAdapter = {
57
+ save(state: Unstable_InteractablePersistedState): void | Promise<void>;
58
+ /**
59
+ * Restores previously saved state. Called when the adapter is attached;
60
+ * loaded state seeds app-scoped interactables that have not been locally
61
+ * edited yet (a local edit always wins over a slow load).
62
+ */
63
+ load?():
64
+ | Unstable_InteractablePersistedState
65
+ | null
66
+ | undefined
67
+ | Promise<Unstable_InteractablePersistedState | null | undefined>;
68
+ };
69
+
70
+ export type Unstable_InteractablesConfig = {
71
+ persistence?: Unstable_InteractablePersistenceAdapter | undefined;
49
72
  };
50
73
 
51
- export type InteractablesMethods = {
52
- getState(): InteractablesState;
53
- register(def: InteractableRegistration): Unsubscribe;
74
+ export type Unstable_InteractablesMethods = {
75
+ getState(): Unstable_InteractablesState;
76
+ register(def: Unstable_InteractableRegistration): Unsubscribe;
54
77
  setState(id: string, updater: (prev: unknown) => unknown): void;
55
- setSelected(id: string, selected: boolean): void;
56
- exportState(): InteractablePersistedState;
57
- importState(saved: InteractablePersistedState): void;
78
+ exportState(): Unstable_InteractablePersistedState;
79
+ importState(saved: Unstable_InteractablePersistedState): void;
58
80
  setPersistenceAdapter(
59
- adapter: InteractablePersistenceAdapter | undefined,
81
+ adapter: Unstable_InteractablePersistenceAdapter | undefined,
60
82
  ): void;
61
83
  flush(): Promise<void>;
62
84
  };
63
85
 
64
- export type InteractablesClientSchema = {
65
- methods: InteractablesMethods;
86
+ export type Unstable_InteractablesClientSchema = {
87
+ methods: Unstable_InteractablesMethods;
66
88
  };
@@ -1,11 +1,13 @@
1
1
  import type { ToolsClientSchema } from "./scopes/tools";
2
2
  import type { DataRenderersClientSchema } from "./scopes/dataRenderers";
3
- import type { InteractablesClientSchema } from "./scopes/interactables";
3
+ import type { InteractablesClientSchema as LegacyInteractablesClientSchema } from "../interactables-legacy/scopes";
4
+ import type { Unstable_InteractablesClientSchema } from "./scopes/interactables";
4
5
 
5
6
  declare module "@assistant-ui/store" {
6
7
  interface ScopeRegistry {
7
8
  tools: ToolsClientSchema;
8
9
  dataRenderers: DataRenderersClientSchema;
9
- interactables: InteractablesClientSchema;
10
+ interactables: LegacyInteractablesClientSchema;
11
+ unstable_interactables: Unstable_InteractablesClientSchema;
10
12
  }
11
13
  }
@@ -0,0 +1,20 @@
1
+ import { useRef } from "react";
2
+ import { isJSONValueEqual } from "../../utils/json/is-json-equal";
3
+
4
+ /**
5
+ * Like `useShallow`, but with JSON deep-equality. Use when a selector derives an
6
+ * equal-but-fresh value on every store update — e.g. folding over
7
+ * `thread.messages`, whose identity changes on every streaming token — where a
8
+ * shallow compare would re-render regardless.
9
+ */
10
+ export function useJSONEqual<S, U>(selector: (state: S) => U): (state: S) => U {
11
+ const prev = useRef<U | undefined>(undefined);
12
+ return (state) => {
13
+ const next = selector(state);
14
+ if (prev.current !== undefined && isJSONValueEqual(prev.current, next)) {
15
+ return prev.current;
16
+ }
17
+ prev.current = next;
18
+ return next;
19
+ };
20
+ }
@@ -41,6 +41,19 @@ export abstract class BaseComposerRuntimeCore
41
41
  protected abstract getAttachmentAdapter(): AttachmentAdapter | undefined;
42
42
  protected abstract getDictationAdapter(): DictationAdapter | undefined;
43
43
 
44
+ protected enrichWithComposerMetadata<
45
+ T extends { metadata?: { custom?: Record<string, unknown> } },
46
+ >(message: T, composerMetadata: Record<string, unknown> | undefined): T {
47
+ if (!composerMetadata) return message;
48
+ return {
49
+ ...message,
50
+ metadata: {
51
+ ...message.metadata,
52
+ custom: { ...message.metadata?.custom, ...composerMetadata },
53
+ },
54
+ } as T;
55
+ }
56
+
44
57
  public get attachmentAccept(): string {
45
58
  return this.getAttachmentAdapter()?.accept ?? "*";
46
59
  }
@@ -7,6 +7,7 @@ import type { DictationAdapter } from "../../adapters/speech";
7
7
  import type { SendOptions } from "../interfaces/composer-runtime-core";
8
8
  import type { ThreadRuntimeCore } from "../interfaces/thread-runtime-core";
9
9
  import { BaseComposerRuntimeCore } from "./base-composer-runtime-core";
10
+ import { gateInteractableComposerMetadata } from "../../model-context/interactable-composer-metadata";
10
11
 
11
12
  export class DefaultEditComposerRuntimeCore extends BaseComposerRuntimeCore {
12
13
  public get canCancel() {
@@ -97,8 +98,24 @@ export class DefaultEditComposerRuntimeCore extends BaseComposerRuntimeCore {
97
98
  ...this._nonTextPassthrough,
98
99
  ] as AppendMessage["content"])
99
100
  : message.content;
101
+ // Gate live state against the new branch's prefix (messages up to the
102
+ // parent): an unchanged interactable re-stamps the prior baseline, an
103
+ // interactable edited since the original message stamps its newest state.
104
+ const messages = this.runtime.messages;
105
+ const parentIndex =
106
+ this._parentId === null
107
+ ? -1
108
+ : messages.findIndex((m) => m.id === this._parentId);
109
+ const composerMetadata = gateInteractableComposerMetadata(
110
+ this.runtime.getModelContext().unstable_composerMetadata,
111
+ messages.slice(0, parentIndex + 1),
112
+ );
113
+ const enriched = this.enrichWithComposerMetadata(
114
+ message,
115
+ composerMetadata,
116
+ );
100
117
  this.runtime.append({
101
- ...message,
118
+ ...enriched,
102
119
  content,
103
120
  parentId: this._parentId,
104
121
  sourceId: this._sourceId,
@@ -11,6 +11,7 @@ import {
11
11
  type QueueItemState,
12
12
  } from "../../store/scopes/queue-item";
13
13
  import { BaseComposerRuntimeCore } from "./base-composer-runtime-core";
14
+ import { gateInteractableComposerMetadata } from "../../model-context/interactable-composer-metadata";
14
15
 
15
16
  export class DefaultThreadComposerRuntimeCore
16
17
  extends BaseComposerRuntimeCore
@@ -84,8 +85,17 @@ export class DefaultThreadComposerRuntimeCore
84
85
  message: Omit<AppendMessage, "parentId" | "sourceId">,
85
86
  options?: SendOptions,
86
87
  ) {
88
+ // Merge provider-contributed metadata onto the outgoing user message
89
+ // (same metadata.custom append path quotes ride). The interactables gate
90
+ // runs here because it needs thread history, unavailable to the provider.
91
+ const composerMetadata = gateInteractableComposerMetadata(
92
+ this.runtime.getModelContext().unstable_composerMetadata,
93
+ this.runtime.messages,
94
+ );
95
+ const enriched = this.enrichWithComposerMetadata(message, composerMetadata);
96
+
87
97
  this.runtime.append({
88
- ...(message as AppendMessage),
98
+ ...(enriched as AppendMessage),
89
99
  parentId: this.runtime.messages.at(-1)?.id ?? null,
90
100
  sourceId: null,
91
101
  startRun: options?.startRun,