@copilotkit/react-core 1.56.0 → 1.56.2-canary.pin-to-send

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 (60) hide show
  1. package/dist/{copilotkit-BebqQrYT.mjs → copilotkit-BBYbekCa.mjs} +265 -76
  2. package/dist/copilotkit-BBYbekCa.mjs.map +1 -0
  3. package/dist/{copilotkit-Cvb6WpAX.cjs → copilotkit-D5JT2Pu3.cjs} +264 -75
  4. package/dist/copilotkit-D5JT2Pu3.cjs.map +1 -0
  5. package/dist/{copilotkit-f2Uq0RwG.d.mts → copilotkit-DArT2Iuw.d.mts} +71 -18
  6. package/dist/copilotkit-DArT2Iuw.d.mts.map +1 -0
  7. package/dist/{copilotkit-Dv8zU8_U.d.cts → copilotkit-KEc28l8G.d.cts} +71 -18
  8. package/dist/copilotkit-KEc28l8G.d.cts.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 +30 -46
  14. package/dist/index.umd.js.map +1 -1
  15. package/dist/v2/index.cjs +1 -1
  16. package/dist/v2/index.css +1 -1
  17. package/dist/v2/index.d.cts +2 -2
  18. package/dist/v2/index.d.mts +2 -2
  19. package/dist/v2/index.mjs +1 -1
  20. package/dist/v2/index.umd.js +264 -79
  21. package/dist/v2/index.umd.js.map +1 -1
  22. package/package.json +6 -6
  23. package/src/components/CopilotListeners.tsx +15 -4
  24. package/src/components/__tests__/CopilotListeners.test.tsx +38 -0
  25. package/src/v2/components/chat/CopilotChat.tsx +80 -4
  26. package/src/v2/components/chat/CopilotChatAssistantMessage.tsx +4 -4
  27. package/src/v2/components/chat/CopilotChatInput.tsx +43 -2
  28. package/src/v2/components/chat/CopilotChatView.tsx +206 -11
  29. package/src/v2/components/chat/__tests__/CopilotChat.absentThreadConnect.test.tsx +66 -0
  30. package/src/v2/components/chat/__tests__/CopilotChatActivityRendering.e2e.test.tsx +300 -2
  31. package/src/v2/components/chat/__tests__/CopilotChatAssistantMessage.thumbs.test.tsx +72 -0
  32. package/src/v2/components/chat/__tests__/CopilotChatInput.test.tsx +38 -0
  33. package/src/v2/components/chat/__tests__/CopilotChatView.connectingGate.test.tsx +56 -0
  34. package/src/v2/components/chat/__tests__/CopilotChatView.pinToSend.test.tsx +94 -0
  35. package/src/v2/components/chat/__tests__/copilot-chat-throttle.test.tsx +0 -1
  36. package/src/v2/components/chat/__tests__/normalize-auto-scroll.test.ts +37 -0
  37. package/src/v2/components/chat/index.ts +2 -0
  38. package/src/v2/components/chat/last-user-message-context.ts +21 -0
  39. package/src/v2/components/chat/normalize-auto-scroll.ts +17 -0
  40. package/src/v2/components/license-warning-banner.tsx +20 -1
  41. package/src/v2/components/ui/button.tsx +12 -11
  42. package/src/v2/hooks/__tests__/use-agent-stability.test.tsx +6 -0
  43. package/src/v2/hooks/__tests__/use-agent-thread-isolation.test.tsx +6 -0
  44. package/src/v2/hooks/__tests__/use-agent-throttle.test.tsx +76 -50
  45. package/src/v2/hooks/__tests__/use-pin-to-send.test.tsx +219 -0
  46. package/src/v2/hooks/__tests__/use-render-custom-messages.test.tsx +55 -0
  47. package/src/v2/hooks/__tests__/use-threads.test.tsx +68 -0
  48. package/src/v2/hooks/use-agent.tsx +34 -77
  49. package/src/v2/hooks/use-pin-to-send.ts +94 -0
  50. package/src/v2/hooks/use-render-custom-messages.tsx +1 -1
  51. package/src/v2/hooks/use-render-tool-call.tsx +3 -0
  52. package/src/v2/hooks/use-render-tool.tsx +3 -0
  53. package/src/v2/hooks/use-threads.tsx +55 -12
  54. package/src/v2/providers/CopilotKitProvider.tsx +2 -11
  55. package/src/v2/types/defineToolCallRenderer.ts +3 -0
  56. package/src/v2/types/react-tool-call-renderer.ts +3 -0
  57. package/dist/copilotkit-BebqQrYT.mjs.map +0 -1
  58. package/dist/copilotkit-Cvb6WpAX.cjs.map +0 -1
  59. package/dist/copilotkit-Dv8zU8_U.d.cts.map +0 -1
  60. package/dist/copilotkit-f2Uq0RwG.d.mts.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@copilotkit/react-core",
