@copilotkit/vue 1.62.1 → 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 (49) hide show
  1. package/dist/index-By5nIKxT.js +1632 -0
  2. package/dist/index-By5nIKxT.js.map +1 -0
  3. package/dist/index-LFxZI63k.cjs +748 -0
  4. package/dist/index-LFxZI63k.cjs.map +1 -0
  5. package/dist/index.cjs +1 -1
  6. package/dist/index.mjs +70 -69
  7. package/dist/styles.css +1 -1
  8. package/dist/{use-render-activity-message-8_HyVZWc.js → use-render-activity-message-DbGuwvBd.js} +3361 -3064
  9. package/dist/use-render-activity-message-DbGuwvBd.js.map +1 -0
  10. package/dist/use-render-activity-message-hlNcbV9l.cjs +85 -0
  11. package/dist/use-render-activity-message-hlNcbV9l.cjs.map +1 -0
  12. package/dist/v2/components/chat/CopilotChat.vue.d.ts.map +1 -1
  13. package/dist/v2/components/chat/CopilotModalHeader.vue.d.ts.map +1 -1
  14. package/dist/v2/components/chat/CopilotThreadsDrawer.vue.d.ts +42 -0
  15. package/dist/v2/components/chat/CopilotThreadsDrawer.vue.d.ts.map +1 -0
  16. package/dist/v2/components/chat/index.d.ts +1 -0
  17. package/dist/v2/components/chat/index.d.ts.map +1 -1
  18. package/dist/v2/components/icons/index.d.ts +1 -1
  19. package/dist/v2/components/icons/index.d.ts.map +1 -1
  20. package/dist/v2/hooks/use-threads.d.ts +12 -0
  21. package/dist/v2/hooks/use-threads.d.ts.map +1 -1
  22. package/dist/v2/index.cjs +1 -1
  23. package/dist/v2/index.mjs +44 -43
  24. package/dist/v2/lib/is-mobile-viewport.d.ts +15 -0
  25. package/dist/v2/lib/is-mobile-viewport.d.ts.map +1 -0
  26. package/dist/v2/providers/CopilotChatConfigurationProvider.vue.d.ts.map +1 -1
  27. package/dist/v2/providers/types.d.ts +24 -0
  28. package/dist/v2/providers/types.d.ts.map +1 -1
  29. package/package.json +5 -4
  30. package/src/v2/components/chat/CopilotChat.vue +22 -4
  31. package/src/v2/components/chat/CopilotModalHeader.vue +61 -2
  32. package/src/v2/components/chat/CopilotThreadsDrawer.vue +285 -0
  33. package/src/v2/components/chat/__tests__/CopilotChat.clearOnFresh.test.ts +160 -0
  34. package/src/v2/components/chat/__tests__/CopilotModalHeader.drawerLauncher.test.ts +125 -0
  35. package/src/v2/components/chat/__tests__/CopilotThreadsDrawer.ssr.test.ts +25 -0
  36. package/src/v2/components/chat/__tests__/CopilotThreadsDrawer.test.ts +835 -0
  37. package/src/v2/components/chat/index.ts +1 -0
  38. package/src/v2/components/icons/index.ts +1 -0
  39. package/src/v2/hooks/__tests__/use-human-in-the-loop.e2e.test.ts +4 -4
  40. package/src/v2/hooks/__tests__/use-threads.test.ts +303 -6
  41. package/src/v2/hooks/use-threads.ts +137 -8
  42. package/src/v2/lib/__tests__/is-mobile-viewport.test.ts +48 -0
  43. package/src/v2/lib/is-mobile-viewport.ts +23 -0
  44. package/src/v2/providers/CopilotChatConfigurationProvider.vue +120 -4
  45. package/src/v2/providers/__tests__/CopilotChatConfigurationProvider.test.ts +105 -1
  46. package/src/v2/providers/types.ts +25 -0
  47. package/dist/use-render-activity-message-8_HyVZWc.js.map +0 -1
  48. package/dist/use-render-activity-message-BqgmNPCz.cjs +0 -85
  49. package/dist/use-render-activity-message-BqgmNPCz.cjs.map +0 -1
@@ -13,6 +13,7 @@ export { default as CopilotChatSuggestionView } from "./CopilotChatSuggestionVie
13
13
  export { default as CopilotChatToolCallsView } from "./CopilotChatToolCallsView.vue";
14
14
  export { default as CopilotChatUserMessage } from "./CopilotChatUserMessage.vue";
