@copilotkit/react-core 1.55.3 → 1.56.1

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/dist/{copilotkit-dwDWYpya.d.cts → copilotkit-BtP7w7cT.d.cts} +56 -10
  2. package/dist/copilotkit-BtP7w7cT.d.cts.map +1 -0
  3. package/dist/{copilotkit-BuhSUZHb.d.mts → copilotkit-CCbxm6JM.d.mts} +56 -10
  4. package/dist/copilotkit-CCbxm6JM.d.mts.map +1 -0
  5. package/dist/{copilotkit-Dgdpbqjt.cjs → copilotkit-CSJw5BG8.cjs} +129 -58
  6. package/dist/copilotkit-CSJw5BG8.cjs.map +1 -0
  7. package/dist/{copilotkit-Cd-NrDyp.mjs → copilotkit-Cj2ZIxVr.mjs} +125 -60
  8. package/dist/copilotkit-Cj2ZIxVr.mjs.map +1 -0
  9. package/dist/index.cjs +1 -1
  10. package/dist/index.d.cts +1 -1
  11. package/dist/index.d.mts +1 -1
  12. package/dist/index.mjs +1 -1
  13. package/dist/index.umd.js +55 -23
  14. package/dist/index.umd.js.map +1 -1
  15. package/dist/v2/index.cjs +2 -1
  16. package/dist/v2/index.d.cts +2 -2
  17. package/dist/v2/index.d.mts +2 -2
  18. package/dist/v2/index.mjs +2 -2
  19. package/dist/v2/index.umd.js +124 -59
  20. package/dist/v2/index.umd.js.map +1 -1
  21. package/package.json +6 -6
  22. package/src/components/CopilotListeners.tsx +15 -4
  23. package/src/components/__tests__/CopilotListeners.test.tsx +38 -0
  24. package/src/components/copilot-provider/__tests__/error-visibility-prod.test.tsx +70 -0
  25. package/src/components/copilot-provider/copilot-messages.tsx +39 -24
  26. package/src/components/copilot-provider/copilotkit-props.tsx +26 -6
  27. package/src/components/copilot-provider/copilotkit.tsx +4 -1
  28. package/src/v2/components/chat/CopilotChatAssistantMessage.tsx +22 -19
  29. package/src/v2/components/chat/CopilotChatInput.tsx +21 -2
  30. package/src/v2/components/chat/CopilotChatReasoningMessage.tsx +17 -4
  31. package/src/v2/components/chat/CopilotChatUserMessage.tsx +13 -10
  32. package/src/v2/components/chat/__tests__/CopilotChat.e2e.test.tsx +131 -5
  33. package/src/v2/components/chat/__tests__/CopilotChatAssistantMessage.test.tsx +1 -1
  34. package/src/v2/components/chat/__tests__/CopilotChatAssistantMessage.thumbs.test.tsx +72 -0
  35. package/src/v2/components/chat/__tests__/CopilotChatCopyButton.clipboard.test.tsx +241 -0
  36. package/src/v2/components/chat/__tests__/CopilotChatInput.test.tsx +38 -0
  37. package/src/v2/components/ui/button.tsx +12 -11
  38. package/src/v2/hooks/__tests__/use-agent-throttle.test.tsx +10 -10
  39. package/src/v2/hooks/__tests__/use-capabilities.test.tsx +76 -0
  40. package/src/v2/hooks/__tests__/use-render-custom-messages.test.tsx +55 -0
  41. package/src/v2/hooks/index.ts +1 -0
  42. package/src/v2/hooks/use-agent.tsx +23 -4
  43. package/src/v2/hooks/use-capabilities.tsx +25 -0
  44. package/src/v2/hooks/use-render-custom-messages.tsx +1 -1
  45. package/src/v2/hooks/use-render-tool-call.tsx +3 -0
  46. package/src/v2/hooks/use-render-tool.tsx +3 -0
  47. package/src/v2/providers/CopilotKitProvider.tsx +15 -2
  48. package/src/v2/types/defineToolCallRenderer.ts +3 -0
  49. package/src/v2/types/react-tool-call-renderer.ts +3 -0
  50. package/dist/copilotkit-BuhSUZHb.d.mts.map +0 -1
  51. package/dist/copilotkit-Cd-NrDyp.mjs.map +0 -1
  52. package/dist/copilotkit-Dgdpbqjt.cjs.map +0 -1
  53. package/dist/copilotkit-dwDWYpya.d.cts.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@copilotkit/react-core",
