@assistant-ui/react 0.15.2 → 0.15.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/client/ExternalThread.d.ts.map +1 -1
  2. package/dist/client/ExternalThread.js +1 -0
  3. package/dist/client/ExternalThread.js.map +1 -1
  4. package/dist/client/InMemoryThreadList.d.ts.map +1 -1
  5. package/dist/client/InMemoryThreadList.js +199 -176
  6. package/dist/client/InMemoryThreadList.js.map +1 -1
  7. package/dist/client/SingleThreadList.d.ts.map +1 -1
  8. package/dist/client/SingleThreadList.js +56 -48
  9. package/dist/client/SingleThreadList.js.map +1 -1
  10. package/dist/legacy-runtime/cloud/auiV0.d.ts +2 -0
  11. package/dist/legacy-runtime/cloud/auiV0.d.ts.map +1 -1
  12. package/dist/legacy-runtime/cloud/auiV0.js +6 -4
  13. package/dist/legacy-runtime/cloud/auiV0.js.map +1 -1
  14. package/dist/legacy-runtime/runtime-cores/assistant-transport/types.d.ts +6 -0
  15. package/dist/legacy-runtime/runtime-cores/assistant-transport/types.d.ts.map +1 -1
  16. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.d.ts.map +1 -1
  17. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js +20 -15
  18. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js.map +1 -1
  19. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  20. package/dist/mcp-apps/McpAppRenderer.js +25 -14
  21. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  22. package/dist/mcp-apps/McpAppsRemoteHost.d.ts.map +1 -1
  23. package/dist/mcp-apps/McpAppsRemoteHost.js +17 -6
  24. package/dist/mcp-apps/McpAppsRemoteHost.js.map +1 -1
  25. package/dist/primitives/message/MessagePartsGrouped.d.ts +6 -1
  26. package/dist/primitives/message/MessagePartsGrouped.d.ts.map +1 -1
  27. package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
  28. package/dist/primitives/suggestion/SuggestionTrigger.d.ts +4 -2
  29. package/dist/primitives/suggestion/SuggestionTrigger.d.ts.map +1 -1
  30. package/dist/primitives/suggestion/SuggestionTrigger.js +21 -42
  31. package/dist/primitives/suggestion/SuggestionTrigger.js.map +1 -1
  32. package/dist/primitives/thread/ThreadSuggestion.d.ts +4 -2
  33. package/dist/primitives/thread/ThreadSuggestion.d.ts.map +1 -1
  34. package/dist/primitives/thread/ThreadSuggestion.js.map +1 -1
  35. package/package.json +6 -6
  36. package/src/client/ExternalThread.ts +1 -0
  37. package/src/client/InMemoryThreadList.ts +27 -7
  38. package/src/client/SingleThreadList.ts +10 -2
  39. package/src/legacy-runtime/cloud/auiV0.ts +8 -2
  40. package/src/legacy-runtime/runtime-cores/assistant-transport/types.ts +6 -0
  41. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.test.tsx +31 -0
  42. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.ts +26 -17
  43. package/src/mcp-apps/McpAppRenderer.test.tsx +262 -3
  44. package/src/mcp-apps/McpAppRenderer.tsx +27 -15
  45. package/src/mcp-apps/McpAppsRemoteHost.ts +18 -9
  46. package/src/primitives/message/MessagePartsGrouped.tsx +6 -1
  47. package/src/primitives/suggestion/SuggestionTrigger.ts +11 -32
  48. package/src/primitives/thread/ThreadSuggestion.ts +2 -1
  49. package/src/tests/RemoteThreadListRuntime.reloadMainThread.test.tsx +115 -0
  50. package/src/tests/threadListItemIsRunning.test.tsx +198 -0
@@ -14,6 +14,7 @@ import type {
14
14
  ToolCallMessagePartProps,
15
15
  } from "@assistant-ui/core/react";
16
16
  import { useAui } from "@assistant-ui/store";
17
+ import { create, type StoreApi, type UseBoundStore } from "zustand";
17
18
 
18
19
  import { useResource, resource, type ResourceElement } from "@assistant-ui/tap";
19
20
  import { McpAppFrame } from "./app-frame";
@@ -55,12 +56,15 @@ export type McpAppRendererOptions = {
55
56
  };
56
57
 
57
58
  type LoadedResourceState = {
59
+ host: McpAppsHost;
58
60
  resourceUri: string;
59
61
  serverId?: string;
60
62
  resource?: McpAppResource;
61
63
  error?: Error;
62
64
  };
