@copilotkit/vue 1.62.0 → 1.62.2

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 (53) hide show
  1. package/PARITY.md +7 -7
  2. package/dist/index-By5nIKxT.js +1632 -0
  3. package/dist/index-By5nIKxT.js.map +1 -0
  4. package/dist/index-LFxZI63k.cjs +748 -0
  5. package/dist/index-LFxZI63k.cjs.map +1 -0
  6. package/dist/index.cjs +1 -1
  7. package/dist/index.mjs +70 -69
  8. package/dist/styles.css +1 -1
  9. package/dist/{use-render-activity-message-Bzh3Bckz.js → use-render-activity-message-DbGuwvBd.js} +3363 -3064
  10. package/dist/use-render-activity-message-DbGuwvBd.js.map +1 -0
  11. package/dist/use-render-activity-message-hlNcbV9l.cjs +85 -0
  12. package/dist/use-render-activity-message-hlNcbV9l.cjs.map +1 -0
  13. package/dist/v2/components/chat/CopilotChat.vue.d.ts.map +1 -1
  14. package/dist/v2/components/chat/CopilotModalHeader.vue.d.ts.map +1 -1
  15. package/dist/v2/components/chat/CopilotThreadsDrawer.vue.d.ts +42 -0
  16. package/dist/v2/components/chat/CopilotThreadsDrawer.vue.d.ts.map +1 -0
  17. package/dist/v2/components/chat/index.d.ts +1 -0
  18. package/dist/v2/components/chat/index.d.ts.map +1 -1
  19. package/dist/v2/components/icons/index.d.ts +1 -1
  20. package/dist/v2/components/icons/index.d.ts.map +1 -1
  21. package/dist/v2/hooks/use-agent.d.ts.map +1 -1
  22. package/dist/v2/hooks/use-threads.d.ts +12 -0
  23. package/dist/v2/hooks/use-threads.d.ts.map +1 -1
  24. package/dist/v2/index.cjs +1 -1
  25. package/dist/v2/index.mjs +44 -43
  26. package/dist/v2/lib/is-mobile-viewport.d.ts +15 -0
  27. package/dist/v2/lib/is-mobile-viewport.d.ts.map +1 -0
  28. package/dist/v2/providers/CopilotChatConfigurationProvider.vue.d.ts.map +1 -1
  29. package/dist/v2/providers/types.d.ts +24 -0
  30. package/dist/v2/providers/types.d.ts.map +1 -1
  31. package/package.json +7 -6
  32. package/src/v2/components/chat/CopilotChat.vue +22 -4
  33. package/src/v2/components/chat/CopilotModalHeader.vue +61 -2
  34. package/src/v2/components/chat/CopilotThreadsDrawer.vue +285 -0
  35. package/src/v2/components/chat/__tests__/CopilotChat.clearOnFresh.test.ts +160 -0
  36. package/src/v2/components/chat/__tests__/CopilotModalHeader.drawerLauncher.test.ts +125 -0
  37. package/src/v2/components/chat/__tests__/CopilotThreadsDrawer.ssr.test.ts +25 -0
  38. package/src/v2/components/chat/__tests__/CopilotThreadsDrawer.test.ts +835 -0
  39. package/src/v2/components/chat/index.ts +1 -0
  40. package/src/v2/components/icons/index.ts +1 -0
  41. package/src/v2/hooks/__tests__/use-agent.test.ts +60 -1
  42. package/src/v2/hooks/__tests__/use-human-in-the-loop.e2e.test.ts +4 -4
  43. package/src/v2/hooks/__tests__/use-threads.test.ts +303 -6
  44. package/src/v2/hooks/use-agent.ts +4 -1
  45. package/src/v2/hooks/use-threads.ts +137 -8
  46. package/src/v2/lib/__tests__/is-mobile-viewport.test.ts +48 -0
  47. package/src/v2/lib/is-mobile-viewport.ts +23 -0
  48. package/src/v2/providers/CopilotChatConfigurationProvider.vue +120 -4
  49. package/src/v2/providers/__tests__/CopilotChatConfigurationProvider.test.ts +105 -1
  50. package/src/v2/providers/types.ts +25 -0
  51. package/dist/use-render-activity-message-Bzh3Bckz.js.map +0 -1
  52. package/dist/use-render-activity-message-CW3vipBB.cjs +0 -85
  53. package/dist/use-render-activity-message-CW3vipBB.cjs.map +0 -1
