@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.
- package/dist/client/ExternalThread.d.ts.map +1 -1
- package/dist/client/ExternalThread.js +1 -0
- package/dist/client/ExternalThread.js.map +1 -1
- package/dist/client/InMemoryThreadList.d.ts.map +1 -1
- package/dist/client/InMemoryThreadList.js +199 -176
- package/dist/client/InMemoryThreadList.js.map +1 -1
- package/dist/client/SingleThreadList.d.ts.map +1 -1
- package/dist/client/SingleThreadList.js +56 -48
- package/dist/client/SingleThreadList.js.map +1 -1
- package/dist/legacy-runtime/cloud/auiV0.d.ts +2 -0
- package/dist/legacy-runtime/cloud/auiV0.d.ts.map +1 -1
- package/dist/legacy-runtime/cloud/auiV0.js +6 -4
- package/dist/legacy-runtime/cloud/auiV0.js.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/types.d.ts +6 -0
- package/dist/legacy-runtime/runtime-cores/assistant-transport/types.d.ts.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.d.ts.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js +20 -15
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js.map +1 -1
- package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
- package/dist/mcp-apps/McpAppRenderer.js +25 -14
- package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
- package/dist/mcp-apps/McpAppsRemoteHost.d.ts.map +1 -1
- package/dist/mcp-apps/McpAppsRemoteHost.js +17 -6
- package/dist/mcp-apps/McpAppsRemoteHost.js.map +1 -1
- package/dist/primitives/message/MessagePartsGrouped.d.ts +6 -1
- package/dist/primitives/message/MessagePartsGrouped.d.ts.map +1 -1
- package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
- package/dist/primitives/suggestion/SuggestionTrigger.d.ts +4 -2
- package/dist/primitives/suggestion/SuggestionTrigger.d.ts.map +1 -1
- package/dist/primitives/suggestion/SuggestionTrigger.js +21 -42
- package/dist/primitives/suggestion/SuggestionTrigger.js.map +1 -1
- package/dist/primitives/thread/ThreadSuggestion.d.ts +4 -2
- package/dist/primitives/thread/ThreadSuggestion.d.ts.map +1 -1
- package/dist/primitives/thread/ThreadSuggestion.js.map +1 -1
- package/package.json +6 -6
- package/src/client/ExternalThread.ts +1 -0
- package/src/client/InMemoryThreadList.ts +27 -7
- package/src/client/SingleThreadList.ts +10 -2
- package/src/legacy-runtime/cloud/auiV0.ts +8 -2
- package/src/legacy-runtime/runtime-cores/assistant-transport/types.ts +6 -0
- package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.test.tsx +31 -0
- package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.ts +26 -17
- package/src/mcp-apps/McpAppRenderer.test.tsx +262 -3
- package/src/mcp-apps/McpAppRenderer.tsx +27 -15
- package/src/mcp-apps/McpAppsRemoteHost.ts +18 -9
- package/src/primitives/message/MessagePartsGrouped.tsx +6 -1
- package/src/primitives/suggestion/SuggestionTrigger.ts +11 -32
- package/src/primitives/thread/ThreadSuggestion.ts +2 -1
- package/src/tests/RemoteThreadListRuntime.reloadMainThread.test.tsx +115 -0
- 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
|
-
|
|
98
|
+
useHostStore,
|
|
95
99
|
optionsRef,
|
|
96
100
|
}: {
|
|
97
101
|
part: ToolCallMessagePartProps;
|
|
98
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
176
|
+
useHostStore.getState().host.callTool({
|
|
170
177
|
...params,
|
|
171
178
|
...(serverIdRef.current ? { serverId: serverIdRef.current } : {}),
|
|
172
179
|
}),
|
|
173
180
|
readResource: (params) =>
|
|
174
|
-
|
|
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
|
|
187
|
+
return useHostStore.getState().host.listResources(params);
|
|
181
188
|
}
|
|
182
|
-
return
|
|
189
|
+
return useHostStore.getState().host.listResources({
|
|
183
190
|
...(isRecord(params) ? params : {}),
|
|
184
191
|
serverId: serverIdRef.current,
|
|
185
192
|
});
|
|
186
193
|
},
|
|
187
194
|
}),
|
|
188
|
-
[aui,
|
|
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
|
|
245
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
96
|
+
getCurrentOptions(),
|
|
87
97
|
"mcp-apps/read-resource",
|
|
88
98
|
params,
|
|
89
99
|
) as Promise<McpAppResource>,
|
|
90
100
|
callTool: (params) =>
|
|
91
|
-
postToHost(
|
|
101
|
+
postToHost(getCurrentOptions(), "tools/call", params),
|
|
92
102
|
readResource: (params) =>
|
|
93
|
-
postToHost(
|
|
103
|
+
postToHost(getCurrentOptions(), "resources/read", params),
|
|
94
104
|
listResources: (params) =>
|
|
95
|
-
postToHost(
|
|
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
|
-
/**
|
|
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 {
|
|
9
|
-
import {
|
|
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
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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
|
+
});
|