@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,335 @@
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
+ // TODO(ENT-1051): import `CollapseChangeDetail` from
20
+ // "@copilotkit/web-components/threads-drawer" once the parallel element PR that
21
+ // adds the collapse feature (property `collapsible` + event `collapse-change`)
22
+ // lands and is published; declared locally here because the built element types
23
+ // in this worktree predate it.
24
+ type CollapseChangeDetail = { collapsed: boolean };
25
+ import { DEFAULT_AGENT_ID } from "@copilotkit/shared";
26
+ import { useThreads } from "../../hooks/use-threads";
27
+ import type { Thread } from "../../hooks/use-threads";
28
+ import { useCopilotChatConfiguration } from "../../providers/useCopilotChatConfiguration";
29
+ import { useLicenseContext } from "../../providers/useLicenseContext";
30
+
31
+ const props = withDefaults(
32
+ defineProps<{
33
+ agentId?: string;
34
+ onThreadSelect?: (threadId: string) => void;
35
+ onNewThread?: () => void;
36
+ onLicensed?: () => void;
37
+ licenseUrl?: string;
38
+ label?: string;
39
+ /**
40
+ * Heading rendered above the thread list (element attribute
41
+ * `recent-label`). Defaults to the element's own `"Recent Conversations"`
42
+ * when omitted.
43
+ */
44
+ recentLabel?: string;
45
+ /**
46
+ * Whether the drawer offers a collapse toggle. Bound to the element's
47
+ * `collapsible` PROPERTY. When `false`, the drawer has no collapse toggle
48
+ * and is always expanded. Defaults to `true`.
49
+ *
50
+ * NOTE: this MUST carry an explicit `true` default below. Vue coerces an
51
+ * omitted `Boolean` prop to `false` (not `undefined`), so without the
52
+ * default the `!== undefined` guard would always fire and force the element
53
+ * to `collapsible=false` — silently disabling collapse. Defaulting to `true`
54
+ * restores the element's own default when the prop is omitted.
55
+ */
56
+ collapsible?: boolean;
57
+ limit?: number;
58
+ dataTestId?: string;
59
+ }>(),
60
+ { dataTestId: "copilot-threads-drawer", collapsible: true },
61
+ );
62
+
63
+ const emit = defineEmits<{
64
+ /**
65
+ * Emitted when the drawer's collapsed state changes (mirrors the element's
66
+ * `collapse-change` event), carrying the new collapsed state.
67
+ */
68
+ "collapse-change": [collapsed: boolean];
69
+ }>();
70
+
71
+ const config = useCopilotChatConfiguration();
72
+ const license = useLicenseContext();
73
+
74
+ const licensed = computed(() => {
75
+ const status = license.value.status;
76
+ const present = status === "valid" || status === "expiring";
77
+ return present && license.value.checkFeature("threads");
78
+ });
79
+ const licensePending = computed(() => license.value.status === null);
80
+
81
+ const resolvedAgentId = computed(
82
+ () => props.agentId ?? config.value?.agentId ?? DEFAULT_AGENT_ID,
83
+ );
84
+ const activeThreadId = computed(() => config.value?.threadId ?? null);
85
+
86
+ // Provider-less fallback: without a surrounding chat configuration there is
87
+ // no shared open-state to bind to, so the wrapper keeps its own local
88
+ // open-state. It starts CLOSED — matching the provider's own default — so a
89
+ // bare `<CopilotThreadsDrawer>` does not render stuck-open and the element's
90
+ // open-change events still toggle it.
91
+ const localDrawerOpen = ref(false);
92
+ const drawerOpen = computed(() =>
93
+ config.value ? config.value.drawerOpen : localDrawerOpen.value,
94
+ );
95
+ function setDrawerOpen(open: boolean) {
96
+ if (config.value) config.value.setDrawerOpen?.(open);
97
+ else localDrawerOpen.value = open;
98
+ }
99
+
100
+ const threadsApi = useThreads({
101
+ agentId: resolvedAgentId,
102
+ includeArchived: true,
103
+ enabled: licensed,
104
+ limit: () => props.limit,
105
+ });
106
+
107
+ function toDrawerThread(t: Thread): DrawerThread {
108
+ return {
109
+ id: t.id,
110
+ name: t.name,
111
+ archived: t.archived,
112
+ createdAt: t.createdAt,
113
+ updatedAt: t.updatedAt,
114
+ ...(t.lastRunAt !== undefined ? { lastRunAt: t.lastRunAt } : {}),
115
+ };
116
+ }
117
+ const drawerThreads = computed(() =>
118
+ threadsApi.threads.value.map(toDrawerThread),
119
+ );
120
+
121
+ // Client-only element registration (SSR-safe). The element module is imported
122
+ // lazily here (never at module scope — see the import note above) so SSR never
123
+ // evaluates its `extends HTMLElement`. `elementTag` holds the resolved custom-
124
+ // element tag and `mounted` gates the render; both are set only after the
125
+ // dynamic import resolves on the client.
126
+ const mounted = ref(false);
127
+ const elementTag = ref<string | null>(null);
128
+ const elRef = ref<CopilotKitThreadsDrawerElement | null>(null);
129
+ onMounted(async () => {
130
+ const mod = await import("@copilotkit/web-components/threads-drawer");
131
+ mod.defineCopilotKitThreadsDrawer();
132
+ elementTag.value = mod.COPILOTKIT_THREADS_DRAWER_TAG;
133
+ mounted.value = true;
134
+ });
135
+
136
+ // Imperatively push object/array/boolean PROPERTIES (v-bind on a custom element
137
+ // would set string attributes for these). Re-runs whenever the element or any
138
+ // reactive value read in the body changes (auto-tracked by watchEffect).
139
+ watchEffect(
140
+ () => {
141
+ const el = elRef.value;
142
+ if (!el) return;
143
+ el.threads = drawerThreads.value;
144
+ el.loading = threadsApi.isLoading.value || licensePending.value;
145
+ el.error = threadsApi.listError.value?.message ?? null;
146
+ el.activeThreadId = activeThreadId.value;
147
+ el.licensed = licensed.value || licensePending.value;
148
+ el.hasMore = threadsApi.hasMoreThreads.value;
149
+ el.fetchingMore = threadsApi.isFetchingMoreThreads.value;
150
+ // Dedicated fetch-more error channel: drives the element's inline
151
+ // "couldn't load more — retry" panel without disturbing the loaded list.
152
+ el.fetchMoreError = threadsApi.fetchMoreError.value?.message ?? null;
153
+ el.open = drawerOpen.value;
154
+ if (props.label !== undefined) el.label = props.label;
155
+ if (props.licenseUrl !== undefined) el.licenseUrl = props.licenseUrl;
156
+ // `collapsible` is a default-true boolean PROPERTY (like `licensed`); leave
157
+ // the element's own default in place when the prop is omitted.
158
+ if (props.collapsible !== undefined) {
159
+ // TODO(ENT-1051): drop the intersection cast once the published element
160
+ // type declares `collapsible` (see the local CollapseChangeDetail note).
161
+ (
162
+ el as CopilotKitThreadsDrawerElement & { collapsible: boolean }
163
+ ).collapsible = props.collapsible;
164
+ }
165
+ },
166
+ { flush: "post" },
167
+ );
168
+
169
+ // Announce drawer presence so the mobile header launcher renders. Register
170
+ // synchronously in setup (React parity: a mount-time effect) and de-register
171
+ // on scope dispose. Not gated on `mounted`: presence is independent of the
172
+ // client-only element registration.
173
+ const unregisterDrawer = config.value?.registerDrawer?.();
174
+ if (unregisterDrawer) onScopeDispose(unregisterDrawer);
175
+
176
+ // --- Outbound event handlers ------------------------------------------------
177
+ /** The chat input textarea's documented `data-testid`. */
178
+ const CHAT_INPUT_TESTID = "copilot-chat-input-textarea";
179
+ /** The chat view container's documented `data-testid`. */
180
+ const CHAT_CONTAINER_TESTID = "copilot-chat-view";
181
+
182
+ /**
183
+ * Returns the chat input element for focus-return after a thread is selected.
184
+ *
185
+ * Best-effort and SCOPED: walks up from the drawer's custom element looking
186
+ * for an ancestor that contains a chat-view container
187
+ * (`data-testid="copilot-chat-view"`), then returns the chat input within
188
+ * that subtree. This avoids focusing the wrong composer on a page hosting
189
+ * more than one chat (multi-chat dashboards), where a document-global lookup
190
+ * would grab whichever input appears first in DOM order rather than the one
191
+ * this drawer drives.
192
+ *
193
+ * Falls back to a document-global lookup when no scoping ancestor is found
194
+ * (e.g. the drawer and chat share no common container, or headless usage).
195
+ */
196
+ function findChatInput(origin: Element | null): HTMLElement | null {
197
+ if (typeof document === "undefined") return null;
198
+ const container = origin?.closest?.(
199
+ `[data-testid="${CHAT_CONTAINER_TESTID}"]`,
200
+ );
201
+ if (container) {
202
+ const scoped = container.querySelector<HTMLElement>(
203
+ `[data-testid="${CHAT_INPUT_TESTID}"]`,
204
+ );
205
+ if (scoped) return scoped;
206
+ }
207
+ return document.querySelector<HTMLElement>(
208
+ `[data-testid="${CHAT_INPUT_TESTID}"]`,
209
+ );
210
+ }
211
+
212
+ function focusChatInput() {
213
+ findChatInput(elRef.value)?.focus();
214
+ }
215
+
216
+ function onThreadSelected(event: Event) {
217
+ const { threadId } = (event as CustomEvent<ThreadSelectedDetail>).detail;
218
+ if (props.onThreadSelect) props.onThreadSelect(threadId);
219
+ else config.value?.setActiveThreadId?.(threadId, { explicit: true });
220
+ focusChatInput();
221
+ }
222
+ function handleNewThread() {
223
+ threadsApi.startNewThread();
224
+ if (props.onNewThread) props.onNewThread();
225
+ else config.value?.startNewThread?.();
226
+ }
227
+ function onArchive(event: Event) {
228
+ const { threadId } = (event as CustomEvent<ArchiveDetail>).detail;
229
+ void threadsApi
230
+ .archiveThread(threadId)
231
+ .catch((e) =>
232
+ console.error("CopilotThreadsDrawer: archiveThread failed", e),
233
+ );
234
+ }
235
+ function onUnarchive(event: Event) {
236
+ const { threadId } = (event as CustomEvent<UnarchiveDetail>).detail;
237
+ void threadsApi
238
+ .unarchiveThread(threadId)
239
+ .catch((e) =>
240
+ console.error("CopilotThreadsDrawer: unarchiveThread failed", e),
241
+ );
242
+ }
243
+ function onDelete(event: Event) {
244
+ const { threadId } = (event as CustomEvent<DeleteDetail>).detail;
245
+ const wasActive = threadId === activeThreadId.value;
246
+ void threadsApi
247
+ .deleteThread(threadId)
248
+ .then(() => {
249
+ if (wasActive) {
250
+ handleNewThread();
251
+ }
252
+ })
253
+ .catch((e) =>
254
+ console.error("CopilotThreadsDrawer: deleteThread failed", e),
255
+ );
256
+ }
257
+ function onFilterChange() {
258
+ threadsApi.refetchThreads();
259
+ }
260
+ function onCollapseChange(event: Event) {
261
+ const { collapsed } = (event as CustomEvent<CollapseChangeDetail>).detail;
262
+ emit("collapse-change", collapsed);
263
+ }
264
+ function onRetry(event: Event) {
265
+ const { scope } = (event as CustomEvent<RetryDetail>).detail;
266
+ if (scope === "fetch-more") threadsApi.fetchMoreThreads();
267
+ else threadsApi.refetchThreads();
268
+ }
269
+ function onLoadMore() {
270
+ threadsApi.fetchMoreThreads();
271
+ }
272
+ function onOpenChange(event: Event) {
273
+ const { open } = (event as CustomEvent<OpenChangeDetail>).detail;
274
+ setDrawerOpen(open);
275
+ }
276
+ function handleLicensed() {
277
+ props.onLicensed?.();
278
+ }
279
+
280
+ defineSlots<{
281
+ default(): unknown;
282
+ /**
283
+ * Optional per-row content. When provided, this slot is rendered as
284
+ * light-DOM children with `slot="row:{id}"` for EVERY thread in the list,
285
+ * so the element projects it in place of the default row for all rows.
286
+ *
287
+ * Unlike React's `renderRow` (which returns `ReactNode | null` and can
288
+ * fall back to the element's built-in row on a per-thread basis by
289
+ * returning `null`), Vue's scoped slot is all-or-nothing: there is no
290
+ * per-row escape hatch back to the element default once the slot is
291
+ * defined. This is an intentional Vue-idiom divergence, not a bug — if a
292
+ * consumer needs per-row fallback, they should replicate the default row
293
+ * markup themselves inside the slot for the threads they don't want to
294
+ * customize.
295
+ */
296
+ row(props: { thread: Thread }): unknown;
297
+ }>();
298
+ </script>
299
+
300
+ <template>
301
+ <component
302
+ :is="elementTag"
303
+ v-if="mounted"
304
+ ref="elRef"
305
+ :data-testid="dataTestId"
306
+ :recent-label="recentLabel"
307
+ @thread-selected="onThreadSelected"
308
+ @new-thread="handleNewThread"
309
+ @archive="onArchive"
310
+ @unarchive="onUnarchive"
311
+ @delete="onDelete"
312
+ @filter-change="onFilterChange"
313
+ @collapse-change="onCollapseChange"
314
+ @retry="onRetry"
315
+ @load-more="onLoadMore"
316
+ @open-change="onOpenChange"
317
+ @licensed="handleLicensed"
318
+ >
319
+ <slot />
320
+ <!--
321
+ When the `row` slot is defined, it is projected for EVERY thread (no
322
+ per-row fallback to the element default) — see the JSDoc on the `row`
323
+ slot above for the React `renderRow` comparison.
324
+ -->
325
+ <template v-if="$slots.row">
326
+ <div
327
+ v-for="t in threadsApi.threads.value"
328
+ :key="t.id"
329
+ v-bind="{ slot: `row:${t.id}` }"
330
+ >
331
+ <slot name="row" :thread="t" />
332
+ </div>
333
+ </template>
334
+ </component>
335
+ </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
+ });