@assistant-ui/core 0.3.0 → 0.3.2

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 (143) hide show
  1. package/dist/internal.d.ts +1 -2
  2. package/dist/internal.js +1 -2
  3. package/dist/react/AssistantProvider.d.ts +1 -1
  4. package/dist/react/AssistantProvider.d.ts.map +1 -1
  5. package/dist/react/AssistantProvider.js +35 -9
  6. package/dist/react/AssistantProvider.js.map +1 -1
  7. package/dist/react/AssistantRuntimeProvider.d.ts +1 -1
  8. package/dist/react/AssistantRuntimeProvider.js +1 -1
  9. package/dist/react/RuntimeAdapter.js +2 -2
  10. package/dist/react/adapters/LocalStorageThreadListAdapter.d.ts.map +1 -1
  11. package/dist/react/adapters/LocalStorageThreadListAdapter.js +17 -33
  12. package/dist/react/adapters/LocalStorageThreadListAdapter.js.map +1 -1
  13. package/dist/react/client/DataRenderers.js +1 -1
  14. package/dist/react/client/Interactables.js +1 -1
  15. package/dist/react/client/Tools.js +2 -2
  16. package/dist/react/index.d.ts +2 -1
  17. package/dist/react/index.js +2 -1
  18. package/dist/react/interactables-legacy/Interactables.js +1 -1
  19. package/dist/react/interactables-legacy/useAssistantInteractable.js +1 -1
  20. package/dist/react/interactables-legacy/useInteractableState.js +2 -2
  21. package/dist/react/model-context/useAssistantContext.js +1 -1
  22. package/dist/react/model-context/useAssistantDataUI.js +2 -2
  23. package/dist/react/model-context/useAssistantInstructions.js +2 -2
  24. package/dist/react/model-context/useAssistantTool.js +2 -2
  25. package/dist/react/model-context/useAssistantToolUI.js +2 -2
  26. package/dist/react/model-context/useAuiToolOverrides.js +1 -1
  27. package/dist/react/model-context/useInlineRender.js +1 -1
  28. package/dist/react/model-context/useInteractable.d.ts.map +1 -1
  29. package/dist/react/model-context/useInteractable.js +3 -5
  30. package/dist/react/model-context/useInteractable.js.map +1 -1
  31. package/dist/react/model-context/useInteractableState.js +2 -2
  32. package/dist/react/model-context/useInteractableVersions.js +2 -2
  33. package/dist/react/model-context/useToolArgsStatus.js +2 -2
  34. package/dist/react/primitive-hooks/useActionBarCopy.js +2 -2
  35. package/dist/react/primitive-hooks/useActionBarEdit.js +2 -2
  36. package/dist/react/primitive-hooks/useActionBarFeedback.js +2 -2
  37. package/dist/react/primitive-hooks/useActionBarReload.js +2 -2
  38. package/dist/react/primitive-hooks/useActionBarSpeak.js +2 -2
  39. package/dist/react/primitive-hooks/useActionBarStopSpeaking.js +2 -2
  40. package/dist/react/primitive-hooks/useBranchPickerNext.js +2 -2
  41. package/dist/react/primitive-hooks/useBranchPickerPrevious.js +2 -2
  42. package/dist/react/primitive-hooks/useComposerAddAttachment.js +2 -2
  43. package/dist/react/primitive-hooks/useComposerCancel.js +2 -2
  44. package/dist/react/primitive-hooks/useComposerDictate.js +2 -2
  45. package/dist/react/primitive-hooks/useComposerSend.js +2 -2
  46. package/dist/react/primitive-hooks/useEditComposerCancel.js +2 -2
  47. package/dist/react/primitive-hooks/useEditComposerSend.js +2 -2
  48. package/dist/react/primitive-hooks/useMessageBranching.js +2 -2
  49. package/dist/react/primitive-hooks/useMessageReload.js +2 -2
  50. package/dist/react/primitive-hooks/useSuggestionTrigger.js +2 -2
  51. package/dist/react/primitive-hooks/useThreadListItemArchive.js +2 -2
  52. package/dist/react/primitive-hooks/useThreadListItemDelete.js +2 -2
  53. package/dist/react/primitive-hooks/useThreadListItemTrigger.js +2 -2
  54. package/dist/react/primitive-hooks/useThreadListItemUnarchive.js +2 -2
  55. package/dist/react/primitive-hooks/useThreadListLoadMore.js +2 -2
  56. package/dist/react/primitive-hooks/useThreadListNew.js +2 -2
  57. package/dist/react/primitive-hooks/useThreadMessageIds.js +1 -1
  58. package/dist/react/primitive-hooks/useVoice.js +2 -2
  59. package/dist/react/primitives/chainOfThought/ChainOfThoughtParts.js +2 -2
  60. package/dist/react/primitives/composer/ComposerAttachments.js +2 -2
  61. package/dist/react/primitives/composer/ComposerIf.js +1 -1
  62. package/dist/react/primitives/composer/ComposerQueue.js +1 -1
  63. package/dist/react/primitives/generativeUI/GenerativeUI.js +2 -2
  64. package/dist/react/primitives/message/MessageAttachments.js +2 -2
  65. package/dist/react/primitives/message/MessageGroupedParts.js +1 -1
  66. package/dist/react/primitives/message/MessageParts.js +2 -2
  67. package/dist/react/primitives/message/MessageQuote.js +2 -2
  68. package/dist/react/primitives/part/PartMessages.js +2 -2
  69. package/dist/react/primitives/thread/ThreadMessages.js +2 -2
  70. package/dist/react/primitives/thread/ThreadSuggestions.js +2 -2
  71. package/dist/react/primitives/threadList/ThreadListItems.js +2 -2
  72. package/dist/react/primitives/threadListItem/ThreadListItemTitle.js +1 -1
  73. package/dist/react/providers/AttachmentByIndexProvider.js +1 -1
  74. package/dist/react/providers/ChainOfThoughtByIndicesProvider.js +1 -1
  75. package/dist/react/providers/ChainOfThoughtPartByIndexProvider.js +1 -1
  76. package/dist/react/providers/MessageByIdProvider.js +1 -1
  77. package/dist/react/providers/MessageByIndexProvider.js +1 -1
  78. package/dist/react/providers/PartByIndexProvider.js +1 -1
  79. package/dist/react/providers/QueueItemByIndexProvider.js +1 -1
  80. package/dist/react/providers/ReadonlyThreadProvider.js +2 -2
  81. package/dist/react/providers/SuggestionByIndexProvider.js +1 -1
  82. package/dist/react/providers/TextMessagePartProvider.js +2 -2
  83. package/dist/react/providers/ThreadListItemByIndexProvider.js +1 -1
  84. package/dist/react/providers/ThreadListItemRuntimeProvider.js +1 -1
  85. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
  86. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js +14 -13
  87. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js.map +1 -1
  88. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +3 -0
  89. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  90. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +22 -12
  91. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
  92. package/dist/react/runtimes/RuntimeAdapterProvider.js +1 -1
  93. package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.js +9 -23
  94. package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.js.map +1 -1
  95. package/dist/react/runtimes/external-message-converter.js +1 -1
  96. package/dist/react/runtimes/useExternalStoreRuntime.js +1 -1
  97. package/dist/react/runtimes/useExternalStoreSharedOptions.js +1 -1
  98. package/dist/react/runtimes/useLocalRuntime.js +1 -1
  99. package/dist/react/runtimes/useRemoteThreadListRuntime.js +7 -13
  100. package/dist/react/runtimes/useRemoteThreadListRuntime.js.map +1 -1
  101. package/dist/react/utils/useJSONEqual.js +1 -1
  102. package/dist/runtime/utils/thread-message-like.js +2 -2
  103. package/dist/runtime/utils/thread-message-like.js.map +1 -1
  104. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  105. package/dist/runtimes/local/local-thread-runtime-core.js +4 -3
  106. package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
  107. package/dist/runtimes/remote-thread-list/types.d.ts +5 -4
  108. package/dist/runtimes/remote-thread-list/types.d.ts.map +1 -1
  109. package/dist/store/clients/chain-of-thought-client.js +1 -1
  110. package/dist/store/clients/model-context-client.js +1 -1
  111. package/dist/store/clients/no-op-composer-client.js +1 -1
  112. package/dist/store/clients/suggestions.js +2 -2
  113. package/dist/store/clients/thread-message-client.js +2 -2
  114. package/dist/store/runtime-clients/composer-runtime-client.js +2 -2
  115. package/dist/store/runtime-clients/message-runtime-client.js +2 -2
  116. package/dist/store/runtime-clients/thread-list-item-runtime-client.js +2 -2
  117. package/dist/store/runtime-clients/thread-list-runtime-client.js +2 -2
  118. package/dist/store/runtime-clients/thread-runtime-client.js +2 -2
  119. package/dist/store/scopes/thread.d.ts +1 -1
  120. package/dist/store/scopes/thread.d.ts.map +1 -1
  121. package/dist/utils/data-url.js +2 -2
  122. package/dist/utils/data-url.js.map +1 -1
  123. package/package.json +4 -4
  124. package/src/internal.ts +0 -7
  125. package/src/react/AssistantProvider.tsx +28 -15
  126. package/src/react/AssistantRuntimeProvider.test.tsx +101 -0
  127. package/src/react/adapters/LocalStorageThreadListAdapter.tsx +28 -7
  128. package/src/react/index.ts +4 -0
  129. package/src/react/model-context/useInteractable.ts +2 -6
  130. package/src/react/runtimes/RemoteThreadListHookInstanceManager.tsx +16 -13
  131. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +53 -10
  132. package/src/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.ts +7 -5
  133. package/src/react/runtimes/useRemoteThreadListRuntime.ts +9 -15
  134. package/src/runtime/utils/thread-message-like.ts +2 -2
  135. package/src/runtimes/local/local-thread-runtime-core.test.ts +38 -0
  136. package/src/runtimes/local/local-thread-runtime-core.ts +6 -3
  137. package/src/runtimes/remote-thread-list/types.ts +5 -4
  138. package/src/store/scopes/thread.ts +1 -1
  139. package/src/tests/remote-thread-list-reactive-threadId.test.ts +0 -92
  140. package/src/tests/thread-message-like.test.ts +15 -0
  141. package/src/tests/useRemoteThreadListRuntime-controlled.test.tsx +293 -0
  142. package/src/utils/data-url.test.ts +26 -2
  143. package/src/utils/data-url.ts +2 -2
