@assistant-ui/core 0.3.2 → 0.3.4

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 (169) hide show
  1. package/dist/index.d.ts +4 -4
  2. package/dist/index.js.map +1 -1
  3. package/dist/internal.d.ts +9 -7
  4. package/dist/internal.js +8 -6
  5. package/dist/react/primitive-hooks/useActionBarCopy.js +51 -24
  6. package/dist/react/primitive-hooks/useActionBarCopy.js.map +1 -1
  7. package/dist/react/primitive-hooks/useSuggestionTrigger.js +29 -25
  8. package/dist/react/primitive-hooks/useSuggestionTrigger.js.map +1 -1
  9. package/dist/react/primitives/message/MessageGroupedParts.d.ts +2 -1
  10. package/dist/react/primitives/message/MessageGroupedParts.d.ts.map +1 -1
  11. package/dist/react/primitives/message/MessageGroupedParts.js +2 -2
  12. package/dist/react/primitives/message/MessageGroupedParts.js.map +1 -1
  13. package/dist/react/primitives/message/MessageParts.d.ts +6 -1
  14. package/dist/react/primitives/message/MessageParts.d.ts.map +1 -1
  15. package/dist/react/primitives/message/MessageParts.js.map +1 -1
  16. package/dist/react/providers/ReadonlyThreadProvider.d.ts.map +1 -1
  17. package/dist/react/providers/ReadonlyThreadProvider.js +1 -0
  18. package/dist/react/providers/ReadonlyThreadProvider.js.map +1 -1
  19. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts +67 -0
  20. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
  21. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js +76 -15
  22. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js.map +1 -1
  23. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +4 -0
  24. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  25. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +21 -0
  26. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
  27. package/dist/react/runtimes/cloud/auiV0.d.ts +26 -2
  28. package/dist/react/runtimes/cloud/auiV0.d.ts.map +1 -1
  29. package/dist/react/runtimes/cloud/auiV0.js +29 -8
  30. package/dist/react/runtimes/cloud/auiV0.js.map +1 -1
  31. package/dist/react/types/MessagePartComponentTypes.d.ts +8 -0
  32. package/dist/react/types/MessagePartComponentTypes.d.ts.map +1 -1
  33. package/dist/runtime/api/bindings.d.ts +5 -0
  34. package/dist/runtime/api/bindings.d.ts.map +1 -1
  35. package/dist/runtime/api/message-runtime.d.ts.map +1 -1
  36. package/dist/runtime/api/message-runtime.js +5 -1
  37. package/dist/runtime/api/message-runtime.js.map +1 -1
  38. package/dist/runtime/api/thread-list-runtime.d.ts +14 -1
  39. package/dist/runtime/api/thread-list-runtime.d.ts.map +1 -1
  40. package/dist/runtime/api/thread-list-runtime.js +6 -1
  41. package/dist/runtime/api/thread-list-runtime.js.map +1 -1
  42. package/dist/runtime/api/thread-runtime.d.ts +7 -1
  43. package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
  44. package/dist/runtime/api/thread-runtime.js +11 -3
  45. package/dist/runtime/api/thread-runtime.js.map +1 -1
  46. package/dist/runtime/base/base-composer-runtime-core.d.ts +3 -0
  47. package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
  48. package/dist/runtime/base/base-composer-runtime-core.js +54 -12
  49. package/dist/runtime/base/base-composer-runtime-core.js.map +1 -1
  50. package/dist/runtime/interfaces/thread-list-runtime-core.d.ts +10 -0
  51. package/dist/runtime/interfaces/thread-list-runtime-core.d.ts.map +1 -1
  52. package/dist/runtime/interfaces/thread-runtime-core.d.ts +14 -0
  53. package/dist/runtime/interfaces/thread-runtime-core.d.ts.map +1 -1
  54. package/dist/runtime/internal.d.ts +4 -4
  55. package/dist/runtime/internal.js +4 -4
  56. package/dist/runtimes/external-store/external-store-adapter.d.ts +9 -0
  57. package/dist/runtimes/external-store/external-store-adapter.d.ts.map +1 -1
  58. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.d.ts +1 -0
  59. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.d.ts.map +1 -1
  60. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.js +4 -0
  61. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.js.map +1 -1
  62. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +1 -0
  63. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  64. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +6 -0
  65. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  66. package/dist/runtimes/local/local-thread-runtime-core.d.ts +1 -0
  67. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  68. package/dist/runtimes/local/local-thread-runtime-core.js +1 -0
  69. package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
  70. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts +1 -0
  71. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts.map +1 -1
  72. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js +1 -0
  73. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js.map +1 -1
  74. package/dist/runtimes/remote-thread-list/empty-thread-core.js +1 -0
  75. package/dist/runtimes/remote-thread-list/empty-thread-core.js.map +1 -1
  76. package/dist/store/clients/chain-of-thought-client.d.ts.map +1 -1
  77. package/dist/store/clients/chain-of-thought-client.js +29 -23
  78. package/dist/store/clients/chain-of-thought-client.js.map +1 -1
  79. package/dist/store/clients/thread-message-client.d.ts.map +1 -1
  80. package/dist/store/clients/thread-message-client.js +101 -89
  81. package/dist/store/clients/thread-message-client.js.map +1 -1
  82. package/dist/store/runtime-clients/thread-list-item-runtime-client.d.ts +1 -0
  83. package/dist/store/runtime-clients/thread-list-item-runtime-client.d.ts.map +1 -1
  84. package/dist/store/runtime-clients/thread-list-item-runtime-client.js +66 -45
  85. package/dist/store/runtime-clients/thread-list-item-runtime-client.js.map +1 -1
  86. package/dist/store/runtime-clients/thread-list-runtime-client.d.ts.map +1 -1
  87. package/dist/store/runtime-clients/thread-list-runtime-client.js +91 -75
  88. package/dist/store/runtime-clients/thread-list-runtime-client.js.map +1 -1
  89. package/dist/store/scopes/thread-list-item.d.ts +7 -0
  90. package/dist/store/scopes/thread-list-item.d.ts.map +1 -1
  91. package/dist/store/scopes/threads.d.ts +1 -0
  92. package/dist/store/scopes/threads.d.ts.map +1 -1
  93. package/dist/types/index.d.ts +2 -2
  94. package/dist/types/message.d.ts +22 -1
  95. package/dist/types/message.d.ts.map +1 -1
  96. package/dist/types/message.js.map +1 -1
  97. package/dist/utils/data-url.d.ts +14 -1
  98. package/dist/utils/data-url.d.ts.map +1 -1
  99. package/dist/utils/data-url.js +23 -1
  100. package/dist/utils/data-url.js.map +1 -1
  101. package/dist/utils/getGroupStatus.d.ts +9 -0
  102. package/dist/utils/getGroupStatus.d.ts.map +1 -0
  103. package/dist/utils/getGroupStatus.js +15 -0
  104. package/dist/utils/getGroupStatus.js.map +1 -0
  105. package/dist/utils/image-media-type.d.ts +13 -0
  106. package/dist/utils/image-media-type.d.ts.map +1 -0
  107. package/dist/utils/image-media-type.js +141 -0
  108. package/dist/utils/image-media-type.js.map +1 -0
  109. package/dist/utils/normalizePartStatus.d.ts +8 -0
  110. package/dist/utils/normalizePartStatus.d.ts.map +1 -0
  111. package/dist/utils/normalizePartStatus.js +39 -0
  112. package/dist/utils/normalizePartStatus.js.map +1 -0
  113. package/dist/utils/wire-media.d.ts +35 -0
  114. package/dist/utils/wire-media.d.ts.map +1 -0
  115. package/dist/utils/wire-media.js +55 -0
  116. package/dist/utils/wire-media.js.map +1 -0
  117. package/package.json +4 -4
  118. package/src/index.ts +1 -0
  119. package/src/internal.ts +13 -1
  120. package/src/react/primitive-hooks/useActionBarCopy.test.ts +109 -14
  121. package/src/react/primitive-hooks/useActionBarCopy.ts +27 -2
  122. package/src/react/primitive-hooks/useSuggestionTrigger.test.ts +148 -0
  123. package/src/react/primitive-hooks/useSuggestionTrigger.ts +10 -4
  124. package/src/react/primitives/message/MessageGroupedParts.tsx +4 -4
  125. package/src/react/primitives/message/MessageParts.tsx +6 -1
  126. package/src/react/providers/ReadonlyThreadProvider.tsx +1 -0
  127. package/src/react/runtimes/RemoteThreadListHookInstanceManager.running.test.tsx +157 -0
  128. package/src/react/runtimes/RemoteThreadListHookInstanceManager.test.tsx +160 -0
  129. package/src/react/runtimes/RemoteThreadListHookInstanceManager.tsx +135 -32
  130. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +37 -0
  131. package/src/react/runtimes/cloud/auiV0.ts +59 -14
  132. package/src/react/types/MessagePartComponentTypes.ts +8 -0
  133. package/src/runtime/api/bindings.ts +5 -0
  134. package/src/runtime/api/message-runtime.test.ts +140 -0
  135. package/src/runtime/api/message-runtime.ts +10 -5
  136. package/src/runtime/api/thread-list-runtime.ts +22 -1
  137. package/src/runtime/api/thread-runtime.ts +15 -6
  138. package/src/runtime/base/base-composer-runtime-core.ts +72 -19
  139. package/src/runtime/interfaces/thread-list-runtime-core.ts +11 -0
  140. package/src/runtime/interfaces/thread-runtime-core.ts +15 -0
  141. package/src/runtimes/external-store/external-store-adapter.ts +9 -0
  142. package/src/runtimes/external-store/external-store-thread-list-runtime-core.ts +9 -0
  143. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +8 -0
  144. package/src/runtimes/local/local-thread-runtime-core.ts +1 -0
  145. package/src/runtimes/readonly/ReadonlyThreadRuntimeCore.ts +1 -0
  146. package/src/runtimes/remote-thread-list/empty-thread-core.ts +1 -0
  147. package/src/store/clients/chain-of-thought-client.ts +2 -7
  148. package/src/store/clients/thread-message-client.test.ts +84 -0
  149. package/src/store/clients/thread-message-client.ts +14 -4
  150. package/src/store/runtime-clients/thread-list-item-runtime-client.ts +13 -2
  151. package/src/store/runtime-clients/thread-list-runtime-client.ts +13 -1
  152. package/src/store/scopes/thread-list-item.ts +7 -0
  153. package/src/store/scopes/threads.ts +1 -0
  154. package/src/tests/RemoteThreadListThreadListRuntimeCore-reloadMainThread.test.ts +257 -0
  155. package/src/tests/auiV0Encode.test.ts +218 -1
  156. package/src/tests/base-composer-runtime-core-addAttachment.test.ts +296 -0
  157. package/src/tests/event-subscription-listener-errors.test.ts +1 -0
  158. package/src/tests/external-store-thread-list-runtime-core.test.ts +39 -1
  159. package/src/types/index.ts +1 -0
  160. package/src/types/message.ts +30 -0
  161. package/src/utils/data-url.ts +24 -0
  162. package/src/utils/getGroupStatus.test.ts +36 -0
  163. package/src/utils/getGroupStatus.ts +31 -0
  164. package/src/utils/image-media-type.test.ts +96 -0
  165. package/src/utils/image-media-type.ts +118 -0
  166. package/src/utils/normalizePartStatus.test.ts +59 -0
  167. package/src/utils/normalizePartStatus.ts +55 -0
  168. package/src/utils/wire-media.test.ts +96 -0
  169. package/src/utils/wire-media.ts +70 -0
