@assistant-ui/core 0.2.21 → 0.2.22

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 (209) hide show
  1. package/dist/adapters/attachment.d.ts.map +1 -1
  2. package/dist/adapters/speech.d.ts.map +1 -1
  3. package/dist/adapters/speech.js +9 -2
  4. package/dist/adapters/speech.js.map +1 -1
  5. package/dist/adapters/voice.d.ts.map +1 -1
  6. package/dist/adapters/voice.js +13 -5
  7. package/dist/adapters/voice.js.map +1 -1
  8. package/dist/internal.d.ts +3 -1
  9. package/dist/internal.js +3 -1
  10. package/dist/model-context/frame/host.d.ts +1 -0
  11. package/dist/model-context/frame/host.d.ts.map +1 -1
  12. package/dist/model-context/frame/host.js +5 -0
  13. package/dist/model-context/frame/host.js.map +1 -1
  14. package/dist/model-context/frame/provider.d.ts.map +1 -1
  15. package/dist/model-context/frame/provider.js +1 -0
  16. package/dist/model-context/frame/provider.js.map +1 -1
  17. package/dist/model-context/interactable-composer-metadata.js.map +1 -1
  18. package/dist/react/AssistantProvider.js.map +1 -1
  19. package/dist/react/adapters/LocalStorageThreadListAdapter.d.ts.map +1 -1
  20. package/dist/react/adapters/LocalStorageThreadListAdapter.js +77 -50
  21. package/dist/react/adapters/LocalStorageThreadListAdapter.js.map +1 -1
  22. package/dist/react/client/Interactables.js.map +1 -1
  23. package/dist/react/client/Tools.d.ts.map +1 -1
  24. package/dist/react/client/interactable-model-context.d.ts.map +1 -1
  25. package/dist/react/model-context/useInteractable.js.map +1 -1
  26. package/dist/react/primitive-hooks/useActionBarCopy.js +13 -13
  27. package/dist/react/primitive-hooks/useActionBarCopy.js.map +1 -1
  28. package/dist/react/primitive-hooks/useComposerAddAttachment.d.ts.map +1 -1
  29. package/dist/react/primitive-hooks/useComposerSend.d.ts.map +1 -1
  30. package/dist/react/primitives/chainOfThought/ChainOfThoughtParts.d.ts.map +1 -1
  31. package/dist/react/primitives/chainOfThought/ChainOfThoughtParts.js +1 -1
  32. package/dist/react/primitives/chainOfThought/ChainOfThoughtParts.js.map +1 -1
  33. package/dist/react/primitives/composer/ComposerAttachments.js.map +1 -1
  34. package/dist/react/primitives/composer/ComposerQueue.d.ts.map +1 -1
  35. package/dist/react/primitives/composer/ComposerQueue.js.map +1 -1
  36. package/dist/react/primitives/generativeUI/GenerativeUI.d.ts.map +1 -1
  37. package/dist/react/primitives/message/MessageAttachments.js.map +1 -1
  38. package/dist/react/primitives/message/MessageGroupedParts.js.map +1 -1
  39. package/dist/react/primitives/message/MessageParts.d.ts +2 -1
  40. package/dist/react/primitives/message/MessageParts.d.ts.map +1 -1
  41. package/dist/react/primitives/message/MessageParts.js +46 -36
  42. package/dist/react/primitives/message/MessageParts.js.map +1 -1
  43. package/dist/react/primitives/part/PartMessages.js.map +1 -1
  44. package/dist/react/primitives/thread/ThreadMessages.js.map +1 -1
  45. package/dist/react/primitives/thread/ThreadSuggestions.js.map +1 -1
  46. package/dist/react/primitives/threadList/ThreadListItems.js.map +1 -1
  47. package/dist/react/providers/ChainOfThoughtPartByIndexProvider.d.ts.map +1 -1
  48. package/dist/react/providers/ChainOfThoughtPartByIndexProvider.js +18 -30
  49. package/dist/react/providers/ChainOfThoughtPartByIndexProvider.js.map +1 -1
  50. package/dist/react/providers/PartByIndexProvider.d.ts.map +1 -1
  51. package/dist/react/providers/PartByIndexProvider.js +18 -30
  52. package/dist/react/providers/PartByIndexProvider.js.map +1 -1
  53. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
  54. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js +1 -0
  55. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js.map +1 -1
  56. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +9 -2
  57. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  58. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +49 -8
  59. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
  60. package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.js +1 -1
  61. package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.js.map +1 -1
  62. package/dist/react/runtimes/cloud/CloudFileAttachmentAdapter.d.ts.map +1 -1
  63. package/dist/react/runtimes/cloud/CloudFileAttachmentAdapter.js +2 -1
  64. package/dist/react/runtimes/cloud/CloudFileAttachmentAdapter.js.map +1 -1
  65. package/dist/react/runtimes/createMessageConverter.d.ts.map +1 -1
  66. package/dist/react/runtimes/external-message-converter.js +1 -1
  67. package/dist/react/runtimes/external-message-converter.js.map +1 -1
  68. package/dist/react/runtimes/useLocalRuntime.d.ts +1 -0
  69. package/dist/react/runtimes/useLocalRuntime.d.ts.map +1 -1
  70. package/dist/react/runtimes/useLocalRuntime.js +9 -1
  71. package/dist/react/runtimes/useLocalRuntime.js.map +1 -1
  72. package/dist/runtime/api/assistant-runtime.d.ts.map +1 -1
  73. package/dist/runtime/api/attachment-runtime.d.ts.map +1 -1
  74. package/dist/runtime/api/composer-runtime.d.ts.map +1 -1
  75. package/dist/runtime/api/message-part-runtime.d.ts.map +1 -1
  76. package/dist/runtime/api/message-runtime.d.ts.map +1 -1
  77. package/dist/runtime/api/message-runtime.js +4 -4
  78. package/dist/runtime/api/message-runtime.js.map +1 -1
  79. package/dist/runtime/api/thread-list-item-runtime.d.ts.map +1 -1
  80. package/dist/runtime/api/thread-list-item-runtime.js +2 -1
  81. package/dist/runtime/api/thread-list-item-runtime.js.map +1 -1
  82. package/dist/runtime/api/thread-list-runtime.d.ts.map +1 -1
  83. package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
  84. package/dist/runtime/base/base-composer-runtime-core.d.ts +4 -1
  85. package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
  86. package/dist/runtime/base/base-composer-runtime-core.js +41 -14
  87. package/dist/runtime/base/base-composer-runtime-core.js.map +1 -1
  88. package/dist/runtime/base/base-thread-runtime-core.d.ts.map +1 -1
  89. package/dist/runtime/base/base-thread-runtime-core.js +2 -1
  90. package/dist/runtime/base/base-thread-runtime-core.js.map +1 -1
  91. package/dist/runtime/base/default-edit-composer-runtime-core.d.ts.map +1 -1
  92. package/dist/runtime/base/default-edit-composer-runtime-core.js +4 -2
  93. package/dist/runtime/base/default-edit-composer-runtime-core.js.map +1 -1
  94. package/dist/runtime/base/default-thread-composer-runtime-core.d.ts.map +1 -1
  95. package/dist/runtime/base/default-thread-composer-runtime-core.js +2 -2
  96. package/dist/runtime/base/default-thread-composer-runtime-core.js.map +1 -1
  97. package/dist/runtime/utils/message-repository.d.ts.map +1 -1
  98. package/dist/runtime/utils/thread-message-like.js +4 -1
  99. package/dist/runtime/utils/thread-message-like.js.map +1 -1
  100. package/dist/runtimes/external-store/external-store-runtime-core.d.ts.map +1 -1
  101. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.d.ts.map +1 -1
  102. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  103. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  104. package/dist/runtimes/local/local-runtime-core.d.ts.map +1 -1
  105. package/dist/runtimes/local/local-thread-list-runtime-core.d.ts.map +1 -1
  106. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  107. package/dist/runtimes/local/local-thread-runtime-core.js +5 -2
  108. package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
  109. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts.map +1 -1
  110. package/dist/runtimes/remote-thread-list/optimistic-state.d.ts +4 -4
  111. package/dist/runtimes/remote-thread-list/optimistic-state.d.ts.map +1 -1
  112. package/dist/runtimes/remote-thread-list/optimistic-state.js +22 -9
  113. package/dist/runtimes/remote-thread-list/optimistic-state.js.map +1 -1
  114. package/dist/runtimes/remote-thread-list/types.d.ts +1 -1
  115. package/dist/runtimes/tool-invocations/ToolInvocationTracker.d.ts.map +1 -1
  116. package/dist/runtimes/tool-invocations/ToolInvocationTracker.js +1 -2
  117. package/dist/runtimes/tool-invocations/ToolInvocationTracker.js.map +1 -1
  118. package/dist/store/clients/chain-of-thought-client.d.ts.map +1 -1
  119. package/dist/store/clients/no-op-composer-client.d.ts.map +1 -1
  120. package/dist/store/runtime-clients/attachment-runtime-client.d.ts.map +1 -1
  121. package/dist/store/runtime-clients/composer-runtime-client.d.ts.map +1 -1
  122. package/dist/store/runtime-clients/message-part-runtime-client.d.ts.map +1 -1
  123. package/dist/store/runtime-clients/message-runtime-client.d.ts.map +1 -1
  124. package/dist/store/runtime-clients/thread-list-item-runtime-client.d.ts.map +1 -1
  125. package/dist/store/runtime-clients/thread-list-runtime-client.d.ts.map +1 -1
  126. package/dist/store/runtime-clients/thread-runtime-client.d.ts.map +1 -1
  127. package/dist/subscribable/subscribable.d.ts +1 -1
  128. package/dist/subscribable/subscribable.d.ts.map +1 -1
  129. package/dist/subscribable/subscribable.js +11 -3
  130. package/dist/subscribable/subscribable.js.map +1 -1
  131. package/dist/tests/remote-thread-list-test-helpers.d.ts +2 -1
  132. package/dist/tests/remote-thread-list-test-helpers.d.ts.map +1 -1
  133. package/dist/tests/remote-thread-list-test-helpers.js +5 -2
  134. package/dist/tests/remote-thread-list-test-helpers.js.map +1 -1
  135. package/dist/types/attachment.d.ts +2 -1
  136. package/dist/types/attachment.d.ts.map +1 -1
  137. package/dist/types/attachment.js +6 -0
  138. package/dist/types/attachment.js.map +1 -0
  139. package/dist/types/message.d.ts +1 -0
  140. package/dist/types/message.d.ts.map +1 -1
  141. package/dist/types/message.js.map +1 -1
  142. package/dist/utils/data-url.d.ts +9 -0
  143. package/dist/utils/data-url.d.ts.map +1 -0
  144. package/dist/utils/data-url.js +14 -0
  145. package/dist/utils/data-url.js.map +1 -0
  146. package/dist/utils/notify-event-listeners.d.ts +6 -0
  147. package/dist/utils/notify-event-listeners.d.ts.map +1 -0
  148. package/dist/utils/notify-event-listeners.js +16 -0
  149. package/dist/utils/notify-event-listeners.js.map +1 -0
  150. package/package.json +10 -9
  151. package/src/adapters/speech.test.ts +88 -0
  152. package/src/adapters/speech.ts +17 -2
  153. package/src/adapters/voice.test.ts +130 -0
  154. package/src/adapters/voice.ts +26 -6
  155. package/src/internal.ts +5 -0
  156. package/src/model-context/frame/host.test.ts +100 -0
  157. package/src/model-context/frame/host.ts +10 -0
  158. package/src/model-context/frame/provider.test.ts +83 -0
  159. package/src/model-context/frame/provider.ts +1 -0
  160. package/src/react/adapters/LocalStorageThreadListAdapter.test.ts +82 -0
  161. package/src/react/adapters/LocalStorageThreadListAdapter.tsx +94 -54
  162. package/src/react/primitive-hooks/useActionBarCopy.test.ts +79 -0
  163. package/src/react/primitive-hooks/useActionBarCopy.ts +4 -3
  164. package/src/react/primitives/chainOfThought/ChainOfThoughtParts.tsx +1 -3
  165. package/src/react/primitives/message/MessageParts.tsx +48 -35
  166. package/src/react/providers/ChainOfThoughtPartByIndexProvider.tsx +19 -2
  167. package/src/react/providers/PartByIndexProvider.tsx +16 -2
  168. package/src/react/runtimes/RemoteThreadListHookInstanceManager.test.tsx +18 -0
  169. package/src/react/runtimes/RemoteThreadListHookInstanceManager.tsx +1 -0
  170. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +48 -7
  171. package/src/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.test.tsx +69 -0
  172. package/src/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.ts +1 -1
  173. package/src/react/runtimes/cloud/CloudFileAttachmentAdapter.test.ts +16 -0
  174. package/src/react/runtimes/cloud/CloudFileAttachmentAdapter.ts +2 -1
  175. package/src/react/runtimes/external-message-converter.test.tsx +21 -0
  176. package/src/react/runtimes/external-message-converter.ts +1 -1
  177. package/src/react/runtimes/useLocalRuntime.test.tsx +56 -0
  178. package/src/react/runtimes/useLocalRuntime.ts +13 -1
  179. package/src/runtime/api/message-runtime.test.ts +81 -0
  180. package/src/runtime/api/message-runtime.ts +4 -6
  181. package/src/runtime/api/thread-list-item-runtime.ts +6 -1
  182. package/src/runtime/base/base-composer-runtime-core.ts +77 -31
  183. package/src/runtime/base/base-thread-runtime-core.ts +2 -1
  184. package/src/runtime/base/default-edit-composer-runtime-core.ts +4 -2
  185. package/src/runtime/base/default-thread-composer-runtime-core.ts +2 -2
  186. package/src/runtime/utils/thread-message-like.ts +1 -0
  187. package/src/runtimes/local/local-thread-runtime-core.test.ts +150 -1
  188. package/src/runtimes/local/local-thread-runtime-core.ts +4 -2
  189. package/src/runtimes/remote-thread-list/optimistic-state.test.ts +63 -0
  190. package/src/runtimes/remote-thread-list/optimistic-state.ts +49 -17
  191. package/src/runtimes/remote-thread-list/types.ts +1 -1
  192. package/src/runtimes/tool-invocations/EDGE_CASES.md +13 -19
  193. package/src/runtimes/tool-invocations/ToolInvocationTracker.test.ts +127 -6
  194. package/src/runtimes/tool-invocations/ToolInvocationTracker.ts +4 -2
  195. package/src/subscribable/subscribable.test.ts +58 -0
  196. package/src/subscribable/subscribable.ts +13 -3
  197. package/src/tests/OptimisticState-list-race.test.ts +33 -0
  198. package/src/tests/RemoteThreadListThreadListRuntimeCore-switchToThread-order.test.ts +223 -0
  199. package/src/tests/base-composer-runtime-core-addAttachment.test.ts +44 -0
  200. package/src/tests/base-composer-runtime-core-send.test.ts +433 -5
  201. package/src/tests/default-edit-composer-runtime-core.test.ts +43 -5
  202. package/src/tests/event-subscription-listener-errors.test.ts +205 -0
  203. package/src/tests/remote-thread-list-test-helpers.ts +14 -7
  204. package/src/tests/thread-message-like.test.ts +57 -0
  205. package/src/types/attachment.ts +5 -0
  206. package/src/types/message.ts +1 -0
  207. package/src/utils/data-url.test.ts +50 -0
  208. package/src/utils/data-url.ts +9 -0
  209. package/src/utils/notify-event-listeners.ts +30 -0
