@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
@@ -5,6 +5,7 @@ import {
5
5
  ɵcreateThreadStore,
6
6
  ɵselectHasNextPage,
7
7
  ɵselectIsFetchingNextPage,
8
+ ɵselectIsMutating,
8
9
  ɵselectThreads,
9
10
  ɵselectThreadsError,
10
11
  ɵselectThreadsIsLoading,
@@ -32,17 +33,29 @@ export interface UseThreadsInput {
32
33
  agentId: MaybeRefOrGetter<string>;
33
34
  includeArchived?: MaybeRefOrGetter<boolean | undefined>;
34
35
  limit?: MaybeRefOrGetter<number | undefined>;
36
+ /**
37
+ * When `false`, the hook clears the store context and issues no `/threads`
38
+ * request — used by the drawer to skip fetching while unlicensed. Defaults
39
+ * to `true`.
40
+ */
41
+ enabled?: MaybeRefOrGetter<boolean | undefined>;
35
42
  }
36
43
 
37
44
  export interface UseThreadsResult {
38
45
  threads: Ref<Thread[]>;
39
46
  isLoading: Ref<boolean>;
40
47
  error: Ref<Error | null>;
48
+ /** Genuine list-load/mutation errors only — excludes dev/config errors. */
49
+ listError: Ref<Error | null>;
41
50
  hasMoreThreads: Ref<boolean>;
42
51
  isFetchingMoreThreads: Ref<boolean>;
52
+ isMutating: Ref<boolean>;
43
53
  fetchMoreThreads: () => void;
54
+ refetchThreads: () => void;
55
+ startNewThread: () => void;
44
56
  renameThread: (threadId: string, name: string) => Promise<void>;
45
57
  archiveThread: (threadId: string) => Promise<void>;
58
+ unarchiveThread: (threadId: string) => Promise<void>;
46
59
  deleteThread: (threadId: string) => Promise<void>;
47
60
  }
48
61
 
@@ -77,6 +90,41 @@ export function useThreads(input: UseThreadsInput): UseThreadsResult {
77
90
  toValue(input.includeArchived),
78
91
  );
79
92
  const resolvedLimit = computed(() => toValue(input.limit));
93
+ const resolvedEnabled = computed(() => toValue(input.enabled) ?? true);
94
+
95
+ // Thread-endpoint capability, derived from the runtime's `/info` payload.
96
+ // `threadEndpoints` is `undefined` on legacy runtimes that don't advertise
97
+ // the capability, so we treat "not explicitly false" as supported (matching
98
+ // React's `!== false` semantics) to preserve legacy thread behavior.
99
+ const threadListEndpointSupported = computed(
100
+ () => copilotkit.value.threadEndpoints?.list !== false,
101
+ );
102
+ const threadMutationsSupported = computed(
103
+ () => copilotkit.value.threadEndpoints?.mutations !== false,
104
+ );
105
+ // Connected, but the runtime explicitly does not serve the list endpoint.
106
+ const threadEndpointsUnavailable = computed(
107
+ () =>
108
+ !!copilotkit.value.runtimeUrl &&
109
+ copilotkit.value.runtimeConnectionStatus ===
110
+ CopilotKitCoreRuntimeConnectionStatus.Connected &&
111
+ !threadListEndpointSupported.value,
112
+ );
113
+ const threadEndpointsError = computed<Error | null>(() =>
114
+ threadEndpointsUnavailable.value
115
+ ? new Error(
116
+ "Thread endpoints are not available on this CopilotKit runtime",
117
+ )
118
+ : null,
119
+ );
120
+ const threadMutationsError = computed<Error | null>(() =>
121
+ threadMutationsSupported.value
122
+ ? null
123
+ : new Error(
124
+ "Thread mutations are not available on this CopilotKit runtime",
125
+ ),
126
+ );
127
+
80
128
  const headersKey = computed(() =>
81
129
  JSON.stringify(
82
130
  Object.entries(copilotkit.value.headers ?? {}).sort(([left], [right]) =>
@@ -90,6 +138,7 @@ export function useThreads(input: UseThreadsInput): UseThreadsResult {
90
138
  const storeError = ref<Error | null>(null);
91
139
  const hasMoreThreads = ref(false);
92
140
  const isFetchingMoreThreads = ref(false);
141
+ const isMutating = ref(false);
93
142
 
94
143
  bindThreadStoreSelector(store, ɵselectThreads, threads as Ref<Thread[]>);
95
144
  bindThreadStoreSelector(store, ɵselectThreadsIsLoading, storeIsLoading);
@@ -100,12 +149,33 @@ export function useThreads(input: UseThreadsInput): UseThreadsResult {
100
149
  ɵselectIsFetchingNextPage,
101
150
  isFetchingMoreThreads,
102
151
  );
152
+ bindThreadStoreSelector(store, ɵselectIsMutating, isMutating);
103
153
 
104
154
  store.start();
105
155
  onScopeDispose(() => {
106
156
  store.stop();
107
157
  });
108
158
 
159
+ // Register this store in core's single-slot thread-store registry so other
160
+ // surfaces (e.g. the chat view) can resolve the same store for the agent.
161
+ //
162
+ // Gated on `resolvedEnabled`: a disabled (e.g. unlicensed) drawer must not
163
+ // claim the agentId slot. The registry is single-slot/last-writer-wins, so
164
+ // registering an inert store would evict — and on unmount tear down — a
165
+ // co-mounted live store for the same agent. Staying unregistered while
166
+ // disabled leaves the live store's registration intact.
167
+ watch(
168
+ [resolvedEnabled, resolvedAgentId],
169
+ ([enabled, agentId], _prev, onCleanup) => {
170
+ if (!enabled) return;
171
+ copilotkit.value.registerThreadStore(agentId, store);
172
+ onCleanup(() => {
173
+ copilotkit.value.unregisterThreadStore(agentId);
174
+ });
175
+ },
176
+ { immediate: true },
177
+ );
178
+
109
179
  // Tracks whether we've dispatched the first real context to the store.
110
180
  // The store itself starts with `isLoading: false`, so before we dispatch
111
181
  // consumers would otherwise see an empty, non-loading state (empty-list
@@ -135,10 +205,23 @@ export function useThreads(input: UseThreadsInput): UseThreadsResult {
135
205
  resolvedAgentId,
136
206
  resolvedIncludeArchived,
137
207
  resolvedLimit,
208
+ resolvedEnabled,
209
+ threadListEndpointSupported,
138
210
  ],
139
- ([runtimeUrl, runtimeStatus, , wsUrl, agentId, includeArchived, limit]) => {
140
- if (!runtimeUrl) {
211
+ ([
212
+ runtimeUrl,
213
+ runtimeStatus,
214
+ ,
215
+ wsUrl,
216
+ agentId,
217
+ includeArchived,
218
+ limit,
219
+ enabled,
220
+ listEndpointSupported,
221
+ ]) => {
222
+ if (!runtimeUrl || !enabled) {
141
223
  store.setContext(null);
224
+ hasDispatchedContext.value = false;
142
225
  return;
143
226
  }
144
227
 
@@ -146,6 +229,15 @@ export function useThreads(input: UseThreadsInput): UseThreadsResult {
146
229
  return;
147
230
  }
148
231
 
232
+ // Connected, but the runtime explicitly does not serve the list
233
+ // endpoint: stay inert so no `/threads` fetch fires. The
234
+ // `threadEndpointsError` surfaces via `error` instead.
235
+ if (!listEndpointSupported) {
236
+ store.setContext(null);
237
+ hasDispatchedContext.value = false;
238
+ return;
239
+ }
240
+
149
241
  const context: ɵThreadRuntimeContext = {
150
242
  runtimeUrl,
151
243
  headers: { ...copilotkit.value.headers },
@@ -168,19 +260,44 @@ export function useThreads(input: UseThreadsInput): UseThreadsResult {
168
260
  );
169
261
 
170
262
  const preConnectLoading = computed(
171
- () => !!copilotkit.value.runtimeUrl && !hasDispatchedContext.value,
263
+ () =>
264
+ !!copilotkit.value.runtimeUrl &&
265
+ resolvedEnabled.value &&
266
+ !threadEndpointsUnavailable.value &&
267
+ !hasDispatchedContext.value,
172
268
  );
173
269
 
174
270
  const isLoading = computed(() =>
175
- runtimeError.value
271
+ runtimeError.value || threadEndpointsError.value
176
272
  ? false
177
273
  : preConnectLoading.value || storeIsLoading.value,
178
274
  );
179
275
 
276
+ // Folds developer/config errors (missing runtime URL, runtime without
277
+ // thread endpoints) together with genuine store errors. `listError` below
278
+ // deliberately excludes the config errors.
180
279
  const error = computed<Error | null>(
181
- () => runtimeError.value ?? storeError.value,
280
+ () => runtimeError.value ?? threadEndpointsError.value ?? storeError.value,
182
281
  );
183
282
 
283
+ // Genuine store errors only. Unlike `error`, this omits the dev/config
284
+ // `runtimeError`/`threadEndpointsError` strings so they are never shown in
285
+ // user-facing error UI.
286
+ const listError = computed<Error | null>(() => storeError.value);
287
+
288
+ // Reject mutations locally (before touching the network) when the runtime
289
+ // reports mutations are unsupported, matching React's `guardMutation`.
290
+ function guardMutation<TArgs extends unknown[]>(
291
+ mutation: (...args: TArgs) => Promise<void>,
292
+ ): (...args: TArgs) => Promise<void> {
293
+ return (...args: TArgs) => {
294
+ if (threadMutationsError.value) {
295
+ return Promise.reject(threadMutationsError.value);
296
+ }
297
+ return mutation(...args);
298
+ };
299
+ }
300
+
184
301
  return {
185
302
  threads: computed(() =>
186
303
  threads.value.map(
@@ -197,12 +314,24 @@ export function useThreads(input: UseThreadsInput): UseThreadsResult {
197
314
  ),
198
315
  isLoading,
199
316
  error,
317
+ listError,
200
318
  hasMoreThreads,
201
319
  isFetchingMoreThreads,
320
+ isMutating,
202
321
  fetchMoreThreads: () => store.fetchNextPage(),
203
- renameThread: (threadId: string, name: string) =>
322
+ refetchThreads: () => store.refetchThreads(),
323
+ startNewThread: () => store.startNewThread(),
324
+ renameThread: guardMutation((threadId: string, name: string) =>
204
325
  store.renameThread(threadId, name),
205
- archiveThread: (threadId: string) => store.archiveThread(threadId),
206
- deleteThread: (threadId: string) => store.deleteThread(threadId),
326
+ ),
327
+ archiveThread: guardMutation((threadId: string) =>
328
+ store.archiveThread(threadId),
329
+ ),
330
+ unarchiveThread: guardMutation((threadId: string) =>
331
+ store.unarchiveThread(threadId),
332
+ ),
333
+ deleteThread: guardMutation((threadId: string) =>
334
+ store.deleteThread(threadId),
335
+ ),
207
336
  };
208
337
  }
@@ -0,0 +1,48 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { isMobileViewport } from "../is-mobile-viewport";
3
+
4
+ describe("isMobileViewport", () => {
5
+ const originalMatchMedia = window.matchMedia;
6
+
7
+ afterEach(() => {
8
+ window.matchMedia = originalMatchMedia;
9
+ vi.restoreAllMocks();
10
+ });
11
+
12
+ it("returns false when window.matchMedia is undefined", () => {
13
+ // @ts-expect-error - simulating an environment without matchMedia support.
14
+ delete window.matchMedia;
15
+
16
+ expect(isMobileViewport()).toBe(false);
17
+ });
18
+
19
+ it("returns true when the mobile media query matches", () => {
20
+ window.matchMedia = vi.fn().mockReturnValue({
21
+ matches: true,
22
+ media: "(max-width: 767px)",
23
+ onchange: null,
24
+ addEventListener: vi.fn(),
25
+ removeEventListener: vi.fn(),
26
+ addListener: vi.fn(),
27
+ removeListener: vi.fn(),
28
+ dispatchEvent: vi.fn(),
29
+ } as unknown as MediaQueryList);
30
+
31
+ expect(isMobileViewport()).toBe(true);
32
+ });
33
+
34
+ it("returns false when the mobile media query does not match", () => {
35
+ window.matchMedia = vi.fn().mockReturnValue({
36
+ matches: false,
37
+ media: "(max-width: 767px)",
38
+ onchange: null,
39
+ addEventListener: vi.fn(),
40
+ removeEventListener: vi.fn(),
41
+ addListener: vi.fn(),
42
+ removeListener: vi.fn(),
43
+ dispatchEvent: vi.fn(),
44
+ } as unknown as MediaQueryList);
45
+
46
+ expect(isMobileViewport()).toBe(false);
47
+ });
48
+ });
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Media query for the mobile breakpoint, shared so the string literal isn't
3
+ * duplicated across call sites within this package (e.g. `CopilotModalHeader`,
4
+ * which needs a live `matchMedia` change-listener and so can't call
5
+ * `isMobileViewport()` directly).
6
+ */
7
+ export const MOBILE_MAX_WIDTH_QUERY = "(max-width: 767px)";
8
+
9
+ /**
10
+ * Reports whether the viewport is in the mobile range (`<768px`), where the
11
+ * drawer and chat modal are mutually exclusive. SSR-safe (returns `false`
12
+ * when `window` is absent) and guards environments where `window` exists but
13
+ * `window.matchMedia` does not (some test runners, embedded webviews).
14
+ */
15
+ export function isMobileViewport(): boolean {
16
+ if (
17
+ typeof window === "undefined" ||
18
+ typeof window.matchMedia !== "function"
19
+ ) {
20
+ return false;
21
+ }
22
+ return window.matchMedia(MOBILE_MAX_WIDTH_QUERY).matches;
23
+ }
@@ -7,6 +7,7 @@ import { CopilotChatDefaultLabels } from "./types";
7
7
  import type { CopilotChatConfigurationValue, CopilotChatLabels } from "./types";
8
8
  import type { CopilotChatConfigurationProviderProps } from "./CopilotChatConfigurationProvider.types";
9
9
  import { useShallowStableRef } from "../lib/shallow-stable";
10
+ import { isMobileViewport } from "../lib/is-mobile-viewport";
10
11
 
11
12
  // Vue normalizes optional Boolean props to `false` when not supplied; declare
12
13
  // `undefined` defaults so we can faithfully distinguish "caller passed false"
@@ -24,6 +25,20 @@ const parentConfig = inject<ComputedRef<CopilotChatConfigurationValue> | null>(
24
25
  null,
25
26
  );
26
27
  const parentConfigValue = computed(() => parentConfig?.value ?? null);
28
+
29
+ // Caller-authoritative when a threadId prop is supplied AND not explicitly
30
+ // flagged non-explicit. A <CopilotKit>-seeded non-explicit id
31
+ // (threadId + hasExplicitThreadId=false) stays overridable — the round-2 fix.
32
+ const propIsAuthoritative = computed(
33
+ () => props.threadId !== undefined && props.hasExplicitThreadId !== false,
34
+ );
35
+
36
+ // Imperative active-thread override (a picked row or a fresh startNewThread).
37
+ const activeThreadOverride = ref<{
38
+ threadId: string;
39
+ explicit: boolean;
40
+ } | null>(null);
41
+
27
42
  const stableLabels = useShallowStableRef(computed(() => props.labels));
28
43
 
29
44
  const mergedLabels = computed<CopilotChatLabels>(() => ({
@@ -38,6 +53,8 @@ const resolvedAgentId = computed(
38
53
 
39
54
  const fallbackThreadId = randomUUID();
40
55
  const resolvedThreadId = computed(() => {
56
+ if (propIsAuthoritative.value) return props.threadId as string;
57
+ if (activeThreadOverride.value) return activeThreadOverride.value.threadId;
41
58
  if (props.threadId) return props.threadId;
42
59
  if (parentConfigValue.value?.threadId)
43
60
  return parentConfigValue.value.threadId;
@@ -45,11 +62,13 @@ const resolvedThreadId = computed(() => {
45
62
  });
46
63
 
47
64
  const resolvedHasExplicitThreadId = computed(() => {
48
- const ownExplicit =
49
- props.hasExplicitThreadId !== undefined
65
+ if (propIsAuthoritative.value) return true;
66
+ const own = activeThreadOverride.value
67
+ ? activeThreadOverride.value.explicit
68
+ : props.hasExplicitThreadId !== undefined
50
69
  ? props.hasExplicitThreadId
51
70
  : !!props.threadId;
52
- return ownExplicit || !!parentConfigValue.value?.hasExplicitThreadId;
71
+ return own || !!parentConfigValue.value?.hasExplicitThreadId;
53
72
  });
54
73
 
55
74
  const shouldCreateModalState = computed(
@@ -76,13 +95,110 @@ const resolvedSetModalOpen = computed(() =>
76
95
  : parentConfigValue.value?.setModalOpen,
77
96
  );
78
97
 
98
+ function setActiveThreadId(threadId: string, options?: { explicit?: boolean }) {
99
+ if (propIsAuthoritative.value) {
100
+ console.warn(
101
+ "[CopilotKit] Ignoring setActiveThreadId(): threadId is controlled " +
102
+ "via the `threadId` prop on CopilotChatConfigurationProvider.",
103
+ );
104
+ return;
105
+ }
106
+ activeThreadOverride.value = {
107
+ threadId,
108
+ explicit: options?.explicit ?? true,
109
+ };
110
+ }
111
+
112
+ function startNewThread() {
113
+ if (propIsAuthoritative.value) {
114
+ console.warn(
115
+ "[CopilotKit] Ignoring startNewThread(): threadId is controlled via " +
116
+ "the `threadId` prop on CopilotChatConfigurationProvider.",
117
+ );
118
+ return;
119
+ }
120
+ activeThreadOverride.value = { threadId: randomUUID(), explicit: false };
121
+ }
122
+
123
+ // Drawer presence + open state. The top-most provider owns the state; a nested
124
+ // provider proxies its parent so the whole chain shares one drawer.
125
+ const ownDrawerOpen = ref(false);
126
+ const ownDrawerCount = ref(0);
127
+
128
+ function ownSetDrawerOpen(open: boolean) {
129
+ ownDrawerOpen.value = open;
130
+ // Mobile mutual-exclusion: opening the drawer closes the chat modal.
131
+ if (open && isMobileViewport()) {
132
+ resolvedSetModalOpen.value?.(false);
133
+ }
134
+ }
135
+
136
+ function ownRegisterDrawer(): () => void {
137
+ ownDrawerCount.value += 1;
138
+ return () => {
139
+ ownDrawerCount.value = Math.max(0, ownDrawerCount.value - 1);
140
+ };
141
+ }
142
+
143
+ const resolvedDrawerOpen = computed(() =>
144
+ parentConfigValue.value
145
+ ? parentConfigValue.value.drawerOpen
146
+ : ownDrawerOpen.value,
147
+ );
148
+ const resolvedSetDrawerOpen = computed(() =>
149
+ parentConfigValue.value
150
+ ? parentConfigValue.value.setDrawerOpen
151
+ : ownSetDrawerOpen,
152
+ );
153
+ const resolvedDrawerRegistered = computed(() =>
154
+ parentConfigValue.value
155
+ ? parentConfigValue.value.drawerRegistered
156
+ : ownDrawerCount.value > 0,
157
+ );
158
+ const resolvedRegisterDrawer = computed(() =>
159
+ parentConfigValue.value
160
+ ? parentConfigValue.value.registerDrawer
161
+ : ownRegisterDrawer,
162
+ );
163
+
164
+ // Public modal setter (mobile mutual-exclusion, other direction: opening the
165
+ // chat modal closes the drawer). Preserve the contract that it is `undefined`
166
+ // when this provider owns no modal state AND no parent provides one —
167
+ // CopilotChatToggleButton relies on that absence to fall back to its own
168
+ // local open-state. When a real modal setter exists (own `isModalDefaultOpen`
169
+ // state or an inherited parent setter), wrap it so opening the modal on
170
+ // mobile also closes the drawer.
171
+ //
172
+ // Note: this diverges from the React reference (CopilotChatConfigurationProvider.tsx),
173
+ // which keeps `setModalOpen` always-defined and always backed by internal state. We
174
+ // intentionally take the minimal Vue-local fix here — preserving the `undefined`
175
+ // contract CopilotChatToggleButton depends on — rather than also reworking
176
+ // `resolvedIsModalOpen`'s backing, to avoid changing bare-provider modal-open
177
+ // semantics.
178
+ const publicSetModalOpen = computed(() => {
179
+ const base = resolvedSetModalOpen.value; // undefined when no modal state + no parent
180
+ if (!base) return undefined;
181
+ return (open: boolean) => {
182
+ if (open && isMobileViewport()) {
183
+ resolvedSetDrawerOpen.value?.(false);
184
+ }
185
+ base(open);
186
+ };
187
+ });
188
+
79
189
  const configurationValue = computed<CopilotChatConfigurationValue>(() => ({
80
190
  labels: mergedLabels.value,
81
191
  agentId: resolvedAgentId.value,
82
192
  threadId: resolvedThreadId.value,
83
193
  hasExplicitThreadId: resolvedHasExplicitThreadId.value,
84
194
  isModalOpen: resolvedIsModalOpen.value,
85
- setModalOpen: resolvedSetModalOpen.value,
195
+ setModalOpen: publicSetModalOpen.value,
196
+ drawerOpen: resolvedDrawerOpen.value,
197
+ setDrawerOpen: resolvedSetDrawerOpen.value,
198
+ drawerRegistered: resolvedDrawerRegistered.value,
199
+ registerDrawer: resolvedRegisterDrawer.value,
200
+ setActiveThreadId,
201
+ startNewThread,
86
202
  }));
87
203
 
88
204
  provide(CopilotChatConfigurationKey, configurationValue);
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { describe, expect, it, vi } from "vitest";
2
2
  import { mount } from "@vue/test-utils";
3
3
  import { defineComponent, h, nextTick } from "vue";
4
4
  import { DEFAULT_AGENT_ID } from "@copilotkit/shared";
@@ -307,3 +307,107 @@ describe("CopilotChatConfigurationProvider", () => {
307
307
  expect(wrapper.find("[data-testid=child-modal]").text()).toBe("false");
308
308
  });
309
309
  });
310
+
311
+ function harness(providerProps: Record<string, unknown>) {
312
+ let cfg!: ReturnType<typeof useCopilotChatConfiguration>;
313
+ const Probe = defineComponent({
314
+ setup() {
315
+ cfg = useCopilotChatConfiguration();
316
+ return () => h("div", cfg.value?.threadId ?? "none");
317
+ },
318
+ });
319
+ mount(CopilotChatConfigurationProvider, {
320
+ props: providerProps,
321
+ slots: { default: () => h(Probe) },
322
+ });
323
+ return () => cfg.value!;
324
+ }
325
+
326
+ describe("CopilotChatConfiguration active-thread setters", () => {
327
+ it("setActiveThreadId overrides a non-explicit seed and flags it explicit", async () => {
328
+ // A <CopilotKit>-style non-explicit seed: threadId prop + hasExplicitThreadId=false.
329
+ const cfg = harness({ threadId: "seed-uuid", hasExplicitThreadId: false });
330
+ cfg().setActiveThreadId("picked-thread");
331
+ await nextTick();
332
+ expect(cfg().threadId).toBe("picked-thread");
333
+ expect(cfg().hasExplicitThreadId).toBe(true);
334
+ });
335
+
336
+ it("startNewThread mints a fresh non-explicit thread", async () => {
337
+ const cfg = harness({ threadId: "seed-uuid", hasExplicitThreadId: false });
338
+ cfg().setActiveThreadId("picked-thread");
339
+ await nextTick();
340
+ cfg().startNewThread();
341
+ await nextTick();
342
+ expect(cfg().threadId).not.toBe("picked-thread");
343
+ expect(cfg().hasExplicitThreadId).toBe(false);
344
+ });
345
+
346
+ it("a caller-authoritative threadId prop is NOT overridable", async () => {
347
+ const cfg = harness({ threadId: "controlled" }); // no hasExplicitThreadId => authoritative
348
+ cfg().setActiveThreadId("ignored");
349
+ await nextTick();
350
+ expect(cfg().threadId).toBe("controlled");
351
+ });
352
+ });
353
+
354
+ describe("CopilotChatConfiguration drawer-awareness", () => {
355
+ it("registerDrawer flips drawerRegistered and cleans up", async () => {
356
+ const cfg = harness({ isModalDefaultOpen: true });
357
+ expect(cfg().drawerRegistered).toBe(false);
358
+ const unregister = cfg().registerDrawer();
359
+ await nextTick();
360
+ expect(cfg().drawerRegistered).toBe(true);
361
+ unregister();
362
+ await nextTick();
363
+ expect(cfg().drawerRegistered).toBe(false);
364
+ });
365
+
366
+ it("opening the drawer on mobile closes the modal", async () => {
367
+ vi.spyOn(window, "matchMedia").mockReturnValue({
368
+ matches: true,
369
+ } as MediaQueryList);
370
+ const cfg = harness({ isModalDefaultOpen: true });
371
+ expect(cfg().isModalOpen).toBe(true);
372
+ cfg().setDrawerOpen(true);
373
+ await nextTick();
374
+ expect(cfg().drawerOpen).toBe(true);
375
+ expect(cfg().isModalOpen).toBe(false);
376
+ vi.restoreAllMocks();
377
+ });
378
+
379
+ it("opening the modal on mobile closes the drawer", async () => {
380
+ vi.spyOn(window, "matchMedia").mockReturnValue({
381
+ matches: true,
382
+ } as MediaQueryList);
383
+ const cfg = harness({ isModalDefaultOpen: true });
384
+
385
+ // First open the drawer (closing the modal via the existing exclusion).
386
+ cfg().setDrawerOpen(true);
387
+ await nextTick();
388
+ expect(cfg().drawerOpen).toBe(true);
389
+ expect(cfg().isModalOpen).toBe(false);
390
+
391
+ // Now open the modal and confirm the reverse exclusion closes the drawer.
392
+ cfg().setModalOpen(true);
393
+ await nextTick();
394
+ expect(cfg().isModalOpen).toBe(true);
395
+ expect(cfg().drawerOpen).toBe(false);
396
+ vi.restoreAllMocks();
397
+ });
398
+ });
399
+
400
+ describe("CopilotChatConfiguration modal-setter presence contract", () => {
401
+ it("bare provider (no isModalDefaultOpen, no parent) exposes setModalOpen as undefined", () => {
402
+ const cfg = harness({ threadId: "thread-1" });
403
+ expect(cfg().setModalOpen).toBeUndefined();
404
+ });
405
+
406
+ it("provider with isModalDefaultOpen exposes a working setModalOpen", async () => {
407
+ const cfg = harness({ isModalDefaultOpen: true });
408
+ expect(typeof cfg().setModalOpen).toBe("function");
409
+ cfg().setModalOpen(false);
410
+ await nextTick();
411
+ expect(cfg().isModalOpen).toBe(false);
412
+ });
413
+ });
@@ -37,4 +37,29 @@ export interface CopilotChatConfigurationValue {
37
37
  hasExplicitThreadId: boolean;
38
38
  isModalOpen?: boolean;
39
39
  setModalOpen?: (open: boolean) => void;
40
+ /**
41
+ * Switches the active thread to `threadId`. `explicit` (default `true`)
42
+ * marks it a caller-driven choice so the welcome screen is suppressed.
43
+ * No-op when the provider's `threadId` prop is caller-authoritative.
44
+ */
45
+ setActiveThreadId: (
46
+ threadId: string,
47
+ options?: { explicit?: boolean },
48
+ ) => void;
49
+ /**
50
+ * Switches to a freshly-minted, non-explicit thread (welcome screen shows).
51
+ * No-op when the provider's `threadId` prop is caller-authoritative.
52
+ */
53
+ startNewThread: () => void;
54
+ /** Whether the mobile off-canvas drawer overlay is open. */
55
+ drawerOpen: boolean;
56
+ /**
57
+ * Toggles the drawer. On mobile (`<=767px`) opening the drawer closes the
58
+ * chat modal (mutual-exclusion). Desktop imposes no constraint.
59
+ */
60
+ setDrawerOpen: (open: boolean) => void;
61
+ /** True when at least one drawer has registered (gates the header launcher). */
62
+ drawerRegistered: boolean;
63
+ /** Announces drawer presence; returns a cleanup that de-registers it. */
64
+ registerDrawer: () => () => void;
40
65
  }