@copilotkit/vue 1.62.1 → 1.62.2-canary.1784333495
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/index.cjs +1 -1
- package/dist/index.mjs +70 -69
- package/dist/styles.css +1 -1
- package/dist/use-render-activity-message-CIvEoQzA.cjs +85 -0
- package/dist/use-render-activity-message-CIvEoQzA.cjs.map +1 -0
- package/dist/{use-render-activity-message-8_HyVZWc.js → use-render-activity-message-CbKeqosY.js} +3514 -3207
- package/dist/use-render-activity-message-CbKeqosY.js.map +1 -0
- package/dist/v2/components/chat/CopilotChat.vue.d.ts.map +1 -1
- package/dist/v2/components/chat/CopilotChatAssistantMessage.vue.d.ts +8 -0
- package/dist/v2/components/chat/CopilotChatAssistantMessage.vue.d.ts.map +1 -1
- package/dist/v2/components/chat/CopilotModalHeader.vue.d.ts.map +1 -1
- package/dist/v2/components/chat/CopilotThreadsDrawer.vue.d.ts +65 -0
- package/dist/v2/components/chat/CopilotThreadsDrawer.vue.d.ts.map +1 -0
- package/dist/v2/components/chat/index.d.ts +1 -0
- package/dist/v2/components/chat/index.d.ts.map +1 -1
- package/dist/v2/components/icons/index.d.ts +1 -1
- package/dist/v2/components/icons/index.d.ts.map +1 -1
- package/dist/v2/hooks/use-threads.d.ts +19 -0
- package/dist/v2/hooks/use-threads.d.ts.map +1 -1
- package/dist/v2/index.cjs +1 -1
- package/dist/v2/index.mjs +44 -43
- package/dist/v2/lib/is-mobile-viewport.d.ts +15 -0
- package/dist/v2/lib/is-mobile-viewport.d.ts.map +1 -0
- package/dist/v2/providers/CopilotChatConfigurationProvider.vue.d.ts.map +1 -1
- package/dist/v2/providers/types.d.ts +24 -0
- package/dist/v2/providers/types.d.ts.map +1 -1
- package/package.json +5 -4
- package/src/v2/components/chat/CopilotChat.vue +22 -4
- package/src/v2/components/chat/CopilotModalHeader.vue +61 -2
- package/src/v2/components/chat/CopilotThreadsDrawer.vue +335 -0
- package/src/v2/components/chat/__tests__/CopilotChat.clearOnFresh.test.ts +160 -0
- package/src/v2/components/chat/__tests__/CopilotModalHeader.drawerLauncher.test.ts +125 -0
- package/src/v2/components/chat/__tests__/CopilotThreadsDrawer.ssr.test.ts +25 -0
- package/src/v2/components/chat/__tests__/CopilotThreadsDrawer.test.ts +910 -0
- package/src/v2/components/chat/index.ts +1 -0
- package/src/v2/components/icons/index.ts +1 -0
- package/src/v2/hooks/__tests__/use-human-in-the-loop.e2e.test.ts +4 -4
- package/src/v2/hooks/__tests__/use-threads.test.ts +315 -6
- package/src/v2/hooks/use-threads.ts +148 -8
- package/src/v2/lib/__tests__/is-mobile-viewport.test.ts +48 -0
- package/src/v2/lib/is-mobile-viewport.ts +23 -0
- package/src/v2/providers/CopilotChatConfigurationProvider.vue +120 -4
- package/src/v2/providers/__tests__/CopilotChatConfigurationProvider.test.ts +105 -1
- package/src/v2/providers/types.ts +25 -0
- package/vite.config.ts +7 -0
- package/dist/use-render-activity-message-8_HyVZWc.js.map +0 -1
- package/dist/use-render-activity-message-BqgmNPCz.cjs +0 -85
- package/dist/use-render-activity-message-BqgmNPCz.cjs.map +0 -1
|
@@ -0,0 +1,910 @@
|
|
|
1
|
+
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { h, nextTick, toValue } from "vue";
|
|
3
|
+
import type { MaybeRefOrGetter } from "vue";
|
|
4
|
+
import { flushPromises, mount } from "@vue/test-utils";
|
|
5
|
+
import CopilotKitProvider from "../../../providers/CopilotKitProvider.vue";
|
|
6
|
+
import CopilotChatConfigurationProvider from "../../../providers/CopilotChatConfigurationProvider.vue";
|
|
7
|
+
import { useCopilotChatConfiguration } from "../../../providers/useCopilotChatConfiguration";
|
|
8
|
+
import CopilotThreadsDrawer from "../CopilotThreadsDrawer.vue";
|
|
9
|
+
import {
|
|
10
|
+
COPILOTKIT_THREADS_DRAWER_TAG,
|
|
11
|
+
defineCopilotKitThreadsDrawer,
|
|
12
|
+
} from "@copilotkit/web-components/threads-drawer";
|
|
13
|
+
import type { CopilotKitThreadsDrawer as CopilotKitThreadsDrawerElement } from "@copilotkit/web-components/threads-drawer";
|
|
14
|
+
// The package name `@copilotkit/vue` does not resolve from within its own
|
|
15
|
+
// test suite (no build output / self-referencing node_modules symlink in
|
|
16
|
+
// this workspace), so the package entry barrel is imported by relative
|
|
17
|
+
// path here. This still verifies the full re-export chain from the chat
|
|
18
|
+
// barrel up through components/index.ts and v2/index.ts to the package
|
|
19
|
+
// root entry.
|
|
20
|
+
import * as vue from "../../../../index";
|
|
21
|
+
import type { LicenseContextValue } from "../../../providers/license-context";
|
|
22
|
+
import type { Thread } from "../../../hooks/use-threads";
|
|
23
|
+
|
|
24
|
+
const ThreadIdProbe = {
|
|
25
|
+
setup() {
|
|
26
|
+
const config = useCopilotChatConfiguration();
|
|
27
|
+
return () =>
|
|
28
|
+
h("span", { "data-testid": "thread-id" }, String(config.value?.threadId));
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const HasExplicitThreadIdProbe = {
|
|
33
|
+
setup() {
|
|
34
|
+
const config = useCopilotChatConfiguration();
|
|
35
|
+
return () =>
|
|
36
|
+
h(
|
|
37
|
+
"span",
|
|
38
|
+
{ "data-testid": "has-explicit-thread-id" },
|
|
39
|
+
String(config.value?.hasExplicitThreadId),
|
|
40
|
+
);
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Reads the config-backed `drawerOpen` so `open-change` routing can be
|
|
45
|
+
* asserted without reaching into the element's own `open` property (which
|
|
46
|
+
* is itself derived FROM this same config value, so a separate probe keeps
|
|
47
|
+
* the assertion honest about which layer actually changed). */
|
|
48
|
+
const DrawerOpenProbe = {
|
|
49
|
+
setup() {
|
|
50
|
+
const config = useCopilotChatConfiguration();
|
|
51
|
+
return () =>
|
|
52
|
+
h(
|
|
53
|
+
"span",
|
|
54
|
+
{ "data-testid": "drawer-open" },
|
|
55
|
+
String(config.value?.drawerOpen),
|
|
56
|
+
);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Deterministic, controllable stand-in for the real `useThreads` composable so
|
|
61
|
+
// delete/active-thread behavior can be asserted without a live runtime. Reset
|
|
62
|
+
// per-test in `beforeEach`. `useThreadsInput` captures the input passed by the
|
|
63
|
+
// wrapper on the most recent call so tests can assert on `limit`/`enabled`
|
|
64
|
+
// forwarding (both are `MaybeRefOrGetter`, so callers must unwrap with
|
|
65
|
+
// `toValue`).
|
|
66
|
+
const useThreadsMocks = vi.hoisted(() => ({
|
|
67
|
+
deleteThread: vi.fn().mockResolvedValue(undefined),
|
|
68
|
+
startNewThread: vi.fn(),
|
|
69
|
+
archiveThread: vi.fn().mockResolvedValue(undefined),
|
|
70
|
+
unarchiveThread: vi.fn().mockResolvedValue(undefined),
|
|
71
|
+
fetchMoreThreads: vi.fn(),
|
|
72
|
+
refetchThreads: vi.fn(),
|
|
73
|
+
listError: { value: null as Error | null },
|
|
74
|
+
fetchMoreError: { value: null as Error | null },
|
|
75
|
+
error: { value: null as Error | null },
|
|
76
|
+
useThreadsInput: null as Record<string, unknown> | null,
|
|
77
|
+
// Mutable so individual tests (e.g. row-slot projection) can seed threads;
|
|
78
|
+
// reset to empty in `beforeEach`.
|
|
79
|
+
threads: { value: [] as Thread[] },
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
vi.mock("../../../hooks/use-threads", () => ({
|
|
83
|
+
useThreads: (input: Record<string, unknown>) => {
|
|
84
|
+
useThreadsMocks.useThreadsInput = input;
|
|
85
|
+
return {
|
|
86
|
+
threads: useThreadsMocks.threads,
|
|
87
|
+
isLoading: { value: false },
|
|
88
|
+
error: useThreadsMocks.error,
|
|
89
|
+
listError: useThreadsMocks.listError,
|
|
90
|
+
fetchMoreError: useThreadsMocks.fetchMoreError,
|
|
91
|
+
hasMoreThreads: { value: false },
|
|
92
|
+
isFetchingMoreThreads: { value: false },
|
|
93
|
+
isMutating: { value: false },
|
|
94
|
+
fetchMoreThreads: useThreadsMocks.fetchMoreThreads,
|
|
95
|
+
refetchThreads: useThreadsMocks.refetchThreads,
|
|
96
|
+
startNewThread: useThreadsMocks.startNewThread,
|
|
97
|
+
renameThread: vi.fn().mockResolvedValue(undefined),
|
|
98
|
+
archiveThread: useThreadsMocks.archiveThread,
|
|
99
|
+
unarchiveThread: useThreadsMocks.unarchiveThread,
|
|
100
|
+
deleteThread: useThreadsMocks.deleteThread,
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
}));
|
|
104
|
+
|
|
105
|
+
function makeThread(id: string): Thread {
|
|
106
|
+
return {
|
|
107
|
+
id,
|
|
108
|
+
agentId: "test-agent",
|
|
109
|
+
name: `Thread ${id}`,
|
|
110
|
+
archived: false,
|
|
111
|
+
createdAt: "2024-01-01T00:00:00.000Z",
|
|
112
|
+
updatedAt: "2024-01-01T00:00:00.000Z",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Controllable stand-in for the license context so resolved-licensed and
|
|
117
|
+
// resolved-unlicensed states can be driven directly, without routing a real
|
|
118
|
+
// runtime connection through `CopilotKitProvider`. Defaults to a permissive
|
|
119
|
+
// "pending" (status null) context, matching the provider's own default
|
|
120
|
+
// before a runtime responds, and is reset per-test in `beforeEach`.
|
|
121
|
+
const licenseContextMock = vi.hoisted(() => ({
|
|
122
|
+
value: {
|
|
123
|
+
status: null,
|
|
124
|
+
license: null,
|
|
125
|
+
checkFeature: () => true,
|
|
126
|
+
getLimit: () => null,
|
|
127
|
+
} as LicenseContextValue,
|
|
128
|
+
}));
|
|
129
|
+
|
|
130
|
+
vi.mock("../../../providers/useLicenseContext", () => ({
|
|
131
|
+
useLicenseContext: () => ({
|
|
132
|
+
get value() {
|
|
133
|
+
return licenseContextMock.value;
|
|
134
|
+
},
|
|
135
|
+
}),
|
|
136
|
+
}));
|
|
137
|
+
|
|
138
|
+
// Mounts the drawer and waits for it to settle before returning. The wrapper
|
|
139
|
+
// registers the `<copilotkit-threads-drawer>` element ASYNCHRONOUSLY (a
|
|
140
|
+
// dynamic `import()` inside `onMounted`, kept lazy so `@copilotkit/vue` stays
|
|
141
|
+
// SSR-safe — see CopilotThreadsDrawer.vue). `flushPromises()` resolves that
|
|
142
|
+
// dynamic import (already cached via the static import + `beforeAll` register
|
|
143
|
+
// below, so it settles on a microtask) which flips `mounted` and sets
|
|
144
|
+
// `elementTag`; the trailing `nextTick()` flushes the resulting render plus
|
|
145
|
+
// the `flush: "post"` property-push watcher. Callers must `await` this.
|
|
146
|
+
async function mountDrawer(
|
|
147
|
+
props: Record<string, unknown> = {},
|
|
148
|
+
extraSlotChildren: unknown[] = [],
|
|
149
|
+
) {
|
|
150
|
+
const wrapper = mount(CopilotKitProvider, {
|
|
151
|
+
props: { runtimeUrl: "/api/copilotkit" },
|
|
152
|
+
slots: {
|
|
153
|
+
default: () =>
|
|
154
|
+
h(
|
|
155
|
+
CopilotChatConfigurationProvider,
|
|
156
|
+
{ isModalDefaultOpen: true },
|
|
157
|
+
{
|
|
158
|
+
default: () => [
|
|
159
|
+
h(CopilotThreadsDrawer, props),
|
|
160
|
+
...extraSlotChildren,
|
|
161
|
+
],
|
|
162
|
+
},
|
|
163
|
+
),
|
|
164
|
+
},
|
|
165
|
+
attachTo: document.body,
|
|
166
|
+
});
|
|
167
|
+
await flushPromises();
|
|
168
|
+
await nextTick();
|
|
169
|
+
return wrapper;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
describe("CopilotThreadsDrawer", () => {
|
|
173
|
+
beforeAll(() => {
|
|
174
|
+
// Belt-and-braces: ensure the custom element is registered even if
|
|
175
|
+
// onMounted timing is flaky in the jsdom test env.
|
|
176
|
+
defineCopilotKitThreadsDrawer();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
beforeEach(() => {
|
|
180
|
+
useThreadsMocks.deleteThread.mockClear();
|
|
181
|
+
useThreadsMocks.deleteThread.mockResolvedValue(undefined);
|
|
182
|
+
useThreadsMocks.startNewThread.mockClear();
|
|
183
|
+
useThreadsMocks.archiveThread.mockClear();
|
|
184
|
+
useThreadsMocks.archiveThread.mockResolvedValue(undefined);
|
|
185
|
+
useThreadsMocks.unarchiveThread.mockClear();
|
|
186
|
+
useThreadsMocks.unarchiveThread.mockResolvedValue(undefined);
|
|
187
|
+
useThreadsMocks.fetchMoreThreads.mockClear();
|
|
188
|
+
useThreadsMocks.refetchThreads.mockClear();
|
|
189
|
+
useThreadsMocks.listError.value = null;
|
|
190
|
+
useThreadsMocks.fetchMoreError.value = null;
|
|
191
|
+
useThreadsMocks.error.value = null;
|
|
192
|
+
useThreadsMocks.useThreadsInput = null;
|
|
193
|
+
useThreadsMocks.threads.value = [];
|
|
194
|
+
licenseContextMock.value = {
|
|
195
|
+
status: null,
|
|
196
|
+
license: null,
|
|
197
|
+
checkFeature: () => true,
|
|
198
|
+
getLimit: () => null,
|
|
199
|
+
};
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("renders the custom element and sets domain properties", async () => {
|
|
203
|
+
const wrapper = await mountDrawer({ label: "Chats" });
|
|
204
|
+
|
|
205
|
+
const el = wrapper.find(COPILOTKIT_THREADS_DRAWER_TAG)
|
|
206
|
+
.element as unknown as CopilotKitThreadsDrawerElement;
|
|
207
|
+
|
|
208
|
+
expect(el).toBeTruthy();
|
|
209
|
+
expect(Array.isArray(el.threads)).toBe(true);
|
|
210
|
+
expect(el.label).toBe("Chats");
|
|
211
|
+
// Unlicensed-by-default test runtime -> license status is null (pending).
|
|
212
|
+
// Pending must never flash the locked view: loading true, licensed true.
|
|
213
|
+
expect(el.loading).toBe(true);
|
|
214
|
+
expect(el.licensed).toBe(true);
|
|
215
|
+
|
|
216
|
+
wrapper.unmount();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("routes thread-selected to the config's setActiveThreadId", async () => {
|
|
220
|
+
const wrapper = await mountDrawer({}, [h(ThreadIdProbe)]);
|
|
221
|
+
|
|
222
|
+
const el = wrapper.find(COPILOTKIT_THREADS_DRAWER_TAG).element;
|
|
223
|
+
el.dispatchEvent(
|
|
224
|
+
new CustomEvent("thread-selected", {
|
|
225
|
+
detail: { threadId: "t-42" },
|
|
226
|
+
bubbles: true,
|
|
227
|
+
composed: true,
|
|
228
|
+
}),
|
|
229
|
+
);
|
|
230
|
+
await nextTick();
|
|
231
|
+
|
|
232
|
+
expect(wrapper.get("[data-testid='thread-id']").text()).toBe("t-42");
|
|
233
|
+
|
|
234
|
+
wrapper.unmount();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("invokes onLicensed when the element emits licensed", async () => {
|
|
238
|
+
const onLicensed = vi.fn();
|
|
239
|
+
const wrapper = await mountDrawer({ onLicensed });
|
|
240
|
+
|
|
241
|
+
const el = wrapper.find(COPILOTKIT_THREADS_DRAWER_TAG).element;
|
|
242
|
+
el.dispatchEvent(
|
|
243
|
+
new CustomEvent("licensed", {
|
|
244
|
+
detail: { licenseUrl: null },
|
|
245
|
+
bubbles: true,
|
|
246
|
+
composed: true,
|
|
247
|
+
}),
|
|
248
|
+
);
|
|
249
|
+
await nextTick();
|
|
250
|
+
|
|
251
|
+
expect(onLicensed).toHaveBeenCalledTimes(1);
|
|
252
|
+
|
|
253
|
+
wrapper.unmount();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("returns focus to the chat input after a thread is selected", async () => {
|
|
257
|
+
// Real focusable element carrying the Vue chat input's documented
|
|
258
|
+
// testid, planted in the live DOM alongside the drawer (mountDrawer
|
|
259
|
+
// attaches to document.body). Against the pre-fix selector
|
|
260
|
+
// (`copilot-chat-textarea`, which does not exist in Vue) this never
|
|
261
|
+
// matches and `document.activeElement` stays on <body> — red.
|
|
262
|
+
const input = document.createElement("textarea");
|
|
263
|
+
input.setAttribute("data-testid", "copilot-chat-input-textarea");
|
|
264
|
+
document.body.appendChild(input);
|
|
265
|
+
|
|
266
|
+
const wrapper = await mountDrawer({}, [h(ThreadIdProbe)]);
|
|
267
|
+
|
|
268
|
+
const el = wrapper.find(COPILOTKIT_THREADS_DRAWER_TAG).element;
|
|
269
|
+
el.dispatchEvent(
|
|
270
|
+
new CustomEvent("thread-selected", {
|
|
271
|
+
detail: { threadId: "t-42" },
|
|
272
|
+
bubbles: true,
|
|
273
|
+
composed: true,
|
|
274
|
+
}),
|
|
275
|
+
);
|
|
276
|
+
await nextTick();
|
|
277
|
+
|
|
278
|
+
expect(document.activeElement).toBe(input);
|
|
279
|
+
|
|
280
|
+
wrapper.unmount();
|
|
281
|
+
input.remove();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("resets the active thread when the ACTIVE thread is deleted", async () => {
|
|
285
|
+
const wrapper = await mountDrawer({}, [
|
|
286
|
+
h(ThreadIdProbe),
|
|
287
|
+
h(HasExplicitThreadIdProbe),
|
|
288
|
+
]);
|
|
289
|
+
|
|
290
|
+
const el = wrapper.find(COPILOTKIT_THREADS_DRAWER_TAG).element;
|
|
291
|
+
|
|
292
|
+
// Seed the active thread via an explicit selection first.
|
|
293
|
+
el.dispatchEvent(
|
|
294
|
+
new CustomEvent("thread-selected", {
|
|
295
|
+
detail: { threadId: "t-active" },
|
|
296
|
+
bubbles: true,
|
|
297
|
+
composed: true,
|
|
298
|
+
}),
|
|
299
|
+
);
|
|
300
|
+
await nextTick();
|
|
301
|
+
expect(wrapper.get("[data-testid='thread-id']").text()).toBe("t-active");
|
|
302
|
+
expect(wrapper.get("[data-testid='has-explicit-thread-id']").text()).toBe(
|
|
303
|
+
"true",
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
el.dispatchEvent(
|
|
307
|
+
new CustomEvent("delete", {
|
|
308
|
+
detail: { threadId: "t-active" },
|
|
309
|
+
bubbles: true,
|
|
310
|
+
composed: true,
|
|
311
|
+
}),
|
|
312
|
+
);
|
|
313
|
+
// Let the deleteThread() promise resolve and its .then() run.
|
|
314
|
+
await flushPromises();
|
|
315
|
+
await nextTick();
|
|
316
|
+
|
|
317
|
+
expect(useThreadsMocks.deleteThread).toHaveBeenCalledWith("t-active");
|
|
318
|
+
expect(useThreadsMocks.startNewThread).toHaveBeenCalledTimes(1);
|
|
319
|
+
expect(wrapper.get("[data-testid='thread-id']").text()).not.toBe(
|
|
320
|
+
"t-active",
|
|
321
|
+
);
|
|
322
|
+
expect(wrapper.get("[data-testid='has-explicit-thread-id']").text()).toBe(
|
|
323
|
+
"false",
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
wrapper.unmount();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("leaves the active thread intact when a NON-active thread is deleted", async () => {
|
|
330
|
+
const wrapper = await mountDrawer({}, [h(ThreadIdProbe)]);
|
|
331
|
+
|
|
332
|
+
const el = wrapper.find(COPILOTKIT_THREADS_DRAWER_TAG).element;
|
|
333
|
+
|
|
334
|
+
el.dispatchEvent(
|
|
335
|
+
new CustomEvent("thread-selected", {
|
|
336
|
+
detail: { threadId: "t-active" },
|
|
337
|
+
bubbles: true,
|
|
338
|
+
composed: true,
|
|
339
|
+
}),
|
|
340
|
+
);
|
|
341
|
+
await nextTick();
|
|
342
|
+
expect(wrapper.get("[data-testid='thread-id']").text()).toBe("t-active");
|
|
343
|
+
|
|
344
|
+
el.dispatchEvent(
|
|
345
|
+
new CustomEvent("delete", {
|
|
346
|
+
detail: { threadId: "t-other" },
|
|
347
|
+
bubbles: true,
|
|
348
|
+
composed: true,
|
|
349
|
+
}),
|
|
350
|
+
);
|
|
351
|
+
await flushPromises();
|
|
352
|
+
await nextTick();
|
|
353
|
+
|
|
354
|
+
expect(useThreadsMocks.deleteThread).toHaveBeenCalledWith("t-other");
|
|
355
|
+
expect(useThreadsMocks.startNewThread).not.toHaveBeenCalled();
|
|
356
|
+
expect(wrapper.get("[data-testid='thread-id']").text()).toBe("t-active");
|
|
357
|
+
|
|
358
|
+
wrapper.unmount();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("starts closed and reflects open-change via the local fallback when there is no configuration provider", async () => {
|
|
362
|
+
const wrapper = mount(CopilotKitProvider, {
|
|
363
|
+
props: { runtimeUrl: "/api/copilotkit" },
|
|
364
|
+
slots: {
|
|
365
|
+
default: () => h(CopilotThreadsDrawer, {}),
|
|
366
|
+
},
|
|
367
|
+
attachTo: document.body,
|
|
368
|
+
});
|
|
369
|
+
await flushPromises();
|
|
370
|
+
await nextTick();
|
|
371
|
+
|
|
372
|
+
const el = wrapper.find(COPILOTKIT_THREADS_DRAWER_TAG)
|
|
373
|
+
.element as unknown as CopilotKitThreadsDrawerElement;
|
|
374
|
+
|
|
375
|
+
expect(el.open).toBe(false);
|
|
376
|
+
|
|
377
|
+
el.dispatchEvent(
|
|
378
|
+
new CustomEvent("open-change", {
|
|
379
|
+
detail: { open: true },
|
|
380
|
+
bubbles: true,
|
|
381
|
+
composed: true,
|
|
382
|
+
}),
|
|
383
|
+
);
|
|
384
|
+
await nextTick();
|
|
385
|
+
|
|
386
|
+
expect(el.open).toBe(true);
|
|
387
|
+
|
|
388
|
+
wrapper.unmount();
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("routes archive to threadsApi.archiveThread", async () => {
|
|
392
|
+
const wrapper = await mountDrawer();
|
|
393
|
+
|
|
394
|
+
const el = wrapper.find(COPILOTKIT_THREADS_DRAWER_TAG).element;
|
|
395
|
+
el.dispatchEvent(
|
|
396
|
+
new CustomEvent("archive", {
|
|
397
|
+
detail: { threadId: "t-archive" },
|
|
398
|
+
bubbles: true,
|
|
399
|
+
composed: true,
|
|
400
|
+
}),
|
|
401
|
+
);
|
|
402
|
+
await flushPromises();
|
|
403
|
+
|
|
404
|
+
expect(useThreadsMocks.archiveThread).toHaveBeenCalledWith("t-archive");
|
|
405
|
+
|
|
406
|
+
wrapper.unmount();
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("routes unarchive to threadsApi.unarchiveThread", async () => {
|
|
410
|
+
const wrapper = await mountDrawer();
|
|
411
|
+
|
|
412
|
+
const el = wrapper.find(COPILOTKIT_THREADS_DRAWER_TAG).element;
|
|
413
|
+
el.dispatchEvent(
|
|
414
|
+
new CustomEvent("unarchive", {
|
|
415
|
+
detail: { threadId: "t-unarchive" },
|
|
416
|
+
bubbles: true,
|
|
417
|
+
composed: true,
|
|
418
|
+
}),
|
|
419
|
+
);
|
|
420
|
+
await flushPromises();
|
|
421
|
+
|
|
422
|
+
expect(useThreadsMocks.unarchiveThread).toHaveBeenCalledWith("t-unarchive");
|
|
423
|
+
|
|
424
|
+
wrapper.unmount();
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("routes filter-change to threadsApi.refetchThreads", async () => {
|
|
428
|
+
const wrapper = await mountDrawer();
|
|
429
|
+
|
|
430
|
+
const el = wrapper.find(COPILOTKIT_THREADS_DRAWER_TAG).element;
|
|
431
|
+
el.dispatchEvent(
|
|
432
|
+
new CustomEvent("filter-change", {
|
|
433
|
+
detail: { filter: "all" },
|
|
434
|
+
bubbles: true,
|
|
435
|
+
composed: true,
|
|
436
|
+
}),
|
|
437
|
+
);
|
|
438
|
+
await nextTick();
|
|
439
|
+
|
|
440
|
+
expect(useThreadsMocks.refetchThreads).toHaveBeenCalledTimes(1);
|
|
441
|
+
|
|
442
|
+
wrapper.unmount();
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("routes new-thread to threadsApi.startNewThread AND the config's startNewThread", async () => {
|
|
446
|
+
const wrapper = await mountDrawer({}, [
|
|
447
|
+
h(ThreadIdProbe),
|
|
448
|
+
h(HasExplicitThreadIdProbe),
|
|
449
|
+
]);
|
|
450
|
+
|
|
451
|
+
const el = wrapper.find(COPILOTKIT_THREADS_DRAWER_TAG).element;
|
|
452
|
+
|
|
453
|
+
// Seed an explicit active thread first so the config-backed reset is
|
|
454
|
+
// observable (it flips the thread id and marks it non-explicit again).
|
|
455
|
+
el.dispatchEvent(
|
|
456
|
+
new CustomEvent("thread-selected", {
|
|
457
|
+
detail: { threadId: "t-active" },
|
|
458
|
+
bubbles: true,
|
|
459
|
+
composed: true,
|
|
460
|
+
}),
|
|
461
|
+
);
|
|
462
|
+
await nextTick();
|
|
463
|
+
expect(wrapper.get("[data-testid='thread-id']").text()).toBe("t-active");
|
|
464
|
+
|
|
465
|
+
el.dispatchEvent(
|
|
466
|
+
new CustomEvent("new-thread", {
|
|
467
|
+
detail: {},
|
|
468
|
+
bubbles: true,
|
|
469
|
+
composed: true,
|
|
470
|
+
}),
|
|
471
|
+
);
|
|
472
|
+
await nextTick();
|
|
473
|
+
|
|
474
|
+
// threadsApi.startNewThread (the store-level reset) always fires.
|
|
475
|
+
expect(useThreadsMocks.startNewThread).toHaveBeenCalledTimes(1);
|
|
476
|
+
// The config's startNewThread also fired: the active thread id changed
|
|
477
|
+
// away from the explicitly-selected one and is no longer explicit.
|
|
478
|
+
expect(wrapper.get("[data-testid='thread-id']").text()).not.toBe(
|
|
479
|
+
"t-active",
|
|
480
|
+
);
|
|
481
|
+
expect(wrapper.get("[data-testid='has-explicit-thread-id']").text()).toBe(
|
|
482
|
+
"false",
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
wrapper.unmount();
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("new-thread prefers onNewThread over the config's startNewThread when provided", async () => {
|
|
489
|
+
const onNewThread = vi.fn();
|
|
490
|
+
const wrapper = await mountDrawer({ onNewThread }, [h(ThreadIdProbe)]);
|
|
491
|
+
|
|
492
|
+
// Seed an explicit active thread so a config-driven reset would be
|
|
493
|
+
// observable if it (incorrectly) fired.
|
|
494
|
+
const el = wrapper.find(COPILOTKIT_THREADS_DRAWER_TAG).element;
|
|
495
|
+
el.dispatchEvent(
|
|
496
|
+
new CustomEvent("thread-selected", {
|
|
497
|
+
detail: { threadId: "t-active" },
|
|
498
|
+
bubbles: true,
|
|
499
|
+
composed: true,
|
|
500
|
+
}),
|
|
501
|
+
);
|
|
502
|
+
await nextTick();
|
|
503
|
+
expect(wrapper.get("[data-testid='thread-id']").text()).toBe("t-active");
|
|
504
|
+
|
|
505
|
+
el.dispatchEvent(
|
|
506
|
+
new CustomEvent("new-thread", {
|
|
507
|
+
detail: {},
|
|
508
|
+
bubbles: true,
|
|
509
|
+
composed: true,
|
|
510
|
+
}),
|
|
511
|
+
);
|
|
512
|
+
await nextTick();
|
|
513
|
+
|
|
514
|
+
expect(useThreadsMocks.startNewThread).toHaveBeenCalledTimes(1);
|
|
515
|
+
expect(onNewThread).toHaveBeenCalledTimes(1);
|
|
516
|
+
// Config-backed startNewThread was NOT used: the active thread the
|
|
517
|
+
// provider tracks is unchanged (a config-driven reset would clear it).
|
|
518
|
+
expect(wrapper.get("[data-testid='thread-id']").text()).toBe("t-active");
|
|
519
|
+
|
|
520
|
+
wrapper.unmount();
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it("routes retry(scope: fetch-more) to threadsApi.fetchMoreThreads", async () => {
|
|
524
|
+
const wrapper = await mountDrawer();
|
|
525
|
+
|
|
526
|
+
const el = wrapper.find(COPILOTKIT_THREADS_DRAWER_TAG).element;
|
|
527
|
+
el.dispatchEvent(
|
|
528
|
+
new CustomEvent("retry", {
|
|
529
|
+
detail: { scope: "fetch-more" },
|
|
530
|
+
bubbles: true,
|
|
531
|
+
composed: true,
|
|
532
|
+
}),
|
|
533
|
+
);
|
|
534
|
+
await nextTick();
|
|
535
|
+
|
|
536
|
+
expect(useThreadsMocks.fetchMoreThreads).toHaveBeenCalledTimes(1);
|
|
537
|
+
expect(useThreadsMocks.refetchThreads).not.toHaveBeenCalled();
|
|
538
|
+
|
|
539
|
+
wrapper.unmount();
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("routes retry(scope: initial) to threadsApi.refetchThreads", async () => {
|
|
543
|
+
const wrapper = await mountDrawer();
|
|
544
|
+
|
|
545
|
+
const el = wrapper.find(COPILOTKIT_THREADS_DRAWER_TAG).element;
|
|
546
|
+
el.dispatchEvent(
|
|
547
|
+
new CustomEvent("retry", {
|
|
548
|
+
detail: { scope: "initial" },
|
|
549
|
+
bubbles: true,
|
|
550
|
+
composed: true,
|
|
551
|
+
}),
|
|
552
|
+
);
|
|
553
|
+
await nextTick();
|
|
554
|
+
|
|
555
|
+
expect(useThreadsMocks.refetchThreads).toHaveBeenCalledTimes(1);
|
|
556
|
+
expect(useThreadsMocks.fetchMoreThreads).not.toHaveBeenCalled();
|
|
557
|
+
|
|
558
|
+
wrapper.unmount();
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it("routes load-more to threadsApi.fetchMoreThreads", async () => {
|
|
562
|
+
const wrapper = await mountDrawer();
|
|
563
|
+
|
|
564
|
+
const el = wrapper.find(COPILOTKIT_THREADS_DRAWER_TAG).element;
|
|
565
|
+
el.dispatchEvent(
|
|
566
|
+
new CustomEvent("load-more", {
|
|
567
|
+
detail: {},
|
|
568
|
+
bubbles: true,
|
|
569
|
+
composed: true,
|
|
570
|
+
}),
|
|
571
|
+
);
|
|
572
|
+
await nextTick();
|
|
573
|
+
|
|
574
|
+
expect(useThreadsMocks.fetchMoreThreads).toHaveBeenCalledTimes(1);
|
|
575
|
+
|
|
576
|
+
wrapper.unmount();
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it("routes open-change to the config's setDrawerOpen under a surrounding provider", async () => {
|
|
580
|
+
const wrapper = await mountDrawer({}, [h(DrawerOpenProbe)]);
|
|
581
|
+
|
|
582
|
+
// The provider's drawerOpen always starts closed, regardless of
|
|
583
|
+
// `isModalDefaultOpen` (that prop only seeds the chat modal).
|
|
584
|
+
expect(wrapper.get("[data-testid='drawer-open']").text()).toBe("false");
|
|
585
|
+
|
|
586
|
+
const el = wrapper.find(COPILOTKIT_THREADS_DRAWER_TAG).element;
|
|
587
|
+
el.dispatchEvent(
|
|
588
|
+
new CustomEvent("open-change", {
|
|
589
|
+
detail: { open: true },
|
|
590
|
+
bubbles: true,
|
|
591
|
+
composed: true,
|
|
592
|
+
}),
|
|
593
|
+
);
|
|
594
|
+
await nextTick();
|
|
595
|
+
|
|
596
|
+
// The config-backed value flips — proving the event routed through
|
|
597
|
+
// config.setDrawerOpen rather than only updating the element locally.
|
|
598
|
+
expect(wrapper.get("[data-testid='drawer-open']").text()).toBe("true");
|
|
599
|
+
|
|
600
|
+
el.dispatchEvent(
|
|
601
|
+
new CustomEvent("open-change", {
|
|
602
|
+
detail: { open: false },
|
|
603
|
+
bubbles: true,
|
|
604
|
+
composed: true,
|
|
605
|
+
}),
|
|
606
|
+
);
|
|
607
|
+
await nextTick();
|
|
608
|
+
|
|
609
|
+
expect(wrapper.get("[data-testid='drawer-open']").text()).toBe("false");
|
|
610
|
+
|
|
611
|
+
wrapper.unmount();
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it("resolved-unlicensed status ('none') shows the locked view and skips the thread fetch", async () => {
|
|
615
|
+
licenseContextMock.value = {
|
|
616
|
+
status: "none",
|
|
617
|
+
license: null,
|
|
618
|
+
checkFeature: () => true,
|
|
619
|
+
getLimit: () => null,
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
const wrapper = await mountDrawer();
|
|
623
|
+
|
|
624
|
+
const el = wrapper.find(COPILOTKIT_THREADS_DRAWER_TAG)
|
|
625
|
+
.element as unknown as CopilotKitThreadsDrawerElement;
|
|
626
|
+
|
|
627
|
+
expect(el.licensed).toBe(false);
|
|
628
|
+
expect(
|
|
629
|
+
toValue(
|
|
630
|
+
useThreadsMocks.useThreadsInput?.enabled as MaybeRefOrGetter<boolean>,
|
|
631
|
+
),
|
|
632
|
+
).toBe(false);
|
|
633
|
+
|
|
634
|
+
wrapper.unmount();
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it("resolved-unlicensed status ('invalid') shows the locked view", async () => {
|
|
638
|
+
licenseContextMock.value = {
|
|
639
|
+
status: "invalid",
|
|
640
|
+
license: null,
|
|
641
|
+
checkFeature: () => false,
|
|
642
|
+
getLimit: () => null,
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
const wrapper = await mountDrawer();
|
|
646
|
+
|
|
647
|
+
const el = wrapper.find(COPILOTKIT_THREADS_DRAWER_TAG)
|
|
648
|
+
.element as unknown as CopilotKitThreadsDrawerElement;
|
|
649
|
+
|
|
650
|
+
expect(el.licensed).toBe(false);
|
|
651
|
+
|
|
652
|
+
wrapper.unmount();
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it("surfaces a genuine listError to the element's error string", async () => {
|
|
656
|
+
const listError = new Error("boom");
|
|
657
|
+
useThreadsMocks.listError.value = listError;
|
|
658
|
+
useThreadsMocks.error.value = listError;
|
|
659
|
+
|
|
660
|
+
const wrapper = await mountDrawer();
|
|
661
|
+
|
|
662
|
+
const el = wrapper.find(COPILOTKIT_THREADS_DRAWER_TAG)
|
|
663
|
+
.element as unknown as CopilotKitThreadsDrawerElement;
|
|
664
|
+
|
|
665
|
+
expect(el.error).toBe(listError.message);
|
|
666
|
+
|
|
667
|
+
wrapper.unmount();
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
it("forwards fetchMoreError to the element's fetchMoreError property without touching error", async () => {
|
|
671
|
+
useThreadsMocks.fetchMoreError.value = new Error("couldn't load more");
|
|
672
|
+
|
|
673
|
+
const wrapper = await mountDrawer();
|
|
674
|
+
|
|
675
|
+
const el = wrapper.find(COPILOTKIT_THREADS_DRAWER_TAG)
|
|
676
|
+
.element as unknown as CopilotKitThreadsDrawerElement;
|
|
677
|
+
|
|
678
|
+
expect(el.fetchMoreError).toBe("couldn't load more");
|
|
679
|
+
// The dedicated fetch-more channel must NOT bleed into the initial-list error.
|
|
680
|
+
expect(el.error).toBeNull();
|
|
681
|
+
|
|
682
|
+
wrapper.unmount();
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it("suppresses a config/runtime error that is not a listError from the element's error string", async () => {
|
|
686
|
+
// Only the combined `error` channel carries a dev/config error; `listError`
|
|
687
|
+
// stays null because no genuine list-load failure occurred. The element
|
|
688
|
+
// must not leak the developer-facing message into the end-user error UI.
|
|
689
|
+
useThreadsMocks.error.value = new Error("Runtime URL is not configured");
|
|
690
|
+
useThreadsMocks.listError.value = null;
|
|
691
|
+
|
|
692
|
+
const wrapper = await mountDrawer();
|
|
693
|
+
|
|
694
|
+
const el = wrapper.find(COPILOTKIT_THREADS_DRAWER_TAG)
|
|
695
|
+
.element as unknown as CopilotKitThreadsDrawerElement;
|
|
696
|
+
|
|
697
|
+
expect(el.error).toBeNull();
|
|
698
|
+
|
|
699
|
+
wrapper.unmount();
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
it("forwards the limit prop to useThreads", async () => {
|
|
703
|
+
const wrapper = await mountDrawer({ limit: 20 });
|
|
704
|
+
|
|
705
|
+
expect(
|
|
706
|
+
toValue(
|
|
707
|
+
useThreadsMocks.useThreadsInput?.limit as MaybeRefOrGetter<number>,
|
|
708
|
+
),
|
|
709
|
+
).toBe(20);
|
|
710
|
+
|
|
711
|
+
wrapper.unmount();
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it("sets the element's licenseUrl when the prop is provided", async () => {
|
|
715
|
+
const wrapper = await mountDrawer({
|
|
716
|
+
licenseUrl: "https://example.com/upgrade",
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
const el = wrapper.find(COPILOTKIT_THREADS_DRAWER_TAG)
|
|
720
|
+
.element as unknown as CopilotKitThreadsDrawerElement;
|
|
721
|
+
|
|
722
|
+
expect(el.licenseUrl).toBe("https://example.com/upgrade");
|
|
723
|
+
|
|
724
|
+
wrapper.unmount();
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
it("binds recent-label to the element", async () => {
|
|
728
|
+
const wrapper = await mountDrawer({ recentLabel: "History" });
|
|
729
|
+
|
|
730
|
+
const el = wrapper.find(COPILOTKIT_THREADS_DRAWER_TAG).element;
|
|
731
|
+
expect(el.getAttribute("recent-label")).toBe("History");
|
|
732
|
+
|
|
733
|
+
wrapper.unmount();
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
it("sets the element's collapsible property to false when collapsible is false", async () => {
|
|
737
|
+
const wrapper = await mountDrawer({ collapsible: false });
|
|
738
|
+
|
|
739
|
+
const el = wrapper.find(COPILOTKIT_THREADS_DRAWER_TAG)
|
|
740
|
+
.element as unknown as {
|
|
741
|
+
collapsible: boolean;
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
expect(el.collapsible).toBe(false);
|
|
745
|
+
|
|
746
|
+
wrapper.unmount();
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
it("leaves the element collapsible (true) when the prop is omitted", async () => {
|
|
750
|
+
// Regression: Vue coerces an omitted Boolean prop to `false`, so without the
|
|
751
|
+
// wrapper's explicit `collapsible: true` withDefaults the element would be
|
|
752
|
+
// forced to `false` and the collapse toggle would silently vanish.
|
|
753
|
+
const wrapper = await mountDrawer({});
|
|
754
|
+
|
|
755
|
+
const el = wrapper.find(COPILOTKIT_THREADS_DRAWER_TAG)
|
|
756
|
+
.element as unknown as {
|
|
757
|
+
collapsible: boolean;
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
expect(el.collapsible).toBe(true);
|
|
761
|
+
|
|
762
|
+
wrapper.unmount();
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
it("re-emits the element's collapse-change event as collapse-change(collapsed)", async () => {
|
|
766
|
+
const wrapper = await mountDrawer();
|
|
767
|
+
|
|
768
|
+
const drawer = wrapper.findComponent(CopilotThreadsDrawer);
|
|
769
|
+
const el = wrapper.find(COPILOTKIT_THREADS_DRAWER_TAG).element;
|
|
770
|
+
el.dispatchEvent(
|
|
771
|
+
new CustomEvent("collapse-change", {
|
|
772
|
+
detail: { collapsed: true },
|
|
773
|
+
bubbles: true,
|
|
774
|
+
composed: true,
|
|
775
|
+
}),
|
|
776
|
+
);
|
|
777
|
+
await nextTick();
|
|
778
|
+
|
|
779
|
+
expect(drawer.emitted("collapse-change")?.[0]).toEqual([true]);
|
|
780
|
+
|
|
781
|
+
wrapper.unmount();
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it("leaves the element's default licenseUrl when the prop is omitted", async () => {
|
|
785
|
+
const wrapper = await mountDrawer();
|
|
786
|
+
|
|
787
|
+
const el = wrapper.find(COPILOTKIT_THREADS_DRAWER_TAG)
|
|
788
|
+
.element as unknown as CopilotKitThreadsDrawerElement;
|
|
789
|
+
|
|
790
|
+
expect(el.licenseUrl).toBe("https://docs.copilotkit.ai/intelligence");
|
|
791
|
+
|
|
792
|
+
wrapper.unmount();
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
it('projects the row slot as a light-DOM child per thread with slot="row:<id>"', async () => {
|
|
796
|
+
useThreadsMocks.threads.value = [
|
|
797
|
+
makeThread("t-1"),
|
|
798
|
+
makeThread("t-2"),
|
|
799
|
+
makeThread("t-3"),
|
|
800
|
+
];
|
|
801
|
+
|
|
802
|
+
// mountDrawer doesn't accept slots directly, so mount explicitly with
|
|
803
|
+
// the `row` scoped slot wired through.
|
|
804
|
+
const withRowSlot = mount(CopilotKitProvider, {
|
|
805
|
+
props: { runtimeUrl: "/api/copilotkit" },
|
|
806
|
+
slots: {
|
|
807
|
+
default: () =>
|
|
808
|
+
h(
|
|
809
|
+
CopilotChatConfigurationProvider,
|
|
810
|
+
{ isModalDefaultOpen: true },
|
|
811
|
+
{
|
|
812
|
+
default: () =>
|
|
813
|
+
h(
|
|
814
|
+
CopilotThreadsDrawer,
|
|
815
|
+
{},
|
|
816
|
+
{
|
|
817
|
+
row: ({ thread }: { thread: Thread }) =>
|
|
818
|
+
h("span", { class: "custom-row" }, `custom:${thread.id}`),
|
|
819
|
+
},
|
|
820
|
+
),
|
|
821
|
+
},
|
|
822
|
+
),
|
|
823
|
+
},
|
|
824
|
+
attachTo: document.body,
|
|
825
|
+
});
|
|
826
|
+
await flushPromises();
|
|
827
|
+
await nextTick();
|
|
828
|
+
|
|
829
|
+
const el = withRowSlot.find(COPILOTKIT_THREADS_DRAWER_TAG).element;
|
|
830
|
+
const projected = Array.from(
|
|
831
|
+
el.querySelectorAll<HTMLElement>("[slot^='row:']"),
|
|
832
|
+
);
|
|
833
|
+
|
|
834
|
+
expect(projected).toHaveLength(3);
|
|
835
|
+
const bySlot = new Map(
|
|
836
|
+
projected.map((node) => [node.getAttribute("slot"), node]),
|
|
837
|
+
);
|
|
838
|
+
for (const t of ["t-1", "t-2", "t-3"]) {
|
|
839
|
+
const node = bySlot.get(`row:${t}`);
|
|
840
|
+
expect(node).toBeTruthy();
|
|
841
|
+
expect(node?.querySelector(".custom-row")?.textContent).toBe(
|
|
842
|
+
`custom:${t}`,
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
withRowSlot.unmount();
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
it("returns focus to the chat input WITHIN the drawer's own chat-view container on a multi-chat page", async () => {
|
|
850
|
+
// Two independent chat-view containers, each with its own input, to
|
|
851
|
+
// prove `findChatInput` scopes to the drawer's ANCESTOR container rather
|
|
852
|
+
// than grabbing whichever input is first in document order.
|
|
853
|
+
const containerA = document.createElement("div");
|
|
854
|
+
containerA.setAttribute("data-testid", "copilot-chat-view");
|
|
855
|
+
const inputA = document.createElement("textarea");
|
|
856
|
+
inputA.setAttribute("data-testid", "copilot-chat-input-textarea");
|
|
857
|
+
containerA.appendChild(inputA);
|
|
858
|
+
|
|
859
|
+
const containerB = document.createElement("div");
|
|
860
|
+
containerB.setAttribute("data-testid", "copilot-chat-view");
|
|
861
|
+
const inputB = document.createElement("textarea");
|
|
862
|
+
inputB.setAttribute("data-testid", "copilot-chat-input-textarea");
|
|
863
|
+
containerB.appendChild(inputB);
|
|
864
|
+
|
|
865
|
+
document.body.appendChild(containerA);
|
|
866
|
+
document.body.appendChild(containerB);
|
|
867
|
+
|
|
868
|
+
// Mount the drawer directly into containerB, so the correct scoped
|
|
869
|
+
// target is inputB, NOT inputA (which appears first in DOM order).
|
|
870
|
+
const wrapper = mount(CopilotKitProvider, {
|
|
871
|
+
props: { runtimeUrl: "/api/copilotkit" },
|
|
872
|
+
slots: {
|
|
873
|
+
default: () =>
|
|
874
|
+
h(
|
|
875
|
+
CopilotChatConfigurationProvider,
|
|
876
|
+
{ isModalDefaultOpen: true },
|
|
877
|
+
{
|
|
878
|
+
default: () => h(CopilotThreadsDrawer, {}),
|
|
879
|
+
},
|
|
880
|
+
),
|
|
881
|
+
},
|
|
882
|
+
attachTo: containerB,
|
|
883
|
+
});
|
|
884
|
+
await flushPromises();
|
|
885
|
+
await nextTick();
|
|
886
|
+
|
|
887
|
+
const el = wrapper.find(COPILOTKIT_THREADS_DRAWER_TAG).element;
|
|
888
|
+
el.dispatchEvent(
|
|
889
|
+
new CustomEvent("thread-selected", {
|
|
890
|
+
detail: { threadId: "t-42" },
|
|
891
|
+
bubbles: true,
|
|
892
|
+
composed: true,
|
|
893
|
+
}),
|
|
894
|
+
);
|
|
895
|
+
await nextTick();
|
|
896
|
+
|
|
897
|
+
expect(document.activeElement).toBe(inputB);
|
|
898
|
+
expect(document.activeElement).not.toBe(inputA);
|
|
899
|
+
|
|
900
|
+
wrapper.unmount();
|
|
901
|
+
containerA.remove();
|
|
902
|
+
containerB.remove();
|
|
903
|
+
});
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
describe("CopilotThreadsDrawer package export", () => {
|
|
907
|
+
it("is exported from the package entry", () => {
|
|
908
|
+
expect((vue as Record<string, unknown>).CopilotThreadsDrawer).toBeDefined();
|
|
909
|
+
});
|
|
910
|
+
});
|