@@ -31,7 +31,10 @@ export type ThreadListState = {
31
31
  readonly isLoadingMore: boolean;
32
32
  readonly hasMore: boolean;
33
33
  readonly threadItems: Readonly<
34
- Record<string, Omit<ThreadListItemState, "isMain" | "threadId">>
34
+ Record<
35
+ string,
36
+ Omit<ThreadListItemState, "isMain" | "threadId" | "isRunning">
37
+ >
35
38
  >;
36
39
  };
37
40
 
@@ -56,6 +59,18 @@ export type ThreadListRuntime = {
56
59
 
57
60
  getLoadThreadsPromise(): Promise<void>;
58
61
  reload(): Promise<void>;
62
+ /**
63
+ * Refetches the open thread's remote state, for state that changed out of
64
+ * band and so never reached the stream. When the runtime declares the
65
+ * in-place capability (`unstable_refetchThread`), composer drafts survive,
66
+ * existing messages stay rendered during the refetch, and the promise
67
+ * settles with the refetch, rejecting if it fails; that runtime also owns
68
+ * what happens to a run in progress, since this does not stop one. Runtimes
69
+ * without the capability have their hook remounted instead, which discards
70
+ * unsent composer input and ends any run, and the promise resolves once the
71
+ * new runtime attaches. A thread that has not been sent yet is left alone.
72
+ */
73
+ reloadMainThread(): Promise<void>;
59
74
  loadMore(): Promise<void>;
60
75
  };