@@ -23,6 +23,37 @@ export type AsyncStorageLike = {
23
23
  removeItem(key: string): Promise<void>;
24
24
  };
25
25
 
26
+ class KeyedMutationQueue {
27
+ private readonly tails = new Map<string, Promise<void>>();
28
+
29
+ run<T>(key: string, mutation: () => Promise<T>): Promise<T> {
30
+ const previous = this.tails.get(key);
31
+ const result = previous ? previous.then(mutation) : mutation();
32
+ const tail = result.then(
33
+ () => undefined,
34
+ () => undefined,
35
+ );
36
+
37
+ this.tails.set(key, tail);
38
+ void tail.then(() => {
39
+ if (this.tails.get(key) === tail) this.tails.delete(key);
40
+ });
41
+
42
+ return result;
43
+ }
44
+ }
45
+
46
+ const mutationQueues = new WeakMap<AsyncStorageLike, KeyedMutationQueue>();
47
+
48
+ const getMutationQueue = (storage: AsyncStorageLike): KeyedMutationQueue => {
49
+ let queue = mutationQueues.get(storage);
50
+ if (!queue) {
51
+ queue = new KeyedMutationQueue();
52
+ mutationQueues.set(storage, queue);
53
+ }
54
+ return queue;
55
+ };
56
+
26
57
  type LocalStorageAdapterOptions = {
27
58
  storage: AsyncStorageLike;
28
59
  prefix?: string | undefined;
@@ -248,6 +279,7 @@ class AsyncStorageHistoryAdapter implements ThreadHistoryAdapter {
248
279
  private storage: AsyncStorageLike,
249
280
  private aui: ReturnType<typeof useAui>,
250
281
  private prefix: string,
282
+ private mutationQueue: KeyedMutationQueue,
251
283
  ) {}
252
284
 
253
285
  private _messagesKey(remoteId: string) {
@@ -266,31 +298,34 @@ class AsyncStorageHistoryAdapter implements ThreadHistoryAdapter {
266
298
  const { remoteId } = await this.aui.threadListItem().initialize();
267
299
 
268
300
  const key = this._messagesKey(remoteId);
269
- const raw = await this.storage.getItem(key);
270
- const repo = parseStoredMessageRepository(raw);
301
+ await this.mutationQueue.run(key, async () => {
302
+ const raw = await this.storage.getItem(key);
303
+ const repo = parseStoredMessageRepository(raw);
304
+
305
+ const idx = repo.messages.findIndex(
306
+ (m) => m.message.id === item.message.id,
307
+ );
308
+ if (idx >= 0) {
309
+ repo.messages[idx] = item;
310
+ } else {
311
+ repo.messages.push(item);
312
+ }
313
+ repo.headId = item.message.id;
271
314
 
272
- const idx = repo.messages.findIndex(
273
- (m) => m.message.id === item.message.id,
274
- );
275
- if (idx >= 0) {
276
- repo.messages[idx] = item;
277
- } else {
278
- repo.messages.push(item);
279
- }
280
- repo.headId = item.message.id;
281
-
282
- await this.storage.setItem(key, JSON.stringify(repo));
315
+ await this.storage.setItem(key, JSON.stringify(repo));
316
+ });
283
317
  }
284
318
  }
285
319
 
286
320
  const createHistoryProvider = (
287
321
  storage: AsyncStorageLike,
288
322
  prefix: string,
323
+ mutationQueue: KeyedMutationQueue,
289
324
  ): FC<PropsWithChildren> => {
290
325
  const Provider: FC<PropsWithChildren> = ({ children }) => {
291
326
  const aui = useAui();
292
327
  const history = useMemo(
293
- () => new AsyncStorageHistoryAdapter(storage, aui, prefix),
328
+ () => new AsyncStorageHistoryAdapter(storage, aui, prefix, mutationQueue),
294
329
  [aui],
295
330
  );
296
331
  const adapters = useMemo(() => ({ history }), [history]);
@@ -311,6 +346,7 @@ export const createLocalStorageAdapter = (
311
346
 
312
347
  const threadsKey = `${prefix}threads`;
313
348
  const messagesKey = (threadId: string) => `${prefix}messages:${threadId}`;
349
+ const mutationQueue = getMutationQueue(storage);
314
350
 
315
351
  const loadThreadMetadata = async (): Promise<StoredThreadMetadata[]> => {
316
352
  const raw = await storage.getItem(threadsKey);
@@ -323,8 +359,22 @@ export const createLocalStorageAdapter = (
323
359
  await storage.setItem(threadsKey, JSON.stringify(threads));
324
360
  };
325
361
 
362
+ const updateThreadMetadata = async (
363
+ remoteId: string,
364
+ update: (thread: StoredThreadMetadata) => void,
365
+ ): Promise<void> => {
366
+ await mutationQueue.run(threadsKey, async () => {
367
+ const threads = await loadThreadMetadata();
368
+ const thread = threads.find((item) => item.remoteId === remoteId);
369
+ if (thread) {
370
+ update(thread);
371
+ await saveThreadMetadata(threads);
372
+ }
373
+ });
374
+ };
375
+
326
376
  const adapter: RemoteThreadListAdapter = {
327
- unstable_Provider: createHistoryProvider(storage, prefix),
377
+ unstable_Provider: createHistoryProvider(storage, prefix, mutationQueue),
328
378
 
329
379
  async list(): Promise<RemoteThreadListResponse> {
330
380
  const threads = await loadThreadMetadata();
@@ -343,64 +393,57 @@ export const createLocalStorageAdapter = (
343
393
  threadId: string,
344
394
  ): Promise<RemoteThreadInitializeResponse> {
345
395
  const remoteId = threadId;
346
- const threads = await loadThreadMetadata();
396
+ return mutationQueue.run(threadsKey, async () => {
397
+ const threads = await loadThreadMetadata();
347
398
 
348
- // Only add if not already present
349
- if (!threads.some((t) => t.remoteId === remoteId)) {
350
- threads.unshift({
351
- remoteId,
352
- status: "regular",
353
- });
354
- await saveThreadMetadata(threads);
355
- }
399
+ // Only add if not already present
400
+ if (!threads.some((t) => t.remoteId === remoteId)) {
401
+ threads.unshift({
402
+ remoteId,
403
+ status: "regular",
404
+ });
405
+ await saveThreadMetadata(threads);
406
+ }
356
407
 
357
- return { remoteId, externalId: undefined };
408
+ return { remoteId, externalId: undefined };
409
+ });
358
410
  },
359
411
 
360
412
  async rename(remoteId: string, newTitle: string): Promise<void> {
361
- const threads = await loadThreadMetadata();
362
- const thread = threads.find((t) => t.remoteId === remoteId);
363
- if (thread) {
413
+ await updateThreadMetadata(remoteId, (thread) => {
364
414
  thread.title = newTitle;
365
- await saveThreadMetadata(threads);
366
- }
415
+ });
367
416
  },
368
417
 
369
418
  async updateCustom(
370
419
  remoteId: string,
371
420
  custom: Record<string, unknown> | undefined,
372
421
  ): Promise<void> {
373
- const threads = await loadThreadMetadata();
374
- const thread = threads.find((t) => t.remoteId === remoteId);
375
- if (thread) {
422
+ await updateThreadMetadata(remoteId, (thread) => {
376
423
  thread.custom = custom;
377
- await saveThreadMetadata(threads);
378
- }
424
+ });
379
425
  },
380
426
 
381
427
  async archive(remoteId: string): Promise<void> {
382
- const threads = await loadThreadMetadata();
383
- const thread = threads.find((t) => t.remoteId === remoteId);
384
- if (thread) {
428
+ await updateThreadMetadata(remoteId, (thread) => {
385
429
  thread.status = "archived";
386
- await saveThreadMetadata(threads);
387
- }
430
+ });
388
431
  },
389
432
 
390
433
  async unarchive(remoteId: string): Promise<void> {
391
- const threads = await loadThreadMetadata();
392
- const thread = threads.find((t) => t.remoteId === remoteId);
393
- if (thread) {
434
+ await updateThreadMetadata(remoteId, (thread) => {
394
435
  thread.status = "regular";
395
- await saveThreadMetadata(threads);
396
- }
436
+ });
397
437
  },
398
438
 
399
439
  async delete(remoteId: string): Promise<void> {
400
- const threads = await loadThreadMetadata();
401
- const filtered = threads.filter((t) => t.remoteId !== remoteId);
402
- await saveThreadMetadata(filtered);
403
- await storage.removeItem(messagesKey(remoteId));
440
+ await mutationQueue.run(threadsKey, async () => {
441
+ const threads = await loadThreadMetadata();
442
+ const filtered = threads.filter((t) => t.remoteId !== remoteId);
443
+ await saveThreadMetadata(filtered);
444
+ });
445
+ const key = messagesKey(remoteId);
446
+ await mutationQueue.run(key, () => storage.removeItem(key));
404
447
  },
405
448
 
406
449
  async fetch(threadId: string): Promise<RemoteThreadMetadata> {
@@ -427,12 +470,9 @@ export const createLocalStorageAdapter = (
427
470
  const title = await titleGenerator.generateTitle(messages);
428
471
 
429
472
  // Update the stored title
430
- const threads = await loadThreadMetadata();
431
- const thread = threads.find((t) => t.remoteId === remoteId);
432
- if (thread) {
473
+ await updateThreadMetadata(remoteId, (thread) => {
433
474
  thread.title = title;
434
- await saveThreadMetadata(threads);
435
- }
475
+ });
436
476
 
437
477
  // Return a stream with a single text part
438
478
  return createAssistantStream((controller) => {
@@ -0,0 +1,79 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const mocks = vi.hoisted(() => {
4
+ const setIsCopied = vi.fn();
5
+
6
+ return {
7
+ setIsCopied,
8
+ state: {
9
+ message: {
10
+ role: "assistant",
11
+ status: { type: "complete", reason: "stop" },
12
+ parts: [{ type: "text", text: "Hello" }],
13
+ isCopied: false,
14
+ },
15
+ composer: {
16
+ isEditing: false,
17
+ text: "",
18
+ },
19
+ },
20
+ aui: {
21
+ message: () => ({
22
+ getCopyText: () => "Hello",
23
+ setIsCopied,
24
+ }),
25
+ },
26
+ };
27
+ });
28
+
29
+ vi.mock("react", async (importOriginal) => ({
30
+ ...(await importOriginal<typeof import("react")>()),
31
+ useCallback: ((callback: unknown) =>
32
+ callback) as typeof import("react").useCallback,
33
+ }));
34
+
35
+ vi.mock("@assistant-ui/store", async (importOriginal) => ({
36
+ ...(await importOriginal<typeof import("@assistant-ui/store")>()),
37
+ useAui: () => mocks.aui,
38
+ useAuiState: ((selector: (state: typeof mocks.state) => unknown) =>
39
+ selector(mocks.state)) as typeof import("@assistant-ui/store").useAuiState,
40
+ }));
41
+
42
+ import { useActionBarCopy } from "./useActionBarCopy";
43
+
44
+ afterEach(() => {
45
+ vi.clearAllMocks();
46
+ });
47
+
48
+ describe("useActionBarCopy", () => {
49
+ it("does not report copy success without a clipboard handler", async () => {
50
+ const { copy, disabled } = useActionBarCopy();
51
+
52
+ copy();
53
+ await Promise.resolve();
54
+
55
+ expect(disabled).toBe(true);
56
+ expect(mocks.setIsCopied).not.toHaveBeenCalled();
57
+ });
58
+
59
+ it("reports copy success after the clipboard handler resolves", async () => {
60
+ const copyToClipboard = vi.fn();
61
+ const { copy } = useActionBarCopy({ copyToClipboard });
62
+
63
+ copy();
64
+ await Promise.resolve();
65
+
66
+ expect(copyToClipboard).toHaveBeenCalledWith("Hello");
67
+ expect(mocks.setIsCopied).toHaveBeenCalledWith(true);
68
+ });
69
+
70
+ it("does not report copy success when the clipboard handler rejects", async () => {
71
+ const copyToClipboard = vi.fn().mockRejectedValue(new Error("denied"));
72
+ const { copy } = useActionBarCopy({ copyToClipboard });
73
+
74
+ copy();
75
+ await Promise.resolve();
76
+
77
+ expect(mocks.setIsCopied).not.toHaveBeenCalled();
78
+ });
79
+ });
@@ -23,13 +23,14 @@ export const useActionBarCopy = ({
23
23
  const composerValue = useAuiState((s) => s.composer.text);
24
24
 
25
25
  const copy = useCallback(() => {
26
+ if (!copyToClipboard) return;
27
+
26
28
  const valueToCopy = isEditing ? composerValue : aui.message().getCopyText();
27
29
  if (!valueToCopy) return;
28
30
 
29
- const write = copyToClipboard ?? (() => {});
30
31
  // The rejection handler swallows clipboard write failures (permission denied,
31
32
  // API unavailable) so they don't surface as unhandled promise rejections.
32
- Promise.resolve(write(valueToCopy)).then(
33
+ Promise.resolve(copyToClipboard(valueToCopy)).then(
33
34
  () => {
34
35
  aui.message().setIsCopied(true);
35
36
  setTimeout(() => aui.message().setIsCopied(false), copiedDuration);
@@ -38,5 +39,5 @@ export const useActionBarCopy = ({
38
39
  );
39
40
  }, [aui, isEditing, composerValue, copiedDuration, copyToClipboard]);
40
41
 
41
- return { copy, disabled, isCopied };
42
+ return { copy, disabled: disabled || !copyToClipboard, isCopied };
42
43
  };
@@ -51,9 +51,7 @@ const ChainOfThoughtPrimitivePartsInner: FC<{
51
51
  Array.from({ length: partsLength }, (_, index) => (
52
52
  <ChainOfThoughtPartByIndexProvider key={index} index={index}>
53
53
  <RenderChildrenWithAccessor
54
- getItemState={(aui) =>
55
- aui.chainOfThought().part({ index }).getState()
56
- }
54
+ getItemState={(aui) => aui.part().getState()}
57
55
  >
58
56
  {(getItem) =>
59
57
  children({
@@ -728,50 +728,63 @@ const EMPTY_RUNNING_TEXT_PART: Extract<EnrichedPartState, { type: "text" }> =
728
728
  * `<MessagePrimitive.GroupedParts>`. Returns whatever `children`
729
729
  * returns — callers decide how to handle a `null` return.
730
730
  */
731
- export const MessagePartChildren: FC<{
731
+ type MessagePartChildrenProps = {
732
732
  index: number;
733
733
  children: (value: { part: EnrichedPartState }) => ReactNode;
734
- }> = ({ index, children }) => {
734
+ };
735
+
736
+ const MessagePartChildrenInner: FC<
737
+ Pick<MessagePartChildrenProps, "children">
738
+ > = ({ children }) => {
735
739
  const aui = useAui();
736
740
  // Subscribed (not snapshotted like `tools`) so fallbacks registered
737
741
  // after the first render trigger a re-render and `hasUI` re-evaluates.
738
742
  const dataRenderers = useAuiState((s) => s.dataRenderers);
739
743
 
744
+ return (
745
+ <RenderChildrenWithAccessor
746
+ getItemState={(client) => client.part().getState()}
747
+ >
748
+ {(getItem) =>
749
+ children({
750
+ get part() {
751
+ const state = getItem();
752
+ if (state.type === "tool-call") {
753
+ const toolsState = aui.tools().getState();
754
+ const hasUI = resolveToolRender(toolsState, state) !== null;
755
+ const partMethods = aui.part();
756
+ return {
757
+ ...state,
758
+ toolUI: hasUI ? <RegisteredToolUI /> : null,
759
+ addResult: partMethods.addToolResult,
760
+ resume: partMethods.resumeToolCall,
761
+ respondToApproval: partMethods.respondToToolApproval,
762
+ };
763
+ }
764
+ if (state.type === "data") {
765
+ const hasUI =
766
+ getDataRenderer(dataRenderers, state.name, undefined) !==
767
+ undefined;
768
+ return {
769
+ ...state,
770
+ dataRendererUI: hasUI ? <RegisteredDataRendererUI /> : null,
771
+ };
772
+ }
773
+ return state;
774
+ },
775
+ })
776
+ }
777
+ </RenderChildrenWithAccessor>
778
+ );
779
+ };
780
+
781
+ export const MessagePartChildren: FC<MessagePartChildrenProps> = ({
782
+ index,
783
+ children,
784
+ }) => {
740
785
  return (
741
786
  <PartByIndexProvider index={index}>
742
- <RenderChildrenWithAccessor
743
- getItemState={(aui) => aui.message().part({ index }).getState()}
744
- >
745
- {(getItem) =>
746
- children({
747
- get part() {
748
- const state = getItem();
749
- if (state.type === "tool-call") {
750
- const toolsState = aui.tools().getState();
751
- const hasUI = resolveToolRender(toolsState, state) !== null;
752
- const partMethods = aui.message().part({ index });
753
- return {
754
- ...state,
755
- toolUI: hasUI ? <RegisteredToolUI /> : null,
756
- addResult: partMethods.addToolResult,
757
- resume: partMethods.resumeToolCall,
758
- respondToApproval: partMethods.respondToToolApproval,
759
- };
760
- }
761
- if (state.type === "data") {
762
- const hasUI =
763
- getDataRenderer(dataRenderers, state.name, undefined) !==
764
- undefined;
765
- return {
766
- ...state,
767
- dataRendererUI: hasUI ? <RegisteredDataRendererUI /> : null,
768
- };
769
- }
770
- return state;
771
- },
772
- })
773
- }
774
- </RenderChildrenWithAccessor>
787
+ <MessagePartChildrenInner>{children}</MessagePartChildrenInner>
775
788
  </PartByIndexProvider>
776
789
  );
777
790
  };
@@ -1,16 +1,33 @@
1
- import type { FC, PropsWithChildren } from "react";
1
+ import { useMemo, type FC, type PropsWithChildren } from "react";
2
2
  import { useAui, AuiProvider, Derived } from "@assistant-ui/store";
3
+ import type { PartMethods } from "../../store/scopes/part";
3
4
 
4
5
  export const ChainOfThoughtPartByIndexProvider: FC<
5
6
  PropsWithChildren<{
6
7
  index: number;
7
8
  }>
8
9
  > = ({ index, children }) => {
10
+ const lastPartRef = useMemo(
11
+ () => ({ index, current: null as PartMethods | null }),
12
+ [index],
13
+ );
9
14
  const aui = useAui({
10
15
  part: Derived({
11
16
  source: "chainOfThought",
12
17
  query: { type: "index", index },
13
- get: (aui) => aui.chainOfThought().part({ index }),
18
+ get: (aui) => {
19
+ const chainOfThought = aui.chainOfThought();
20
+ if (
21
+ index >= chainOfThought.getState().parts.length &&
22
+ lastPartRef.current
23
+ ) {
24
+ return lastPartRef.current;
25
+ }
26
+
27
+ const part = chainOfThought.part({ index });
28
+ lastPartRef.current = part;
29
+ return part;
30
+ },
14
31
  }),
15
32
  });
16
33
 
@@ -1,16 +1,30 @@
1
- import type { FC, PropsWithChildren } from "react";
1
+ import { useMemo, type FC, type PropsWithChildren } from "react";
2
2
  import { useAui, AuiProvider, Derived } from "@assistant-ui/store";
3
+ import type { PartMethods } from "../../store/scopes/part";
3
4
 
4
5
  export const PartByIndexProvider: FC<
5
6
  PropsWithChildren<{
6
7
  index: number;
7
8
  }>
8
9
  > = ({ index, children }) => {
10
+ const lastPartRef = useMemo(
11
+ () => ({ index, current: null as PartMethods | null }),
12
+ [index],
13
+ );
9
14
  const aui = useAui({
10
15
  part: Derived({
11
16
  source: "message",
12
17
  query: { type: "index", index },
13
- get: (aui) => aui.message().part({ index }),
18
+ get: (aui) => {
19
+ const message = aui.message();
20
+ if (index >= message.getState().parts.length && lastPartRef.current) {
21
+ return lastPartRef.current;
22
+ }
23
+
24
+ const part = message.part({ index });
25
+ lastPartRef.current = part;
26
+ return part;
27
+ },
14
28
  }),
15
29
  });
16
30
 
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { ThreadListRuntimeCore } from "../../runtime/interfaces/thread-list-runtime-core";
3
+ import { RemoteThreadListHookInstanceManager } from "./RemoteThreadListHookInstanceManager";
4
+
5
+ describe("RemoteThreadListHookInstanceManager", () => {
6
+ it("rejects a pending start when the thread runtime is stopped", async () => {
7
+ const manager = new RemoteThreadListHookInstanceManager(() => {
8
+ throw new Error("Runtime hook should not render during this test");
9
+ }, {} as ThreadListRuntimeCore);
10
+
11
+ const startPromise = manager.startThreadRuntime("thread-1");
12
+ manager.stopThreadRuntime("thread-1");
13
+
14
+ await expect(startPromise).rejects.toThrow(
15
+ "Thread was deleted before runtime was started",
16
+ );
17
+ });
18
+ });
@@ -85,6 +85,7 @@ export class RemoteThreadListHookInstanceManager extends BaseSubscribable {
85
85
  public stopThreadRuntime(threadId: string) {
86
86
  this.instances.delete(threadId);
87
87
  this.useAliveThreadsKeysChanged.setState({}, true);
88
+ this._notifySubscribers();
88
89
  }
89
90
 
90
91
  public setRuntimeHook(newRuntimeHook: RemoteThreadListHook) {
@@ -44,6 +44,8 @@ export class RemoteThreadListThreadListRuntimeCore
44
44
  private _loadThreadsPromise: Promise<void> | undefined;
45
45
  private _loadMorePromise: Promise<void> | undefined;
46
46
  private _loadGeneration = 0;
47
+ private _switchGeneration = 0;
48
+ private _switchTask: Promise<void> | undefined;
47
49
 
48
50
  private _mainThreadId!: string;
49
51
  private readonly _state = new OptimisticState<RemoteThreadState>({
@@ -307,15 +309,28 @@ export class RemoteThreadListThreadListRuntimeCore
307
309
  return getThreadData(this._state.value, threadIdOrRemoteId);
308
310
  }
309
311
 
310
- public async switchToThread(
312
+ public switchToThread(
311
313
  threadIdOrRemoteId: string,
312
314
  options?: { unarchive?: boolean },
315
+ ): Promise<void> {
316
+ const generation = ++this._switchGeneration;
317
+ const task = this._switchToThread(threadIdOrRemoteId, options, generation);
318
+ this._switchTask = task;
319
+ return task;
320
+ }
321
+
322
+ private async _switchToThread(
323
+ threadIdOrRemoteId: string,
324
+ options: { unarchive?: boolean } | undefined,
325
+ generation: number,
313
326
  ): Promise<void> {
314
327
  let data = this.getItemById(threadIdOrRemoteId);
315
328
 
316
329
  if (!data) {
317
330
  const remoteMetadata =
318
331
  await this._options.adapter.fetch(threadIdOrRemoteId);
332
+ if (generation !== this._switchGeneration) return;
333
+
319
334
  const state = this._state.value;
320
335
  const mappingId = createThreadMappingId(remoteMetadata.remoteId);
321
336
 
@@ -380,8 +395,11 @@ export class RemoteThreadListThreadListRuntimeCore
380
395
  task.then(() => this._notifySubscribers());
381
396
  }
382
397
 
398
+ if (generation !== this._switchGeneration) return;
399
+
383
400
  if (data.status === "archived" && options?.unarchive !== false) {
384
401
  await this.unarchive(data.id);
402
+ if (generation !== this._switchGeneration) return;
385
403
  }
386
404
  this._mainThreadId = data.id;
387
405
 
@@ -389,13 +407,21 @@ export class RemoteThreadListThreadListRuntimeCore
389
407
  this._notifyThreadIdChange();
390
408
  }
391
409
 
392
- public async switchToNewThread(): Promise<void> {
410
+ public switchToNewThread(): Promise<void> {
411
+ const generation = ++this._switchGeneration;
412
+ const task = this._switchToNewThread(generation);
413
+ this._switchTask = task;
414
+ return task;
415
+ }
416
+
417
+ private async _switchToNewThread(generation: number): Promise<void> {
393
418
  // an initialization transaction is in progress, wait for it to settle
394
419
  while (
395
420
  this._state.baseValue.newThreadId !== undefined &&
396
421
  this._state.value.newThreadId === undefined
397
422
  ) {
398
423
  await this._state.waitForUpdate();
424
+ if (generation !== this._switchGeneration) return;
399
425
  }
400
426
 
401
427
  const state = this._state.value;
@@ -427,7 +453,7 @@ export class RemoteThreadListThreadListRuntimeCore
427
453
  });
428
454
  }
429
455
 
430
- return this.switchToThread(id);
456
+ return this._switchToThread(id, undefined, generation);
431
457
  }
432
458
 
433
459
  public initialize = async (threadId: string) => {
@@ -436,10 +462,11 @@ export class RemoteThreadListThreadListRuntimeCore
436
462
  if (!data) throw threadNotFoundError(threadId, "initializing it");
437
463
  if (data.status === "new")
438
464
  throw threadStatusError(threadId, data.status, "be initialized here");
439
- return data.initializeTask;
465
+ const { remoteId, externalId } = await data.initializeTask;
466
+ return { remoteId, externalId };
440
467
  }
441
468
 
442
- return this._state.optimisticUpdate({
469
+ const { remoteId, externalId } = await this._state.optimisticUpdate({
443
470
  execute: () => {
444
471
  return this._options.adapter.initialize(threadId);
445
472
  },
@@ -482,6 +509,7 @@ export class RemoteThreadListThreadListRuntimeCore
482
509
  };
483
510
  },
484
511
  });
512
+ return { remoteId, externalId };
485
513
  };
486
514
 
487
515
  public generateTitle = async (threadId: string) => {
@@ -604,8 +632,21 @@ export class RemoteThreadListThreadListRuntimeCore
604
632
  if (threadId === this.newThreadId)
605
633
  throw new Error("Cannot ensure new thread is not main");
606
634
 
607
- if (threadId === this._mainThreadId) {
608
- await this.switchToNewThread();
635
+ let lastAwaitedTask: Promise<void> | undefined;
636
+
637
+ while (threadId === this._mainThreadId) {
638
+ let switchTask = this._switchTask;
639
+ const startedFallback = !switchTask || switchTask === lastAwaitedTask;
640
+ if (startedFallback) switchTask = this.switchToNewThread();
641
+ lastAwaitedTask = switchTask;
642
+
643
+ try {
644
+ await switchTask;
645
+ } catch (error) {
646
+ if (startedFallback && this._switchTask === switchTask) {
647
+ throw error;
648
+ }
649
+ }
609
650
  }
610
651
  }
611
652