@assistant-ui/core 0.3.3 → 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 (127) hide show
  1. package/dist/internal.d.ts +4 -2
  2. package/dist/internal.js +4 -2
  3. package/dist/react/primitive-hooks/useSuggestionTrigger.js +29 -25
  4. package/dist/react/primitive-hooks/useSuggestionTrigger.js.map +1 -1
  5. package/dist/react/primitives/message/MessageParts.d.ts +6 -1
  6. package/dist/react/primitives/message/MessageParts.d.ts.map +1 -1
  7. package/dist/react/primitives/message/MessageParts.js.map +1 -1
  8. package/dist/react/providers/ReadonlyThreadProvider.d.ts.map +1 -1
  9. package/dist/react/providers/ReadonlyThreadProvider.js +1 -0
  10. package/dist/react/providers/ReadonlyThreadProvider.js.map +1 -1
  11. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts +67 -0
  12. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
  13. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js +76 -15
  14. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js.map +1 -1
  15. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +4 -0
  16. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  17. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +21 -0
  18. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
  19. package/dist/react/runtimes/cloud/auiV0.d.ts +2 -0
  20. package/dist/react/runtimes/cloud/auiV0.d.ts.map +1 -1
  21. package/dist/react/runtimes/cloud/auiV0.js +6 -4
  22. package/dist/react/runtimes/cloud/auiV0.js.map +1 -1
  23. package/dist/react/types/MessagePartComponentTypes.d.ts +8 -0
  24. package/dist/react/types/MessagePartComponentTypes.d.ts.map +1 -1
  25. package/dist/runtime/api/bindings.d.ts +5 -0
  26. package/dist/runtime/api/bindings.d.ts.map +1 -1
  27. package/dist/runtime/api/thread-list-runtime.d.ts +14 -1
  28. package/dist/runtime/api/thread-list-runtime.d.ts.map +1 -1
  29. package/dist/runtime/api/thread-list-runtime.js +6 -1
  30. package/dist/runtime/api/thread-list-runtime.js.map +1 -1
  31. package/dist/runtime/api/thread-runtime.d.ts +7 -1
  32. package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
  33. package/dist/runtime/api/thread-runtime.js +11 -3
  34. package/dist/runtime/api/thread-runtime.js.map +1 -1
  35. package/dist/runtime/base/base-composer-runtime-core.d.ts +3 -0
  36. package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
  37. package/dist/runtime/base/base-composer-runtime-core.js +54 -12
  38. package/dist/runtime/base/base-composer-runtime-core.js.map +1 -1
  39. package/dist/runtime/interfaces/thread-list-runtime-core.d.ts +10 -0
  40. package/dist/runtime/interfaces/thread-list-runtime-core.d.ts.map +1 -1
  41. package/dist/runtime/interfaces/thread-runtime-core.d.ts +14 -0
  42. package/dist/runtime/interfaces/thread-runtime-core.d.ts.map +1 -1
  43. package/dist/runtimes/external-store/external-store-adapter.d.ts +9 -0
  44. package/dist/runtimes/external-store/external-store-adapter.d.ts.map +1 -1
  45. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.d.ts +1 -0
  46. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.d.ts.map +1 -1
  47. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.js +4 -0
  48. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.js.map +1 -1
  49. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +1 -0
  50. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  51. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +6 -0
  52. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  53. package/dist/runtimes/local/local-thread-runtime-core.d.ts +1 -0
  54. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  55. package/dist/runtimes/local/local-thread-runtime-core.js +1 -0
  56. package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
  57. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts +1 -0
  58. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts.map +1 -1
  59. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js +1 -0
  60. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js.map +1 -1
  61. package/dist/runtimes/remote-thread-list/empty-thread-core.js +1 -0
  62. package/dist/runtimes/remote-thread-list/empty-thread-core.js.map +1 -1
  63. package/dist/store/runtime-clients/thread-list-item-runtime-client.d.ts +1 -0
  64. package/dist/store/runtime-clients/thread-list-item-runtime-client.d.ts.map +1 -1
  65. package/dist/store/runtime-clients/thread-list-item-runtime-client.js +66 -45
  66. package/dist/store/runtime-clients/thread-list-item-runtime-client.js.map +1 -1
  67. package/dist/store/runtime-clients/thread-list-runtime-client.d.ts.map +1 -1
  68. package/dist/store/runtime-clients/thread-list-runtime-client.js +91 -75
  69. package/dist/store/runtime-clients/thread-list-runtime-client.js.map +1 -1
  70. package/dist/store/scopes/thread-list-item.d.ts +7 -0
  71. package/dist/store/scopes/thread-list-item.d.ts.map +1 -1
  72. package/dist/store/scopes/threads.d.ts +1 -0
  73. package/dist/store/scopes/threads.d.ts.map +1 -1
  74. package/dist/types/message.d.ts +11 -0
  75. package/dist/types/message.d.ts.map +1 -1
  76. package/dist/types/message.js.map +1 -1
  77. package/dist/utils/data-url.d.ts +14 -1
  78. package/dist/utils/data-url.d.ts.map +1 -1
  79. package/dist/utils/data-url.js +23 -1
  80. package/dist/utils/data-url.js.map +1 -1
  81. package/dist/utils/image-media-type.d.ts +13 -0
  82. package/dist/utils/image-media-type.d.ts.map +1 -0
  83. package/dist/utils/image-media-type.js +141 -0
  84. package/dist/utils/image-media-type.js.map +1 -0
  85. package/dist/utils/wire-media.d.ts +35 -0
  86. package/dist/utils/wire-media.d.ts.map +1 -0
  87. package/dist/utils/wire-media.js +55 -0
  88. package/dist/utils/wire-media.js.map +1 -0
  89. package/package.json +3 -3
  90. package/src/internal.ts +12 -1
  91. package/src/react/primitive-hooks/useSuggestionTrigger.test.ts +148 -0
  92. package/src/react/primitive-hooks/useSuggestionTrigger.ts +10 -4
  93. package/src/react/primitives/message/MessageParts.tsx +6 -1
  94. package/src/react/providers/ReadonlyThreadProvider.tsx +1 -0
  95. package/src/react/runtimes/RemoteThreadListHookInstanceManager.running.test.tsx +157 -0
  96. package/src/react/runtimes/RemoteThreadListHookInstanceManager.test.tsx +160 -0
  97. package/src/react/runtimes/RemoteThreadListHookInstanceManager.tsx +135 -32
  98. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +37 -0
  99. package/src/react/runtimes/cloud/auiV0.ts +8 -2
  100. package/src/react/types/MessagePartComponentTypes.ts +8 -0
  101. package/src/runtime/api/bindings.ts +5 -0
  102. package/src/runtime/api/thread-list-runtime.ts +22 -1
  103. package/src/runtime/api/thread-runtime.ts +15 -6
  104. package/src/runtime/base/base-composer-runtime-core.ts +72 -19
  105. package/src/runtime/interfaces/thread-list-runtime-core.ts +11 -0
  106. package/src/runtime/interfaces/thread-runtime-core.ts +15 -0
  107. package/src/runtimes/external-store/external-store-adapter.ts +9 -0
  108. package/src/runtimes/external-store/external-store-thread-list-runtime-core.ts +9 -0
  109. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +8 -0
  110. package/src/runtimes/local/local-thread-runtime-core.ts +1 -0
  111. package/src/runtimes/readonly/ReadonlyThreadRuntimeCore.ts +1 -0
  112. package/src/runtimes/remote-thread-list/empty-thread-core.ts +1 -0
  113. package/src/store/runtime-clients/thread-list-item-runtime-client.ts +13 -2
  114. package/src/store/runtime-clients/thread-list-runtime-client.ts +13 -1
  115. package/src/store/scopes/thread-list-item.ts +7 -0
  116. package/src/store/scopes/threads.ts +1 -0
  117. package/src/tests/RemoteThreadListThreadListRuntimeCore-reloadMainThread.test.ts +257 -0
  118. package/src/tests/auiV0Encode.test.ts +125 -1
  119. package/src/tests/base-composer-runtime-core-addAttachment.test.ts +296 -0
  120. package/src/tests/event-subscription-listener-errors.test.ts +1 -0
  121. package/src/tests/external-store-thread-list-runtime-core.test.ts +39 -1
  122. package/src/types/message.ts +11 -0
  123. package/src/utils/data-url.ts +24 -0
  124. package/src/utils/image-media-type.test.ts +96 -0
  125. package/src/utils/image-media-type.ts +118 -0
  126. package/src/utils/wire-media.test.ts +96 -0
  127. package/src/utils/wire-media.ts +70 -0