61
76
 
@@ -91,6 +106,7 @@ const getThreadListItemState = (
91
106
  lastMessageAt: threadData.lastMessageAt,
92
107
  custom: threadData.custom,
93
108
  isMain: threadData.id === threadList.mainThreadId,
109
+ isRunning: threadList.unstable_isThreadRunning?.(threadData.id) ?? false,
94
110
  };
95
111
  };
96
112
 
@@ -155,6 +171,7 @@ export class ThreadListRuntimeImpl implements ThreadListRuntime {
155
171
  this.switchToNewThread = this.switchToNewThread.bind(this);
156
172
  this.getLoadThreadsPromise = this.getLoadThreadsPromise.bind(this);
157
173
  this.reload = this.reload.bind(this);
174
+ this.reloadMainThread = this.reloadMainThread.bind(this);
158
175
  this.loadMore = this.loadMore.bind(this);
159
176
  this.getState = this.getState.bind(this);
160
177
  this.subscribe = this.subscribe.bind(this);
@@ -183,6 +200,10 @@ export class ThreadListRuntimeImpl implements ThreadListRuntime {
183
200
  return this._core.reload?.() ?? RESOLVED_PROMISE;
184
201
  }
185
202
 
203
+ public reloadMainThread(): Promise<void> {
204
+ return this._core.reloadMainThread?.() ?? RESOLVED_PROMISE;
205
+ }
206
+
186
207
  public loadMore(): Promise<void> {
187
208
  return this._core.loadMore?.() ?? RESOLVED_PROMISE;
188
209
  }
@@ -193,22 +193,31 @@ export type ThreadState = {
193
193
  readonly voice: VoiceSessionState | undefined;
194
194
  };
195
195
 
196
+ /**
197
+ * The canonical `isRunning` derivation. A runtime that tracks run state itself
198
+ * reports it directly; the rest fall back to the trailing assistant message.
199
+ */
200
+ export const getThreadRuntimeCoreIsRunning = (
201
+ runtime: ThreadRuntimeCore,
202
+ ): boolean => {
203
+ if (runtime.isRunning !== undefined) return runtime.isRunning;
204
+ const lastMessage = runtime.messages.at(-1);
205
+ return (
206
+ lastMessage?.role === "assistant" && lastMessage.status.type === "running"
207
+ );
208
+ };
209
+
196
210
  export const getThreadState = (
197
211
  runtime: ThreadRuntimeCore,
198
212
  threadListItemState: ThreadListItemState,
199
213
  ): ThreadState => {
200
- const lastMessage = runtime.messages.at(-1);
201
214
  return Object.freeze({
202
215
  threadId: threadListItemState.id,
203
216
  metadata: threadListItemState,
204
217
  capabilities: runtime.capabilities,
205
218
  isDisabled: runtime.isDisabled,
206
219
  isLoading: runtime.isLoading,
207
- isRunning:
208
- runtime.isRunning ??
209
- (lastMessage?.role !== "assistant"
210
- ? false
211
- : lastMessage.status.type === "running"),
220
+ isRunning: getThreadRuntimeCoreIsRunning(runtime),
212
221
  messages: runtime.messages,
213
222
  state: runtime.state,
214
223
  suggestions: runtime.suggestions,
@@ -34,6 +34,11 @@ import { notifyEventListeners } from "../../utils/notify-event-listeners";
34
34
  const isAttachmentComplete = (a: Attachment): a is CompleteAttachment =>
35
35
  a.status.type === "complete";
36
36
 
37
+ type AttachmentAddOperation = {
38
+ cancelled: boolean;
39
+ attachmentIds: Set<string>;
40
+ };
41
+
37
42
  export abstract class BaseComposerRuntimeCore
38
43
  extends BaseSubscribable
39
44
  implements ComposerRuntimeCore
@@ -138,6 +143,22 @@ export abstract class BaseComposerRuntimeCore
138
143
  protected _isSending = false;
139
144
  private _removedDuringSend = new Set<string>();
140
145
  private _sendGeneration = 0;
146
+ private _attachmentAddOperations = new Set<AttachmentAddOperation>();
147
+
148
+ private _cancelAttachmentAdd(attachmentId: string) {
149
+ for (const operation of [...this._attachmentAddOperations]) {
150
+ if (!operation.attachmentIds.has(attachmentId)) continue;
151
+ operation.cancelled = true;
152
+ this._attachmentAddOperations.delete(operation);
153
+ }
154
+ }
155
+
156
+ private _cancelAllAttachmentAdds() {
157
+ for (const operation of this._attachmentAddOperations) {
158
+ operation.cancelled = true;
159
+ }
160
+ this._attachmentAddOperations.clear();
161
+ }
141
162
 
142
163
  private _emptyTextAndAttachments() {
143
164
  this._attachments = [];
@@ -154,6 +175,8 @@ export abstract class BaseComposerRuntimeCore
154
175
  }
155
176
 
156
177
  public async reset() {
178
+ this._cancelAllAttachmentAdds();
179
+
157
180
  // A send whose adapter never settles must not brick the composer; reset is
158
181
  // the escape hatch that releases the in-flight lock. Bumping the generation
159
182
  // invalidates that send entirely so a late-settling upload can neither
@@ -182,6 +205,7 @@ export abstract class BaseComposerRuntimeCore
182
205
  }
183
206
 
184
207
  public async clearAttachments() {
208
+ this._cancelAllAttachmentAdds();
185
209
  const task = this._onClearAttachments();
186
210
  this.setAttachments([]);
187
211
 
@@ -324,23 +348,6 @@ export abstract class BaseComposerRuntimeCore
324
348
  return;
325
349
  }
326
350
 
327
- const upsertAttachment = (a: PendingAttachment) => {
328
- const idx = this._attachments.findIndex(
329
- (attachment) => attachment.id === a.id,
330
- );
331
- if (idx !== -1)
332
- this._attachments = [
333
- ...this._attachments.slice(0, idx),
334
- a,
335
- ...this._attachments.slice(idx + 1),
336
- ];
337
- else {
338
- this._attachments = [...this._attachments, a];
339
- }
340
-
341
- this._notifySubscribers();
342
- };
343
-
344
351
  const adapter = this.getAttachmentAdapter();
345
352
  if (!adapter) {
346
353
  const message = "Attachments are not supported";
@@ -361,19 +368,46 @@ export abstract class BaseComposerRuntimeCore
361
368
  throw err;
362
369
  }
363
370
 
371
+ const operation: AttachmentAddOperation = {
372
+ cancelled: false,
373
+ attachmentIds: new Set(),
374
+ };
375
+ const operations = this._attachmentAddOperations;
376
+ operations.add(operation);
377
+ const upsertAttachment = (a: PendingAttachment) => {
378
+ if (operation.cancelled) return false;
379
+
380
+ operation.attachmentIds.add(a.id);
381
+ const idx = this._attachments.findIndex(
382
+ (attachment) => attachment.id === a.id,
383
+ );
384
+ if (idx !== -1)
385
+ this._attachments = [
386
+ ...this._attachments.slice(0, idx),
387
+ a,
388
+ ...this._attachments.slice(idx + 1),
389
+ ];
390
+ else {
391
+ this._attachments = [...this._attachments, a];
392
+ }
393
+
394
+ this._notifySubscribers();
395
+ return true;
396
+ };
364
397
  let lastAttachment: PendingAttachment | undefined;
365
398
  try {
366
399
  const promiseOrGenerator = adapter.add({ file: fileOrAttachment });
367
400
  if (Symbol.asyncIterator in promiseOrGenerator) {
368
401
  for await (const r of promiseOrGenerator) {
369
402
  lastAttachment = r;
370
- upsertAttachment(r);
403
+ if (!upsertAttachment(r)) break;
371
404
  }
372
405
  } else {
373
406
  lastAttachment = await promiseOrGenerator;
374
407
  upsertAttachment(lastAttachment);
375
408
  }
376
409
  } catch (e) {
410
+ if (operation.cancelled) return;
377
411
  if (lastAttachment) {
378
412
  upsertAttachment({
379
413
  ...lastAttachment,
@@ -391,8 +425,11 @@ export abstract class BaseComposerRuntimeCore
391
425
  e instanceof Error ? e : undefined,
392
426
  );
393
427
  throw e;
428
+ } finally {
429
+ operations.delete(operation);
394
430
  }
395
431
 
432
+ if (operation.cancelled) return;
396
433
  if (
397
434
  lastAttachment?.status.type === "incomplete" &&
398
435
  lastAttachment.status.reason === "error"
@@ -434,6 +471,8 @@ export abstract class BaseComposerRuntimeCore
434
471
  if (index === -1) throw new Error("Attachment not found");
435
472
  const attachment = this._attachments[index]!;
436
473
 
474
+ this._cancelAttachmentAdd(attachmentId);
475
+
437
476
  // A send in flight may already be uploading this attachment; the upload
438
477
  // can't be cancelled, so mark it to be dropped from the outgoing message
439
478
  // before any await gives the upload a chance to settle first.
@@ -442,7 +481,21 @@ export abstract class BaseComposerRuntimeCore
442
481
  if (!isAttachmentComplete(attachment)) {
443
482
  const adapter = this.getAttachmentAdapter();
444
483
  if (!adapter) throw new Error("Attachments are not supported");
445
- await adapter.remove(attachment);
484
+ try {
485
+ await adapter.remove(attachment);
486
+ } catch (error) {
487
+ const message = error instanceof Error ? error.message : String(error);
488
+ this._attachments = this._attachments.map((candidate) =>
489
+ candidate.id === attachmentId && !isAttachmentComplete(candidate)
490
+ ? {
491
+ ...candidate,
492
+ status: { type: "incomplete", reason: "error", message },
493
+ }
494
+ : candidate,
495
+ );
496
+ this._notifySubscribers();
497
+ throw error;
498
+ }
446
499
  }
447
500
  this._attachments = this._attachments.filter((a) => a.id !== attachmentId);
448
501
  this._notifySubscribers();
@@ -31,6 +31,16 @@ export type ThreadListRuntimeCore = {
31
31
  getMainThreadRuntimeCore(): ThreadRuntimeCore;
32
32
  getThreadRuntimeCore(threadId: string): ThreadRuntimeCore;
33
33
 
34
+ /**
35
+ * Whether the thread currently has a run in progress, including a run on a
36
+ * thread that is not the main one. Implemented by thread lists that keep
37
+ * runtimes alive for non-main threads, and they notify their subscribers
38
+ * whenever the answer changes. A thread list that mounts only the main thread
39
+ * leaves this undefined: its other threads have no runtime and so cannot be
40
+ * running, and the main thread's run state is read from its runtime directly.
41
+ */
42
+ unstable_isThreadRunning?(threadId: string): boolean;
43
+
34
44
  getItemById(threadId: string): ThreadListItemCoreState | undefined;
35
45
 
36
46
  switchToThread(
@@ -41,6 +51,7 @@ export type ThreadListRuntimeCore = {
41
51
 
42
52
  getLoadThreadsPromise(): Promise<void>;
43
53
  reload?(): Promise<void>;
54
+ reloadMainThread?(): Promise<void>;
44
55
  loadMore?(): Promise<void>;
45
56
 
46
57
  detach(threadId: string): Promise<void>;
@@ -23,6 +23,8 @@ export type RuntimeCapabilities = {
23
23
  readonly switchBranchDuringRun: boolean;
24
24
  readonly edit: boolean;
25
25
  readonly reload: boolean;
26
+ /** Whether the runtime can refetch this thread's remote state in place. */
27
+ readonly refetchThread: boolean;
26
28
  readonly delete: boolean;
27
29
  readonly cancel: boolean;
28
30
  readonly unstable_copy: boolean;
@@ -216,6 +218,19 @@ export type ThreadRuntimeCore = Readonly<{
216
218
 
217
219
  reset(initialMessages?: readonly ThreadMessageLike[]): void;
218
220
 
221
+ /**
222
+ * Re-fetches this thread's state from its backing store, in place: no
223
+ * runtime-hook remount, so runtime identity and composer drafts survive.
224
+ * Presence signals the capability to `threads.reloadMainThread()`, which
225
+ * calls this and propagates its rejection. It does not touch a run in
226
+ * progress first, because stopping one is `cancelRun`, whose contract is
227
+ * that the user abandoned a send: it returns the trailing user message to
228
+ * the composer. An implementation is therefore responsible for whatever
229
+ * coordination a concurrent run needs. Runtimes without remote state leave
230
+ * it undefined.
231
+ */
232
+ unstable_refetchThread?: (() => Promise<void>) | undefined;
233
+
219
234
  /**
220
235
  * @deprecated This API is still under active development and might change without notice.
221
236
  * For state-derivable transitions, prefer `subscribe` + `getState`. This channel is the
@@ -143,6 +143,15 @@ type ExternalStoreAdapterBase<T> = {
143
143
  | undefined;
144
144
  onResume?: ((config: ResumeRunConfig) => Promise<void>) | undefined;
145
145
  onCancel?: (() => Promise<void>) | undefined;
146
+ /**
147
+ * Re-fetches the thread's state from the backing store, in place; a
148
+ * rejection reaches the `threads.reloadMainThread()` caller. Unrelated to
149
+ * `onReload`, which re-generates an assistant message.
150
+ *
151
+ * The caller does not stop a run in progress first, so an adapter that can
152
+ * stream owns whatever coordination one needs.
153
+ */
154
+ onRefetchThread?: (() => Promise<void>) | undefined;
146
155
  onAddToolResult?:
147
156
  | ((options: AddToolResultOptions) => Promise<void> | void)
148
157
  | undefined;
@@ -169,6 +169,15 @@ export class ExternalStoreThreadListRuntimeCore implements ThreadListRuntimeCore
169
169
  this._notifySubscribers();
170
170
  }
171
171
 
172
+ public async reloadMainThread(): Promise<void> {
173
+ // There is no runtime hook to remount here, so the capability is the only
174
+ // path and an adapter without it has nothing to refetch with.
175
+ if (!this._mainThread.unstable_refetchThread) return;
176
+ // No unsent-thread guard: every entry here is regular or archived, so the
177
+ // "new" status the remote thread list has to exclude cannot occur.
178
+ await this._mainThread.unstable_refetchThread();
179
+ }
180
+
172
181
  public async switchToThread(
173
182
  threadId: string,
174
183
  _options?: { unarchive?: boolean },
@@ -65,6 +65,7 @@ export class ExternalStoreThreadRuntimeCore
65
65
  edit: false,
66
66
  delete: false,
67
67
  reload: false,
68
+ refetchThread: false,
68
69
  cancel: false,
69
70
  unstable_copy: false,
70
71
  speech: false,
@@ -102,6 +103,12 @@ export class ExternalStoreThreadRuntimeCore
102
103
  return this._store.adapters;
103
104
  }
104
105
 
106
+ // A getter, not a method, so its presence tracks the adapter.
107
+ public get unstable_refetchThread(): (() => Promise<void>) | undefined {
108
+ if (!this._store.onRefetchThread) return undefined;
109
+ return () => this._store.onRefetchThread!();
110
+ }
111
+
105
112
  public suggestions: readonly ThreadSuggestion[] = [];
106
113
  public extras: unknown = undefined;
107
114
 
@@ -156,6 +163,7 @@ export class ExternalStoreThreadRuntimeCore
156
163
  this._store.onDelete !== undefined ||
157
164
  this._store.setMessages !== undefined,
158
165
  reload: this._store.onReload !== undefined,
166
+ refetchThread: this._store.onRefetchThread !== undefined,
159
167
  cancel: this._store.onCancel !== undefined,
160
168
  speech: this._store.adapters?.speech !== undefined,
161
169
  dictation: this._store.adapters?.dictation !== undefined,
@@ -53,6 +53,7 @@ export class LocalThreadRuntimeCore
53
53
  edit: true,
54
54
  delete: false,
55
55
  reload: true,
56
+ refetchThread: false,
56
57
  cancel: true,
57
58
  unstable_copy: true,
58
59
  speech: false,
@@ -208,6 +208,7 @@ export class ReadonlyThreadRuntimeCore
208
208
  edit: false,
209
209
  delete: false,
210
210
  reload: false,
211
+ refetchThread: false,
211
212
  cancel: false,
212
213
  unstable_copy: false,
213
214
  speech: false,
@@ -189,6 +189,7 @@ export const EMPTY_THREAD_CORE: ThreadRuntimeCore = {
189
189
  edit: false,
190
190
  delete: false,
191
191
  reload: false,
192
+ refetchThread: false,
192
193
  cancel: false,
193
194
  unstable_copy: false,
194
195
  speech: false,
@@ -5,12 +5,8 @@ import type {
5
5
  ChainOfThoughtState,
6
6
  ChainOfThoughtPart,
7
7
  } from "../scopes/chain-of-thought";
8
- import type { MessagePartStatus } from "../../types/message";
9
8
  import type { PartMethods } from "../scopes/part";
10
-
11
- const COMPLETE_STATUS: MessagePartStatus = Object.freeze({
12
- type: "complete",
13
- });
9
+ import { getGroupStatus } from "../../utils/getGroupStatus";
14
10
 
15
11
  const useChainOfThoughtClient = ({
16
12
  parts,
@@ -22,8 +18,7 @@ const useChainOfThoughtClient = ({
22
18
  const [collapsed, setCollapsed] = useState(true);
23
19
 
24
20
  const status = useMemo(() => {
25
- const lastPart = parts[parts.length - 1];
26
- return lastPart?.status ?? COMPLETE_STATUS;
21
+ return getGroupStatus(parts);
27
22
  }, [parts]);
28
23
 
29
24
  const state = useMemo<ChainOfThoughtState>(
@@ -0,0 +1,84 @@
1
+ import { createTapRoot, useResource } from "@assistant-ui/tap";
2
+ import { describe, expect, it } from "vitest";
3
+ import type { ThreadAssistantMessage } from "../../types/message";
4
+ import { ThreadMessageClient } from "./thread-message-client";
5
+
6
+ describe("ThreadMessageClient", () => {
7
+ const getPartStatus = (
8
+ part: ThreadAssistantMessage["content"][number],
9
+ status: ThreadAssistantMessage["status"],
10
+ ) => {
11
+ const message: ThreadAssistantMessage = {
12
+ id: "message-1",
13
+ role: "assistant",
14
+ createdAt: new Date(0),
15
+ content: [part],
16
+ status,
17
+ metadata: {
18
+ unstable_state: null,
19
+ unstable_annotations: [],
20
+ unstable_data: [],
21
+ steps: [],
22
+ custom: {},
23
+ },
24
+ };
25
+ const root = createTapRoot(function ThreadMessageRoot() {
26
+ return useResource(ThreadMessageClient({ message, index: 0 }));
27
+ });
28
+
29
+ try {
30
+ return root.getValue().getState().parts[0]?.status;
31
+ } finally {
32
+ root.unmount();
33
+ }
34
+ };
35
+
36
+ it("preserves a running part status on a running detached message", () => {
37
+ const part = {
38
+ type: "text",
39
+ text: "done",
40
+ status: { type: "running" },
41
+ } as unknown as ThreadAssistantMessage["content"][number];
42
+
43
+ expect(getPartStatus(part, { type: "running" })).toEqual({
44
+ type: "running",
45
+ });
46
+ });
47
+
48
+ it("normalizes an unknown incomplete reason on a running detached message", () => {
49
+ const part = {
50
+ type: "text",
51
+ text: "done",
52
+ status: { type: "incomplete", reason: "unknown" },
53
+ } as unknown as ThreadAssistantMessage["content"][number];
54
+
55
+ expect(getPartStatus(part, { type: "running" })).toEqual({
56
+ type: "incomplete",
57
+ reason: "other",
58
+ });
59
+ });
60
+
61
+ it("normalizes an upstream complete reason on a running detached message", () => {
62
+ const part = {
63
+ type: "text",
64
+ text: "done",
65
+ status: { type: "complete", reason: "unknown" },
66
+ } as unknown as ThreadAssistantMessage["content"][number];
67
+
68
+ expect(getPartStatus(part, { type: "running" })).toEqual({
69
+ type: "complete",
70
+ });
71
+ });
72
+
73
+ it("marks parts complete on a non-running detached message", () => {
74
+ const part = {
75
+ type: "text",
76
+ text: "done",
77
+ status: { type: "running" },
78
+ } as unknown as ThreadAssistantMessage["content"][number];
79
+
80
+ expect(getPartStatus(part, { type: "complete", reason: "stop" })).toEqual({
81
+ type: "complete",
82
+ });
83
+ });
84
+ });
@@ -11,19 +11,27 @@ import { useClientLookup } from "@assistant-ui/store";
11
11
  import type { MessageState } from "../scopes/message";
12
12
  import type { PartState } from "../scopes/part";
13
13
  import { NoOpComposerClient } from "./no-op-composer-client";
14
+ import {
15
+ COMPLETE_STATUS,
16
+ normalizePartStatus,
17
+ } from "../../utils/normalizePartStatus";
14
18
  import { getThreadMessageText } from "../../utils/text";
15
19
 
16
20
  const useThreadMessagePartClient = ({
17
21
  part,
22
+ isMessageRunning,
18
23
  }: {
19
24
  part: ThreadAssistantMessagePart | ThreadUserMessagePart;
25
+ isMessageRunning: boolean;
20
26
  }): ClientOutput<"part"> => {
21
27
  const state = useMemo<PartState>(() => {
22
28
  return {
23
29
  ...part,
24
- status: { type: "complete" },
30
+ status: isMessageRunning
31
+ ? (normalizePartStatus(part) ?? COMPLETE_STATUS)
32
+ : COMPLETE_STATUS,
25
33
  };
26
- }, [part]);
34
+ }, [part, isMessageRunning]);
27
35
 
28
36
  return {
29
37
  getState: () => state,
@@ -74,6 +82,8 @@ const useThreadMessageClient = ({
74
82
  }: ThreadMessageClientProps): ClientOutput<"message"> => {
75
83
  const [isCopiedState, setIsCopied] = useState(false);
76
84
  const [isHoveringState, setIsHovering] = useState(false);
85
+ const isMessageRunning =
86
+ message.role === "assistant" && message.status.type === "running";
77
87
 
78
88
  const parts = useClientLookup(
79
89
  message.content.map((part, idx) =>
@@ -81,8 +91,8 @@ const useThreadMessageClient = ({
81
91
  "toolCallId" in part && part.toolCallId != null
82
92
  ? `toolCallId-${part.toolCallId}`
83
93
  : `index-${idx}`,
84
- ThreadMessagePartClient({ part }),
85
- [part],
94
+ ThreadMessagePartClient({ part, isMessageRunning }),
95
+ [part, isMessageRunning],
86
96
  ),
87
97
  ),
88
98
  );
@@ -1,5 +1,5 @@
1
1
  import type { Unsubscribe } from "../../types/unsubscribe";
2
- import { useEffect } from "react";
2
+ import { useEffect, useMemo } from "react";
3
3
  import { resource } from "@assistant-ui/tap";
4
4
  import { type ClientOutput, useAssistantEmit } from "@assistant-ui/store";
5
5
  import type {
@@ -10,10 +10,21 @@ import { useSubscribable } from "./useSubscribable";
10
10
 
11
11
  const useThreadListItemClient = ({
12
12
  runtime,
13
+ mainThreadIsRunning = false,
13
14
  }: {
14
15
  runtime: ThreadListItemRuntime;
16
+ // A thread list that cannot report per-thread run state still leaves the open
17
+ // thread observable, and the thread client tracks that reactively. Omitted
18
+ // where the runtime state is already authoritative for every thread.
19
+ mainThreadIsRunning?: boolean | undefined;
15
20
  }): ClientOutput<"threadListItem"> => {
16
- const state = useSubscribable(runtime);
21
+ const runtimeState = useSubscribable(runtime);
22
+ const state = useMemo(() => {
23
+ const isRunning =
24
+ runtimeState.isRunning || (runtimeState.isMain && mainThreadIsRunning);
25
+ if (isRunning === runtimeState.isRunning) return runtimeState;
26
+ return { ...runtimeState, isRunning };
27
+ }, [runtimeState, mainThreadIsRunning]);
17
28
  const emit = useAssistantEmit();
18
29
 
19
30
  // Bind thread list item events to event manager
@@ -15,9 +15,11 @@ import type { ThreadsState } from "../scopes/threads";
15
15
  const useThreadListItemClientById = ({
16
16
  runtime,
17
17
  id,
18
+ mainThreadIsRunning,
18
19
  }: {
19
20
  runtime: ThreadListRuntime;
20
21
  id: string;
22
+ mainThreadIsRunning: boolean;
21
23
  }) => {
22
24
  const threadListItemRuntime = useMemo(
23
25
  () => runtime.getItemById(id),
@@ -26,6 +28,7 @@ const useThreadListItemClientById = ({
26
28
  return useResource(
27
29
  ThreadListItemClient({
28
30
  runtime: threadListItemRuntime,
31
+ mainThreadIsRunning,
29
32
  }),
30
33
  );
31
34
  };
@@ -48,7 +51,15 @@ const useThreadListClient = ({
48
51
  );
49
52
  const threadItems = useClientLookup(
50
53
  Object.keys(runtimeState.threadItems).map((id) =>
51
- withKey(id, ThreadListItemClientById({ runtime, id }), [runtime, id]),
54
+ withKey(
55
+ id,
56
+ ThreadListItemClientById({
57
+ runtime,
58
+ id,
59
+ mainThreadIsRunning: main.state.isRunning,
60
+ }),
61
+ [runtime, id, main.state.isRunning],
62
+ ),
52
63
  ),
53
64
  );
54
65
 
@@ -93,6 +104,7 @@ const useThreadListClient = ({
93
104
  },
94
105
  getLoadThreadsPromise: () => runtime.getLoadThreadsPromise(),
95
106
  reload: () => runtime.reload(),
107
+ reloadMainThread: () => runtime.reloadMainThread(),
96
108
  loadMore: () => runtime.loadMore(),
97
109
  __internal_getAssistantRuntime: () => __internal_assistantRuntime,
98
110
  };
@@ -9,6 +9,13 @@ export type ThreadListItemState = {
9
9
  readonly lastMessageAt?: Date | undefined;
10
10
  readonly status: ThreadListItemStatus;
11
11
  readonly custom?: Record<string, unknown> | undefined;
12
+ /**
13
+ * Whether this thread has a run in progress, including a run that continues
14
+ * after the user switches to another thread. A thread list that mounts only
15
+ * the open thread has no runtime to run the others, so they read as not
16
+ * running rather than as unknown.
17
+ */
18
+ readonly isRunning: boolean;
12
19
  };
13
20
 
14
21
  export type ThreadListItemMethods = {
@@ -30,6 +30,7 @@ export type ThreadsMethods = {
30
30
  thread(selector: "main"): ThreadMethods;
31
31
  getLoadThreadsPromise(): Promise<void>;
32
32
  reload(): Promise<void>;
33
+ reloadMainThread(): Promise<void>;
33
34
  loadMore(): Promise<void>;
34
35
  __internal_getAssistantRuntime?(): AssistantRuntime;
35
36
  };