@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.
Files changed (48) hide show
  1. package/dist/index.cjs +1 -1
  2. package/dist/index.mjs +70 -69
  3. package/dist/styles.css +1 -1
  4. package/dist/use-render-activity-message-CIvEoQzA.cjs +85 -0
  5. package/dist/use-render-activity-message-CIvEoQzA.cjs.map +1 -0
  6. package/dist/{use-render-activity-message-8_HyVZWc.js → use-render-activity-message-CbKeqosY.js} +3514 -3207
  7. package/dist/use-render-activity-message-CbKeqosY.js.map +1 -0
  8. package/dist/v2/components/chat/CopilotChat.vue.d.ts.map +1 -1
  9. package/dist/v2/components/chat/CopilotChatAssistantMessage.vue.d.ts +8 -0
  10. package/dist/v2/components/chat/CopilotChatAssistantMessage.vue.d.ts.map +1 -1
  11. package/dist/v2/components/chat/CopilotModalHeader.vue.d.ts.map +1 -1
  12. package/dist/v2/components/chat/CopilotThreadsDrawer.vue.d.ts +65 -0
  13. package/dist/v2/components/chat/CopilotThreadsDrawer.vue.d.ts.map +1 -0
  14. package/dist/v2/components/chat/index.d.ts +1 -0
  15. package/dist/v2/components/chat/index.d.ts.map +1 -1
  16. package/dist/v2/components/icons/index.d.ts +1 -1
  17. package/dist/v2/components/icons/index.d.ts.map +1 -1
  18. package/dist/v2/hooks/use-threads.d.ts +19 -0
  19. package/dist/v2/hooks/use-threads.d.ts.map +1 -1
  20. package/dist/v2/index.cjs +1 -1
  21. package/dist/v2/index.mjs +44 -43
  22. package/dist/v2/lib/is-mobile-viewport.d.ts +15 -0
  23. package/dist/v2/lib/is-mobile-viewport.d.ts.map +1 -0
  24. package/dist/v2/providers/CopilotChatConfigurationProvider.vue.d.ts.map +1 -1
  25. package/dist/v2/providers/types.d.ts +24 -0
  26. package/dist/v2/providers/types.d.ts.map +1 -1
  27. package/package.json +5 -4
  28. package/src/v2/components/chat/CopilotChat.vue +22 -4
  29. package/src/v2/components/chat/CopilotModalHeader.vue +61 -2
  30. package/src/v2/components/chat/CopilotThreadsDrawer.vue +335 -0
  31. package/src/v2/components/chat/__tests__/CopilotChat.clearOnFresh.test.ts +160 -0
  32. package/src/v2/components/chat/__tests__/CopilotModalHeader.drawerLauncher.test.ts +125 -0
  33. package/src/v2/components/chat/__tests__/CopilotThreadsDrawer.ssr.test.ts +25 -0
  34. package/src/v2/components/chat/__tests__/CopilotThreadsDrawer.test.ts +910 -0
  35. package/src/v2/components/chat/index.ts +1 -0
  36. package/src/v2/components/icons/index.ts +1 -0
  37. package/src/v2/hooks/__tests__/use-human-in-the-loop.e2e.test.ts +4 -4
  38. package/src/v2/hooks/__tests__/use-threads.test.ts +315 -6
  39. package/src/v2/hooks/use-threads.ts +148 -8
  40. package/src/v2/lib/__tests__/is-mobile-viewport.test.ts +48 -0
  41. package/src/v2/lib/is-mobile-viewport.ts +23 -0
  42. package/src/v2/providers/CopilotChatConfigurationProvider.vue +120 -4
  43. package/src/v2/providers/__tests__/CopilotChatConfigurationProvider.test.ts +105 -1
  44. package/src/v2/providers/types.ts +25 -0
  45. package/vite.config.ts +7 -0
  46. package/dist/use-render-activity-message-8_HyVZWc.js.map +0 -1
  47. package/dist/use-render-activity-message-BqgmNPCz.cjs +0 -85
  48. 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
+ });