@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.
Files changed (137) hide show
  1. package/dist/adapters/voice.js +11 -0
  2. package/dist/adapters/voice.js.map +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/internal.d.ts +2 -1
  5. package/dist/internal.js +2 -1
  6. package/dist/model-context/frame/host.d.ts +1 -1
  7. package/dist/model-context/frame/host.d.ts.map +1 -1
  8. package/dist/model-context/frame/host.js +17 -3
  9. package/dist/model-context/frame/host.js.map +1 -1
  10. package/dist/model-context/frame/provider.d.ts +4 -0
  11. package/dist/model-context/frame/provider.d.ts.map +1 -1
  12. package/dist/model-context/frame/provider.js +50 -3
  13. package/dist/model-context/frame/provider.js.map +1 -1
  14. package/dist/model-context/frame/types.d.ts +4 -1
  15. package/dist/model-context/frame/types.d.ts.map +1 -1
  16. package/dist/model-context/frame/types.js.map +1 -1
  17. package/dist/react/client/RemoteThreadList.d.ts +1 -1
  18. package/dist/react/client/RemoteThreadList.d.ts.map +1 -1
  19. package/dist/react/client/RemoteThreadList.js +95 -23
  20. package/dist/react/client/RemoteThreadList.js.map +1 -1
  21. package/dist/react/primitive-hooks/useActionBarCopy.js +7 -1
  22. package/dist/react/primitive-hooks/useActionBarCopy.js.map +1 -1
  23. package/dist/react/primitive-hooks/useThreadIsEmpty.d.ts +3 -0
  24. package/dist/react/primitive-hooks/useThreadIsEmpty.d.ts.map +1 -1
  25. package/dist/react/primitive-hooks/useThreadIsEmpty.js +3 -0
  26. package/dist/react/primitive-hooks/useThreadIsEmpty.js.map +1 -1
  27. package/dist/react/primitive-hooks/useThreadIsRunning.d.ts +3 -0
  28. package/dist/react/primitive-hooks/useThreadIsRunning.d.ts.map +1 -1
  29. package/dist/react/primitive-hooks/useThreadIsRunning.js +3 -0
  30. package/dist/react/primitive-hooks/useThreadIsRunning.js.map +1 -1
  31. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts +2 -0
  32. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
  33. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js +6 -0
  34. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js.map +1 -1
  35. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +6 -0
  36. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  37. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +192 -50
  38. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
  39. package/dist/react/runtimes/RemoteThreadResource.d.ts.map +1 -1
  40. package/dist/react/runtimes/RemoteThreadResource.js +2 -0
  41. package/dist/react/runtimes/RemoteThreadResource.js.map +1 -1
  42. package/dist/react/runtimes/assistant-transport/useAssistantTransportRuntime.d.ts.map +1 -1
  43. package/dist/react/runtimes/assistant-transport/useAssistantTransportRuntime.js +1 -1
  44. package/dist/react/runtimes/assistant-transport/useAssistantTransportRuntime.js.map +1 -1
  45. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.d.ts.map +1 -1
  46. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.js +38 -5
  47. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.js.map +1 -1
  48. package/dist/runtime/utils/message-repository.d.ts.map +1 -1
  49. package/dist/runtime/utils/message-repository.js +27 -18
  50. package/dist/runtime/utils/message-repository.js.map +1 -1
  51. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  52. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +2 -0
  53. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  54. package/dist/runtimes/inert/InertThreadRuntimeCore.d.ts +102 -0
  55. package/dist/runtimes/inert/InertThreadRuntimeCore.d.ts.map +1 -0
  56. package/dist/runtimes/inert/InertThreadRuntimeCore.js +156 -0
  57. package/dist/runtimes/inert/InertThreadRuntimeCore.js.map +1 -0
  58. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  59. package/dist/runtimes/local/local-thread-runtime-core.js +25 -6
  60. package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
  61. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts +12 -60
  62. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts.map +1 -1
  63. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js +10 -145
  64. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js.map +1 -1
  65. package/dist/runtimes/remote-thread-list/adapter-changed.d.ts +9 -0
  66. package/dist/runtimes/remote-thread-list/adapter-changed.d.ts.map +1 -0
  67. package/dist/runtimes/remote-thread-list/adapter-changed.js +13 -0
  68. package/dist/runtimes/remote-thread-list/adapter-changed.js.map +1 -0
  69. package/dist/runtimes/remote-thread-list/empty-thread-core.d.ts.map +1 -1
  70. package/dist/runtimes/remote-thread-list/empty-thread-core.js +16 -155
  71. package/dist/runtimes/remote-thread-list/empty-thread-core.js.map +1 -1
  72. package/dist/runtimes/remote-thread-list/optimistic-state.d.ts +2 -0
  73. package/dist/runtimes/remote-thread-list/optimistic-state.d.ts.map +1 -1
  74. package/dist/runtimes/remote-thread-list/optimistic-state.js +11 -0
  75. package/dist/runtimes/remote-thread-list/optimistic-state.js.map +1 -1
  76. package/dist/runtimes/remote-thread-list/types.d.ts +3 -0
  77. package/dist/runtimes/remote-thread-list/types.d.ts.map +1 -1
  78. package/dist/store/clients/external-thread.js +1 -1
  79. package/dist/store/clients/external-thread.js.map +1 -1
  80. package/dist/store/runtime-clients/handle-runtime-action.d.ts.map +1 -1
  81. package/dist/store/runtime-clients/handle-runtime-action.js +2 -0
  82. package/dist/store/runtime-clients/handle-runtime-action.js.map +1 -1
  83. package/dist/types/message.d.ts +1 -1
  84. package/dist/types/message.js.map +1 -1
  85. package/dist/utils/silent-runtime-action.d.ts +6 -0
  86. package/dist/utils/silent-runtime-action.d.ts.map +1 -0
  87. package/dist/utils/silent-runtime-action.js +7 -0
  88. package/dist/utils/silent-runtime-action.js.map +1 -0
  89. package/package.json +4 -4
  90. package/src/adapters/voice.test.ts +66 -1
  91. package/src/adapters/voice.ts +8 -0
  92. package/src/internal.ts +1 -0
  93. package/src/model-context/frame/host.test.ts +64 -5
  94. package/src/model-context/frame/host.ts +16 -3
  95. package/src/model-context/frame/provider.test.ts +347 -20
  96. package/src/model-context/frame/provider.ts +58 -2
  97. package/src/model-context/frame/types.ts +5 -0
  98. package/src/react/client/RemoteThreadList.test.ts +196 -0
  99. package/src/react/client/RemoteThreadList.ts +93 -24
  100. package/src/react/primitive-hooks/useActionBarCopy.test.ts +15 -0
  101. package/src/react/primitive-hooks/useActionBarCopy.ts +13 -3
  102. package/src/react/primitive-hooks/useThreadIsEmpty.ts +3 -0
  103. package/src/react/primitive-hooks/useThreadIsRunning.ts +3 -0
  104. package/src/react/runtimes/RemoteThreadListHookInstanceManager.tsx +12 -0
  105. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.republish.test.ts +87 -0
  106. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +268 -63
  107. package/src/react/runtimes/RemoteThreadResource.ts +2 -0
  108. package/src/react/runtimes/assistant-transport/useAssistantTransportRuntime.test.tsx +58 -0
  109. package/src/react/runtimes/assistant-transport/useAssistantTransportRuntime.ts +3 -1
  110. package/src/react/runtimes/cloud/useCloudThreadListAdapter.test.tsx +98 -3
  111. package/src/react/runtimes/cloud/useCloudThreadListAdapter.tsx +68 -6
  112. package/src/react/runtimes/useLocalRuntime.test.tsx +5 -5
  113. package/src/runtime/utils/message-repository.ts +26 -18
  114. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +4 -0
  115. package/src/runtimes/inert/InertThreadRuntimeCore.test.ts +253 -0
  116. package/src/runtimes/inert/InertThreadRuntimeCore.ts +232 -0
  117. package/src/runtimes/local/local-thread-runtime-core.test.ts +319 -0
  118. package/src/runtimes/local/local-thread-runtime-core.ts +38 -8
  119. package/src/runtimes/readonly/ReadonlyThreadRuntimeCore.ts +17 -209
  120. package/src/runtimes/remote-thread-list/adapter-changed.ts +10 -0
  121. package/src/runtimes/remote-thread-list/empty-thread-core.ts +28 -221
  122. package/src/runtimes/remote-thread-list/optimistic-state.test.ts +18 -0
  123. package/src/runtimes/remote-thread-list/optimistic-state.ts +13 -0
  124. package/src/runtimes/remote-thread-list/types.ts +4 -0
  125. package/src/store/clients/external-thread.ts +1 -1
  126. package/src/store/runtime-clients/handle-runtime-action.test.ts +40 -0
  127. package/src/store/runtime-clients/handle-runtime-action.ts +3 -0
  128. package/src/tests/MessageRepository.test.ts +48 -0
  129. package/src/tests/RemoteThreadListThreadListRuntimeCore-errors.test.ts +2 -2
  130. package/src/tests/RemoteThreadListThreadListRuntimeCore-loadMore.test.ts +8 -6
  131. package/src/tests/empty-thread-core.test.ts +6 -0
  132. package/src/tests/external-store-thread-runtime-core-adapter.test.ts +20 -0
  133. package/src/tests/external-thread-isEmpty.test.tsx +100 -0
  134. package/src/tests/remote-thread-list-adapter-switch.test.ts +383 -0
  135. package/src/tests/useRemoteThreadListRuntime-controlled.test.tsx +67 -0
  136. package/src/types/message.ts +1 -1
  137. 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: () => session.adapter.list(),
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
- store.update({
467
- ...store.baseValue,
468
- cursor: undefined,
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
- }, [getLoadThreadsPromise, session, store]);
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
- return toInitializeResult(await data.initializeTask);
739
+ const result = toInitializeResult(await data.initializeTask);
740
+ requireAdapterGeneration(adapterGeneration);
741
+ return result;
701
742
  }