3
- "version": "1.56.0",
3
+ "version": "1.56.2-canary.pin-to-send",
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/a2ui-renderer": "1.56.0",
77
- "@copilotkit/core": "1.56.0",
78
- "@copilotkit/runtime-client-gql": "1.56.0",
79
- "@copilotkit/web-inspector": "1.56.0",
80
- "@copilotkit/shared": "1.56.0"
76
+ "@copilotkit/core": "1.56.2-canary.pin-to-send",
77
+ "@copilotkit/shared": "1.56.2-canary.pin-to-send",
78
+ "@copilotkit/runtime-client-gql": "1.56.2-canary.pin-to-send",
79
+ "@copilotkit/web-inspector": "1.56.2-canary.pin-to-send",
80
+ "@copilotkit/a2ui-renderer": "1.56.2-canary.pin-to-send"
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
+ });
@@ -33,6 +33,10 @@ import {
33
33
  transcribeAudio,
34
34
  TranscriptionError,
35
35
  } from "../../lib/transcription-client";
36
+ import {
37
+ LastUserMessageContext,
38
+ type LastUserMessageState,
39
+ } from "./last-user-message-context";
36
40
 
37
41
  export type CopilotChatProps = Omit<
38
42
  CopilotChatViewProps,
@@ -97,9 +101,10 @@ export function CopilotChat({
97
101
  // Apply priority: props > existing config > defaults
98
102
  const resolvedAgentId =
99
103
  agentId ?? existingConfig?.agentId ?? DEFAULT_AGENT_ID;
104
+ const providedThreadId = threadId ?? existingConfig?.threadId;
100
105
  const resolvedThreadId = useMemo(
101
- () => threadId ?? existingConfig?.threadId ?? randomUUID(),
102
- [threadId, existingConfig?.threadId],
106
+ () => providedThreadId ?? randomUUID(),
107
+ [providedThreadId],
103
108
  );
104
109
 
105
110
  const { agent } = useAgent({
@@ -191,7 +196,24 @@ export function CopilotChat({
191
196
  ...restProps
192
197
  } = props;
193
198
 
199
+ // Tracks the last threadId for which connectAgent has completed (success or
200
+ // failure). When the user supplies a threadId, we're in "resume existing
201
+ // thread" mode — the welcome screen should be suppressed until the connect
202
+ // resolves, otherwise switching threads flashes the welcome screen while the
203
+ // new thread's messages are still en route.
204
+ const [lastConnectedThreadId, setLastConnectedThreadId] = useState<
205
+ string | null
206
+ >(null);
207
+ const isConnecting =
208
+ !!providedThreadId && lastConnectedThreadId !== resolvedThreadId;
209
+
194
210
  useEffect(() => {
211
+ // When no threadId was supplied by the caller, resolvedThreadId is a UUID
212
+ // minted in this browser tab. The backend has never seen it, so /connect
213
+ // would always 404. Skip the call — a real thread is only created once
214
+ // the user runs the agent for the first time.
215
+ if (!providedThreadId) return;
216
+
195
217
  let detached = false;
196
218
 
197
219
  // Create a fresh AbortController so we can cancel the HTTP request on cleanup.
@@ -212,6 +234,25 @@ export function CopilotChat({
212
234
  // connectAgent already emits via the subscriber system, but catch
213
235
  // here to prevent unhandled rejections from unexpected errors.
214
236
  console.error("CopilotChat: connectAgent failed", error);
237
+ } finally {
238
+ // Whether the connect succeeded or failed, we're no longer in the
239
+ // transitional "connecting" state for this thread — unblock the
240
+ // welcome-screen-suppression so the view can settle.
241
+ //
242
+ // Defer one animation frame so any trailing React commits from the
243
+ // bootstrap replay (final assistant message content) paint before
244
+ // isConnecting flips off. Without this, suggestions + copy button
245
+ // can briefly appear against an incompletely-laid-out message tree
246
+ // and visibly snap once the last text chunk lands.
247
+ if (!detached) {
248
+ const raf =
249
+ typeof requestAnimationFrame === "function"
250
+ ? requestAnimationFrame
251
+ : (cb: () => void) => setTimeout(cb, 16);
252
+ raf(() => {
253
+ if (!detached) setLastConnectedThreadId(resolvedThreadId);
254
+ });
255
+ }
215
256
  }
216
257
  };
217
258
  connect(agent);
@@ -229,7 +270,7 @@ export function CopilotChat({
229
270
  };
230
271
  // copilotkit is intentionally excluded — it is a stable ref that never changes.
231
272
  // eslint-disable-next-line react-hooks/exhaustive-deps
232
- }, [resolvedThreadId, agent, resolvedAgentId]);
273
+ }, [resolvedThreadId, agent, resolvedAgentId, providedThreadId]);
233
274
 
234
275
  const onSubmitInput = useCallback(
235
276
  async (value: string) => {
@@ -497,6 +538,37 @@ export function CopilotChat({
497
538
  [messagesMemoKey],
498
539
  );
499
540
 
541
+ // Compute the ID of the last user message for scroll-pinning logic.
542
+ const lastUserMessageId = useMemo(() => {
543
+ for (let i = messages.length - 1; i >= 0; i--) {
544
+ if (messages[i].role === "user") return messages[i].id;
545
+ }
546
+ return null;
547
+ }, [messages]);
548
+
549
+ // Track a nonce that increments each time a new user message ID appears.
550
+ // Using useState ensures the context value propagates correctly on the
551
+ // render that follows the state update (approach b from the design doc).
552
+ const [sendNonce, setSendNonce] = useState(0);
553
+ // Seed with the current value so restoring a thread with existing messages
554
+ // does not count as a new send. Only later-render id transitions bump.
555
+ const prevLastUserMessageIdRef = useRef<string | null>(lastUserMessageId);
556
+
557
+ useEffect(() => {
558
+ if (
559
+ lastUserMessageId &&
560
+ lastUserMessageId !== prevLastUserMessageIdRef.current
561
+ ) {
562
+ setSendNonce((n) => n + 1);
563
+ prevLastUserMessageIdRef.current = lastUserMessageId;
564
+ }
565
+ }, [lastUserMessageId]);
566
+
567
+ const lastUserMessageState = useMemo<LastUserMessageState>(
568
+ () => ({ id: lastUserMessageId, sendNonce }),
569
+ [lastUserMessageId, sendNonce],
570
+ );
571
+
500
572
  const finalProps: CopilotChatViewProps = {
501
573
  ...mergedProps,
502
574
  messages,
@@ -521,6 +593,8 @@ export function CopilotChat({
521
593
  onDragOver: handleDragOver,
522
594
  onDragLeave: handleDragLeave,
523
595
  onDrop: handleDrop,
596
+ isConnecting,
597
+ hasExplicitThreadId: !!providedThreadId,
524
598
  };
525
599
 
526
600
  // Always create a provider with merged values
@@ -564,7 +638,9 @@ export function CopilotChat({
564
638
  {transcriptionError}
565
639
  </div>
566
640
  )}
567
- {RenderedChatView}
641
+ <LastUserMessageContext.Provider value={lastUserMessageState}>
642
+ {RenderedChatView}
643
+ </LastUserMessageContext.Provider>
568
644
  </div>
569
645
  </CopilotChatConfigurationProvider>
570
646
  );
@@ -98,7 +98,7 @@ export function CopilotChatAssistantMessage({
98
98
  thumbsUpButton,
99
99
  CopilotChatAssistantMessage.ThumbsUpButton,
100
100
  {
101
- onClick: onThumbsUp,
101
+ onClick: onThumbsUp ? () => onThumbsUp(message) : undefined,
102
102
  },
103
103
  );
104
104
 
@@ -106,7 +106,7 @@ export function CopilotChatAssistantMessage({
106
106
  thumbsDownButton,
107
107
  CopilotChatAssistantMessage.ThumbsDownButton,
108
108
  {
109
- onClick: onThumbsDown,
109
+ onClick: onThumbsDown ? () => onThumbsDown(message) : undefined,
110
110
  },
111
111
  );
112
112
 
@@ -114,7 +114,7 @@ export function CopilotChatAssistantMessage({
114
114
  readAloudButton,
115
115
  CopilotChatAssistantMessage.ReadAloudButton,
116
116
  {
117
- onClick: onReadAloud,
117
+ onClick: onReadAloud ? () => onReadAloud(message) : undefined,
118
118
  },
119
119
  );
120
120
 
@@ -122,7 +122,7 @@ export function CopilotChatAssistantMessage({
122
122
  regenerateButton,
123
123
  CopilotChatAssistantMessage.RegenerateButton,
124
124
  {
125
- onClick: onRegenerate,
125
+ onClick: onRegenerate ? () => onRegenerate(message) : undefined,
126
126
  },
127
127
  );
128
128
 
@@ -87,6 +87,19 @@ type CopilotChatInputRestProps = {
87
87
  containerRef?: React.Ref<HTMLDivElement>;
88
88
  /** Whether to show the disclaimer. Default: true for absolute positioning, false for static */
89
89
  showDisclaimer?: boolean;
90
+ /**
91
+ * Set to `true` when the input sits at the bottom of its container as a
92
+ * flex-last-child (visible position is driven by layout, not CSS
93
+ * positioning). Triggers reservation of bottom space for the fixed
94
+ * CopilotKit license banner via the
95
+ * `--copilotkit-license-banner-offset` CSS var so the two don't overlap.
96
+ *
97
+ * Not needed when `positioning === "absolute"`; that mode already pins the
98
+ * input to the bottom and picks up the same reservation automatically.
99
+ * Leave unset (default `false`) for inputs rendered mid-layout such as the
100
+ * welcome screen, where the banner offset would push the input off-center.
101
+ */
102
+ bottomAnchored?: boolean;
90
103
  } & Omit<React.HTMLAttributes<HTMLDivElement>, "onChange">;
91
104
 
92
105
  type CopilotChatInputBaseProps = WithSlots<
@@ -130,6 +143,7 @@ export function CopilotChatInput({
130
143
  keyboardHeight = 0,
131
144
  containerRef,
132
145
  showDisclaimer,
146
+ bottomAnchored = false,
133
147
  textArea,
134
148
  sendButton,
135
149
  startTranscribeButton,
@@ -384,6 +398,12 @@ export function CopilotChatInput({
384
398
  );
385
399
 
386
400
  const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
401
+ // Skip key handling during IME composition (e.g. CJK input).
402
+ // The compositionend event will fire separately when composition ends.
403
+ if (e.nativeEvent.isComposing || e.keyCode === 229) {
404
+ return;
405
+ }
406
+
387
407
  if (commandQuery !== null && mode === "input") {
388
408
  if (e.key === "ArrowDown") {
389
409
  if (filteredCommands.length > 0) {
@@ -455,10 +475,12 @@ export function CopilotChatInput({
455
475
 
456
476
  onSubmitMessage(trimmed);
457
477
 
478
+ // Always clear the input after sending, including controlled mode.
479
+ // In controlled mode, onChange("") notifies the parent to reset its state.
458
480
  if (!isControlled) {
459
481
  setInternalValue("");
460
- onChange?.("");
461
482
  }
483
+ onChange?.("");
462
484
 
463
485
  if (inputRef.current) {
464
486
  inputRef.current.focus();
@@ -470,6 +492,12 @@ export function CopilotChatInput({
470
492
  value: resolvedValue,
471
493
  onChange: handleChange,
472
494
  onKeyDown: handleKeyDown,
495
+ onCompositionStart: () => {
496
+ isComposingRef.current = true;
497
+ },
498
+ onCompositionEnd: () => {
499
+ isComposingRef.current = false;
500
+ },
473
501
  autoFocus: autoFocus,
474
502
  className: twMerge(
475
503
  "cpk:w-full cpk:py-3",
@@ -612,9 +640,14 @@ export function CopilotChatInput({
612
640
  }
613
641
  };
614
642
 
643
+ // Track whether an IME composition is active so we can avoid
644
+ // resetting textarea.value during measurement (which would break
645
+ // the composition session).
646
+ const isComposingRef = useRef(false);
647
+
615
648
  const ensureMeasurements = useCallback(() => {
616
649
  const textarea = inputRef.current;
617
- if (!textarea) {
650
+ if (!textarea || isComposingRef.current) {
618
651
  return;
619
652
  }
620
653
 
@@ -1078,6 +1111,14 @@ export function CopilotChatInput({
1078
1111
  transform:
1079
1112
  keyboardHeight > 0 ? `translateY(-${keyboardHeight}px)` : undefined,
1080
1113
  transition: "transform 0.2s ease-out",
1114
+ // Reserve room when the fixed license banner is visible so it doesn't
1115
+ // overlap the input. Applied only for bottom-anchored inputs (either
1116
+ // `positioning === "absolute"`, or an explicitly-flagged flex-last-child
1117
+ // input in run state). The welcome-screen input sits mid-layout and
1118
+ // must stay still when the banner is present.
1119
+ ...(positioning === "absolute" || bottomAnchored
1120
+ ? { paddingBottom: "var(--copilotkit-license-banner-offset, 0px)" }
1121
+ : {}),
1081
1122
  }}
1082
1123
  {...props}
1083
1124
  >