15
15
  export { default as CopilotChatView } from "./CopilotChatView.vue";
16
+ export { default as CopilotThreadsDrawer } from "./CopilotThreadsDrawer.vue";
16
17
  import _CopilotChatToggleButton from "./CopilotChatToggleButton.vue";
17
18
  import CopilotChatToggleButtonCloseIcon from "./CopilotChatToggleButtonCloseIcon";
18
19
  import CopilotChatToggleButtonOpenIcon from "./CopilotChatToggleButtonOpenIcon";
@@ -19,4 +19,5 @@ export {
19
19
  RefreshCw as IconRefreshCw,
20
20
  CheckCircle as IconCheckCircle,
21
21
  Circle as IconCircle,
22
+ PanelLeftOpen as IconPanelLeftOpen,
22
23
  } from "lucide-vue-next";
@@ -944,13 +944,13 @@ describe("HITL Thread Reconnection Bug", () => {
944
944
  expect(screen.getByTestId("hitl-action").textContent).toBe("delete");
945
945
  });
946
946
 
947
+ // Core-level coverage asserts that passive replay does not re-invoke local
948
+ // frontend handlers for replayed assistant tool calls.
947
949
  await waitFor(() => {
948
- expect(screen.getByTestId("hitl-status").textContent).toBe(
949
- ToolCallStatus.Executing,
950
+ expect(screen.getByTestId("hitl-status").textContent).toMatch(
951
+ /^(executing|inProgress)$/,
950
952
  );
951
953
  });
952
-
953
- expect(screen.getByTestId("hitl-respond")).toBeDefined();
954
954
  });
955
955
 