3
- "version": "1.55.3",
3
+ "version": "1.56.1",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "ai",
@@ -73,11 +73,11 @@
73
73
  "untruncate-json": "^0.0.1",
74
74
  "use-stick-to-bottom": "^1.1.1",
75
75
  "zod-to-json-schema": "^3.24.5",
76
- "@copilotkit/core": "1.55.3",
77
- "@copilotkit/runtime-client-gql": "1.55.3",
78
- "@copilotkit/web-inspector": "1.55.3",
79
- "@copilotkit/shared": "1.55.3",
80
- "@copilotkit/a2ui-renderer": "1.55.3"
76
+ "@copilotkit/runtime-client-gql": "1.56.1",
77
+ "@copilotkit/core": "1.56.1",
78
+ "@copilotkit/shared": "1.56.1",
79
+ "@copilotkit/web-inspector": "1.56.1",
80
+ "@copilotkit/a2ui-renderer": "1.56.1"
81
81
  },
82
82
  "devDependencies": {
83
83
  "@tailwindcss/cli": "^4.1.11",
@@ -60,16 +60,27 @@ const usePredictStateSubscription = (agent?: AbstractAgent) => {
60
60
  }, [agent, getSubscriber]);
61
61
  };
62
62
 
63
- export function CopilotListeners() {
64
- const { copilotkit } = useCopilotKit();
63
+ function CopilotListenersAgentSubscription() {
65
64
  const existingConfig = useCopilotChatConfiguration();
66
65
  const resolvedAgentId = existingConfig?.agentId;
67
- const { setBannerError } = useToast();
68
66
 
69
67
  const { agent } = useAgent({ agentId: resolvedAgentId });
70
68
 
71
69
  usePredictStateSubscription(agent);
72
70
 
71
+ return null;
72
+ }
73
+
74
+ export function CopilotListeners() {
75
+ const { copilotkit } = useCopilotKit();
76
+ const { setBannerError } = useToast();
77
+
78
+ // Only render the agent subscription when agents are registered or a runtime
79
+ // is configured. Without this guard, useAgent() throws when the agents map is
80
+ // empty and no runtimeUrl is set (#3249).
81
+ const hasAgents = Object.keys(copilotkit.agents ?? {}).length > 0;
82
+ const hasRuntime = copilotkit.runtimeUrl !== undefined;
83
+
73
84
  useEffect(() => {
74
85
  const subscriber: CopilotKitCoreSubscriber = {
75
86
  onError: ({ error, code, context }) => {
@@ -122,5 +133,5 @@ export function CopilotListeners() {
122
133
  };
123
134
  }, [copilotkit?.subscribe]);
124
135
 
125
- return null;
136
+ return hasAgents || hasRuntime ? <CopilotListenersAgentSubscription /> : null;
126
137
  }
@@ -0,0 +1,38 @@
1
+ import React from "react";
2
+ import { describe, it, expect, vi, beforeEach } from "vitest";
3
+ import { render } from "@testing-library/react";
4
+ import { CopilotListeners } from "../CopilotListeners";
5
+ import { CopilotKitProvider } from "../../v2/providers/CopilotKitProvider";
6
+ import { CopilotChatConfigurationProvider } from "../../v2/providers/CopilotChatConfigurationProvider";
7
+ import { ToastProvider } from "../toast/toast-provider";
8
+
9
+ /**
10
+ * Regression test for #3249: CopilotListeners throws when no agents registered.
11
+ *
12
+ * When CopilotKitProvider has no agents registered (empty agents map) and no
13
+ * runtimeUrl, useAgent() inside CopilotListeners throws. The component should
14
+ * handle this gracefully and render null without crashing.
15
+ */
16
+ describe("CopilotListeners (#3249)", () => {
17
+ beforeEach(() => {
18
+ vi.spyOn(console, "error").mockImplementation(() => {});
19
+ });
20
+
21
+ it("does not throw when no agents are registered", () => {
22
+ // No agents, no runtimeUrl - should not crash
23
+ expect(() => {
24
+ render(
25
+ <ToastProvider enabled={false}>
26
+ <CopilotKitProvider>
27
+ <CopilotChatConfigurationProvider
28
+ agentId="default"
29
+ threadId="test-thread"
30
+ >
31
+ <CopilotListeners />
32
+ </CopilotChatConfigurationProvider>
33
+ </CopilotKitProvider>
34
+ </ToastProvider>,
35
+ );
36
+ }).not.toThrow();
37
+ });
38
+ });
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { ErrorVisibility } from "@copilotkit/shared";
3
+ import { getErrorSuppression } from "../copilot-messages";
4
+
5
+ /**
6
+ * Regression tests for #2431: error visibility when showDevConsole=false.
7
+ *
8
+ * The bug: `routeError` returned early for ALL errors when `isDev` was false,
9
+ * suppressing TOAST and BANNER errors that should always reach the user.
10
+ *
11
+ * The fix: only SILENT and DEV_ONLY errors are suppressed in production;
12
+ * TOAST, BANNER, and untagged errors are always surfaced.
13
+ */
14
+ describe("getErrorSuppression — error visibility routing (#2431)", () => {
15
+ // --- Production (isDev = false) ---
16
+
17
+ it("surfaces TOAST errors in production", () => {
18
+ expect(getErrorSuppression(ErrorVisibility.TOAST, false)).toBeNull();
19
+ });
20
+
21
+ it("surfaces BANNER errors in production", () => {
22
+ expect(getErrorSuppression(ErrorVisibility.BANNER, false)).toBeNull();
23
+ });
24
+
25
+ it("suppresses DEV_ONLY errors in production", () => {
26
+ expect(getErrorSuppression(ErrorVisibility.DEV_ONLY, false)).not.toBeNull();
27
+ });
28
+
29
+ it("suppresses SILENT errors in production", () => {
30
+ expect(getErrorSuppression(ErrorVisibility.SILENT, false)).not.toBeNull();
31
+ });
32
+
33
+ it("surfaces errors with no visibility tag in production", () => {
34
+ expect(getErrorSuppression(undefined, false)).toBeNull();
35
+ });
36
+
37
+ // --- Development (isDev = true) ---
38
+
39
+ it("surfaces TOAST errors in development", () => {
40
+ expect(getErrorSuppression(ErrorVisibility.TOAST, true)).toBeNull();
41
+ });
42
+
43
+ it("surfaces BANNER errors in development", () => {
44
+ expect(getErrorSuppression(ErrorVisibility.BANNER, true)).toBeNull();
45
+ });
46
+
47
+ it("surfaces DEV_ONLY errors in development", () => {
48
+ expect(getErrorSuppression(ErrorVisibility.DEV_ONLY, true)).toBeNull();
49
+ });
50
+
51
+ it("suppresses SILENT errors in development", () => {
52
+ expect(getErrorSuppression(ErrorVisibility.SILENT, true)).not.toBeNull();
53
+ });
54
+
55
+ it("surfaces errors with no visibility tag in development", () => {
56
+ expect(getErrorSuppression(undefined, true)).toBeNull();
57
+ });
58
+
59
+ // --- Log prefix strings ---
60
+
61
+ it("returns a 'Silent Error' prefix for SILENT visibility", () => {
62
+ const prefix = getErrorSuppression(ErrorVisibility.SILENT, false);
63
+ expect(prefix).toContain("Silent");
64
+ });
65
+
66
+ it("returns a 'hidden in production' prefix for DEV_ONLY visibility", () => {
67
+ const prefix = getErrorSuppression(ErrorVisibility.DEV_ONLY, false);
68
+ expect(prefix).toContain("hidden in production");
69
+ });
70
+ });
@@ -31,6 +31,33 @@ import {
31
31
  } from "@copilotkit/shared";
32
32
  import { Suggestion } from "@copilotkit/core";
33
33
 
34
+ /**
35
+ * Determine whether a GraphQL error should be suppressed based on its visibility
36
+ * and whether the dev console is active.
37
+ *
38
+ * Returns `null` when the error should be surfaced to the UI, or a log prefix
39
+ * string when the error should be suppressed (logged to console only).
40
+ *
41
+ * Exported for unit testing.
42
+ */
43
+ export function getErrorSuppression(
44
+ visibility: ErrorVisibility | undefined,
45
+ isDev: boolean,
46
+ ): string | null {
47
+ // Silent errors are always suppressed
48
+ if (visibility === ErrorVisibility.SILENT) {
49
+ return "CopilotKit Silent Error:";
50
+ }
51
+
52
+ // DEV_ONLY errors are suppressed in production
53
+ if (!isDev && visibility === ErrorVisibility.DEV_ONLY) {
54
+ return "CopilotKit Error (hidden in production):";
55
+ }
56
+
57
+ // All other visibilities (TOAST, BANNER, undefined) are always surfaced
58
+ return null;
59
+ }
60
+
34
61
  // Helper to determine if error should show as banner based on visibility and legacy patterns
35
62
  function shouldShowAsBanner(gqlError: GraphQLError): boolean {
36
63
  const extensions = gqlError.extensions;
@@ -224,20 +251,13 @@ export function CopilotMessages({ children }: { children: ReactNode }) {
224
251
  const visibility = extensions?.visibility as ErrorVisibility;
225
252
  const isDev = shouldShowDevConsole(showDevConsole);
226
253
 
227
- if (!isDev) {
228
- console.error(
229
- "CopilotKit Error (hidden in production):",
230
- gqlError.message,
231
- );
232
- return;
233
- }
234
-
235
- // Silent errors - just log
236
- if (visibility === ErrorVisibility.SILENT) {
237
- console.error("CopilotKit Silent Error:", gqlError.message);
254
+ const suppression = getErrorSuppression(visibility, isDev);
255
+ if (suppression) {
256
+ console.error(suppression, gqlError.message);
238
257
  return;
239
258
  }
240
259
 
260
+ // TOAST and BANNER errors are always surfaced, even in production
241
261
  // All other errors (including DEV_ONLY) show as banners for consistency
242
262
  const ckError = createStructuredError(gqlError);
243
263
  if (ckError) {
@@ -259,19 +279,14 @@ export function CopilotMessages({ children }: { children: ReactNode }) {
259
279
  // Process all errors as banners
260
280
  graphQLErrors.forEach(routeError);
261
281
  } else {
262
- const isDev = shouldShowDevConsole(showDevConsole);
263
- if (!isDev) {
264
- console.error("CopilotKit Error (hidden in production):", error);
265
- } else {
266
- // Route non-GraphQL errors to banner as well
267
- const fallbackError = new CopilotKitError({
268
- message: error?.message || String(error),
269
- code: CopilotKitErrorCode.UNKNOWN,
270
- });
271
- setBannerError(fallbackError);
272
- // Trace the non-GraphQL error
273
- traceUIError(fallbackError, error);
274
- }
282
+ // Non-GraphQL errors are always surfaced to the user
283
+ const fallbackError = new CopilotKitError({
284
+ message: error?.message || String(error),
285
+ code: CopilotKitErrorCode.UNKNOWN,
286
+ });
287
+ setBannerError(fallbackError);
288
+ // Trace the non-GraphQL error
289
+ traceUIError(fallbackError, error);
275
290
  }
276
291
  },
277
292
  [setBannerError, showDevConsole, traceUIError],
@@ -1,7 +1,7 @@
1
1
  import { ForwardedParametersInput } from "@copilotkit/runtime-client-gql";
2
2
  import { ReactNode } from "react";
3
3
  import { AuthState } from "../../context/copilot-context";
4
- import { CopilotErrorHandler } from "@copilotkit/shared";
4
+ import { CopilotErrorHandler, DebugConfig } from "@copilotkit/shared";
5
5
  import { CopilotKitProviderProps } from "../../v2";
6
6
  /**
7
7
  * Props for CopilotKit.
@@ -68,15 +68,19 @@ export interface CopilotKitProps extends Omit<
68
68
 
69
69
  /**
70
70
  * Additional headers to be sent with the request.
71
+ * Can be a static object or a function that returns headers dynamically
72
+ * (useful for refreshing auth tokens).
71
73
  *
72
74
  * For example:
73
- * ```json
74
- * {
75
- * "Authorization": "Bearer X"
76
- * }
75
+ * ```tsx
76
+ * // Static headers
77
+ * headers={{ "Authorization": "Bearer X" }}
78
+ *
79
+ * // Dynamic headers (re-evaluated on each render)
80
+ * headers={() => ({ "Authorization": `Bearer ${getToken()}` })}
77
81
  * ```
78
82
  */
79
- headers?: Record<string, string>;
83
+ headers?: Record<string, string> | (() => Record<string, string>);
80
84
 
81
85
  /**
82
86
  * The children to be rendered within the CopilotKit.
@@ -191,4 +195,20 @@ export interface CopilotKitProps extends Omit<
191
195
  * to enabled.
192
196
  */
193
197
  enableInspector?: boolean;
198
+
199
+ /**
200
+ * Enable debug logging. On the server (`CopilotRuntime`), this enables
201
+ * structured Pino logging of the AG-UI event pipeline. On the client,
202
+ * this configuration is forwarded to the AG-UI transport layer
203
+ * (`transformChunks`) for transport-level debug output.
204
+ *
205
+ * Pass `true` for full output, or an object for granular control:
206
+ *
207
+ * ```tsx
208
+ * <CopilotKit debug={true} runtimeUrl="...">
209
+ * {children}
210
+ * </CopilotKit>
211
+ * ```
212
+ */
213
+ debug?: DebugConfig;
194
214
  }
@@ -311,7 +311,10 @@ export function CopilotKitInternal(cpkProps: CopilotKitProps) {
311
311
  publicApiKey: publicApiKey,
312
312
  ...(cloud ? { cloud } : {}),
313
313
  chatApiEndpoint: chatApiEndpoint,
314
- headers: props.headers || {},
314
+ headers:
315
+ typeof props.headers === "function"
316
+ ? props.headers()
317
+ : props.headers || {},
315
318
  properties: props.properties || {},
316
319
  transcribeAudioUrl: props.transcribeAudioUrl,
317
320
  textToSpeechUrl: props.textToSpeechUrl,
@@ -22,6 +22,7 @@ import {
22
22
  import { useKatexStyles } from "../../hooks/useKatexStyles";
23
23
  import { WithSlots, renderSlot } from "../../lib/slots";
24
24
  import { Streamdown } from "streamdown";
25
+ import { copyToClipboard } from "@copilotkit/shared";
25
26
  import CopilotChatToolCallsView from "./CopilotChatToolCallsView";
26
27
 
27
28
  export type CopilotChatAssistantMessageProps = WithSlots<
@@ -86,12 +87,9 @@ export function CopilotChatAssistantMessage({
86
87
  {
87
88
  onClick: async () => {
88
89
  if (message.content) {
89
- try {
90
- await navigator.clipboard.writeText(message.content);
91
- } catch (err) {
92
- console.error("Failed to copy message:", err);
93
- }
90
+ return await copyToClipboard(message.content);
94
91
  }
92
+ return false;
95
93
  },
96
94
  },
97
95
  );
@@ -100,7 +98,7 @@ export function CopilotChatAssistantMessage({
100
98
  thumbsUpButton,
101
99
  CopilotChatAssistantMessage.ThumbsUpButton,
102
100
  {
103
- onClick: onThumbsUp,
101
+ onClick: onThumbsUp ? () => onThumbsUp(message) : undefined,
104
102
  },
105
103
  );
106
104
 
@@ -108,7 +106,7 @@ export function CopilotChatAssistantMessage({
108
106
  thumbsDownButton,
109
107
  CopilotChatAssistantMessage.ThumbsDownButton,
110
108
  {
111
- onClick: onThumbsDown,
109
+ onClick: onThumbsDown ? () => onThumbsDown(message) : undefined,
112
110
  },
113
111
  );
114
112
 
@@ -116,7 +114,7 @@ export function CopilotChatAssistantMessage({
116
114
  readAloudButton,
117
115
  CopilotChatAssistantMessage.ReadAloudButton,
118
116
  {
119
- onClick: onReadAloud,
117
+ onClick: onReadAloud ? () => onReadAloud(message) : undefined,
120
118
  },
121
119
  );
122
120
 
@@ -124,7 +122,7 @@ export function CopilotChatAssistantMessage({
124
122
  regenerateButton,
125
123
  CopilotChatAssistantMessage.RegenerateButton,
126
124
  {
127
- onClick: onRegenerate,
125
+ onClick: onRegenerate ? () => onRegenerate(message) : undefined,
128
126
  },
129
127
  );
130
128
 
@@ -275,18 +273,23 @@ export namespace CopilotChatAssistantMessage {
275
273
  };
276
274
  }, []);
277
275
 
278
- const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
279
- setCopied(true);
280
- if (timerRef.current !== null) {
281
- clearTimeout(timerRef.current);
276
+ const handleClick = async (event: React.MouseEvent<HTMLButtonElement>) => {
277
+ let success = false;
278
+ if (onClick) {
279
+ // onClick may return a boolean indicating copy success
280
+ const result = await Promise.resolve(onClick(event));
281
+ success = result === true;
282
282
  }
283
- timerRef.current = setTimeout(() => {
284
- timerRef.current = null;
285
- setCopied(false);
286
- }, 2000);
287
283
 
288
- if (onClick) {
289
- onClick(event);
284
+ if (success) {
285
+ setCopied(true);
286
+ if (timerRef.current !== null) {
287
+ clearTimeout(timerRef.current);
288
+ }
289
+ timerRef.current = setTimeout(() => {
290
+ timerRef.current = null;
291
+ setCopied(false);
292
+ }, 2000);
290
293
  }
291
294
  };
292
295
 
@@ -384,6 +384,12 @@ export function CopilotChatInput({
384
384
  );
385
385
 
386
386
  const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
387
+ // Skip key handling during IME composition (e.g. CJK input).
388
+ // The compositionend event will fire separately when composition ends.
389
+ if (e.nativeEvent.isComposing || e.keyCode === 229) {
390
+ return;
391
+ }
392
+
387
393
  if (commandQuery !== null && mode === "input") {
388
394
  if (e.key === "ArrowDown") {
389
395
  if (filteredCommands.length > 0) {
@@ -455,10 +461,12 @@ export function CopilotChatInput({
455
461
 
456
462
  onSubmitMessage(trimmed);
457
463
 
464
+ // Always clear the input after sending, including controlled mode.
465
+ // In controlled mode, onChange("") notifies the parent to reset its state.
458
466
  if (!isControlled) {
459
467
  setInternalValue("");
460
- onChange?.("");
461
468
  }
469
+ onChange?.("");
462
470
 
463
471
  if (inputRef.current) {
464
472
  inputRef.current.focus();
@@ -470,6 +478,12 @@ export function CopilotChatInput({
470
478
  value: resolvedValue,
471
479
  onChange: handleChange,
472
480
  onKeyDown: handleKeyDown,
481
+ onCompositionStart: () => {
482
+ isComposingRef.current = true;
483
+ },
484
+ onCompositionEnd: () => {
485
+ isComposingRef.current = false;
486
+ },
473
487
  autoFocus: autoFocus,
474
488
  className: twMerge(
475
489
  "cpk:w-full cpk:py-3",
@@ -612,9 +626,14 @@ export function CopilotChatInput({
612
626
  }
613
627
  };
614
628
 
629
+ // Track whether an IME composition is active so we can avoid
630
+ // resetting textarea.value during measurement (which would break
631
+ // the composition session).
632
+ const isComposingRef = useRef(false);
633
+
615
634
  const ensureMeasurements = useCallback(() => {
616
635
  const textarea = inputRef.current;
617
- if (!textarea) {
636
+ if (!textarea || isComposingRef.current) {
618
637
  return;
619
638
  }
620
639
 
@@ -71,18 +71,31 @@ export function CopilotChatReasoningMessage({
71
71
  return () => clearInterval(timer);
72
72
  }, [isStreaming]);
73
73
 
74
- // Default to open while streaming, auto-collapse when streaming ends
74
+ // Default to open while streaming, auto-collapse when streaming ends.
75
+ // Track whether the user has manually toggled so auto-collapse doesn't
76
+ // override their explicit intent (prevents flaky test failures on CI
77
+ // where async forceUpdate timing can race with click handlers).
75
78
  const [isOpen, setIsOpen] = useState(isStreaming);
79
+ const userToggledRef = useRef(false);
76
80
 
77
81
  useEffect(() => {
78
82
  if (isStreaming) {
83
+ // Reset user-toggle tracking when a new streaming session starts
84
+ userToggledRef.current = false;
79
85
  setIsOpen(true);
80
- } else {
81
- // Auto-collapse when reasoning finishes
86
+ } else if (!userToggledRef.current) {
87
+ // Auto-collapse only if the user hasn't manually toggled
82
88
  setIsOpen(false);
83
89
  }
84
90
  }, [isStreaming]);
85
91
 
92
+ const handleToggle = hasContent
93
+ ? () => {
94
+ userToggledRef.current = true;
95
+ setIsOpen((prev) => !prev);
96
+ }
97
+ : undefined;
98
+
86
99
  const label = isStreaming
87
100
  ? "Thinking…"
88
101
  : `Thought for ${formatDuration(elapsed)}`;
@@ -92,7 +105,7 @@ export function CopilotChatReasoningMessage({
92
105
  label,
93
106
  hasContent,
94
107
  isStreaming,
95
- onClick: hasContent ? () => setIsOpen((prev) => !prev) : undefined,
108
+ onClick: handleToggle,
96
109
  });
97
110
 
98
111
  const boundContent = renderSlot(
@@ -18,6 +18,7 @@ import {
18
18
  type AudioInputPart,
19
19
  type VideoInputPart,
20
20
  type DocumentInputPart,
21
+ copyToClipboard,
21
22
  } from "@copilotkit/shared";
22
23
  import { CopilotChatAttachmentRenderer } from "./CopilotChatAttachmentRenderer";
23
24
 
@@ -147,12 +148,9 @@ export function CopilotChatUserMessage({
147
148
  {
148
149
  onClick: async () => {
149
150
  if (flattenedContent) {
150
- try {
151
- await navigator.clipboard.writeText(flattenedContent);
152
- } catch (err) {
153
- console.error("Failed to copy message:", err);
154
- }
151
+ return await copyToClipboard(flattenedContent);
155
152
  }
153
+ return false;
156
154
  },
157
155
  },
158
156
  );
@@ -314,12 +312,17 @@ export namespace CopilotChatUserMessage {
314
312
  const labels = config?.labels ?? CopilotChatDefaultLabels;
315
313
  const [copied, setCopied] = useState(false);
316
314
 
317
- const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
318
- setCopied(true);
319
- setTimeout(() => setCopied(false), 2000);
320
-
315
+ const handleClick = async (event: React.MouseEvent<HTMLButtonElement>) => {
316
+ let success = false;
321
317
  if (onClick) {
322
- onClick(event);
318
+ // onClick may return a boolean indicating copy success
319
+ const result = await Promise.resolve(onClick(event));
320
+ success = result === true;
321
+ }
322
+
323
+ if (success) {
324
+ setCopied(true);
325
+ setTimeout(() => setCopied(false), 2000);
323
326
  }
324
327
  };
325
328