@@ -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,
@@ -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
  };
@@ -0,0 +1,257 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { createCore, makeAdapter } from "./remote-thread-list-test-helpers";
3
+ import type { RemoteThreadListThreadListRuntimeCore } from "../react/runtimes/RemoteThreadListThreadListRuntimeCore";
4
+
5
+ type HookManagerStub = {
6
+ startThreadRuntime: (id: string) => Promise<unknown>;
7
+ __internal_restartThreadRuntime: (id: string) => Promise<unknown>;
8
+ getThreadRuntimeCore: (id: string) =>
9
+ | {
10
+ unstable_refetchThread?: () => Promise<void>;
11
+ capabilities?: { cancel: boolean };
12
+ cancelRun?: () => void;
13
+ }
14
+ | undefined;
15
+ };
16
+
17
+ const hookManagerOf = (core: RemoteThreadListThreadListRuntimeCore) =>
18
+ (core as unknown as { _hookManager: HookManagerStub })._hookManager;
19
+
20
+ const openRegularThread = async () => {
21
+ const core = createCore(
22
+ makeAdapter({
23
+ list: async () => ({
24
+ threads: [
25
+ {
26
+ status: "regular" as const,
27
+ remoteId: "t-1",
28
+ externalId: "t-1",
29
+ title: "Open",
30
+ },
31
+ ],
32
+ }),
33
+ }),
34
+ );
35
+ await core.getLoadThreadsPromise();
36
+ await core.switchToThread("t-1");
37
+ return core;
38
+ };
39
+
40
+ describe("RemoteThreadListThreadListRuntimeCore.reloadMainThread", () => {
41
+ it("restarts the runtime of the thread that is currently open", async () => {
42
+ const core = await openRegularThread();
43
+ const threadId = core.mainThreadId;
44
+
45
+ const restart = vi.fn(async () => ({}));
46
+ hookManagerOf(core).__internal_restartThreadRuntime = restart;
47
+
48
+ await core.reloadMainThread();
49
+
50
+ expect(restart).toHaveBeenCalledExactlyOnceWith(threadId);
51
+ });
52
+
53
+ it("notifies subscribers so the reloaded thread re-renders", async () => {
54
+ const core = await openRegularThread();
55
+ hookManagerOf(core).__internal_restartThreadRuntime = async () => ({});
56
+
57
+ const callback = vi.fn();
58
+ core.subscribe(callback);
59
+ await core.reloadMainThread();
60
+
61
+ expect(callback).toHaveBeenCalled();
62
+ });
63
+
64
+ it("leaves an unsent thread alone, since it holds no remote state", async () => {
65
+ const core = createCore(makeAdapter());
66
+ await core.switchToNewThread();
67
+
68
+ const restart = vi.fn(async () => ({}));
69
+ hookManagerOf(core).__internal_restartThreadRuntime = restart;
70
+
71
+ await expect(core.reloadMainThread()).resolves.toBeUndefined();
72
+ expect(restart).not.toHaveBeenCalled();
73
+ });
74
+
75
+ it("does nothing before the initial thread is open", async () => {
76
+ // the constructor switches to a new thread, so this is the window between
77
+ // construction and that switch settling
78
+ const core = createCore(makeAdapter());
79
+ (core as unknown as { _mainThreadId: string | undefined })._mainThreadId =
80
+ undefined;
81
+
82
+ const restart = vi.fn(async () => ({}));
83
+ hookManagerOf(core).__internal_restartThreadRuntime = restart;
84
+
85
+ await expect(core.reloadMainThread()).resolves.toBeUndefined();
86
+ expect(restart).not.toHaveBeenCalled();
87
+ });
88
+
89
+ it("still notifies when a redundant switch to the same thread lands mid-reload", async () => {
90
+ const core = await openRegularThread();
91
+ const threadId = core.mainThreadId;
92
+
93
+ let releaseRestart!: () => void;
94
+ hookManagerOf(core).__internal_restartThreadRuntime = () =>
95
+ new Promise((resolve) => {
96
+ releaseRestart = () => resolve({});
97
+ });
98
+
99
+ const reloadTask = core.reloadMainThread();
100
+ // bumps the switch generation without changing the main thread
101
+ await core.switchToThread(threadId);
102
+
103
+ const callback = vi.fn();
104
+ core.subscribe(callback);
105
+ releaseRestart();
106
+ await reloadTask;
107
+
108
+ expect(callback).toHaveBeenCalled();
109
+ });
110
+
111
+ it("resolves quietly when the thread is removed mid-reload", async () => {
112
+ const core = await openRegularThread();
113
+
114
+ hookManagerOf(core).__internal_restartThreadRuntime = async () => {
115
+ (core as unknown as { _mainThreadId: string })._mainThreadId = "other";
116
+ throw new Error("Thread was deleted before runtime was started");
117
+ };
118
+
119
+ await expect(core.reloadMainThread()).resolves.toBeUndefined();
120
+ });
121
+
122
+ it("surfaces a restart failure that is not a lifecycle handover", async () => {
123
+ const core = await openRegularThread();
124
+
125
+ hookManagerOf(core).__internal_restartThreadRuntime = async () => {
126
+ throw new Error("boom");
127
+ };
128
+
129
+ await expect(core.reloadMainThread()).rejects.toThrow("boom");
130
+ });
131
+
132
+ it("lets a switch to a different thread win the notification", async () => {
133
+ const core = await openRegularThread();
134
+
135
+ let releaseRestart!: () => void;
136
+ hookManagerOf(core).__internal_restartThreadRuntime = () =>
137
+ new Promise((resolve) => {
138
+ releaseRestart = () => resolve({});
139
+ });
140
+
141
+ const reloadTask = core.reloadMainThread();
142
+ (core as unknown as { _mainThreadId: string })._mainThreadId = "elsewhere";
143
+
144
+ const callback = vi.fn();
145
+ core.subscribe(callback);
146
+ releaseRestart();
147
+ await reloadTask;
148
+
149
+ expect(callback).not.toHaveBeenCalled();
150
+ });
151
+ });
152
+
153
+ describe("RemoteThreadListThreadListRuntimeCore.reloadMainThread capability dispatch", () => {
154
+ it("prefers the runtime's in-place reload over remounting", async () => {
155
+ const core = await openRegularThread();
156
+ const reload = vi.fn(async () => {});
157
+ const restart = vi.fn(async () => ({}));
158
+ hookManagerOf(core).getThreadRuntimeCore = () => ({
159
+ unstable_refetchThread: reload,
160
+ capabilities: { cancel: false },
161
+ });
162
+ hookManagerOf(core).__internal_restartThreadRuntime = restart;
163
+
164
+ await core.reloadMainThread();
165
+
166
+ expect(reload).toHaveBeenCalledOnce();
167
+ expect(restart).not.toHaveBeenCalled();
168
+ });
169
+
170
+ it("leaves a run in progress to the capability implementation", async () => {
171
+ const core = await openRegularThread();
172
+ const cancelRun = vi.fn();
173
+ hookManagerOf(core).getThreadRuntimeCore = () => ({
174
+ unstable_refetchThread: async () => {},
175
+ capabilities: { cancel: true },
176
+ isRunning: true,
177
+ cancelRun,
178
+ });
179
+
180
+ await core.reloadMainThread();
181
+
182
+ // cancelRun returns the trailing user message to the composer, which is
183
+ // the wrong thing to do to a thread that is only being refetched
184
+ expect(cancelRun).not.toHaveBeenCalled();
185
+ });
186
+
187
+ it("notifies subscribers after an in-place reload", async () => {
188
+ const core = await openRegularThread();
189
+ hookManagerOf(core).getThreadRuntimeCore = () => ({
190
+ unstable_refetchThread: async () => {},
191
+ capabilities: { cancel: false },
192
+ });
193
+
194
+ const callback = vi.fn();
195
+ core.subscribe(callback);
196
+ await core.reloadMainThread();
197
+
198
+ expect(callback).toHaveBeenCalled();
199
+ });
200
+
201
+ it("falls back to remounting when the runtime lacks the capability", async () => {
202
+ const core = await openRegularThread();
203
+ const restart = vi.fn(async () => ({}));
204
+ hookManagerOf(core).getThreadRuntimeCore = () => ({});
205
+ hookManagerOf(core).__internal_restartThreadRuntime = restart;
206
+
207
+ await core.reloadMainThread();
208
+
209
+ expect(restart).toHaveBeenCalledExactlyOnceWith(core.mainThreadId);
210
+ });
211
+
212
+ it("falls back to remounting when no runtime is attached yet", async () => {
213
+ const core = await openRegularThread();
214
+ const restart = vi.fn(async () => ({}));
215
+ hookManagerOf(core).getThreadRuntimeCore = () => undefined;
216
+ hookManagerOf(core).__internal_restartThreadRuntime = restart;
217
+
218
+ await core.reloadMainThread();
219
+
220
+ expect(restart).toHaveBeenCalledOnce();
221
+ });
222
+
223
+ it("lets a switch to a different thread win the notification after an in-place reload", async () => {
224
+ const core = await openRegularThread();
225
+
226
+ let releaseReload!: () => void;
227
+ hookManagerOf(core).getThreadRuntimeCore = () => ({
228
+ unstable_refetchThread: () =>
229
+ new Promise<void>((resolve) => {
230
+ releaseReload = resolve;
231
+ }),
232
+ capabilities: { cancel: false },
233
+ });
234
+
235
+ const reloadTask = core.reloadMainThread();
236
+ (core as unknown as { _mainThreadId: string })._mainThreadId = "elsewhere";
237
+
238
+ const callback = vi.fn();
239
+ core.subscribe(callback);
240
+ releaseReload();
241
+ await reloadTask;
242
+
243
+ expect(callback).not.toHaveBeenCalled();
244
+ });
245
+
246
+ it("propagates an in-place reload failure", async () => {
247
+ const core = await openRegularThread();
248
+ hookManagerOf(core).getThreadRuntimeCore = () => ({
249
+ unstable_refetchThread: async () => {
250
+ throw new Error("refetch failed");
251
+ },
252
+ capabilities: { cancel: false },
253
+ });
254
+
255
+ await expect(core.reloadMainThread()).rejects.toThrow("refetch failed");
256
+ });
257
+ });