956
956
  it("should handle tool call after connect (fresh run)", async () => {
@@ -22,6 +22,7 @@ type ThreadState = {
22
22
  error: Error | null;
23
23
  hasNextPage: boolean;
24
24
  isFetchingNextPage: boolean;
25
+ isMutating: boolean;
25
26
  context: {
26
27
  runtimeUrl: string;
27
28
  headers: Record<string, string>;
@@ -107,6 +108,7 @@ vi.mock("@copilotkit/core", () => {
107
108
  error: null,
108
109
  hasNextPage: false,
109
110
  isFetchingNextPage: false,
111
+ isMutating: false,
110
112
  context: null,
111
113
  };
112
114
  private listeners = new Set<() => void>();
@@ -121,6 +123,7 @@ vi.mock("@copilotkit/core", () => {
121
123
  error: null,
122
124
  hasNextPage: false,
123
125
  isFetchingNextPage: false,
126
+ isMutating: false,
124
127
  context: null,
125
128
  };
126
129
  if (this.socket) {
@@ -266,6 +269,27 @@ vi.mock("@copilotkit/core", () => {
266
269
  });
267
270
  }
268
271
 
272
+ async unarchiveThread(threadId: string): Promise<void> {
273
+ const context = this.requireContext();
274
+ await fetchMock(`${context.runtimeUrl}/threads/${threadId}/unarchive`, {
275
+ method: "POST",
276
+ headers: context.headers,
277
+ body: JSON.stringify({
278
+ agentId: context.agentId,
279
+ }),
280
+ });
281
+ }
282
+
283
+ refetchThreads(): void {
284
+ if (!this.state.context) return;
285
+ void this.fetchThreads(this.state.context);
286
+ }
287
+
288
+ startNewThread(): void {
289
+ // Test double: mirrors the real store's "reset to a fresh thread" no-op
290
+ // shape closely enough for surface-presence assertions.
291
+ }
292
+
269
293
  fetchNextPage(): void {
270
294
  this.state.isFetchingNextPage = true;
271
295
  this.notify();
@@ -319,6 +343,7 @@ vi.mock("@copilotkit/core", () => {
319
343
  ɵselectThreadsError: select((state) => state.error),
320
344
  ɵselectHasNextPage: select((state) => state.hasNextPage),
321
345
  ɵselectIsFetchingNextPage: select((state) => state.isFetchingNextPage),
346
+ ɵselectIsMutating: select((state) => state.isMutating),
322
347
  };
323
348
  });
324
349
 
@@ -336,22 +361,48 @@ function getDispatchedContexts(): Array<ThreadState["context"]> {
336
361
  return threadMocks.dispatchedContexts;
337
362
  }
338
363
 
364
+ type ThreadEndpointRuntimeInfo = {
365
+ list: boolean;
366
+ inspect: boolean;
367
+ mutations: boolean;
368
+ realtimeMetadata: boolean;
369
+ };
370
+
371
+ const supportedThreadEndpoints: ThreadEndpointRuntimeInfo = {
372
+ list: true,
373
+ inspect: true,
374
+ mutations: true,
375
+ realtimeMetadata: true,
376
+ };
377
+
378
+ type MockCopilotKit = {
379
+ runtimeUrl: string | undefined;
380
+ runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus;
381
+ headers: Record<string, string>;
382
+ intelligence: { wsUrl?: string } | undefined;
383
+ threadEndpoints: ThreadEndpointRuntimeInfo | undefined;
384
+ registerThreadStore: ReturnType<typeof vi.fn>;
385
+ unregisterThreadStore: ReturnType<typeof vi.fn>;
386
+ };
387
+
339
388
  function setupCopilotKit(
340
389
  runtimeUrl: string | undefined = "http://localhost:4000",
341
390
  runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus = CopilotKitCoreRuntimeConnectionStatus.Connected,
391
+ overrides: Partial<
392
+ Pick<MockCopilotKit, "threadEndpoints" | "intelligence">
393
+ > = {},
342
394
  ) {
343
- const copilotkit = ref<{
344
- runtimeUrl: string | undefined;
345
- runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus;
346
- headers: Record<string, string>;
347
- intelligence: { wsUrl?: string } | undefined;
348
- }>({
395
+ const copilotkit = ref<MockCopilotKit>({
349
396
  runtimeUrl,
350
397
  runtimeConnectionStatus,
351
398
  headers: { Authorization: "Bearer test-token" },
352
399
  intelligence: {
353
400
  wsUrl: "ws://localhost:4000/client",
354
401
  },
402
+ threadEndpoints: supportedThreadEndpoints,
403
+ registerThreadStore: vi.fn(),
404
+ unregisterThreadStore: vi.fn(),
405
+ ...overrides,
355
406
  });
356
407
  mockUseCopilotKit.mockReturnValue({ copilotkit });
357
408
  return copilotkit;
@@ -399,6 +450,7 @@ function mountHook(
399
450
  agentId: string | Ref<string>;
400
451
  includeArchived?: boolean | Ref<boolean | undefined>;
401
452
  limit?: number | Ref<number | undefined>;
453
+ enabled?: boolean | Ref<boolean | undefined>;
402
454
  } = defaultInput,
403
455
  ) {
404
456
  let result: UseThreadsResult | undefined;
@@ -1038,4 +1090,249 @@ describe("useThreads", () => {
1038
1090
  ]);
1039
1091
  });
1040
1092
  });
1093
+
1094
+ describe("useThreads augmented surface", () => {
1095
+ it("exposes listError, isMutating, and the missing mutations/actions", async () => {
1096
+ fetchMock
1097
+ .mockReturnValueOnce(
1098
+ jsonResponse({ threads: sampleThreads, joinCode: "jc-1" }),
1099
+ )
1100
+ .mockReturnValueOnce(jsonResponse({ joinToken: "jt-1" }));
1101
+
1102
+ const { getResult } = mountHook();
1103
+
1104
+ await vi.waitFor(() => {
1105
+ expect(getResult().isLoading.value).toBe(false);
1106
+ });
1107
+
1108
+ expect(getResult().listError).toBeDefined();
1109
+ expect(getResult().listError.value).toBeNull();
1110
+ expect(getResult().isMutating).toBeDefined();
1111
+ expect(getResult().isMutating.value).toBe(false);
1112
+ expect(typeof getResult().unarchiveThread).toBe("function");
1113
+ expect(typeof getResult().refetchThreads).toBe("function");
1114
+ expect(typeof getResult().startNewThread).toBe("function");
1115
+ });
1116
+
1117
+ it("listError excludes the dev 'Runtime URL is not configured' error", async () => {
1118
+ // No runtimeUrl -> `error` is the dev string, but `listError` stays null.
1119
+ setupCopilotKit("");
1120
+
1121
+ const { getResult } = mountHook();
1122
+
1123
+ await nextTick();
1124
+
1125
+ expect(getResult().error.value).toBeInstanceOf(Error);
1126
+ expect(getResult().error.value?.message).toBe(
1127
+ "Runtime URL is not configured",
1128
+ );
1129
+ expect(getResult().listError.value).toBeNull();
1130
+ });
1131
+
1132
+ it("unarchiveThread calls through to the store's unarchive contract", async () => {
1133
+ fetchMock
1134
+ .mockReturnValueOnce(
1135
+ jsonResponse({ threads: sampleThreads, joinCode: "jc-1" }),
1136
+ )
1137
+ .mockReturnValueOnce(jsonResponse({ joinToken: "jt-1" }))
1138
+ .mockReturnValueOnce(jsonResponse({}));
1139
+
1140
+ const { getResult } = mountHook();
1141
+
1142
+ await vi.waitFor(() => {
1143
+ expect(getResult().isLoading.value).toBe(false);
1144
+ });
1145
+
1146
+ await getResult().unarchiveThread("t-1");
1147
+
1148
+ const [url, options] = fetchMock.mock.calls[2];
1149
+ expect(url).toContain("/threads/t-1/unarchive");
1150
+ expect(options.method).toBe("POST");
1151
+ expect(JSON.parse(options.body)).toMatchObject({
1152
+ agentId: "agent-1",
1153
+ });
1154
+ });
1155
+
1156
+ it("does not dispatch a context (or fetch) when enabled is false", async () => {
1157
+ const { getResult } = mountHook({ ...defaultInput, enabled: false });
1158
+
1159
+ await nextTick();
1160
+
1161
+ expect(fetchMock).not.toHaveBeenCalled();
1162
+ expect(getResult().threads.value).toEqual([]);
1163
+ const lastDispatched = getDispatchedContexts().at(-1);
1164
+ expect(lastDispatched).toBeNull();
1165
+ // A disabled consumer must not be stuck in a permanent loading state:
1166
+ // preConnectLoading must factor in `enabled`, otherwise isLoading would
1167
+ // stay true forever since hasDispatchedContext never gets set.
1168
+ expect(getResult().isLoading.value).toBe(false);
1169
+ });
1170
+
1171
+ it("re-arms the pre-connect loading indicator when enabled toggles false -> true", async () => {
1172
+ const enabled = ref(false);
1173
+
1174
+ fetchMock
1175
+ .mockReturnValueOnce(
1176
+ jsonResponse({ threads: sampleThreads, joinCode: "jc-1" }),
1177
+ )
1178
+ .mockReturnValueOnce(jsonResponse({ joinToken: "jt-1" }));
1179
+
1180
+ const { getResult } = mountHook({ ...defaultInput, enabled });
1181
+
1182
+ await nextTick();
1183
+
1184
+ // Disabled: inert, not loading, no fetch.
1185
+ expect(getResult().isLoading.value).toBe(false);
1186
+ expect(fetchMock).not.toHaveBeenCalled();
1187
+ expect(getDispatchedContexts().at(-1)).toBeNull();
1188
+
1189
+ // Re-enable: the pre-connect loading indicator must re-arm (i.e.
1190
+ // hasDispatchedContext was reset to false on disable) so consumers see
1191
+ // isLoading=true again rather than a stale empty-list flash while the
1192
+ // new context dispatch/fetch is in flight.
1193
+ enabled.value = true;
1194
+ await nextTick();
1195
+
1196
+ expect(getResult().isLoading.value).toBe(true);
1197
+
1198
+ await vi.waitFor(() => {
1199
+ expect(getResult().isLoading.value).toBe(false);
1200
+ });
1201
+
1202
+ expect(fetchMock).toHaveBeenCalledWith(
1203
+ expect.stringContaining("/threads?agentId=agent-1"),
1204
+ expect.objectContaining({ method: "GET" }),
1205
+ );
1206
+ const nonNullContexts = getDispatchedContexts().filter(
1207
+ (context) => context !== null,
1208
+ );
1209
+ expect(nonNullContexts).toHaveLength(1);
1210
+ });
1211
+ });
1212
+
1213
+ describe("thread store registration (core registry)", () => {
1214
+ it("registers the thread store with core when enabled", async () => {
1215
+ const copilotkit = setupCopilotKit();
1216
+
1217
+ fetchMock
1218
+ .mockReturnValueOnce(
1219
+ jsonResponse({ threads: sampleThreads, joinCode: "jc-1" }),
1220
+ )
1221
+ .mockReturnValueOnce(jsonResponse({ joinToken: "jt-1" }));
1222
+
1223
+ mountHook();
1224
+
1225
+ await vi.waitFor(() => {
1226
+ expect(copilotkit.value.registerThreadStore).toHaveBeenCalledWith(
1227
+ "agent-1",
1228
+ expect.objectContaining({ select: expect.any(Function) }),
1229
+ );
1230
+ });
1231
+ });
1232
+
1233
+ it("does not register the thread store when disabled (avoids evicting a live store for the same agent)", async () => {
1234
+ const copilotkit = setupCopilotKit();
1235
+
1236
+ mountHook({ ...defaultInput, enabled: false });
1237
+
1238
+ await nextTick();
1239
+
1240
+ expect(copilotkit.value.registerThreadStore).not.toHaveBeenCalled();
1241
+ });
1242
+
1243
+ it("unregisters the thread store with core on unmount", async () => {
1244
+ const copilotkit = setupCopilotKit();
1245
+
1246
+ fetchMock
1247
+ .mockReturnValueOnce(
1248
+ jsonResponse({ threads: sampleThreads, joinCode: "jc-1" }),
1249
+ )
1250
+ .mockReturnValueOnce(jsonResponse({ joinToken: "jt-1" }));
1251
+
1252
+ const { wrapper } = mountHook();
1253
+
1254
+ await vi.waitFor(() => {
1255
+ expect(copilotkit.value.registerThreadStore).toHaveBeenCalled();
1256
+ });
1257
+
1258
+ wrapper.unmount();
1259
+
1260
+ expect(copilotkit.value.unregisterThreadStore).toHaveBeenCalledWith(
1261
+ "agent-1",
1262
+ );
1263
+ });
1264
+ });
1265
+
1266
+ describe("threadEndpoints gating", () => {
1267
+ it("does not fetch when the runtime does not advertise the list endpoint and surfaces the endpoints-unavailable error", async () => {
1268
+ setupCopilotKit(
1269
+ "http://localhost:4000",
1270
+ CopilotKitCoreRuntimeConnectionStatus.Connected,
1271
+ {
1272
+ threadEndpoints: {
1273
+ list: false,
1274
+ inspect: false,
1275
+ mutations: false,
1276
+ realtimeMetadata: false,
1277
+ },
1278
+ intelligence: undefined,
1279
+ },
1280
+ );
1281
+
1282
+ const { getResult } = mountHook();
1283
+
1284
+ await vi.waitFor(() => {
1285
+ expect(getResult().isLoading.value).toBe(false);
1286
+ });
1287
+
1288
+ expect(fetchMock).not.toHaveBeenCalled();
1289
+ expect(getResult().threads.value).toEqual([]);
1290
+ const lastDispatched = getDispatchedContexts().at(-1);
1291
+ expect(lastDispatched).toBeNull();
1292
+ expect(getResult().error.value?.message).toBe(
1293
+ "Thread endpoints are not available on this CopilotKit runtime",
1294
+ );
1295
+ // The config error must NOT leak into the user-facing list error.
1296
+ expect(getResult().listError.value).toBeNull();
1297
+ });
1298
+
1299
+ it("rejects mutations locally when the runtime reports mutations are unsupported", async () => {
1300
+ setupCopilotKit(
1301
+ "http://localhost:4000",
1302
+ CopilotKitCoreRuntimeConnectionStatus.Connected,
1303
+ {
1304
+ threadEndpoints: {
1305
+ list: true,
1306
+ inspect: true,
1307
+ mutations: false,
1308
+ realtimeMetadata: false,
1309
+ },
1310
+ intelligence: undefined,
1311
+ },
1312
+ );
1313
+ fetchMock.mockReturnValueOnce(jsonResponse({ threads: sampleThreads }));
1314
+
1315
+ const { getResult } = mountHook();
1316
+
1317
+ await vi.waitFor(() => {
1318
+ expect(getResult().isLoading.value).toBe(false);
1319
+ });
1320
+
1321
+ fetchMock.mockClear();
1322
+
1323
+ await expect(getResult().renameThread("t-1", "Renamed")).rejects.toThrow(
1324
+ "Thread mutations are not available on this CopilotKit runtime",
1325
+ );
1326
+ await expect(getResult().archiveThread("t-1")).rejects.toThrow(
1327
+ "Thread mutations are not available on this CopilotKit runtime",
1328
+ );
1329
+ await expect(getResult().unarchiveThread("t-1")).rejects.toThrow(
1330
+ "Thread mutations are not available on this CopilotKit runtime",
1331
+ );
1332
+ await expect(getResult().deleteThread("t-1")).rejects.toThrow(
1333
+ "Thread mutations are not available on this CopilotKit runtime",
1334
+ );
1335
+ expect(fetchMock).not.toHaveBeenCalled();
1336
+ });
1337
+ });
1041
1338
  });
@@ -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
+ });