63
65
 
66
+ type UseHostStore = UseBoundStore<StoreApi<{ host: McpAppsHost }>>;
67
+
64
68
  function getInput(part: {
65
69
  status: { type: string };
66
70
  argsText: string;
@@ -91,11 +95,11 @@ function extractSendMessageText(params: unknown): string | undefined {
91
95
 
92
96
  function InlineRenderer({
93
97
  part,
94
- internalsRef,
98
+ useHostStore,
95
99
  optionsRef,
96
100
  }: {
97
101
  part: ToolCallMessagePartProps;
98
- internalsRef: MutableRefObject<{ host: McpAppsHost }>;
102
+ useHostStore: UseHostStore;
99
103
  optionsRef: MutableRefObject<McpAppRendererOptions>;
100
104
  }) {
101
105
  const opts = optionsRef.current;
@@ -113,17 +117,19 @@ function InlineRenderer({
113
117
 
114
118
  const [loadedResource, setLoadedResource] = useState<LoadedResourceState>();
115
119
 
120
+ const host = useHostStore((state) => state.host);
116
121
  const resourceUri = appForRender?.resourceUri;
117
122
  const serverId = appForRender?.serverId;
118
123
  const serverIdRef = useRef<string | undefined>(undefined);
119
124
  serverIdRef.current = serverId;
120
125
  useEffect(() => {
121
- if (appForRender == null || resourceUri == null) return;
126
+ if (resourceUri == null) return;
122
127
  let cancelled = false;
128
+ const targetHost = host;
123
129
  const targetUri = resourceUri;
124
130
  const targetServerId = serverId;
125
131
 
126
- internalsRef.current.host
132
+ targetHost
127
133
  .loadResource({
128
134
  uri: targetUri,
129
135
  ...(targetServerId ? { serverId: targetServerId } : {}),
@@ -131,6 +137,7 @@ function InlineRenderer({
131
137
  .then((res) => {
132
138
  if (!cancelled)
133
139
  setLoadedResource({
140
+ host: targetHost,
134
141
  resourceUri: targetUri,
135
142
  ...(targetServerId !== undefined
136
143
  ? { serverId: targetServerId }
@@ -141,6 +148,7 @@ function InlineRenderer({
141
148
  .catch((error: unknown) => {
142
149
  if (!cancelled) {
143
150
  setLoadedResource({
151
+ host: targetHost,
144
152
  resourceUri: targetUri,
145
153
  ...(targetServerId !== undefined
146
154
  ? { serverId: targetServerId }
@@ -153,8 +161,7 @@ function InlineRenderer({
153
161
  return () => {
154
162
  cancelled = true;
155
163
  };
156
- // oxlint-disable-next-line react/exhaustive-deps -- re-fetch only when URI or server identity changes; appForRender identity is unstable and internalsRef is a stable ref
157
- }, [resourceUri, serverId]);
164
+ }, [host, resourceUri, serverId]);
158
165
 
159
166
  const bridgeHandlers = useMemo<McpAppBridgeHandlers>(
160
167
  () => ({
@@ -166,29 +173,30 @@ function InlineRenderer({
166
173
  return { ok: true };
167
174
  },
168
175
  callTool: (params) =>
169
- internalsRef.current.host.callTool({
176
+ useHostStore.getState().host.callTool({
170
177
  ...params,
171
178
  ...(serverIdRef.current ? { serverId: serverIdRef.current } : {}),
172
179
  }),
173
180
  readResource: (params) =>
174
- internalsRef.current.host.readResource({
181
+ useHostStore.getState().host.readResource({
175
182
  ...params,
176
183
  ...(serverIdRef.current ? { serverId: serverIdRef.current } : {}),
177
184
  }),
178
185
  listResources: (params) => {
179
186
  if (!serverIdRef.current) {
180
- return internalsRef.current.host.listResources(params);
187
+ return useHostStore.getState().host.listResources(params);
181
188
  }
182
- return internalsRef.current.host.listResources({
189
+ return useHostStore.getState().host.listResources({
183
190
  ...(isRecord(params) ? params : {}),
184
191
  serverId: serverIdRef.current,
185
192
  });
186
193
  },
187
194
  }),
188
- [aui, internalsRef],
195
+ [aui, useHostStore],
189
196
  );
190
197
 
191
198
  const loadedResourceForApp =
199
+ loadedResource?.host === host &&
192
200
  loadedResource?.resourceUri === appForRender?.resourceUri &&
193
201
  loadedResource?.serverId === appForRender?.serverId
194
202
  ? loadedResource
@@ -241,20 +249,24 @@ const useMcpAppRenderer = (
241
249
  const optionsRef = useRef<McpAppRendererOptions>(options);
242
250
  optionsRef.current = options;
243
251
 
244
- const internalsRef = useRef<{ host: McpAppsHost }>({ host });
245
- internalsRef.current = { host };
252
+ const [useHostStore] = useState(() =>
253
+ create<{ host: McpAppsHost }>(() => ({ host })),
254
+ );
255
+ useEffect(() => {
256
+ useHostStore.setState({ host });
257
+ }, [host, useHostStore]);
246
258
 
247
259
  const render = useMemo((): ToolCallMessagePartComponent => {
248
260
  const Render: ToolCallMessagePartComponent = (props) => (
249
261
  <InlineRenderer
250
262
  part={props}
251
- internalsRef={internalsRef}
263
+ useHostStore={useHostStore}
252
264
  optionsRef={optionsRef}
253
265
  />
254
266
  );
255
267
  Render.displayName = "McpAppRenderer";
256
268
  return Render;
257
- }, []);
269
+ }, [useHostStore]);
258
270
 
259
271
  return { render };
260
272
  };
@@ -79,23 +79,32 @@ const useMcpAppsRemoteHost = (
79
79
  const optionsRef = useRef(options);
80
80
  optionsRef.current = options;
81
81
 
82
- return useMemo(
83
- (): McpAppsHost => ({
82
+ const url = options.url;
83
+
84
+ return useMemo((): McpAppsHost => {
85
+ const getCurrentOptions = (): McpAppsRemoteHostOptions => {
86
+ const current = optionsRef.current;
87
+ return {
88
+ url,
89
+ ...(current.fetch !== undefined ? { fetch: current.fetch } : {}),
90
+ ...(current.headers !== undefined ? { headers: current.headers } : {}),
91
+ };
92
+ };
93
+ return {
84
94
  loadResource: (params) =>
85
95
  postToHost(
86
- optionsRef.current,
96
+ getCurrentOptions(),
87
97
  "mcp-apps/read-resource",
88
98
  params,
89
99
  ) as Promise<McpAppResource>,
90
100
  callTool: (params) =>
91
- postToHost(optionsRef.current, "tools/call", params),
101
+ postToHost(getCurrentOptions(), "tools/call", params),
92
102
  readResource: (params) =>
93
- postToHost(optionsRef.current, "resources/read", params),
103
+ postToHost(getCurrentOptions(), "resources/read", params),
94
104
  listResources: (params) =>
95
- postToHost(optionsRef.current, "resources/list", params),
96
- }),
97
- [],
98
- );
105
+ postToHost(getCurrentOptions(), "resources/list", params),
106
+ };
107
+ }, [url]);
99
108
  };
100
109
 
101
110
  export const McpAppsRemoteHost = resource(useMcpAppsRemoteHost);
@@ -151,7 +151,12 @@ export namespace MessagePrimitiveUnstable_PartsGrouped {
151
151
  Image?: ImageMessagePartComponent | undefined;
152
152
  /** Component for rendering file content */
153
153
  File?: FileMessagePartComponent | undefined;
154
- /** Component for rendering audio content (experimental) */
154
+ /**
155
+ * Component for rendering audio content.
156
+ *
157
+ * @deprecated Render audio through the `File` slot instead, branching
158
+ * on an `audio/*` mime type.
159
+ */
155
160
  Unstable_Audio?: Unstable_AudioMessagePartComponent | undefined;
156
161
  /** Configuration for data part rendering */
157
162
  data?:
@@ -5,12 +5,12 @@ import {
5
5
  type ActionButtonProps,
6
6
  createActionButton,
7
7
  } from "../../utils/createActionButton";
8
- import { useCallback } from "react";
9
- import { useAuiState, useAui } from "@assistant-ui/store";
8
+ import { useAuiState } from "@assistant-ui/store";
9
+ import { useSuggestionTrigger as useSuggestionTriggerBehavior } from "@assistant-ui/core/react";
10
10
 
11
11
  const useSuggestionTrigger = ({
12
12
  send,
13
- clearComposer = true,
13
+ clearComposer,
14
14
  }: {
15
15
  /**
16
16
  * When true, automatically sends the message.
@@ -19,7 +19,8 @@ const useSuggestionTrigger = ({
19
19
  send?: boolean | undefined;
20
20
 
21
21
  /**
22
- * Whether to clear the composer after sending.
22
+ * Whether to clear the composer after sending. A send queued while a run is
23
+ * in progress never clears the composer.
23
24
  * When send is set to false, determines if composer text is replaced with suggestion (true, default),
24
25
  * or if it's appended to the composer text (false).
25
26
  *
@@ -27,37 +28,15 @@ const useSuggestionTrigger = ({
27
28
  */
28
29
  clearComposer?: boolean | undefined;
29
30
  }) => {
30
- const aui = useAui();
31
- const disabled = useAuiState((s) => s.thread.isDisabled);
32
31
  const prompt = useAuiState((s) => s.suggestion.prompt);
33
-
34
- const resolvedSend = send ?? false;
35
-
36
- const callback = useCallback(() => {
37
- const isRunning = aui.thread.getState().isRunning;
38
-
39
- if (resolvedSend && !isRunning) {
40
- aui.thread.append({
41
- content: [{ type: "text", text: prompt }],
42
- runConfig: aui.composer.getState().runConfig,
43
- });
44
- if (clearComposer) {
45
- aui.composer.setText("");
46
- }
47
- } else {
48
- if (clearComposer) {
49
- aui.composer.setText(prompt);
50
- } else {
51
- const currentText = aui.composer.getState().text;
52
- aui.composer.setText(
53
- currentText.trim() ? `${currentText} ${prompt}` : prompt,
54
- );
55
- }
56
- }
57
- }, [aui, resolvedSend, clearComposer, prompt]);
32
+ const { trigger, disabled } = useSuggestionTriggerBehavior({
33
+ prompt,
34
+ send,
35
+ clearComposer,
36
+ });
58
37
 
59
38
  if (disabled) return null;
60
- return callback;
39
+ return trigger;
61
40
  };
62
41
 
63
42
  export namespace SuggestionPrimitiveTrigger {
@@ -24,7 +24,8 @@ const useThreadSuggestion = ({
24
24
  send?: boolean | undefined;
25
25
 
26
26
  /**
27
- * Whether to clear the composer after sending.
27
+ * Whether to clear the composer after sending. A send queued while a run is
28
+ * in progress never clears the composer.
28
29
  * When send is set to false, determines if composer text is replaced with suggestion (true, default),
29
30
  * or if it's appended to the composer text (false).
30
31
  *
@@ -0,0 +1,115 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, render, waitFor } from "@testing-library/react";
4
+ import { type FC, useEffect } from "react";
5
+ import { describe, expect, it } from "vitest";
6
+ import {
7
+ useRemoteThreadListRuntime,
8
+ type AssistantRuntime,
9
+ } from "@assistant-ui/core/react";
10
+ import { makeAdapter } from "./remote-thread-list-test-helpers";
11
+ import { useLocalRuntime } from "../legacy-runtime/runtime-cores/local/useLocalRuntime";
12
+ import { AssistantRuntimeProvider } from "../context";
13
+ import type { ChatModelAdapter } from "../index";
14
+
15
+ const noOpAdapter: ChatModelAdapter = {
16
+ async *run() {},
17
+ };
18
+
19
+ const renderThreadList = async (mounts: { count: number }) => {
20
+ const capture: { runtime: AssistantRuntime | null } = { runtime: null };
21
+
22
+ const adapter = 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
+ const Inner: FC = () => {
36
+ const runtime = useRemoteThreadListRuntime({
37
+ runtimeHook: function useTestRuntimeHook() {
38
+ useEffect(() => {
39
+ mounts.count++;
40
+ }, []);
41
+ return useLocalRuntime(noOpAdapter);
42
+ },
43
+ adapter,
44
+ });
45
+ capture.runtime = runtime;
46
+ return (
47
+ <AssistantRuntimeProvider runtime={runtime}>
48
+ {null}
49
+ </AssistantRuntimeProvider>
50
+ );
51
+ };
52
+
53
+ await act(async () => {
54
+ render(<Inner />);
55
+ });
56
+ await waitFor(() => expect(capture.runtime).not.toBeNull());
57
+ return capture;
58
+ };
59
+
60
+ // binders mount asynchronously, so a snapshot taken too early attributes a
61
+ // still-pending mount to whatever ran next
62
+ const settle = async (mounts: { count: number }) => {
63
+ let previous = -1;
64
+ await waitFor(() => {
65
+ const seen = mounts.count;
66
+ const stable = seen === previous;
67
+ previous = seen;
68
+ expect(stable).toBe(true);
69
+ });
70
+ return mounts.count;
71
+ };
72
+
73
+ describe("threads.reloadMainThread", () => {
74
+ it("remounts the runtime hook of the open thread", async () => {
75
+ const mounts = { count: 0 };
76
+ const capture = await renderThreadList(mounts);
77
+ const runtime = capture.runtime!;
78
+
79
+ await act(async () => {
80
+ await runtime.threads.switchToThread("t-1");
81
+ });
82
+ const beforeReload = await settle(mounts);
83
+ expect(beforeReload).toBeGreaterThan(0);
84
+
85
+ await act(async () => {
86
+ await runtime.threads.reloadMainThread();
87
+ });
88
+
89
+ // the hook ran again, which is what re-runs the adapter's load()
90
+ await waitFor(() => expect(mounts.count).toBe(beforeReload + 1));
91
+ });
92
+
93
+ it("keeps a thread runtime readable across the remount", async () => {
94
+ const mounts = { count: 0 };
95
+ const capture = await renderThreadList(mounts);
96
+ const runtime = capture.runtime!;
97
+
98
+ await act(async () => {
99
+ await runtime.threads.switchToThread("t-1");
100
+ });
101
+
102
+ const seen: boolean[] = [];
103
+ const unsubscribe = runtime.threads.subscribe(() => {
104
+ seen.push(runtime.threads.getState().mainThreadId === "t-1");
105
+ });
106
+
107
+ await act(async () => {
108
+ await runtime.threads.reloadMainThread();
109
+ });
110
+ unsubscribe();
111
+
112
+ expect(seen.length).toBeGreaterThan(0);
113
+ expect(seen.every(Boolean)).toBe(true);
114
+ });
115
+ });
@@ -0,0 +1,198 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, render, screen, waitFor } from "@testing-library/react";
4
+ import { type FC, useEffect, useReducer } from "react";
5
+ import { describe, expect, it } from "vitest";
6
+ import { useRemoteThreadListRuntime } from "@assistant-ui/core/react";
7
+ import { makeAdapter } from "./remote-thread-list-test-helpers";
8
+ import { AssistantRuntimeProvider } from "../context";
9
+ import * as ThreadListPrimitive from "../primitives/threadList";
10
+ import {
11
+ useAui,
12
+ useAuiState,
13
+ type AssistantRuntime,
14
+ type ChatModelAdapter,
15
+ } from "../index";
16
+ import { useExternalStoreRuntime } from "../legacy-runtime/runtime-cores/external-store/useExternalStoreRuntime";
17
+ import { useLocalRuntime } from "../legacy-runtime/runtime-cores/local/useLocalRuntime";
18
+
19
+ const Probe: FC = () => {
20
+ const id = useAuiState((s) => s.threadListItem.id);
21
+ const isRunning = useAuiState((s) => s.threadListItem.isRunning);
22
+ return (
23
+ <span data-testid={`item-${id}`}>{isRunning ? "running" : "idle"}</span>
24
+ );
25
+ };
26
+
27
+ const Items: FC = () => (
28
+ <ThreadListPrimitive.Items components={{ ThreadListItem: Probe }} />
29
+ );
30
+
31
+ describe("threadListItem.isRunning", () => {
32
+ // LocalThreadListRuntimeCore reports no per-thread run state, so the item
33
+ // reaches its own run state only through the open thread.
34
+ it("tracks the open thread's run on a thread list with no per-thread capability", async () => {
35
+ let release!: () => void;
36
+ const adapter: ChatModelAdapter = {
37
+ async *run() {
38
+ await new Promise<void>((resolve) => {
39
+ release = resolve;
40
+ });
41
+ yield { content: [{ type: "text", text: "done" }] };
42
+ },
43
+ };
44
+
45
+ const capture: { runtime: AssistantRuntime | null } = { runtime: null };
46
+ const Inner: FC = () => {
47
+ const runtime = useLocalRuntime(adapter);
48
+ capture.runtime = runtime;
49
+ return (
50
+ <AssistantRuntimeProvider runtime={runtime}>
51
+ <Probe />
52
+ </AssistantRuntimeProvider>
53
+ );
54
+ };
55
+
56
+ await act(async () => {
57
+ render(<Inner />);
58
+ });
59
+ const testId = `item-${capture.runtime!.threads.mainItem.getState().id}`;
60
+ expect(screen.getByTestId(testId).textContent).toBe("idle");
61
+
62
+ await act(async () => {
63
+ capture.runtime!.thread.append({
64
+ role: "user",
65
+ content: [{ type: "text", text: "hi" }],
66
+ });
67
+ });
68
+ await waitFor(() =>
69
+ expect(screen.getByTestId(testId).textContent).toBe("running"),
70
+ );
71
+
72
+ await act(async () => {
73
+ release();
74
+ });
75
+ await waitFor(() =>
76
+ expect(screen.getByTestId(testId).textContent).toBe("idle"),
77
+ );
78
+ });
79
+
80
+ it("reports the run of a single-thread runtime that cannot observe background threads", async () => {
81
+ const Inner: FC<{ isRunning: boolean }> = ({ isRunning }) => {
82
+ const runtime = useExternalStoreRuntime({
83
+ messages: [],
84
+ isRunning,
85
+ onNew: async () => {},
86
+ });
87
+ return (
88
+ <AssistantRuntimeProvider runtime={runtime}>
89
+ <Items />
90
+ </AssistantRuntimeProvider>
91
+ );
92
+ };
93
+
94
+ const view = render(<Inner isRunning={false} />);
95
+ await waitFor(() =>
96
+ expect(screen.getByTestId("item-DEFAULT_THREAD_ID").textContent).toBe(
97
+ "idle",
98
+ ),
99
+ );
100
+
101
+ await act(async () => {
102
+ view.rerender(<Inner isRunning />);
103
+ });
104
+
105
+ expect(screen.getByTestId("item-DEFAULT_THREAD_ID").textContent).toBe(
106
+ "running",
107
+ );
108
+ });
109
+
110
+ it("keeps reporting a thread the user switched away from as running", async () => {
111
+ const running = new Map<string, boolean>();
112
+ const listeners = new Set<() => void>();
113
+ const setRunning = (threadId: string, value: boolean) => {
114
+ running.set(threadId, value);
115
+ for (const listener of listeners) listener();
116
+ };
117
+
118
+ const useTestRuntimeHook = () => {
119
+ const aui = useAui();
120
+ const threadId = aui.threadListItem.getState().id;
121
+ const [, forceUpdate] = useReducer((n: number) => n + 1, 0);
122
+ useEffect(() => {
123
+ listeners.add(forceUpdate);
124
+ return () => {
125
+ listeners.delete(forceUpdate);
126
+ };
127
+ }, []);
128
+
129
+ return useExternalStoreRuntime({
130
+ messages: [],
131
+ isRunning: running.get(threadId) ?? false,
132
+ onNew: async () => {},
133
+ });
134
+ };
135
+
136
+ const adapter = makeAdapter({
137
+ list: async () => ({
138
+ threads: [
139
+ {
140
+ status: "regular" as const,
141
+ remoteId: "t-1",
142
+ externalId: "t-1",
143
+ title: "First",
144
+ },
145
+ {
146
+ status: "regular" as const,
147
+ remoteId: "t-2",
148
+ externalId: "t-2",
149
+ title: "Second",
150
+ },
151
+ ],
152
+ }),
153
+ });
154
+
155
+ const capture: { runtime: AssistantRuntime | null } = { runtime: null };
156
+ const Inner: FC = () => {
157
+ const runtime = useRemoteThreadListRuntime({
158
+ runtimeHook: useTestRuntimeHook,
159
+ adapter,
160
+ });
161
+ capture.runtime = runtime;
162
+ return (
163
+ <AssistantRuntimeProvider runtime={runtime}>
164
+ <Items />
165
+ </AssistantRuntimeProvider>
166
+ );
167
+ };
168
+
169
+ await act(async () => {
170
+ render(<Inner />);
171
+ });
172
+ await waitFor(() => expect(screen.getByTestId("item-t-1")).toBeTruthy());
173
+
174
+ await act(async () => {
175
+ await capture.runtime!.threads.switchToThread("t-1");
176
+ });
177
+ await act(async () => {
178
+ setRunning("t-1", true);
179
+ });
180
+ await waitFor(() =>
181
+ expect(screen.getByTestId("item-t-1").textContent).toBe("running"),
182
+ );
183
+
184
+ await act(async () => {
185
+ await capture.runtime!.threads.switchToThread("t-2");
186
+ });
187
+
188
+ expect(screen.getByTestId("item-t-1").textContent).toBe("running");
189
+ expect(screen.getByTestId("item-t-2").textContent).toBe("idle");
190
+
191
+ await act(async () => {
192
+ setRunning("t-1", false);
193
+ });
194
+ await waitFor(() =>
195
+ expect(screen.getByTestId("item-t-1").textContent).toBe("idle"),
196
+ );
197
+ });
198
+ });