@assistant-ui/core 0.3.3 → 0.3.5
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.
- package/dist/internal.d.ts +4 -2
- package/dist/internal.js +4 -2
- package/dist/react/primitive-hooks/useSuggestionTrigger.js +29 -25
- package/dist/react/primitive-hooks/useSuggestionTrigger.js.map +1 -1
- package/dist/react/primitives/message/MessageParts.d.ts +6 -1
- package/dist/react/primitives/message/MessageParts.d.ts.map +1 -1
- package/dist/react/primitives/message/MessageParts.js.map +1 -1
- package/dist/react/providers/ReadonlyThreadProvider.d.ts.map +1 -1
- package/dist/react/providers/ReadonlyThreadProvider.js +1 -0
- package/dist/react/providers/ReadonlyThreadProvider.js.map +1 -1
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts +67 -0
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js +76 -15
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js.map +1 -1
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +4 -0
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +22 -1
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
- package/dist/react/runtimes/cloud/auiV0.d.ts +2 -0
- package/dist/react/runtimes/cloud/auiV0.d.ts.map +1 -1
- package/dist/react/runtimes/cloud/auiV0.js +6 -4
- package/dist/react/runtimes/cloud/auiV0.js.map +1 -1
- package/dist/react/types/MessagePartComponentTypes.d.ts +8 -0
- package/dist/react/types/MessagePartComponentTypes.d.ts.map +1 -1
- package/dist/runtime/api/bindings.d.ts +5 -0
- package/dist/runtime/api/bindings.d.ts.map +1 -1
- package/dist/runtime/api/thread-list-runtime.d.ts +14 -1
- package/dist/runtime/api/thread-list-runtime.d.ts.map +1 -1
- package/dist/runtime/api/thread-list-runtime.js +6 -1
- package/dist/runtime/api/thread-list-runtime.js.map +1 -1
- package/dist/runtime/api/thread-runtime.d.ts +7 -1
- package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
- package/dist/runtime/api/thread-runtime.js +11 -3
- package/dist/runtime/api/thread-runtime.js.map +1 -1
- package/dist/runtime/base/base-composer-runtime-core.d.ts +3 -0
- package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
- package/dist/runtime/base/base-composer-runtime-core.js +54 -12
- package/dist/runtime/base/base-composer-runtime-core.js.map +1 -1
- package/dist/runtime/interfaces/thread-list-runtime-core.d.ts +10 -0
- package/dist/runtime/interfaces/thread-list-runtime-core.d.ts.map +1 -1
- package/dist/runtime/interfaces/thread-runtime-core.d.ts +14 -0
- package/dist/runtime/interfaces/thread-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/external-store/external-store-adapter.d.ts +9 -0
- package/dist/runtimes/external-store/external-store-adapter.d.ts.map +1 -1
- package/dist/runtimes/external-store/external-store-thread-list-runtime-core.d.ts +1 -0
- package/dist/runtimes/external-store/external-store-thread-list-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/external-store/external-store-thread-list-runtime-core.js +4 -0
- package/dist/runtimes/external-store/external-store-thread-list-runtime-core.js.map +1 -1
- package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +1 -0
- package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/external-store/external-store-thread-runtime-core.js +6 -0
- package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
- package/dist/runtimes/local/local-thread-runtime-core.d.ts +1 -0
- package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/local/local-thread-runtime-core.js +1 -0
- package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
- package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts +1 -0
- package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts.map +1 -1
- package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js +1 -0
- package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js.map +1 -1
- package/dist/runtimes/remote-thread-list/empty-thread-core.js +1 -0
- package/dist/runtimes/remote-thread-list/empty-thread-core.js.map +1 -1
- package/dist/store/runtime-clients/thread-list-item-runtime-client.d.ts +1 -0
- package/dist/store/runtime-clients/thread-list-item-runtime-client.d.ts.map +1 -1
- package/dist/store/runtime-clients/thread-list-item-runtime-client.js +66 -45
- package/dist/store/runtime-clients/thread-list-item-runtime-client.js.map +1 -1
- package/dist/store/runtime-clients/thread-list-runtime-client.d.ts.map +1 -1
- package/dist/store/runtime-clients/thread-list-runtime-client.js +91 -75
- package/dist/store/runtime-clients/thread-list-runtime-client.js.map +1 -1
- package/dist/store/scopes/thread-list-item.d.ts +7 -0
- package/dist/store/scopes/thread-list-item.d.ts.map +1 -1
- package/dist/store/scopes/threads.d.ts +1 -0
- package/dist/store/scopes/threads.d.ts.map +1 -1
- package/dist/types/message.d.ts +11 -0
- package/dist/types/message.d.ts.map +1 -1
- package/dist/types/message.js.map +1 -1
- package/dist/utils/data-url.d.ts +14 -1
- package/dist/utils/data-url.d.ts.map +1 -1
- package/dist/utils/data-url.js +23 -1
- package/dist/utils/data-url.js.map +1 -1
- package/dist/utils/image-media-type.d.ts +13 -0
- package/dist/utils/image-media-type.d.ts.map +1 -0
- package/dist/utils/image-media-type.js +141 -0
- package/dist/utils/image-media-type.js.map +1 -0
- package/dist/utils/wire-media.d.ts +35 -0
- package/dist/utils/wire-media.d.ts.map +1 -0
- package/dist/utils/wire-media.js +55 -0
- package/dist/utils/wire-media.js.map +1 -0
- package/package.json +4 -4
- package/src/internal.ts +12 -1
- package/src/react/primitive-hooks/useSuggestionTrigger.test.ts +148 -0
- package/src/react/primitive-hooks/useSuggestionTrigger.ts +10 -4
- package/src/react/primitives/message/MessageParts.tsx +6 -1
- package/src/react/providers/ReadonlyThreadProvider.tsx +1 -0
- package/src/react/runtimes/RemoteThreadListHookInstanceManager.running.test.tsx +157 -0
- package/src/react/runtimes/RemoteThreadListHookInstanceManager.test.tsx +160 -0
- package/src/react/runtimes/RemoteThreadListHookInstanceManager.tsx +135 -32
- package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +41 -1
- package/src/react/runtimes/cloud/auiV0.ts +8 -2
- package/src/react/types/MessagePartComponentTypes.ts +8 -0
- package/src/runtime/api/bindings.ts +5 -0
- package/src/runtime/api/thread-list-runtime.ts +22 -1
- package/src/runtime/api/thread-runtime.ts +15 -6
- package/src/runtime/base/base-composer-runtime-core.ts +72 -19
- package/src/runtime/interfaces/thread-list-runtime-core.ts +11 -0
- package/src/runtime/interfaces/thread-runtime-core.ts +15 -0
- package/src/runtimes/external-store/external-store-adapter.ts +9 -0
- package/src/runtimes/external-store/external-store-thread-list-runtime-core.ts +9 -0
- package/src/runtimes/external-store/external-store-thread-runtime-core.ts +8 -0
- package/src/runtimes/local/local-thread-runtime-core.ts +1 -0
- package/src/runtimes/readonly/ReadonlyThreadRuntimeCore.ts +1 -0
- package/src/runtimes/remote-thread-list/empty-thread-core.ts +1 -0
- package/src/store/runtime-clients/thread-list-item-runtime-client.ts +13 -2
- package/src/store/runtime-clients/thread-list-runtime-client.ts +13 -1
- package/src/store/scopes/thread-list-item.ts +7 -0
- package/src/store/scopes/threads.ts +1 -0
- package/src/tests/RemoteThreadListThreadListRuntimeCore-reloadMainThread.test.ts +257 -0
- package/src/tests/RemoteThreadListThreadListRuntimeCore-switchToThread-order.test.ts +31 -0
- package/src/tests/auiV0Encode.test.ts +125 -1
- package/src/tests/base-composer-runtime-core-addAttachment.test.ts +296 -0
- package/src/tests/event-subscription-listener-errors.test.ts +1 -0
- package/src/tests/external-store-thread-list-runtime-core.test.ts +39 -1
- package/src/types/message.ts +11 -0
- package/src/utils/data-url.ts +24 -0
- package/src/utils/image-media-type.test.ts +96 -0
- package/src/utils/image-media-type.ts +118 -0
- package/src/utils/wire-media.test.ts +96 -0
- 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
|
-
|
|
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,
|
|
@@ -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
|
|
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(
|
|
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
|
+
});
|