@@ -1,5 +1,12 @@
1
1
  import { type AssistantStream, createAssistantStream } from "assistant-stream";
2
- import { type FC, type PropsWithChildren, useMemo } from "react";
2
+ import {
3
+ type FC,
4
+ type PropsWithChildren,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from "react";
3
10
  import { useAui } from "@assistant-ui/store";
4
11
  import type {
5
12
  RemoteThreadInitializeResponse,
@@ -276,22 +283,26 @@ export const parseStoredMessageRepository = (
276
283
 
277
284
  class AsyncStorageHistoryAdapter implements ThreadHistoryAdapter {
278
285
  private storage: AsyncStorageLike;
279
- private aui: ReturnType<typeof useAui>;
286
+ private getAui: () => ReturnType<typeof useAui>;
280
287
  private prefix: string;
281
288
  private mutationQueue: KeyedMutationQueue;
282
289
 
283
290
  constructor(
284
291
  storage: AsyncStorageLike,
285
- aui: ReturnType<typeof useAui>,
292
+ getAui: () => ReturnType<typeof useAui>,
286
293
  prefix: string,
287
294
  mutationQueue: KeyedMutationQueue,
288
295
  ) {
289
296
  this.storage = storage;
290
- this.aui = aui;
297
+ this.getAui = getAui;
291
298
  this.prefix = prefix;
292
299
  this.mutationQueue = mutationQueue;
293
300
  }
294
301
 
302
+ private get aui(): ReturnType<typeof useAui> {
303
+ return this.getAui();
304
+ }
305
+
295
306
  private _messagesKey(remoteId: string) {
296
307
  return `${this.prefix}messages:${remoteId}`;
297
308
  }
@@ -334,9 +345,19 @@ const createHistoryProvider = (
334
345
  ): FC<PropsWithChildren> => {
335
346
  const Provider: FC<PropsWithChildren> = ({ children }) => {
336
347
  const aui = useAui();
337
- const history = useMemo(
338
- () => new AsyncStorageHistoryAdapter(storage, aui, prefix, mutationQueue),
339
- [aui],
348
+ // Not useEffectEvent: history adapter methods run during render (SSR load).
349
+ const auiRef = useRef(aui);
350
+ useEffect(() => {
351
+ auiRef.current = aui;
352
+ });
353
+ const [history] = useState(
354
+ () =>
355
+ new AsyncStorageHistoryAdapter(
356
+ storage,
357
+ () => auiRef.current,
358
+ prefix,
359
+ mutationQueue,
360
+ ),
340
361
  );
341
362
  const adapters = useMemo(() => ({ history }), [history]);
342
363
 
@@ -226,6 +226,10 @@ export {
226
226
  type StreamingTimingOptions,
227
227
  type StreamingTimingState,
228
228
  } from "./runtimes/useStreamingTiming";
229
+ export {
230
+ createRuntimeExtras,
231
+ type RuntimeExtras,
232
+ } from "./runtimes/createRuntimeExtras";
229
233
  export { RemoteThreadListHookInstanceManager } from "./runtimes/RemoteThreadListHookInstanceManager";
230
234
  export { RemoteThreadListThreadListRuntimeCore } from "./runtimes/RemoteThreadListThreadListRuntimeCore";
231
235
  export { useRemoteThreadListRuntime } from "./runtimes/useRemoteThreadListRuntime";
@@ -90,14 +90,10 @@ const useInteractable = <TSchema extends Unstable_InteractableStateSchema>(
90
90
 
91
91
  const autoId = useId().replace(/[^a-zA-Z0-9]/g, "");
92
92
 
93
- // Whether this component renders inside a message part is fixed for its
94
- // lifetime, so conditioning the selectors on it is safe.
95
- const inPart = aui.part.source != null;
96
93
  const updateToolName = interactableToolName(name);
97
94
  const part = useAuiState((s) => {
98
- if (!inPart) return undefined;
99
- const p = s.part;
100
- return p.type === "tool-call" ? p : undefined;
95
+ const p = s.optional.part;
96
+ return p?.type === "tool-call" ? p : undefined;
101
97
  });
102
98
 
103
99
  // Inside an update_{name} part, the instance id comes from the call itself:
@@ -4,6 +4,7 @@ import {
4
4
  useCallback,
5
5
  useRef,
6
6
  useEffect,
7
+ useEffectEvent,
7
8
  useLayoutEffect,
8
9
  memo,
9
10
  type PropsWithChildren,
@@ -142,23 +143,25 @@ export class RemoteThreadListHookInstanceManager extends BaseSubscribable {
142
143
  }
143
144
  }, [threadBinding]);
144
145
 
145
- useEffect(() => {
146
- hasInitializedRef.current = false;
147
- return runtime.threads.main.unstable_on("initialize", () => {
148
- if (hasInitializedRef.current) return;
146
+ const handleInitialize = useEffectEvent(() => {
147
+ if (hasInitializedRef.current) return;
149
148
 
150
- const state = aui.threadListItem.getState();
151
- if (state.status !== "new") return;
152
- hasInitializedRef.current = true;
149
+ const state = aui.threadListItem.getState();
150
+ if (state.status !== "new") return;
151
+ hasInitializedRef.current = true;
153
152
 
154
- initPromiseRef.current = aui.threadListItem.initialize();
153
+ initPromiseRef.current = aui.threadListItem.initialize();
155
154
 
156
- const dispose = runtime.thread.unstable_on("runEnd", () => {
157
- dispose();
158
- aui.threadListItem.generateTitle();
159
- });
155
+ const dispose = runtime.thread.unstable_on("runEnd", () => {
156
+ dispose();
157
+ aui.threadListItem.generateTitle();
160
158
  });
161
- }, [runtime, aui]);
159
+ });
160
+
161
+ useEffect(() => {
162
+ hasInitializedRef.current = false;
163
+ return runtime.threads.main.unstable_on("initialize", handleInitialize);
164
+ }, [runtime]);
162
165
 
163
166
  return <>{children}</>;
164
167
  };
@@ -200,6 +200,10 @@ export class RemoteThreadListThreadListRuntimeCore
200
200
 
201
201
  const adapterChanged =
202
202
  this._options !== undefined && this._options.adapter !== options.adapter;
203
+ const controlledThreadIdChanged =
204
+ this._initialThreadLoaded &&
205
+ this._options !== undefined &&
206
+ this._options.threadId !== options.threadId;
203
207
 
204
208
  this._options = options;
205
209
 
@@ -219,15 +223,25 @@ export class RemoteThreadListThreadListRuntimeCore
219
223
  cursor: undefined,
220
224
  });
221
225
  }
226
+
227
+ if (controlledThreadIdChanged) {
228
+ this._switchToThreadFromProp(options.threadId).catch(() => {});
229
+ }
222
230
  }
223
231
 
224
232
  public __internal_load() {
225
233
  this.getLoadThreadsPromise(); // begin loading on initial bind
234
+ if (this._initialThreadLoaded) return;
235
+ this._initialThreadLoaded = true;
236
+
226
237
  const startThreadId =
227
238
  this._options.threadId ?? this._options.initialThreadId;
228
- if (!this._initialThreadLoaded && startThreadId) {
229
- this._initialThreadLoaded = true;
230
- this.switchToThread(startThreadId).catch(() => {});
239
+ if (startThreadId !== undefined) {
240
+ const switchTask =
241
+ this._options.threadId !== undefined
242
+ ? this._switchToThreadFromProp(startThreadId)
243
+ : this.switchToThread(startThreadId);
244
+ switchTask.catch(() => {});
231
245
  }
232
246
  }
233
247
 
@@ -279,11 +293,13 @@ export class RemoteThreadListThreadListRuntimeCore
279
293
 
280
294
  private _lastNotifiedThreadId: string | undefined = undefined;
281
295
 
282
- private _notifyThreadIdChange() {
296
+ private _notifyThreadIdChange(emit = true) {
283
297
  const threadId = this._mainThreadRemoteId;
284
298
  if (this._lastNotifiedThreadId === threadId) return;
285
299
  this._lastNotifiedThreadId = threadId;
286
- this._options.onThreadIdChange?.(threadId);
300
+ if (emit) {
301
+ this._options.onThreadIdChange?.(threadId);
302
+ }
287
303
  }
288
304
 
289
305
  public getMainThreadRuntimeCore() {
@@ -312,9 +328,22 @@ export class RemoteThreadListThreadListRuntimeCore
312
328
  public switchToThread(
313
329
  threadIdOrRemoteId: string,
314
330
  options?: { unarchive?: boolean },
331
+ ): Promise<void> {
332
+ return this._startSwitchToThread(threadIdOrRemoteId, options, true);
333
+ }
334
+
335
+ private _startSwitchToThread(
336
+ threadIdOrRemoteId: string,
337
+ options: { unarchive?: boolean } | undefined,
338
+ emitThreadIdChange: boolean,
315
339
  ): Promise<void> {
316
340
  const generation = ++this._switchGeneration;
317
- const task = this._switchToThread(threadIdOrRemoteId, options, generation);
341
+ const task = this._switchToThread(
342
+ threadIdOrRemoteId,
343
+ options,
344
+ generation,
345
+ emitThreadIdChange,
346
+ );
318
347
  this._switchTask = task;
319
348
  return task;
320
349
  }
@@ -323,6 +352,7 @@ export class RemoteThreadListThreadListRuntimeCore
323
352
  threadIdOrRemoteId: string,
324
353
  options: { unarchive?: boolean } | undefined,
325
354
  generation: number,
355
+ emitThreadIdChange: boolean,
326
356
  ): Promise<void> {
327
357
  let data = this.getItemById(threadIdOrRemoteId);
328
358
 
@@ -418,17 +448,30 @@ export class RemoteThreadListThreadListRuntimeCore
418
448
  this._mainThreadId = data.id;
419
449
 
420
450
  this._notifySubscribers();
421
- this._notifyThreadIdChange();
451
+ this._notifyThreadIdChange(emitThreadIdChange);
422
452
  }
423
453
 
424
454
  public switchToNewThread(): Promise<void> {
455
+ return this._startSwitchToNewThread(true);
456
+ }
457
+
458
+ private _switchToThreadFromProp(threadId: string | undefined): Promise<void> {
459
+ return threadId !== undefined
460
+ ? this._startSwitchToThread(threadId, undefined, false)
461
+ : this._startSwitchToNewThread(false);
462
+ }
463
+
464
+ private _startSwitchToNewThread(emitThreadIdChange: boolean): Promise<void> {
425
465
  const generation = ++this._switchGeneration;
426
- const task = this._switchToNewThread(generation);
466
+ const task = this._switchToNewThread(generation, emitThreadIdChange);
427
467
  this._switchTask = task;
428
468
  return task;
429
469
  }
430
470
 
431
- private async _switchToNewThread(generation: number): Promise<void> {
471
+ private async _switchToNewThread(
472
+ generation: number,
473
+ emitThreadIdChange: boolean,
474
+ ): Promise<void> {
432
475
  // an initialization transaction is in progress, wait for it to settle
433
476
  while (
434
477
  this._state.baseValue.newThreadId !== undefined &&
@@ -467,7 +510,7 @@ export class RemoteThreadListThreadListRuntimeCore
467
510
  });
468
511
  }
469
512
 
470
- return this._switchToThread(id, undefined, generation);
513
+ return this._switchToThread(id, undefined, generation, emitThreadIdChange);
471
514
  }
472
515
 
473
516
  public initialize = async (threadId: string) => {
@@ -22,18 +22,18 @@ const globalPersistence = new WeakMap<
22
22
 
23
23
  class AssistantCloudThreadHistoryAdapter implements ThreadHistoryAdapter {
24
24
  private cloudRef: RefObject<AssistantCloud>;
25
- private auiRef: RefObject<AssistantClient>;
25
+ private getAui: () => AssistantClient;
26
26
 
27
27
  constructor(
28
28
  cloudRef: RefObject<AssistantCloud>,
29
- auiRef: RefObject<AssistantClient>,
29
+ getAui: () => AssistantClient,
30
30
  ) {
31
31
  this.cloudRef = cloudRef;
32
- this.auiRef = auiRef;
32
+ this.getAui = getAui;
33
33
  }
34
34
 
35
35
  private get aui(): AssistantClient {
36
- return this.auiRef.current;
36
+ return this.getAui();
37
37
  }
38
38
 
39
39
  private get _persistence(): CloudMessagePersistence {
@@ -816,12 +816,14 @@ export function useAssistantCloudThreadHistoryAdapter(
816
816
  cloudRef: RefObject<AssistantCloud>,
817
817
  ): ThreadHistoryAdapter {
818
818
  const aui = useAui();
819
+ // Not useEffectEvent: history adapter methods run during render (SSR load).
819
820
  const auiRef = useRef(aui);
820
821
  useEffect(() => {
821
822
  auiRef.current = aui;
822
823
  });
823
824
  const [adapter] = useState(
824
- () => new AssistantCloudThreadHistoryAdapter(cloudRef, auiRef),
825
+ () =>
826
+ new AssistantCloudThreadHistoryAdapter(cloudRef, () => auiRef.current),
825
827
  );
826
828
  return adapter;
827
829
  }
@@ -51,8 +51,7 @@ export const useRemoteThreadListRuntime = (
51
51
  const runtimeHookRef = useRef(options.runtimeHook);
52
52
  runtimeHookRef.current = options.runtimeHook;
53
53
 
54
- // threadId/initialThreadId only affect the constructor; capture once via ref
55
- const startThreadIdRef = useRef(options.threadId ?? options.initialThreadId);
54
+ const initialThreadIdRef = useRef(options.initialThreadId);
56
55
 
57
56
  const stableRuntimeHook = useCallback(() => {
58
57
  return runtimeHookRef.current();
@@ -66,11 +65,17 @@ export const useRemoteThreadListRuntime = (
66
65
  () => ({
67
66
  adapter: options.adapter,
68
67
  allowNesting: options.allowNesting,
69
- initialThreadId: startThreadIdRef.current,
68
+ threadId: options.threadId,
69
+ initialThreadId: initialThreadIdRef.current,
70
70
  runtimeHook: stableRuntimeHook,
71
71
  onThreadIdChange,
72
72
  }),
73
- [options.adapter, options.allowNesting, stableRuntimeHook],
73
+ [
74
+ options.adapter,
75
+ options.allowNesting,
76
+ options.threadId,
77
+ stableRuntimeHook,
78
+ ],
74
79
  );
75
80
 
76
81
  const aui = useAui();
@@ -91,16 +96,5 @@ export const useRemoteThreadListRuntime = (
91
96
 
92
97
  const runtime = useRemoteThreadListRuntimeImpl(stableOptions);
93
98
 
94
- const prevThreadIdRef = useRef(options.threadId);
95
- useEffect(() => {
96
- if (options.threadId === prevThreadIdRef.current) return;
97
- prevThreadIdRef.current = options.threadId;
98
- if (options.threadId) {
99
- runtime.threads.switchToThread(options.threadId).catch(() => {});
100
- } else {
101
- runtime.threads.switchToNewThread().catch(() => {});
102
- }
103
- }, [runtime, options.threadId]);
104
-
105
99
  return runtime;
106
100
  };
@@ -131,12 +131,12 @@ export const fromThreadMessageLike = (
131
131
  }: ImageMessagePart): ImageMessagePart | null => {
132
132
  if (typeof image !== "string") return null;
133
133
  const dataUri = image.match(
134
- /^data:image\/(png|jpeg|jpg|gif|webp|svg\+xml);base64,(.*)$/,
134
+ /^data:image\/(png|jpeg|jpg|gif|webp|svg\+xml);base64,(.*)$/i,
135
135
  );
136
136
  if (dataUri) {
137
137
  return { ...rest, image };
138
138
  }
139
- if (/^(https:\/\/|blob:)/.test(image)) {
139
+ if (/^(https:\/\/|blob:)/i.test(image)) {
140
140
  return { ...rest, image };
141
141
  }
142
142
  console.warn(`Invalid image data format detected`);
@@ -497,6 +497,44 @@ describe("LocalThreadRuntimeCore tool approvals", () => {
497
497
  });
498
498
 
499
499
  describe("LocalThreadRuntimeCore cancellation", () => {
500
+ it("keeps a replacement run cancellable after the previous run settles", async () => {
501
+ let releaseFirst!: () => void;
502
+ let releaseSecond!: () => void;
503
+ const firstGate = new Promise<void>((resolve) => {
504
+ releaseFirst = resolve;
505
+ });
506
+ const secondGate = new Promise<void>((resolve) => {
507
+ releaseSecond = resolve;
508
+ });
509
+ const signals: AbortSignal[] = [];
510
+ const thread = createThread({
511
+ async *run({ abortSignal }) {
512
+ signals.push(abortSignal);
513
+ await (signals.length === 1 ? firstGate : secondGate);
514
+ },
515
+ });
516
+
517
+ const firstAppend = thread.append(userMessage("first"));
518
+ await flush();
519
+ const secondAppend = thread.append(userMessage("second"));
520
+ await flush();
521
+
522
+ expect(signals).toHaveLength(2);
523
+ expect(signals[0]?.aborted).toBe(true);
524
+ expect(signals[1]?.aborted).toBe(false);
525
+
526
+ releaseFirst();
527
+ await firstAppend;
528
+
529
+ thread.cancelRun();
530
+ const replacementWasAborted = signals[1]?.aborted;
531
+
532
+ releaseSecond();
533
+ await secondAppend;
534
+
535
+ expect(replacementWasAborted).toBe(true);
536
+ });
537
+
500
538
  it("marks the message cancelled when a streaming adapter returns after abort", async () => {
501
539
  let released!: () => void;
502
540
  const streaming = new Promise<void>((resolve) => {
@@ -459,7 +459,8 @@ export class LocalThreadRuntimeCore
459
459
 
460
460
  // abort existing run
461
461
  this.abortController?.abort();
462
- this.abortController = new AbortController();
462
+ const abortController = new AbortController();
463
+ this.abortController = abortController;
463
464
 
464
465
  const initialContent = message.content;
465
466
  const initialAnnotations = message.metadata?.unstable_annotations;
@@ -550,7 +551,7 @@ export class LocalThreadRuntimeCore
550
551
  runCallback ??
551
552
  this.adapters.chatModel.run.bind(this.adapters.chatModel);
552
553
 
553
- const abortSignal = this.abortController.signal;
554
+ const abortSignal = abortController.signal;
554
555
  const threadId = this._getThreadId?.();
555
556
  const promiseOrGenerator = runCallback({
556
557
  messages,
@@ -609,7 +610,9 @@ export class LocalThreadRuntimeCore
609
610
  throw e;
610
611
  }
611
612
  } finally {
612
- this.abortController = null;
613
+ if (this.abortController === abortController) {
614
+ this.abortController = null;
615
+ }
613
616
 
614
617
  const history = this._options.adapters.history;
615
618
  const item = {
@@ -78,10 +78,11 @@ export type RemoteThreadListOptions = {
78
78
  threadId?: string | undefined;
79
79
 
80
80
  /**
81
- * Called whenever the active thread's canonical (remote) ID changes, so the
82
- * value can be treated as a managed/controlled variable (e.g. synced to a
83
- * URL query param). Together with `threadId` this forms the controlled
84
- * pattern: `threadId` in, `onThreadIdChange` out.
81
+ * Called whenever the runtime changes the active thread's canonical (remote)
82
+ * ID, so the value can be treated as a managed/controlled variable (e.g.
83
+ * synced to a URL query param). Changes initiated by the controlled
84
+ * `threadId` option are not echoed back. Together these options form the
85
+ * controlled pattern: `threadId` in, `onThreadIdChange` out.
85
86
  *
86
87
  * Only the settled remote ID is emitted: while a freshly created thread is
87
88
  * still optimistic (no remote ID yet) the value is `undefined`, and the real
@@ -108,7 +108,7 @@ export type ThreadMethods = {
108
108
  * @param initialMessages - Optional array of initial messages to populate the thread
109
109
  */
110
110
  reset(initialMessages?: readonly ThreadMessageLike[]): void;
111
- importExternalState?(state: unknown): void;
111
+ importExternalState(state: unknown): void;
112
112
  message(selector: { id: string } | { index: number }): MessageMethods;
113
113
  /** @deprecated This API is still under active development and might change without notice. */
114
114
  stopSpeaking(): void;
@@ -2,98 +2,6 @@ import { describe, it, expect, vi } from "vitest";
2
2
  import type { RemoteThreadListAdapter } from "../runtimes/remote-thread-list/types";
3
3
  import { createCore, makeAdapter } from "./remote-thread-list-test-helpers";
4
4
 
5
- /**
6
- * Tests for the reactive threadId useEffect logic in useRemoteThreadListRuntime.
7
- *
8
- * The useEffect compares options.threadId against a prevThreadIdRef and calls
9
- * switchToThread or switchToNewThread when it changes. We test this logic
10
- * by simulating the ref + comparison pattern.
11
- */
12
-
13
- // Mirrors the useEffect in useRemoteThreadListRuntimeImpl.
14
- // Keep in sync if that implementation changes.
15
- function simulateThreadIdEffect(
16
- prevRef: { current: string | undefined },
17
- threadId: string | undefined,
18
- switchToThread: (id: string) => void,
19
- switchToNewThread: () => void,
20
- ) {
21
- if (threadId === prevRef.current) return;
22
- prevRef.current = threadId;
23
- if (threadId) {
24
- switchToThread(threadId);
25
- } else {
26
- switchToNewThread();
27
- }
28
- }
29
-
30
- describe("threadId reactive effect", () => {
31
- it("does nothing when threadId stays the same", () => {
32
- const ref = { current: "thread-1" };
33
- const switchToThread = vi.fn();
34
- const switchToNewThread = vi.fn();
35
-
36
- simulateThreadIdEffect(ref, "thread-1", switchToThread, switchToNewThread);
37
-
38
- expect(switchToThread).not.toHaveBeenCalled();
39
- expect(switchToNewThread).not.toHaveBeenCalled();
40
- });
41
-
42
- it("calls switchToThread when threadId changes", () => {
43
- const ref = { current: "thread-1" };
44
- const switchToThread = vi.fn();
45
- const switchToNewThread = vi.fn();
46
-
47
- simulateThreadIdEffect(ref, "thread-2", switchToThread, switchToNewThread);
48
-
49
- expect(switchToThread).toHaveBeenCalledWith("thread-2");
50
- expect(ref.current).toBe("thread-2");
51
- });
52
-
53
- it("calls switchToNewThread when threadId becomes undefined", () => {
54
- const ref = { current: "thread-1" };
55
- const switchToThread = vi.fn();
56
- const switchToNewThread = vi.fn();
57
-
58
- simulateThreadIdEffect(ref, undefined, switchToThread, switchToNewThread);
59
-
60
- expect(switchToNewThread).toHaveBeenCalledOnce();
61
- expect(ref.current).toBeUndefined();
62
- });
63
-
64
- it("skips switchToNewThread on first render when threadId is already undefined", () => {
65
- const ref = { current: undefined as string | undefined };
66
- const switchToThread = vi.fn();
67
- const switchToNewThread = vi.fn();
68
-
69
- // First render: undefined === undefined → skipped
70
- simulateThreadIdEffect(ref, undefined, switchToThread, switchToNewThread);
71
- expect(switchToNewThread).not.toHaveBeenCalled();
72
- });
73
-
74
- it("handles full navigation cycle", () => {
75
- const ref = { current: undefined as string | undefined };
76
- const switchToThread = vi.fn();
77
- const switchToNewThread = vi.fn();
78
-
79
- // Mount with thread-1
80
- simulateThreadIdEffect(ref, "thread-1", switchToThread, switchToNewThread);
81
- expect(switchToThread).toHaveBeenCalledWith("thread-1");
82
-
83
- // Navigate to thread-2
84
- simulateThreadIdEffect(ref, "thread-2", switchToThread, switchToNewThread);
85
- expect(switchToThread).toHaveBeenCalledWith("thread-2");
86
-
87
- // Navigate to new thread
88
- simulateThreadIdEffect(ref, undefined, switchToThread, switchToNewThread);
89
- expect(switchToNewThread).toHaveBeenCalledOnce();
90
-
91
- // Navigate back to thread-1
92
- simulateThreadIdEffect(ref, "thread-1", switchToThread, switchToNewThread);
93
- expect(switchToThread).toHaveBeenCalledTimes(3);
94
- });
95
- });
96
-
97
5
  /**
98
6
  * Tests for onThreadIdChange, driving the real
99
7
  * RemoteThreadListThreadListRuntimeCore via a mock adapter (no React). These
@@ -159,6 +159,21 @@ describe("fromThreadMessageLike", () => {
159
159
 
160
160
  expect(result.content).toEqual([]);
161
161
  });
162
+
163
+ it("keeps an image part with an uppercase-scheme data URL", () => {
164
+ const result = fromThreadMessageLike(
165
+ {
166
+ role: "assistant",
167
+ content: [{ type: "image", image: "DATA:IMAGE/PNG;base64,AAAA" }],
168
+ },
169
+ fallbackId,
170
+ fallbackStatus,
171
+ );
172
+
173
+ expect(result.content).toEqual([
174
+ { type: "image", image: "DATA:IMAGE/PNG;base64,AAAA" },
175
+ ]);
176
+ });
162
177
  });
163
178
 
164
179
  describe("providerMetadata passthrough", () => {