@assistant-ui/core 0.3.14 → 0.3.15
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/adapters/voice.js +11 -0
- package/dist/adapters/voice.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/internal.d.ts +2 -1
- package/dist/internal.js +2 -1
- package/dist/model-context/frame/host.d.ts +1 -1
- package/dist/model-context/frame/host.d.ts.map +1 -1
- package/dist/model-context/frame/host.js +17 -3
- package/dist/model-context/frame/host.js.map +1 -1
- package/dist/model-context/frame/provider.d.ts +4 -0
- package/dist/model-context/frame/provider.d.ts.map +1 -1
- package/dist/model-context/frame/provider.js +50 -3
- package/dist/model-context/frame/provider.js.map +1 -1
- package/dist/model-context/frame/types.d.ts +4 -1
- package/dist/model-context/frame/types.d.ts.map +1 -1
- package/dist/model-context/frame/types.js.map +1 -1
- package/dist/react/client/RemoteThreadList.d.ts +1 -1
- package/dist/react/client/RemoteThreadList.d.ts.map +1 -1
- package/dist/react/client/RemoteThreadList.js +95 -23
- package/dist/react/client/RemoteThreadList.js.map +1 -1
- package/dist/react/primitive-hooks/useActionBarCopy.js +7 -1
- package/dist/react/primitive-hooks/useActionBarCopy.js.map +1 -1
- package/dist/react/primitive-hooks/useThreadIsEmpty.d.ts +3 -0
- package/dist/react/primitive-hooks/useThreadIsEmpty.d.ts.map +1 -1
- package/dist/react/primitive-hooks/useThreadIsEmpty.js +3 -0
- package/dist/react/primitive-hooks/useThreadIsEmpty.js.map +1 -1
- package/dist/react/primitive-hooks/useThreadIsRunning.d.ts +3 -0
- package/dist/react/primitive-hooks/useThreadIsRunning.d.ts.map +1 -1
- package/dist/react/primitive-hooks/useThreadIsRunning.js +3 -0
- package/dist/react/primitive-hooks/useThreadIsRunning.js.map +1 -1
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts +2 -0
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js +6 -0
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js.map +1 -1
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +6 -0
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +192 -50
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
- package/dist/react/runtimes/RemoteThreadResource.d.ts.map +1 -1
- package/dist/react/runtimes/RemoteThreadResource.js +2 -0
- package/dist/react/runtimes/RemoteThreadResource.js.map +1 -1
- package/dist/react/runtimes/assistant-transport/useAssistantTransportRuntime.d.ts.map +1 -1
- package/dist/react/runtimes/assistant-transport/useAssistantTransportRuntime.js +1 -1
- package/dist/react/runtimes/assistant-transport/useAssistantTransportRuntime.js.map +1 -1
- package/dist/react/runtimes/cloud/useCloudThreadListAdapter.d.ts.map +1 -1
- package/dist/react/runtimes/cloud/useCloudThreadListAdapter.js +38 -5
- package/dist/react/runtimes/cloud/useCloudThreadListAdapter.js.map +1 -1
- package/dist/runtime/utils/message-repository.d.ts.map +1 -1
- package/dist/runtime/utils/message-repository.js +27 -18
- package/dist/runtime/utils/message-repository.js.map +1 -1
- 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 +2 -0
- package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
- package/dist/runtimes/inert/InertThreadRuntimeCore.d.ts +102 -0
- package/dist/runtimes/inert/InertThreadRuntimeCore.d.ts.map +1 -0
- package/dist/runtimes/inert/InertThreadRuntimeCore.js +156 -0
- package/dist/runtimes/inert/InertThreadRuntimeCore.js.map +1 -0
- package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/local/local-thread-runtime-core.js +25 -6
- package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
- package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts +12 -60
- package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts.map +1 -1
- package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js +10 -145
- package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js.map +1 -1
- package/dist/runtimes/remote-thread-list/adapter-changed.d.ts +9 -0
- package/dist/runtimes/remote-thread-list/adapter-changed.d.ts.map +1 -0
- package/dist/runtimes/remote-thread-list/adapter-changed.js +13 -0
- package/dist/runtimes/remote-thread-list/adapter-changed.js.map +1 -0
- package/dist/runtimes/remote-thread-list/empty-thread-core.d.ts.map +1 -1
- package/dist/runtimes/remote-thread-list/empty-thread-core.js +16 -155
- package/dist/runtimes/remote-thread-list/empty-thread-core.js.map +1 -1
- package/dist/runtimes/remote-thread-list/optimistic-state.d.ts +2 -0
- package/dist/runtimes/remote-thread-list/optimistic-state.d.ts.map +1 -1
- package/dist/runtimes/remote-thread-list/optimistic-state.js +11 -0
- package/dist/runtimes/remote-thread-list/optimistic-state.js.map +1 -1
- package/dist/runtimes/remote-thread-list/types.d.ts +3 -0
- package/dist/runtimes/remote-thread-list/types.d.ts.map +1 -1
- package/dist/store/clients/external-thread.js +1 -1
- package/dist/store/clients/external-thread.js.map +1 -1
- package/dist/store/runtime-clients/handle-runtime-action.d.ts.map +1 -1
- package/dist/store/runtime-clients/handle-runtime-action.js +2 -0
- package/dist/store/runtime-clients/handle-runtime-action.js.map +1 -1
- package/dist/types/message.d.ts +1 -1
- package/dist/types/message.js.map +1 -1
- package/dist/utils/silent-runtime-action.d.ts +6 -0
- package/dist/utils/silent-runtime-action.d.ts.map +1 -0
- package/dist/utils/silent-runtime-action.js +7 -0
- package/dist/utils/silent-runtime-action.js.map +1 -0
- package/package.json +4 -4
- package/src/adapters/voice.test.ts +66 -1
- package/src/adapters/voice.ts +8 -0
- package/src/internal.ts +1 -0
- package/src/model-context/frame/host.test.ts +64 -5
- package/src/model-context/frame/host.ts +16 -3
- package/src/model-context/frame/provider.test.ts +347 -20
- package/src/model-context/frame/provider.ts +58 -2
- package/src/model-context/frame/types.ts +5 -0
- package/src/react/client/RemoteThreadList.test.ts +196 -0
- package/src/react/client/RemoteThreadList.ts +93 -24
- package/src/react/primitive-hooks/useActionBarCopy.test.ts +15 -0
- package/src/react/primitive-hooks/useActionBarCopy.ts +13 -3
- package/src/react/primitive-hooks/useThreadIsEmpty.ts +3 -0
- package/src/react/primitive-hooks/useThreadIsRunning.ts +3 -0
- package/src/react/runtimes/RemoteThreadListHookInstanceManager.tsx +12 -0
- package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.republish.test.ts +87 -0
- package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +268 -63
- package/src/react/runtimes/RemoteThreadResource.ts +2 -0
- package/src/react/runtimes/assistant-transport/useAssistantTransportRuntime.test.tsx +58 -0
- package/src/react/runtimes/assistant-transport/useAssistantTransportRuntime.ts +3 -1
- package/src/react/runtimes/cloud/useCloudThreadListAdapter.test.tsx +98 -3
- package/src/react/runtimes/cloud/useCloudThreadListAdapter.tsx +68 -6
- package/src/react/runtimes/useLocalRuntime.test.tsx +5 -5
- package/src/runtime/utils/message-repository.ts +26 -18
- package/src/runtimes/external-store/external-store-thread-runtime-core.ts +4 -0
- package/src/runtimes/inert/InertThreadRuntimeCore.test.ts +253 -0
- package/src/runtimes/inert/InertThreadRuntimeCore.ts +232 -0
- package/src/runtimes/local/local-thread-runtime-core.test.ts +319 -0
- package/src/runtimes/local/local-thread-runtime-core.ts +38 -8
- package/src/runtimes/readonly/ReadonlyThreadRuntimeCore.ts +17 -209
- package/src/runtimes/remote-thread-list/adapter-changed.ts +10 -0
- package/src/runtimes/remote-thread-list/empty-thread-core.ts +28 -221
- package/src/runtimes/remote-thread-list/optimistic-state.test.ts +18 -0
- package/src/runtimes/remote-thread-list/optimistic-state.ts +13 -0
- package/src/runtimes/remote-thread-list/types.ts +4 -0
- package/src/store/clients/external-thread.ts +1 -1
- package/src/store/runtime-clients/handle-runtime-action.test.ts +40 -0
- package/src/store/runtime-clients/handle-runtime-action.ts +3 -0
- package/src/tests/MessageRepository.test.ts +48 -0
- package/src/tests/RemoteThreadListThreadListRuntimeCore-errors.test.ts +2 -2
- package/src/tests/RemoteThreadListThreadListRuntimeCore-loadMore.test.ts +8 -6
- package/src/tests/empty-thread-core.test.ts +6 -0
- package/src/tests/external-store-thread-runtime-core-adapter.test.ts +20 -0
- package/src/tests/external-thread-isEmpty.test.tsx +100 -0
- package/src/tests/remote-thread-list-adapter-switch.test.ts +383 -0
- package/src/tests/useRemoteThreadListRuntime-controlled.test.tsx +67 -0
- package/src/types/message.ts +1 -1
- package/src/utils/silent-runtime-action.ts +6 -0
|
@@ -542,6 +542,202 @@ describe("RemoteThreadList", () => {
|
|
|
542
542
|
handle.destroy();
|
|
543
543
|
});
|
|
544
544
|
|
|
545
|
+
it("resets selection when reload follows a different adapter instance", async () => {
|
|
546
|
+
const methodsA = makeAdapter({
|
|
547
|
+
list: vi.fn(async () => ({
|
|
548
|
+
threads: [
|
|
549
|
+
{ status: "regular" as const, remoteId: "thread-a", title: "A" },
|
|
550
|
+
],
|
|
551
|
+
})),
|
|
552
|
+
});
|
|
553
|
+
const methodsB = makeAdapter({
|
|
554
|
+
list: vi.fn(async () => ({
|
|
555
|
+
threads: [
|
|
556
|
+
{ status: "regular" as const, remoteId: "thread-b", title: "B" },
|
|
557
|
+
],
|
|
558
|
+
})),
|
|
559
|
+
});
|
|
560
|
+
let adapter: RemoteThreadListAdapter = { ...methodsA };
|
|
561
|
+
const listeners = new Set<() => void>();
|
|
562
|
+
const source: AssistantConfigSource = {
|
|
563
|
+
getConfig: () =>
|
|
564
|
+
AuiConfig({
|
|
565
|
+
threads: RemoteThreadList({
|
|
566
|
+
adapter,
|
|
567
|
+
thread: (id) => StubThread({ threadId: id }) as never,
|
|
568
|
+
}),
|
|
569
|
+
}),
|
|
570
|
+
subscribe: (listener) => {
|
|
571
|
+
listeners.add(listener);
|
|
572
|
+
return () => {
|
|
573
|
+
listeners.delete(listener);
|
|
574
|
+
};
|
|
575
|
+
},
|
|
576
|
+
};
|
|
577
|
+
const handle = createAssistantClient(source);
|
|
578
|
+
handle.subscribe(() => {});
|
|
579
|
+
await handle.getClient().threads.getLoadThreadsPromise();
|
|
580
|
+
flushTapSync(() => handle.getClient().threads.switchToThread("thread-a"));
|
|
581
|
+
await vi.waitFor(() => {
|
|
582
|
+
expect(handle.getClient().threads.item("main").getState().remoteId).toBe(
|
|
583
|
+
"thread-a",
|
|
584
|
+
);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
adapter = { ...methodsB };
|
|
588
|
+
for (const listener of listeners) listener();
|
|
589
|
+
await vi.waitFor(async () => {
|
|
590
|
+
flushTapSync(() => {});
|
|
591
|
+
await handle.getClient().threads.reload();
|
|
592
|
+
expect(handle.getClient().threads.getState().threadIds).toEqual([
|
|
593
|
+
"thread-b",
|
|
594
|
+
]);
|
|
595
|
+
});
|
|
596
|
+
expect(
|
|
597
|
+
handle.getClient().threads.item("main").getState().remoteId,
|
|
598
|
+
).not.toBe("thread-a");
|
|
599
|
+
expect(handle.getClient().threads.item("main").getState().status).toBe(
|
|
600
|
+
"new",
|
|
601
|
+
);
|
|
602
|
+
expect(methodsB.list).toHaveBeenCalled();
|
|
603
|
+
handle.destroy();
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it("does not reset again when retrying a failed replacement load", async () => {
|
|
607
|
+
const methodsA = makeAdapter({
|
|
608
|
+
list: vi.fn(async () => ({
|
|
609
|
+
threads: [
|
|
610
|
+
{ status: "regular" as const, remoteId: "thread-a", title: "A" },
|
|
611
|
+
],
|
|
612
|
+
})),
|
|
613
|
+
});
|
|
614
|
+
const methodsB = makeAdapter({
|
|
615
|
+
list: vi
|
|
616
|
+
.fn()
|
|
617
|
+
.mockRejectedValueOnce(new Error("network"))
|
|
618
|
+
.mockResolvedValueOnce({
|
|
619
|
+
threads: [
|
|
620
|
+
{ status: "regular" as const, remoteId: "thread-b", title: "B" },
|
|
621
|
+
],
|
|
622
|
+
}),
|
|
623
|
+
});
|
|
624
|
+
let adapter: RemoteThreadListAdapter = { ...methodsA };
|
|
625
|
+
const listeners = new Set<() => void>();
|
|
626
|
+
const source: AssistantConfigSource = {
|
|
627
|
+
getConfig: () =>
|
|
628
|
+
AuiConfig({
|
|
629
|
+
threads: RemoteThreadList({
|
|
630
|
+
adapter,
|
|
631
|
+
thread: (id) => StubThread({ threadId: id }) as never,
|
|
632
|
+
}),
|
|
633
|
+
}),
|
|
634
|
+
subscribe: (listener) => {
|
|
635
|
+
listeners.add(listener);
|
|
636
|
+
return () => {
|
|
637
|
+
listeners.delete(listener);
|
|
638
|
+
};
|
|
639
|
+
},
|
|
640
|
+
};
|
|
641
|
+
const handle = createAssistantClient(source);
|
|
642
|
+
handle.subscribe(() => {});
|
|
643
|
+
await handle.getClient().threads.getLoadThreadsPromise();
|
|
644
|
+
flushTapSync(() => handle.getClient().threads.switchToThread("thread-a"));
|
|
645
|
+
await vi.waitFor(() => {
|
|
646
|
+
expect(handle.getClient().threads.item("main").getState().remoteId).toBe(
|
|
647
|
+
"thread-a",
|
|
648
|
+
);
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
adapter = { ...methodsB };
|
|
652
|
+
for (const listener of listeners) listener();
|
|
653
|
+
const consoleError = vi
|
|
654
|
+
.spyOn(console, "error")
|
|
655
|
+
.mockImplementation(() => {});
|
|
656
|
+
try {
|
|
657
|
+
await vi.waitFor(async () => {
|
|
658
|
+
flushTapSync(() => {});
|
|
659
|
+
await handle.getClient().threads.reload();
|
|
660
|
+
expect(handle.getClient().threads.item("main").getState().status).toBe(
|
|
661
|
+
"new",
|
|
662
|
+
);
|
|
663
|
+
});
|
|
664
|
+
const mainAfterFailure = handle
|
|
665
|
+
.getClient()
|
|
666
|
+
.threads.getState().mainThreadId;
|
|
667
|
+
await handle.getClient().threads.reload();
|
|
668
|
+
await vi.waitFor(() => {
|
|
669
|
+
expect(handle.getClient().threads.getState().threadIds).toEqual([
|
|
670
|
+
"thread-b",
|
|
671
|
+
]);
|
|
672
|
+
});
|
|
673
|
+
expect(handle.getClient().threads.getState().mainThreadId).toBe(
|
|
674
|
+
mainAfterFailure,
|
|
675
|
+
);
|
|
676
|
+
} finally {
|
|
677
|
+
consoleError.mockRestore();
|
|
678
|
+
}
|
|
679
|
+
handle.destroy();
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
it("does not send a superseded rename through the replacement adapter", async () => {
|
|
683
|
+
const initializeRequest = deferred<{
|
|
684
|
+
remoteId: string;
|
|
685
|
+
externalId: string | undefined;
|
|
686
|
+
}>();
|
|
687
|
+
const methodsA = makeAdapter({
|
|
688
|
+
list: vi.fn(async () => ({ threads: [] })),
|
|
689
|
+
initialize: vi.fn(() => initializeRequest.promise),
|
|
690
|
+
});
|
|
691
|
+
const methodsB = makeAdapter({
|
|
692
|
+
list: vi.fn(async () => ({
|
|
693
|
+
threads: [
|
|
694
|
+
{ status: "regular" as const, remoteId: "thread-b", title: "B" },
|
|
695
|
+
],
|
|
696
|
+
})),
|
|
697
|
+
});
|
|
698
|
+
let adapter: RemoteThreadListAdapter = { ...methodsA };
|
|
699
|
+
const listeners = new Set<() => void>();
|
|
700
|
+
const source: AssistantConfigSource = {
|
|
701
|
+
getConfig: () =>
|
|
702
|
+
AuiConfig({
|
|
703
|
+
threads: RemoteThreadList({
|
|
704
|
+
adapter,
|
|
705
|
+
thread: (id) => StubThread({ threadId: id }) as never,
|
|
706
|
+
}),
|
|
707
|
+
}),
|
|
708
|
+
subscribe: (listener) => {
|
|
709
|
+
listeners.add(listener);
|
|
710
|
+
return () => {
|
|
711
|
+
listeners.delete(listener);
|
|
712
|
+
};
|
|
713
|
+
},
|
|
714
|
+
};
|
|
715
|
+
const handle = createAssistantClient(source);
|
|
716
|
+
handle.subscribe(() => {});
|
|
717
|
+
await handle.getClient().threads.getLoadThreadsPromise();
|
|
718
|
+
const initializeTask = handle.getClient().threads.item("main").initialize();
|
|
719
|
+
const renameTask = handle.getClient().threads.item("main").rename("leaked");
|
|
720
|
+
|
|
721
|
+
adapter = { ...methodsB };
|
|
722
|
+
for (const listener of listeners) listener();
|
|
723
|
+
await vi.waitFor(async () => {
|
|
724
|
+
flushTapSync(() => {});
|
|
725
|
+
await handle.getClient().threads.reload();
|
|
726
|
+
expect(handle.getClient().threads.getState().threadIds).toEqual([
|
|
727
|
+
"thread-b",
|
|
728
|
+
]);
|
|
729
|
+
});
|
|
730
|
+
initializeRequest.resolve({
|
|
731
|
+
remoteId: "leaked",
|
|
732
|
+
externalId: undefined,
|
|
733
|
+
});
|
|
734
|
+
await expect(initializeTask).rejects.toThrow("adapter changed");
|
|
735
|
+
await expect(renameTask).rejects.toThrow("adapter changed");
|
|
736
|
+
expect(methodsA.rename).not.toHaveBeenCalled();
|
|
737
|
+
expect(methodsB.rename).not.toHaveBeenCalled();
|
|
738
|
+
handle.destroy();
|
|
739
|
+
});
|
|
740
|
+
|
|
545
741
|
it("exposes useAdapters adapters to the thread factory", async () => {
|
|
546
742
|
const history = dummyHistory();
|
|
547
743
|
const capture: { adapters: RuntimeAdapters | null } = { adapters: null };
|
|
@@ -28,6 +28,7 @@ import type {
|
|
|
28
28
|
RemoteThreadInitializeResponse,
|
|
29
29
|
RemoteThreadListAdapter,
|
|
30
30
|
} from "../../runtimes/remote-thread-list/types";
|
|
31
|
+
import { ThreadListAdapterChangedError } from "../../runtimes/remote-thread-list/adapter-changed";
|
|
31
32
|
import type { ThreadMessage } from "../../types/message";
|
|
32
33
|
import { AssistantMessageStream } from "assistant-stream";
|
|
33
34
|
import { handleThreadListAction } from "../../store/runtime-clients/handle-thread-list-action";
|
|
@@ -52,7 +53,7 @@ const EMPTY_LIST: RemoteThreadState = {
|
|
|
52
53
|
|
|
53
54
|
export type RemoteThreadListProps = {
|
|
54
55
|
/**
|
|
55
|
-
* Swapping this to a different backing store does not reload the list. Call `reload()` after a genuine swap. A recreated object for the same store is a no-op.
|
|
56
|
+
* Swapping this to a different backing store does not reload the list. Call `reload()` after a genuine swap. A recreated object for the same store is a no-op. `reload()` after a different adapter instance resets selection and cached records before loading.
|
|
56
57
|
*/
|
|
57
58
|
adapter: RemoteThreadListAdapter;
|
|
58
59
|
/**
|
|
@@ -359,6 +360,8 @@ const useRemoteThreadList = (
|
|
|
359
360
|
initialMainId: seeded.id,
|
|
360
361
|
session: {
|
|
361
362
|
adapter,
|
|
363
|
+
adapterAtLoad: adapter,
|
|
364
|
+
adapterGeneration: 0,
|
|
362
365
|
loadGeneration: 0,
|
|
363
366
|
switchGeneration: 0,
|
|
364
367
|
loadPromise: undefined as Promise<void> | undefined,
|
|
@@ -375,6 +378,9 @@ const useRemoteThreadList = (
|
|
|
375
378
|
};
|
|
376
379
|
});
|
|
377
380
|
|
|
381
|
+
// Config updates can invoke `reload()` in the same turn as the re-render, before the effect below runs.
|
|
382
|
+
session.adapter = adapter;
|
|
383
|
+
|
|
378
384
|
const listState = useSyncExternalStore(
|
|
379
385
|
(onStoreChange) => store.subscribe(onStoreChange),
|
|
380
386
|
() => store.value,
|
|
@@ -417,12 +423,14 @@ const useRemoteThreadList = (
|
|
|
417
423
|
const getLoadThreadsPromise = useCallback(() => {
|
|
418
424
|
if (session.loadPromise) return session.loadPromise;
|
|
419
425
|
const generation = session.loadGeneration;
|
|
426
|
+
const adapter = session.adapter;
|
|
420
427
|
session.loadPromise = store
|
|
421
428
|
.optimisticUpdate({
|
|
422
|
-
execute: () =>
|
|
429
|
+
execute: () => adapter.list(),
|
|
423
430
|
loading: (state) => ({ ...state, isLoading: true }),
|
|
424
431
|
then: (state, page) => {
|
|
425
432
|
if (generation !== session.loadGeneration) return state;
|
|
433
|
+
session.adapterAtLoad = adapter;
|
|
426
434
|
const fresh = classifyThreads(page.threads, {
|
|
427
435
|
threadIds: [],
|
|
428
436
|
archivedThreadIds: [],
|
|
@@ -460,15 +468,34 @@ const useRemoteThreadList = (
|
|
|
460
468
|
}, [session, store]);
|
|
461
469
|
|
|
462
470
|
const reload = useCallback(() => {
|
|
471
|
+
const adapterChanged = adapter !== session.adapterAtLoad;
|
|
463
472
|
session.loadGeneration++;
|
|
464
473
|
session.loadPromise = undefined;
|
|
465
474
|
session.loadMorePromise = undefined;
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
475
|
+
if (adapterChanged) {
|
|
476
|
+
session.adapterGeneration++;
|
|
477
|
+
session.switchGeneration++;
|
|
478
|
+
session.switchTask = undefined;
|
|
479
|
+
session.adapterAtLoad = adapter;
|
|
480
|
+
const seeded = seedNewThread(EMPTY_LIST);
|
|
481
|
+
store.reset({ ...seeded.state, isLoading: true });
|
|
482
|
+
assignMainThreadId(seeded.id);
|
|
483
|
+
notifyRemoteId(undefined, true);
|
|
484
|
+
} else {
|
|
485
|
+
store.update({
|
|
486
|
+
...store.baseValue,
|
|
487
|
+
cursor: undefined,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
470
490
|
return getLoadThreadsPromise();
|
|
471
|
-
}, [
|
|
491
|
+
}, [
|
|
492
|
+
adapter,
|
|
493
|
+
assignMainThreadId,
|
|
494
|
+
getLoadThreadsPromise,
|
|
495
|
+
notifyRemoteId,
|
|
496
|
+
session,
|
|
497
|
+
store,
|
|
498
|
+
]);
|
|
472
499
|
|
|
473
500
|
useEffect(() => {
|
|
474
501
|
void getLoadThreadsPromise();
|
|
@@ -516,6 +543,7 @@ const useRemoteThreadList = (
|
|
|
516
543
|
},
|
|
517
544
|
})
|
|
518
545
|
.catch((error: unknown) => {
|
|
546
|
+
if (generation !== session.loadGeneration) return;
|
|
519
547
|
console.error("[assistant-ui] thread list loadMore failed:", error);
|
|
520
548
|
})
|
|
521
549
|
.then(() => {
|
|
@@ -689,18 +717,34 @@ const useRemoteThreadList = (
|
|
|
689
717
|
[session, store, switchToNewThread],
|
|
690
718
|
);
|
|
691
719
|
|
|
720
|
+
const requireAdapterGeneration = useCallback(
|
|
721
|
+
(generation: number) => {
|
|
722
|
+
if (generation !== session.adapterGeneration) {
|
|
723
|
+
throw new ThreadListAdapterChangedError();
|
|
724
|
+
}
|
|
725
|
+
},
|
|
726
|
+
[session],
|
|
727
|
+
);
|
|
728
|
+
|
|
692
729
|
const initialize = useCallback(
|
|
693
730
|
async (threadId: string) => {
|
|
731
|
+
const currentAdapter = session.adapter;
|
|
732
|
+
const adapterGeneration = session.adapterGeneration;
|
|
694
733
|
if (store.value.newThreadId !== threadId) {
|
|
695
734
|
const data = getThreadData(store.value, threadId);
|
|
696
735
|
if (!data) throw threadNotFoundError(threadId, "initializing it");
|
|
697
736
|
if (data.status === "new") {
|
|
698
737
|
throw threadStatusError(threadId, data.status, "be initialized here");
|
|
699
738
|
}
|
|
700
|
-
|
|
739
|
+
const result = toInitializeResult(await data.initializeTask);
|
|
740
|
+
requireAdapterGeneration(adapterGeneration);
|
|
741
|
+
return result;
|
|
701
742
|
}
|
|
702
743
|
const result = await store.optimisticUpdate({
|
|
703
|
-
execute: () =>
|
|
744
|
+
execute: () => {
|
|
745
|
+
requireAdapterGeneration(adapterGeneration);
|
|
746
|
+
return currentAdapter.initialize(threadId);
|
|
747
|
+
},
|
|
704
748
|
optimistic: (state) => updateStatusReducer(state, threadId, "regular"),
|
|
705
749
|
loading: (state, task) => {
|
|
706
750
|
const mappingId = createThreadMappingId(threadId);
|
|
@@ -716,6 +760,7 @@ const useRemoteThreadList = (
|
|
|
716
760
|
};
|
|
717
761
|
},
|
|
718
762
|
then: (state, { remoteId, externalId }) => {
|
|
763
|
+
if (adapterGeneration !== session.adapterGeneration) return state;
|
|
719
764
|
const data = getThreadData(state, threadId);
|
|
720
765
|
if (!data) return state;
|
|
721
766
|
const mappingId = createThreadMappingId(threadId);
|
|
@@ -737,16 +782,19 @@ const useRemoteThreadList = (
|
|
|
737
782
|
};
|
|
738
783
|
},
|
|
739
784
|
});
|
|
785
|
+
requireAdapterGeneration(adapterGeneration);
|
|
740
786
|
if (threadId === session.mainThreadId) {
|
|
741
787
|
notifyRemoteId(result.remoteId, true);
|
|
742
788
|
}
|
|
743
789
|
return toInitializeResult(result);
|
|
744
790
|
},
|
|
745
|
-
[notifyRemoteId, session, store],
|
|
791
|
+
[notifyRemoteId, requireAdapterGeneration, session, store],
|
|
746
792
|
);
|
|
747
793
|
|
|
748
794
|
const rename = useCallback(
|
|
749
795
|
(threadIdOrRemoteId: string, newTitle: string) => {
|
|
796
|
+
const currentAdapter = session.adapter;
|
|
797
|
+
const adapterGeneration = session.adapterGeneration;
|
|
750
798
|
const data = getThreadData(store.value, threadIdOrRemoteId);
|
|
751
799
|
if (!data) throw threadNotFoundError(threadIdOrRemoteId, "renaming it");
|
|
752
800
|
if (data.status === "new") {
|
|
@@ -755,7 +803,8 @@ const useRemoteThreadList = (
|
|
|
755
803
|
return store.optimisticUpdate({
|
|
756
804
|
execute: async () => {
|
|
757
805
|
const { remoteId } = await data.initializeTask;
|
|
758
|
-
|
|
806
|
+
requireAdapterGeneration(adapterGeneration);
|
|
807
|
+
return currentAdapter.rename(remoteId, newTitle);
|
|
759
808
|
},
|
|
760
809
|
optimistic: (state) => {
|
|
761
810
|
const current = getThreadData(state, threadIdOrRemoteId);
|
|
@@ -773,7 +822,7 @@ const useRemoteThreadList = (
|
|
|
773
822
|
},
|
|
774
823
|
});
|
|
775
824
|
},
|
|
776
|
-
[session, store],
|
|
825
|
+
[requireAdapterGeneration, session, store],
|
|
777
826
|
);
|
|
778
827
|
|
|
779
828
|
const updateCustom = useCallback(
|
|
@@ -781,6 +830,8 @@ const useRemoteThreadList = (
|
|
|
781
830
|
threadIdOrRemoteId: string,
|
|
782
831
|
custom: Record<string, unknown> | undefined,
|
|
783
832
|
) => {
|
|
833
|
+
const currentAdapter = session.adapter;
|
|
834
|
+
const adapterGeneration = session.adapterGeneration;
|
|
784
835
|
const data = getThreadData(store.value, threadIdOrRemoteId);
|
|
785
836
|
if (!data) {
|
|
786
837
|
throw threadNotFoundError(
|
|
@@ -795,7 +846,7 @@ const useRemoteThreadList = (
|
|
|
795
846
|
"update custom metadata",
|
|
796
847
|
);
|
|
797
848
|
}
|
|
798
|
-
if (!
|
|
849
|
+
if (!currentAdapter.updateCustom) {
|
|
799
850
|
throw new Error(
|
|
800
851
|
"Remote thread list adapter does not support updating custom metadata",
|
|
801
852
|
);
|
|
@@ -803,7 +854,7 @@ const useRemoteThreadList = (
|
|
|
803
854
|
return store.optimisticUpdate({
|
|
804
855
|
execute: async () => {
|
|
805
856
|
const { remoteId } = await data.initializeTask;
|
|
806
|
-
|
|
857
|
+
requireAdapterGeneration(adapterGeneration);
|
|
807
858
|
if (!currentAdapter.updateCustom) {
|
|
808
859
|
throw new Error(
|
|
809
860
|
"Remote thread list adapter does not support updating custom metadata",
|
|
@@ -827,30 +878,36 @@ const useRemoteThreadList = (
|
|
|
827
878
|
},
|
|
828
879
|
});
|
|
829
880
|
},
|
|
830
|
-
[session, store],
|
|
881
|
+
[requireAdapterGeneration, session, store],
|
|
831
882
|
);
|
|
832
883
|
|
|
833
884
|
const archive = useCallback(
|
|
834
885
|
async (threadIdOrRemoteId: string) => {
|
|
886
|
+
const currentAdapter = session.adapter;
|
|
887
|
+
const adapterGeneration = session.adapterGeneration;
|
|
835
888
|
const data = getThreadData(store.value, threadIdOrRemoteId);
|
|
836
889
|
if (!data) throw threadNotFoundError(threadIdOrRemoteId, "archiving it");
|
|
837
890
|
if (data.status !== "regular") {
|
|
838
891
|
throw threadStatusError(threadIdOrRemoteId, data.status, "be archived");
|
|
839
892
|
}
|
|
840
893
|
await ensureNotMain(data.id);
|
|
894
|
+
requireAdapterGeneration(adapterGeneration);
|
|
841
895
|
return store.optimisticUpdate({
|
|
842
896
|
execute: async () => {
|
|
843
897
|
const { remoteId } = await data.initializeTask;
|
|
844
|
-
|
|
898
|
+
requireAdapterGeneration(adapterGeneration);
|
|
899
|
+
return currentAdapter.archive(remoteId);
|
|
845
900
|
},
|
|
846
901
|
optimistic: (state) => updateStatusReducer(state, data.id, "archived"),
|
|
847
902
|
});
|
|
848
903
|
},
|
|
849
|
-
[ensureNotMain, session, store],
|
|
904
|
+
[ensureNotMain, requireAdapterGeneration, session, store],
|
|
850
905
|
);
|
|
851
906
|
|
|
852
907
|
const unarchive = useCallback(
|
|
853
908
|
(threadIdOrRemoteId: string) => {
|
|
909
|
+
const currentAdapter = session.adapter;
|
|
910
|
+
const adapterGeneration = session.adapterGeneration;
|
|
854
911
|
const data = getThreadData(store.value, threadIdOrRemoteId);
|
|
855
912
|
if (!data)
|
|
856
913
|
throw threadNotFoundError(threadIdOrRemoteId, "unarchiving it");
|
|
@@ -865,36 +922,43 @@ const useRemoteThreadList = (
|
|
|
865
922
|
execute: async () => {
|
|
866
923
|
try {
|
|
867
924
|
const { remoteId } = await data.initializeTask;
|
|
868
|
-
|
|
925
|
+
requireAdapterGeneration(adapterGeneration);
|
|
926
|
+
return await currentAdapter.unarchive(remoteId);
|
|
869
927
|
} catch (error) {
|
|
870
|
-
|
|
928
|
+
if (adapterGeneration === session.adapterGeneration) {
|
|
929
|
+
await ensureNotMain(data.id);
|
|
930
|
+
}
|
|
871
931
|
throw error;
|
|
872
932
|
}
|
|
873
933
|
},
|
|
874
934
|
optimistic: (state) => updateStatusReducer(state, data.id, "regular"),
|
|
875
935
|
});
|
|
876
936
|
},
|
|
877
|
-
[ensureNotMain, session, store],
|
|
937
|
+
[ensureNotMain, requireAdapterGeneration, session, store],
|
|
878
938
|
);
|
|
879
939
|
|
|
880
940
|
const deleteThread = useCallback(
|
|
881
941
|
async (threadIdOrRemoteId: string) => {
|
|
942
|
+
const currentAdapter = session.adapter;
|
|
943
|
+
const adapterGeneration = session.adapterGeneration;
|
|
882
944
|
const data = getThreadData(store.value, threadIdOrRemoteId);
|
|
883
945
|
if (!data) throw threadNotFoundError(threadIdOrRemoteId, "deleting it");
|
|
884
946
|
if (data.status !== "regular" && data.status !== "archived") {
|
|
885
947
|
throw threadStatusError(threadIdOrRemoteId, data.status, "be deleted");
|
|
886
948
|
}
|
|
887
949
|
await ensureNotMain(data.id);
|
|
950
|
+
requireAdapterGeneration(adapterGeneration);
|
|
888
951
|
onDelete?.(data.id);
|
|
889
952
|
return store.optimisticUpdate({
|
|
890
953
|
execute: async () => {
|
|
891
954
|
const { remoteId } = await data.initializeTask;
|
|
892
|
-
|
|
955
|
+
requireAdapterGeneration(adapterGeneration);
|
|
956
|
+
return currentAdapter.delete(remoteId);
|
|
893
957
|
},
|
|
894
958
|
optimistic: (state) => updateStatusReducer(state, data.id, "deleted"),
|
|
895
959
|
});
|
|
896
960
|
},
|
|
897
|
-
[ensureNotMain, onDelete, session, store],
|
|
961
|
+
[ensureNotMain, onDelete, requireAdapterGeneration, session, store],
|
|
898
962
|
);
|
|
899
963
|
|
|
900
964
|
const generateTitle = useCallback(
|
|
@@ -902,6 +966,8 @@ const useRemoteThreadList = (
|
|
|
902
966
|
threadIdOrRemoteId: string,
|
|
903
967
|
messages: readonly ThreadMessage[] | undefined,
|
|
904
968
|
) => {
|
|
969
|
+
const currentAdapter = session.adapter;
|
|
970
|
+
const adapterGeneration = session.adapterGeneration;
|
|
905
971
|
const data = getThreadData(store.value, threadIdOrRemoteId);
|
|
906
972
|
if (!data) {
|
|
907
973
|
throw threadNotFoundError(threadIdOrRemoteId, "generating its title");
|
|
@@ -914,10 +980,13 @@ const useRemoteThreadList = (
|
|
|
914
980
|
);
|
|
915
981
|
}
|
|
916
982
|
const { remoteId } = await data.initializeTask;
|
|
983
|
+
requireAdapterGeneration(adapterGeneration);
|
|
917
984
|
if (!isSameThread(store.value, data.id, session.mainThreadId)) return;
|
|
918
985
|
if (!messages) return;
|
|
919
|
-
const stream = await
|
|
986
|
+
const stream = await currentAdapter.generateTitle(remoteId, messages);
|
|
987
|
+
requireAdapterGeneration(adapterGeneration);
|
|
920
988
|
await applyTitleStream(stream, (newTitle) => {
|
|
989
|
+
if (adapterGeneration !== session.adapterGeneration) return;
|
|
921
990
|
const state = store.baseValue;
|
|
922
991
|
const current = getThreadData(state, data.id);
|
|
923
992
|
if (!current) return;
|
|
@@ -933,7 +1002,7 @@ const useRemoteThreadList = (
|
|
|
933
1002
|
});
|
|
934
1003
|
});
|
|
935
1004
|
},
|
|
936
|
-
[session, store],
|
|
1005
|
+
[requireAdapterGeneration, session, store],
|
|
937
1006
|
);
|
|
938
1007
|
|
|
939
1008
|
const { mainThreadClient, itemOrder, threadListItems } =
|
|
@@ -82,6 +82,21 @@ describe("useActionBarCopy", () => {
|
|
|
82
82
|
expect(mocks.setIsCopied).not.toHaveBeenCalled();
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
+
it("does not report copy success when the clipboard handler throws synchronously", async () => {
|
|
86
|
+
const copyToClipboard = vi.fn(() => {
|
|
87
|
+
throw new TypeError(
|
|
88
|
+
"Cannot read properties of undefined (reading 'writeText')",
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
const { result } = renderHook(() => useActionBarCopy({ copyToClipboard }));
|
|
92
|
+
|
|
93
|
+
expect(() => result.current.copy()).not.toThrow();
|
|
94
|
+
await Promise.resolve();
|
|
95
|
+
|
|
96
|
+
expect(copyToClipboard).toHaveBeenCalledWith("Hello");
|
|
97
|
+
expect(mocks.setIsCopied).not.toHaveBeenCalled();
|
|
98
|
+
});
|
|
99
|
+
|
|
85
100
|
it("keeps copy success visible for the full duration after copying again", async () => {
|
|
86
101
|
vi.useFakeTimers();
|
|
87
102
|
const copyToClipboard = vi.fn();
|
|
@@ -40,9 +40,19 @@ export const useActionBarCopy = ({
|
|
|
40
40
|
if (!valueToCopy) return;
|
|
41
41
|
const scopeGeneration = scopeGenerationRef.current;
|
|
42
42
|
|
|
43
|
-
// The
|
|
44
|
-
//
|
|
45
|
-
|
|
43
|
+
// The writer runs synchronously inside the press so the user-gesture gating
|
|
44
|
+
// of navigator.clipboard survives (WebKit scopes it to the stack); the try
|
|
45
|
+
// contains a synchronously throwing caller-supplied writer, and the rejection
|
|
46
|
+
// handler swallows clipboard failures (permission denied, API unavailable) so
|
|
47
|
+
// they don't surface as unhandled rejections.
|
|
48
|
+
let write: void | Promise<void>;
|
|
49
|
+
try {
|
|
50
|
+
write = copyToClipboard(valueToCopy);
|
|
51
|
+
} catch {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
Promise.resolve(write).then(
|
|
46
56
|
() => {
|
|
47
57
|
if (scopeGeneration !== scopeGenerationRef.current) return;
|
|
48
58
|
|
|
@@ -173,6 +173,18 @@ export class RemoteThreadListHookInstanceManager extends BaseSubscribable {
|
|
|
173
173
|
this._trackRunning(instance);
|
|
174
174
|
}
|
|
175
175
|
this._notifySubscribers();
|
|
176
|
+
if (previousRuntime !== undefined && previousRuntime !== runtime) {
|
|
177
|
+
for (const callback of this.replacedSubscribers) callback();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private replacedSubscribers = new Set<() => void>();
|
|
182
|
+
|
|
183
|
+
public __internal_subscribeRuntimeReplaced(
|
|
184
|
+
callback: () => void,
|
|
185
|
+
): Unsubscribe {
|
|
186
|
+
this.replacedSubscribers.add(callback);
|
|
187
|
+
return () => this.replacedSubscribers.delete(callback);
|
|
176
188
|
}
|
|
177
189
|
|
|
178
190
|
private _trackRunning(instance: RemoteThreadListHookInstance) {
|