702
743
  const result = await store.optimisticUpdate({
703
- execute: () => session.adapter.initialize(threadId),
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
- return session.adapter.rename(remoteId, newTitle);
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 (!session.adapter.updateCustom) {
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
- const currentAdapter = session.adapter;
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
- return session.adapter.archive(remoteId);
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
- return await session.adapter.unarchive(remoteId);
925
+ requireAdapterGeneration(adapterGeneration);
926
+ return await currentAdapter.unarchive(remoteId);
869
927
  } catch (error) {
870
- await ensureNotMain(data.id);
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
- return session.adapter.delete(remoteId);
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 session.adapter.generateTitle(remoteId, messages);
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 rejection handler swallows clipboard write failures (permission denied,
44
- // API unavailable) so they don't surface as unhandled promise rejections.
45
- Promise.resolve(copyToClipboard(valueToCopy)).then(
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
 
@@ -1,5 +1,8 @@
1
1
  import { useAuiState } from "@assistant-ui/store";
2
2
 
3
+ /**
4
+ * @deprecated Use `useAuiState((s) => s.thread.isEmpty)` instead.
5
+ */
3
6
  export const useThreadIsEmpty = (): boolean => {
4
7
  return useAuiState((s) => s.thread.isEmpty);
5
8
  };
@@ -1,5 +1,8 @@
1
1
  import { useAuiState } from "@assistant-ui/store";
2
2
 
3
+ /**
4
+ * @deprecated Use `useAuiState((s) => s.thread.isRunning)` instead.
5
+ */
3
6
  export const useThreadIsRunning = (): boolean => {
4
7
  return useAuiState((s) => s.thread.isRunning);
5
8
  };
@@ -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) {