@@ -0,0 +1,285 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, onScopeDispose, ref, watchEffect } from "vue";
3
+ // NOTE: the `<copilotkit-threads-drawer>` element is a Lit custom element whose
4
+ // module evaluates `class ... extends HTMLElement` at import time, which throws
5
+ // under SSR (Node has no DOM). It is therefore imported LAZILY (client-only,
6
+ // inside onMounted) rather than statically, so that importing `@copilotkit/vue`
7
+ // stays SSR-safe for every consumer (e.g. Nuxt). Only the erased `import type`
8
+ // below is kept at module scope.
9
+ import type {
10
+ CopilotKitThreadsDrawer as CopilotKitThreadsDrawerElement,
11
+ DrawerThread,
12
+ ThreadSelectedDetail,
13
+ ArchiveDetail,
14
+ UnarchiveDetail,
15
+ DeleteDetail,
16
+ RetryDetail,
17
+ OpenChangeDetail,
18
+ } from "@copilotkit/web-components/threads-drawer";
19
+ import { DEFAULT_AGENT_ID } from "@copilotkit/shared";
20
+ import { useThreads } from "../../hooks/use-threads";
21
+ import type { Thread } from "../../hooks/use-threads";
22
+ import { useCopilotChatConfiguration } from "../../providers/useCopilotChatConfiguration";
23
+ import { useLicenseContext } from "../../providers/useLicenseContext";
24
+
25
+ const props = withDefaults(
26
+ defineProps<{
27
+ agentId?: string;
28
+ onThreadSelect?: (threadId: string) => void;
29
+ onNewThread?: () => void;
30
+ onLicensed?: () => void;
31
+ licenseUrl?: string;
32
+ label?: string;
33
+ limit?: number;
34
+ dataTestId?: string;
35
+ }>(),
36
+ { dataTestId: "copilot-threads-drawer" },
37
+ );
38
+
39
+ const config = useCopilotChatConfiguration();
40
+ const license = useLicenseContext();
41
+
42
+ const licensed = computed(() => {
43
+ const status = license.value.status;
44
+ const present = status === "valid" || status === "expiring";
45
+ return present && license.value.checkFeature("threads");
46
+ });
47
+ const licensePending = computed(() => license.value.status === null);
48
+
49
+ const resolvedAgentId = computed(
50
+ () => props.agentId ?? config.value?.agentId ?? DEFAULT_AGENT_ID,
51
+ );
52
+ const activeThreadId = computed(() => config.value?.threadId ?? null);
53
+
54
+ // Provider-less fallback: without a surrounding chat configuration there is
55
+ // no shared open-state to bind to, so the wrapper keeps its own local
56
+ // open-state. It starts CLOSED — matching the provider's own default — so a
57
+ // bare `<CopilotThreadsDrawer>` does not render stuck-open and the element's
58
+ // open-change events still toggle it.
59
+ const localDrawerOpen = ref(false);
60
+ const drawerOpen = computed(() =>
61
+ config.value ? config.value.drawerOpen : localDrawerOpen.value,
62
+ );
63
+ function setDrawerOpen(open: boolean) {
64
+ if (config.value) config.value.setDrawerOpen?.(open);
65
+ else localDrawerOpen.value = open;
66
+ }
67
+
68
+ const threadsApi = useThreads({
69
+ agentId: resolvedAgentId,
70
+ includeArchived: true,
71
+ enabled: licensed,
72
+ limit: () => props.limit,
73
+ });
74
+
75
+ function toDrawerThread(t: Thread): DrawerThread {
76
+ return {
77
+ id: t.id,
78
+ name: t.name,
79
+ archived: t.archived,
80
+ createdAt: t.createdAt,
81
+ updatedAt: t.updatedAt,
82
+ ...(t.lastRunAt !== undefined ? { lastRunAt: t.lastRunAt } : {}),
83
+ };
84
+ }
85
+ const drawerThreads = computed(() =>
86
+ threadsApi.threads.value.map(toDrawerThread),
87
+ );
88
+
89
+ // Client-only element registration (SSR-safe). The element module is imported
90
+ // lazily here (never at module scope — see the import note above) so SSR never
91
+ // evaluates its `extends HTMLElement`. `elementTag` holds the resolved custom-
92
+ // element tag and `mounted` gates the render; both are set only after the
93
+ // dynamic import resolves on the client.
94
+ const mounted = ref(false);
95
+ const elementTag = ref<string | null>(null);
96
+ const elRef = ref<CopilotKitThreadsDrawerElement | null>(null);
97
+ onMounted(async () => {
98
+ const mod = await import("@copilotkit/web-components/threads-drawer");
99
+ mod.defineCopilotKitThreadsDrawer();
100
+ elementTag.value = mod.COPILOTKIT_THREADS_DRAWER_TAG;
101
+ mounted.value = true;
102
+ });
103
+
104
+ // Imperatively push object/array/boolean PROPERTIES (v-bind on a custom element
105
+ // would set string attributes for these). Re-runs whenever the element or any
106
+ // reactive value read in the body changes (auto-tracked by watchEffect).
107
+ watchEffect(
108
+ () => {
109
+ const el = elRef.value;
110
+ if (!el) return;
111
+ el.threads = drawerThreads.value;
112
+ el.loading = threadsApi.isLoading.value || licensePending.value;
113
+ el.error = threadsApi.listError.value?.message ?? null;
114
+ el.activeThreadId = activeThreadId.value;
115
+ el.licensed = licensed.value || licensePending.value;
116
+ el.hasMore = threadsApi.hasMoreThreads.value;
117
+ el.fetchingMore = threadsApi.isFetchingMoreThreads.value;
118
+ el.open = drawerOpen.value;
119
+ if (props.label !== undefined) el.label = props.label;
120
+ if (props.licenseUrl !== undefined) el.licenseUrl = props.licenseUrl;
121
+ },
122
+ { flush: "post" },
123
+ );
124
+
125
+ // Announce drawer presence so the mobile header launcher renders. Register
126
+ // synchronously in setup (React parity: a mount-time effect) and de-register
127
+ // on scope dispose. Not gated on `mounted`: presence is independent of the
128
+ // client-only element registration.
129
+ const unregisterDrawer = config.value?.registerDrawer?.();
130
+ if (unregisterDrawer) onScopeDispose(unregisterDrawer);
131
+
132
+ // --- Outbound event handlers ------------------------------------------------
133
+ /** The chat input textarea's documented `data-testid`. */
134
+ const CHAT_INPUT_TESTID = "copilot-chat-input-textarea";
135
+ /** The chat view container's documented `data-testid`. */
136
+ const CHAT_CONTAINER_TESTID = "copilot-chat-view";
137
+
138
+ /**
139
+ * Returns the chat input element for focus-return after a thread is selected.
140
+ *
141
+ * Best-effort and SCOPED: walks up from the drawer's custom element looking
142
+ * for an ancestor that contains a chat-view container
143
+ * (`data-testid="copilot-chat-view"`), then returns the chat input within
144
+ * that subtree. This avoids focusing the wrong composer on a page hosting
145
+ * more than one chat (multi-chat dashboards), where a document-global lookup
146
+ * would grab whichever input appears first in DOM order rather than the one
147
+ * this drawer drives.
148
+ *
149
+ * Falls back to a document-global lookup when no scoping ancestor is found
150
+ * (e.g. the drawer and chat share no common container, or headless usage).
151
+ */
152
+ function findChatInput(origin: Element | null): HTMLElement | null {
153
+ if (typeof document === "undefined") return null;
154
+ const container = origin?.closest?.(
155
+ `[data-testid="${CHAT_CONTAINER_TESTID}"]`,
156
+ );
157
+ if (container) {
158
+ const scoped = container.querySelector<HTMLElement>(
159
+ `[data-testid="${CHAT_INPUT_TESTID}"]`,
160
+ );
161
+ if (scoped) return scoped;
162
+ }
163
+ return document.querySelector<HTMLElement>(
164
+ `[data-testid="${CHAT_INPUT_TESTID}"]`,
165
+ );
166
+ }
167
+
168
+ function focusChatInput() {
169
+ findChatInput(elRef.value)?.focus();
170
+ }
171
+
172
+ function onThreadSelected(event: Event) {
173
+ const { threadId } = (event as CustomEvent<ThreadSelectedDetail>).detail;
174
+ if (props.onThreadSelect) props.onThreadSelect(threadId);
175
+ else config.value?.setActiveThreadId?.(threadId, { explicit: true });
176
+ focusChatInput();
177
+ }
178
+ function handleNewThread() {
179
+ threadsApi.startNewThread();
180
+ if (props.onNewThread) props.onNewThread();
181
+ else config.value?.startNewThread?.();
182
+ }
183
+ function onArchive(event: Event) {
184
+ const { threadId } = (event as CustomEvent<ArchiveDetail>).detail;
185
+ void threadsApi
186
+ .archiveThread(threadId)
187
+ .catch((e) =>
188
+ console.error("CopilotThreadsDrawer: archiveThread failed", e),
189
+ );
190
+ }
191
+ function onUnarchive(event: Event) {
192
+ const { threadId } = (event as CustomEvent<UnarchiveDetail>).detail;
193
+ void threadsApi
194
+ .unarchiveThread(threadId)
195
+ .catch((e) =>
196
+ console.error("CopilotThreadsDrawer: unarchiveThread failed", e),
197
+ );
198
+ }
199
+ function onDelete(event: Event) {
200
+ const { threadId } = (event as CustomEvent<DeleteDetail>).detail;
201
+ const wasActive = threadId === activeThreadId.value;
202
+ void threadsApi
203
+ .deleteThread(threadId)
204
+ .then(() => {
205
+ if (wasActive) {
206
+ handleNewThread();
207
+ }
208
+ })
209
+ .catch((e) =>
210
+ console.error("CopilotThreadsDrawer: deleteThread failed", e),
211
+ );
212
+ }
213
+ function onFilterChange() {
214
+ threadsApi.refetchThreads();
215
+ }
216
+ function onRetry(event: Event) {
217
+ const { scope } = (event as CustomEvent<RetryDetail>).detail;
218
+ if (scope === "fetch-more") threadsApi.fetchMoreThreads();
219
+ else threadsApi.refetchThreads();
220
+ }
221
+ function onLoadMore() {
222
+ threadsApi.fetchMoreThreads();
223
+ }
224
+ function onOpenChange(event: Event) {
225
+ const { open } = (event as CustomEvent<OpenChangeDetail>).detail;
226
+ setDrawerOpen(open);
227
+ }
228
+ function handleLicensed() {
229
+ props.onLicensed?.();
230
+ }
231
+
232
+ defineSlots<{
233
+ default(): unknown;
234
+ /**
235
+ * Optional per-row content. When provided, this slot is rendered as
236
+ * light-DOM children with `slot="row:{id}"` for EVERY thread in the list,
237
+ * so the element projects it in place of the default row for all rows.
238
+ *
239
+ * Unlike React's `renderRow` (which returns `ReactNode | null` and can
240
+ * fall back to the element's built-in row on a per-thread basis by
241
+ * returning `null`), Vue's scoped slot is all-or-nothing: there is no
242
+ * per-row escape hatch back to the element default once the slot is
243
+ * defined. This is an intentional Vue-idiom divergence, not a bug — if a
244
+ * consumer needs per-row fallback, they should replicate the default row
245
+ * markup themselves inside the slot for the threads they don't want to
246
+ * customize.
247
+ */
248
+ row(props: { thread: Thread }): unknown;
249
+ }>();
250
+ </script>
251
+
252
+ <template>
253
+ <component
254
+ :is="elementTag"
255
+ v-if="mounted"
256
+ ref="elRef"
257
+ :data-testid="dataTestId"
258
+ @thread-selected="onThreadSelected"
259
+ @new-thread="handleNewThread"
260
+ @archive="onArchive"
261
+ @unarchive="onUnarchive"
262
+ @delete="onDelete"
263
+ @filter-change="onFilterChange"
264
+ @retry="onRetry"
265
+ @load-more="onLoadMore"
266
+ @open-change="onOpenChange"
267
+ @licensed="handleLicensed"
268
+ >
269
+ <slot />
270
+ <!--
271
+ When the `row` slot is defined, it is projected for EVERY thread (no
272
+ per-row fallback to the element default) — see the JSDoc on the `row`
273
+ slot above for the React `renderRow` comparison.
274
+ -->
275
+ <template v-if="$slots.row">
276
+ <div
277
+ v-for="t in threadsApi.threads.value"
278
+ :key="t.id"
279
+ v-bind="{ slot: `row:${t.id}` }"
280
+ >
281
+ <slot name="row" :thread="t" />
282
+ </div>
283
+ </template>
284
+ </component>
285
+ </template>
@@ -0,0 +1,160 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { defineComponent, h, nextTick } from "vue";
3
+ import { flushPromises, mount } from "@vue/test-utils";
4
+ import CopilotKitProvider from "../../../providers/CopilotKitProvider.vue";
5
+ import CopilotChatConfigurationProvider from "../../../providers/CopilotChatConfigurationProvider.vue";
6
+ import { useCopilotChatConfiguration } from "../../../providers/useCopilotChatConfiguration";
7
+ import { useCopilotKit } from "../../../providers/useCopilotKit";
8
+ import { StateCapturingAgent } from "../../../__tests__/utils/agents";
9
+ import CopilotChat from "../CopilotChat.vue";
10
+ import { getThreadClone } from "../../../hooks/use-agent";
11
+
12
+ // Proves the clear-on-fresh watch introduced in CopilotChat.vue:
13
+ // - does NOT clear messages on initial mount
14
+ // - DOES clear messages (setMessages([])) when the surrounding chat
15
+ // configuration transitions to a fresh, non-explicit thread via
16
+ // startNewThread()
17
+ describe("CopilotChat clear-on-fresh", () => {
18
+ it("does not clear messages on initial mount", async () => {
19
+ const agent = new StateCapturingAgent();
20
+
21
+ let core:
22
+ | ReturnType<typeof useCopilotKit>["copilotkit"]["value"]
23
+ | undefined;
24
+ const Probe = defineComponent({
25
+ setup() {
26
+ const { copilotkit } = useCopilotKit();
27
+ core = copilotkit.value;
28
+ return () => null;
29
+ },
30
+ });
31
+
32
+ mount(CopilotKitProvider, {
33
+ props: {
34
+ agents__unsafe_dev_only: { default: agent },
35
+ },
36
+ slots: {
37
+ default: () =>
38
+ h(
39
+ CopilotChatConfigurationProvider,
40
+ { threadId: "seed", hasExplicitThreadId: false },
41
+ {
42
+ default: () =>
43
+ h("div", [h(CopilotChat, { welcomeScreen: false }), h(Probe)]),
44
+ },
45
+ ),
46
+ },
47
+ });
48
+
49
+ await flushPromises();
50
+ await nextTick();
51
+
52
+ const registryAgent = core?.getAgent("default");
53
+ const resolvedAgent = getThreadClone(registryAgent, "seed");
54
+ expect(resolvedAgent).toBeDefined();
55
+
56
+ // Attach the spy only after mount has settled so the clone's own
57
+ // construction-time `setMessages([])` reset (in `cloneForThread`,
58
+ // unrelated to the clear-on-fresh watch) isn't mistaken for a clear.
59
+ const setMessagesSpy = vi.spyOn(resolvedAgent!, "setMessages");
60
+ await nextTick();
61
+
62
+ expect(setMessagesSpy).not.toHaveBeenCalled();
63
+ });
64
+
65
+ it("clears messages when startNewThread() drives a fresh, non-explicit switch", async () => {
66
+ const agent = new StateCapturingAgent();
67
+
68
+ let core:
69
+ | ReturnType<typeof useCopilotKit>["copilotkit"]["value"]
70
+ | undefined;
71
+ let startNewThread: (() => void) | undefined;
72
+ let setActiveThreadId:
73
+ | ((threadId: string, options?: { explicit?: boolean }) => void)
74
+ | undefined;
75
+ let currentThreadId: string | undefined;
76
+
77
+ const Probe = defineComponent({
78
+ setup() {
79
+ const { copilotkit } = useCopilotKit();
80
+ const chatConfig = useCopilotChatConfiguration();
81
+ core = copilotkit.value;
82
+ startNewThread = () => chatConfig.value?.startNewThread?.();
83
+ setActiveThreadId = (threadId, options) =>
84
+ chatConfig.value?.setActiveThreadId?.(threadId, options);
85
+ return () => {
86
+ currentThreadId = chatConfig.value?.threadId;
87
+ return null;
88
+ };
89
+ },
90
+ });
91
+
92
+ mount(CopilotKitProvider, {
93
+ props: {
94
+ agents__unsafe_dev_only: { default: agent },
95
+ },
96
+ slots: {
97
+ default: () =>
98
+ h(
99
+ CopilotChatConfigurationProvider,
100
+ { threadId: "seed", hasExplicitThreadId: false },
101
+ {
102
+ default: () =>
103
+ h("div", [h(CopilotChat, { welcomeScreen: false }), h(Probe)]),
104
+ },
105
+ ),
106
+ },
107
+ });
108
+
109
+ await flushPromises();
110
+ await nextTick();
111
+
112
+ const registryAgent = core?.getAgent("default");
113
+ const seedAgent = getThreadClone(registryAgent, "seed");
114
+ expect(seedAgent).toBeDefined();
115
+ expect(currentThreadId).toBe("seed");
116
+
117
+ expect(startNewThread).toBeDefined();
118
+ startNewThread!();
119
+ await flushPromises();
120
+ await nextTick();
121
+
122
+ expect(currentThreadId).toBeDefined();
123
+ expect(currentThreadId).not.toBe("seed");
124
+ const newAgentThreadId = currentThreadId!;
125
+ const newAgent = getThreadClone(registryAgent, newAgentThreadId);
126
+ expect(newAgent).toBeDefined();
127
+ expect(newAgent).not.toBe(seedAgent);
128
+
129
+ // Primary, behavioral assertion: the new agent ends with no messages
130
+ // after the fresh switch (this alone doesn't distinguish the watch from
131
+ // clone construction, since `cloneForThread` also resets to `[]`).
132
+ expect(newAgent!.messages).toEqual([]);
133
+
134
+ // Move to a third, unrelated non-explicit thread so `newAgent` is no
135
+ // longer the active agent, then dirty it directly (bypassing
136
+ // `cloneForThread`'s construction reset entirely, since the clone
137
+ // already exists in the cache).
138
+ expect(setActiveThreadId).toBeDefined();
139
+ setActiveThreadId!("elsewhere", { explicit: false });
140
+ await flushPromises();
141
+ await nextTick();
142
+ expect(currentThreadId).toBe("elsewhere");
143
+
144
+ newAgent!.setMessages([{ id: "m1", role: "user", content: "hi" } as never]);
145
+ expect(newAgent!.messages.length).toBe(1);
146
+
147
+ // Switch back to `newAgent`'s thread id, non-explicitly. Because the
148
+ // clone already exists in the cache, `getOrCreateThreadClone` returns it
149
+ // without reconstructing it — so any `setMessages([])` observed here
150
+ // must come from the clear-on-fresh watch, not from clone construction.
151
+ // This fails (messages stay dirty) if the watch's
152
+ // `currentAgent.setMessages([])` call is removed.
153
+ setActiveThreadId!(newAgentThreadId, { explicit: false });
154
+ await flushPromises();
155
+ await nextTick();
156
+
157
+ expect(currentThreadId).toBe(newAgentThreadId);
158
+ expect(newAgent!.messages).toEqual([]);
159
+ });
160
+ });
@@ -0,0 +1,125 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { defineComponent, h, nextTick } from "vue";
3
+ import { mount } from "@vue/test-utils";
4
+ import CopilotKitProvider from "../../../providers/CopilotKitProvider.vue";
5
+ import CopilotChatConfigurationProvider from "../../../providers/CopilotChatConfigurationProvider.vue";
6
+ import { useCopilotChatConfiguration } from "../../../providers/useCopilotChatConfiguration";
7
+ import { CopilotModalHeader } from "../index";
8
+
9
+ function mockMatchMedia(matches: boolean) {
10
+ vi.spyOn(window, "matchMedia").mockReturnValue({
11
+ matches,
12
+ media: "(max-width: 767px)",
13
+ onchange: null,
14
+ addEventListener: vi.fn(),
15
+ removeEventListener: vi.fn(),
16
+ addListener: vi.fn(),
17
+ removeListener: vi.fn(),
18
+ dispatchEvent: vi.fn(),
19
+ } as unknown as MediaQueryList);
20
+ }
21
+
22
+ function makeProbe(
23
+ onConfig: (cfg: ReturnType<typeof useCopilotChatConfiguration>) => void,
24
+ ) {
25
+ return defineComponent({
26
+ setup() {
27
+ const config = useCopilotChatConfiguration();
28
+ onConfig(config);
29
+ return () => h("span", { "data-testid": "probe" });
30
+ },
31
+ });
32
+ }
33
+
34
+ describe("CopilotModalHeader drawer launcher", () => {
35
+ afterEach(() => {
36
+ vi.restoreAllMocks();
37
+ });
38
+
39
+ it("does not render the launcher when no drawer is registered (mobile)", () => {
40
+ mockMatchMedia(true);
41
+
42
+ const wrapper = mount(CopilotKitProvider, {
43
+ props: { runtimeUrl: "/api/copilotkit" },
44
+ slots: {
45
+ default: () =>
46
+ h(
47
+ CopilotChatConfigurationProvider,
48
+ { isModalDefaultOpen: true },
49
+ { default: () => h(CopilotModalHeader) },
50
+ ),
51
+ },
52
+ });
53
+
54
+ expect(wrapper.find('[data-testid="drawer-launcher"]').exists()).toBe(
55
+ false,
56
+ );
57
+ });
58
+
59
+ it("renders the launcher and toggles drawerOpen when registered on mobile", async () => {
60
+ mockMatchMedia(true);
61
+
62
+ let cfg!: ReturnType<typeof useCopilotChatConfiguration>;
63
+ const Probe = makeProbe((config) => {
64
+ cfg = config;
65
+ });
66
+
67
+ const wrapper = mount(CopilotKitProvider, {
68
+ props: { runtimeUrl: "/api/copilotkit" },
69
+ slots: {
70
+ default: () =>
71
+ h(
72
+ CopilotChatConfigurationProvider,
73
+ { isModalDefaultOpen: true },
74
+ { default: () => [h(CopilotModalHeader), h(Probe)] },
75
+ ),
76
+ },
77
+ });
78
+
79
+ // No drawer registered yet -> no launcher.
80
+ expect(wrapper.find('[data-testid="drawer-launcher"]').exists()).toBe(
81
+ false,
82
+ );
83
+
84
+ cfg.value?.registerDrawer();
85
+ await nextTick();
86
+
87
+ const launcher = wrapper.find('[data-testid="drawer-launcher"]');
88
+ expect(launcher.exists()).toBe(true);
89
+
90
+ expect(cfg.value?.drawerOpen).toBe(false);
91
+ await launcher.trigger("click");
92
+
93
+ expect(cfg.value?.drawerOpen).toBe(true);
94
+ // Mobile mutual-exclusion: opening the drawer closes the modal.
95
+ expect(cfg.value?.isModalOpen).toBe(false);
96
+ });
97
+
98
+ it("does not render the launcher on desktop even when a drawer is registered", async () => {
99
+ mockMatchMedia(false);
100
+
101
+ let cfg!: ReturnType<typeof useCopilotChatConfiguration>;
102
+ const Probe = makeProbe((config) => {
103
+ cfg = config;
104
+ });
105
+
106
+ const wrapper = mount(CopilotKitProvider, {
107
+ props: { runtimeUrl: "/api/copilotkit" },
108
+ slots: {
109
+ default: () =>
110
+ h(
111
+ CopilotChatConfigurationProvider,
112
+ { isModalDefaultOpen: true },
113
+ { default: () => [h(CopilotModalHeader), h(Probe)] },
114
+ ),
115
+ },
116
+ });
117
+
118
+ cfg.value?.registerDrawer();
119
+ await nextTick();
120
+
121
+ expect(wrapper.find('[data-testid="drawer-launcher"]').exists()).toBe(
122
+ false,
123
+ );
124
+ });
125
+ });
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ // Regression guard: importing @copilotkit/vue must NOT eagerly evaluate the
4
+ // Lit <copilotkit-threads-drawer> element module. The element evaluates
5
+ // `class extends HTMLElement` at import time, which crashes Nuxt/Vite SSR
6
+ // (`HTMLElement is not defined`); it must be imported lazily (client-only,
7
+ // inside the wrapper's onMounted). If the wrapper ever regresses to a static
8
+ // top-level import, importing the package barrel would evaluate the mocked
9
+ // element module and flip the flag -> this test fails.
10
+ const { evaluated } = vi.hoisted(() => ({ evaluated: { current: false } }));
11
+ vi.mock("@copilotkit/web-components/threads-drawer", () => {
12
+ evaluated.current = true;
13
+ return {
14
+ defineCopilotKitThreadsDrawer: () => {},
15
+ COPILOTKIT_THREADS_DRAWER_TAG: "copilotkit-threads-drawer",
16
+ };
17
+ });
18
+
19
+ describe("@copilotkit/vue SSR import safety", () => {
20
+ it("does not eagerly evaluate the Lit element module when the package entry is imported", async () => {
21
+ const mod = await import("../../../../index");
22
+ expect(mod.CopilotThreadsDrawer).toBeDefined();
23
+ expect(evaluated.current).toBe(false);
24
+ });
25
+ });