@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,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
|
+
});
|