@copilotkit/react-core 1.56.1 → 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 (48) hide show
  1. package/dist/{copilotkit-Cj2ZIxVr.mjs → copilotkit-BBYbekCa.mjs} +234 -60
  2. package/dist/copilotkit-BBYbekCa.mjs.map +1 -0
  3. package/dist/{copilotkit-CSJw5BG8.cjs → copilotkit-D5JT2Pu3.cjs} +233 -59
  4. package/dist/copilotkit-D5JT2Pu3.cjs.map +1 -0
  5. package/dist/{copilotkit-CCbxm6JM.d.mts → copilotkit-DArT2Iuw.d.mts} +62 -18
  6. package/dist/copilotkit-DArT2Iuw.d.mts.map +1 -0
  7. package/dist/{copilotkit-BtP7w7cT.d.cts → copilotkit-KEc28l8G.d.cts} +62 -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 +16 -40
  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 +232 -62
  21. package/dist/v2/index.umd.js.map +1 -1
  22. package/package.json +6 -6
  23. package/src/v2/components/chat/CopilotChat.tsx +80 -4
  24. package/src/v2/components/chat/CopilotChatInput.tsx +22 -0
  25. package/src/v2/components/chat/CopilotChatView.tsx +206 -11
  26. package/src/v2/components/chat/__tests__/CopilotChat.absentThreadConnect.test.tsx +66 -0
  27. package/src/v2/components/chat/__tests__/CopilotChatActivityRendering.e2e.test.tsx +300 -2
  28. package/src/v2/components/chat/__tests__/CopilotChatView.connectingGate.test.tsx +56 -0
  29. package/src/v2/components/chat/__tests__/CopilotChatView.pinToSend.test.tsx +94 -0
  30. package/src/v2/components/chat/__tests__/copilot-chat-throttle.test.tsx +0 -1
  31. package/src/v2/components/chat/__tests__/normalize-auto-scroll.test.ts +37 -0
  32. package/src/v2/components/chat/index.ts +2 -0
  33. package/src/v2/components/chat/last-user-message-context.ts +21 -0
  34. package/src/v2/components/chat/normalize-auto-scroll.ts +17 -0
  35. package/src/v2/components/license-warning-banner.tsx +20 -1
  36. package/src/v2/hooks/__tests__/use-agent-stability.test.tsx +6 -0
  37. package/src/v2/hooks/__tests__/use-agent-thread-isolation.test.tsx +6 -0
  38. package/src/v2/hooks/__tests__/use-agent-throttle.test.tsx +76 -50
  39. package/src/v2/hooks/__tests__/use-pin-to-send.test.tsx +219 -0
  40. package/src/v2/hooks/__tests__/use-threads.test.tsx +68 -0
  41. package/src/v2/hooks/use-agent.tsx +34 -77
  42. package/src/v2/hooks/use-pin-to-send.ts +94 -0
  43. package/src/v2/hooks/use-threads.tsx +55 -12
  44. package/src/v2/providers/CopilotKitProvider.tsx +2 -11
  45. package/dist/copilotkit-BtP7w7cT.d.cts.map +0 -1
  46. package/dist/copilotkit-CCbxm6JM.d.mts.map +0 -1
  47. package/dist/copilotkit-CSJw5BG8.cjs.map +0 -1
  48. package/dist/copilotkit-Cj2ZIxVr.mjs.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@copilotkit/react-core",
3
- "version": "1.56.1",
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/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"
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",
@@ -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
  );
@@ -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,
@@ -1097,6 +1111,14 @@ export function CopilotChatInput({
1097
1111
  transform:
1098
1112
  keyboardHeight > 0 ? `translateY(-${keyboardHeight}px)` : undefined,
1099
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
+ : {}),
1100
1122
  }}
1101
1123
  {...props}
1102
1124
  >
@@ -33,10 +33,34 @@ import {
33
33
  CopilotChatDefaultLabels,
34
34
  } from "../../providers/CopilotChatConfigurationProvider";
35
35
  import { useKeyboardHeight } from "../../hooks/use-keyboard-height";
36
+ import { normalizeAutoScroll } from "./normalize-auto-scroll";
37
+ import type { AutoScrollMode } from "./normalize-auto-scroll";
38
+ import { usePinToSend } from "../../hooks/use-pin-to-send";
36
39
 
37
40
  // Height of the feather gradient overlay (h-24 = 6rem = 96px)
38
41
  const FEATHER_HEIGHT = 96;
39
42
 
43
+ // Pin-to-send uses a softer, shorter feather than pin-to-bottom so readable
44
+ // content isn't obscured (h-12 = 3rem = 48px).
45
+ const PIN_TO_SEND_FEATHER_HEIGHT = 48;
46
+
47
+ const PinToSendSoftFeather: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
48
+ className,
49
+ style,
50
+ ...props
51
+ }) => (
52
+ <div
53
+ className={cn(
54
+ "cpk:absolute cpk:bottom-0 cpk:left-0 cpk:right-4 cpk:h-12 cpk:pointer-events-none cpk:z-10 cpk:bg-gradient-to-t",
55
+ "cpk:from-white cpk:to-transparent",
56
+ "cpk:dark:from-[rgb(33,33,33)]",
57
+ className,
58
+ )}
59
+ style={style}
60
+ {...props}
61
+ />
62
+ );
63
+
40
64
  // Forward declaration for WelcomeScreen component type
41
65
  export type WelcomeScreenProps = WithSlots<
42
66
  {
@@ -57,7 +81,7 @@ export type CopilotChatViewProps = WithSlots<
57
81
  },
58
82
  {
59
83
  messages?: Message[];
60
- autoScroll?: boolean;
84
+ autoScroll?: AutoScrollMode | boolean;
61
85
  isRunning?: boolean;
62
86
  suggestions?: Suggestion[];
63
87
  suggestionLoadingIndexes?: ReadonlyArray<number>;
@@ -81,6 +105,21 @@ export type CopilotChatViewProps = WithSlots<
81
105
  onDragOver?: (e: React.DragEvent) => void;
82
106
  onDragLeave?: (e: React.DragEvent) => void;
83
107
  onDrop?: (e: React.DragEvent) => void;
108
+ /**
109
+ * When `true`, suppresses the welcome screen while a thread's initial
110
+ * connect is in flight. Prevents the "How can I help you today?" flash
111
+ * that would otherwise appear between mounting an empty cloned agent and
112
+ * the bootstrap messages arriving from /connect.
113
+ */
114
+ isConnecting?: boolean;
115
+ /**
116
+ * When `true`, the caller has explicitly picked a thread (via `threadId`
117
+ * prop or `CopilotChatConfigurationProvider`). Suppresses the welcome
118
+ * screen unconditionally — a caller-managed thread targets a specific
119
+ * conversation and should render its messages (or an empty panel during
120
+ * connect) rather than a generic "start a new chat" greeting.
121
+ */
122
+ hasExplicitThreadId?: boolean;
84
123
  /**
85
124
  * @deprecated Use the `input` slot's `disclaimer` prop instead:
86
125
  * ```tsx
@@ -139,6 +178,8 @@ export function CopilotChatView({
139
178
  onDragOver,
140
179
  onDragLeave,
141
180
  onDrop,
181
+ isConnecting = false,
182
+ hasExplicitThreadId = false,
142
183
  // Deprecated — forwarded to input slot
143
184
  disclaimer,
144
185
  children,
@@ -219,10 +260,22 @@ export function CopilotChatView({
219
260
  keyboardHeight: isKeyboardOpen ? keyboardHeight : 0,
220
261
  containerRef: inputContainerRef,
221
262
  showDisclaimer: true,
263
+ // This input is the last flex child of the chat column, so it sits at
264
+ // the bottom where the license banner would overlap. The welcome-screen
265
+ // input (below) intentionally omits this flag.
266
+ bottomAnchored: true,
222
267
  ...(disclaimer !== undefined ? { disclaimer } : {}),
223
268
  } as CopilotChatInputProps);
224
269
 
225
- const hasSuggestions = Array.isArray(suggestions) && suggestions.length > 0;
270
+ // Hide suggestions while a thread is connecting or a run is in flight.
271
+ // Otherwise, mid-replay (bootstrap stream from /connect) or mid-run, the
272
+ // suggestions would render against a still-assembling message tree and
273
+ // visibly jump as each final text chunk reflows the layout.
274
+ const hasSuggestions =
275
+ !isConnecting &&
276
+ !isRunning &&
277
+ Array.isArray(suggestions) &&
278
+ suggestions.length > 0;
226
279
  const BoundSuggestionView = hasSuggestions
227
280
  ? renderSlot(suggestionView, CopilotChatSuggestionView, {
228
281
  suggestions,
@@ -258,7 +311,13 @@ export function CopilotChatView({
258
311
  const isEmpty = messages.length === 0;
259
312
  // Type assertion needed because TypeScript doesn't fully propagate `| boolean` through WithSlots
260
313
  const welcomeScreenDisabled = (welcomeScreen as unknown) === false;
261
- const shouldShowWelcomeScreen = isEmpty && !welcomeScreenDisabled;
314
+ // Suppress the welcome screen (1) while the initial connect is in flight
315
+ // and (2) whenever the caller has picked a specific thread. The caller-
316
+ // managed case targets a conversation directly, so the generic welcome
317
+ // greeting is never the right thing to show — even for a thread that
318
+ // happens to have no messages yet.
319
+ const shouldShowWelcomeScreen =
320
+ isEmpty && !welcomeScreenDisabled && !isConnecting && !hasExplicitThreadId;
262
321
 
263
322
  if (shouldShowWelcomeScreen) {
264
323
  // Create a separate input for welcome screen with static positioning and disclaimer visible
@@ -442,9 +501,114 @@ export namespace CopilotChatView {
442
501
  );
443
502
  };
444
503
 
504
+ // Internal component for pin-to-send scroll behavior — not exported on CopilotChatView.
505
+ const PinToSendScrollContainer: React.FC<
506
+ React.HTMLAttributes<HTMLDivElement> & {
507
+ scrollRef: React.MutableRefObject<HTMLElement | null>;
508
+ contentRef: React.MutableRefObject<HTMLElement | null>;
509
+ scrollToBottom: () => void;
510
+ scrollToBottomButton?: SlotValue<
511
+ React.FC<React.ButtonHTMLAttributes<HTMLButtonElement>>
512
+ >;
513
+ feather?: SlotValue<React.FC<React.HTMLAttributes<HTMLDivElement>>>;
514
+ inputContainerHeight: number;
515
+ isResizing: boolean;
516
+ nonAutoScrollEl: HTMLElement | null;
517
+ nonAutoScrollRefCallback: (el: HTMLElement | null) => void;
518
+ showScrollButton: boolean;
519
+ }
520
+ > = ({
521
+ children,
522
+ scrollRef,
523
+ contentRef,
524
+ scrollToBottom,
525
+ scrollToBottomButton,
526
+ feather,
527
+ inputContainerHeight,
528
+ isResizing,
529
+ nonAutoScrollEl,
530
+ nonAutoScrollRefCallback,
531
+ showScrollButton,
532
+ className,
533
+ ...props
534
+ }) => {
535
+ const spacerRef = useRef<HTMLDivElement>(null);
536
+
537
+ usePinToSend({
538
+ scrollRef,
539
+ contentRef,
540
+ spacerRef,
541
+ topOffset: 16,
542
+ });
543
+
544
+ // Pin-to-send uses a SOFTER feather than pin-to-bottom:
545
+ // - default: h-24 + from-white via-white to-transparent (fully opaque
546
+ // bottom half, aggressive). Good for streaming-to-bottom where
547
+ // the edge is always churning.
548
+ // - pin-to-send: h-12 + from-white to-transparent (gradual fade,
549
+ // no opaque midline). Gives a visual soft edge above the input
550
+ // without obscuring otherwise-readable content.
551
+ // Consumers can still override with the `feather` slot.
552
+ const BoundFeather = renderSlot(feather, PinToSendSoftFeather, {});
553
+
554
+ // Feather and scroll-to-bottom button live OUTSIDE the scroll container.
555
+ // `position: absolute` children of an `overflow: auto` element are
556
+ // positioned relative to the scroll *content*, which means they scroll
557
+ // away with it. Placing them as siblings of the scroll container
558
+ // (inside a `relative` wrapper) keeps them pinned to the viewport bottom.
559
+ return (
560
+ <ScrollElementContext.Provider value={nonAutoScrollEl}>
561
+ <div
562
+ className={cn(
563
+ "cpk:h-full cpk:max-h-full cpk:flex cpk:flex-col cpk:min-h-0 cpk:relative",
564
+ className,
565
+ )}
566
+ >
567
+ <div
568
+ ref={nonAutoScrollRefCallback}
569
+ className="cpk:flex-1 cpk:min-h-0 cpk:overflow-y-auto cpk:overflow-x-hidden"
570
+ {...props}
571
+ >
572
+ <div
573
+ ref={contentRef}
574
+ className="cpk:px-4 cpk:sm:px-0 cpk:[div[data-sidebar-chat]_&]:px-8 cpk:[div[data-popup-chat]_&]:px-6"
575
+ >
576
+ {children}
577
+ </div>
578
+ <div
579
+ ref={spacerRef}
580
+ data-pin-to-send-spacer
581
+ aria-hidden="true"
582
+ style={{ height: 0, flex: "0 0 auto" }}
583
+ />
584
+ </div>
585
+ {/* Soft feather — pinned to wrapper bottom */}
586
+ {BoundFeather}
587
+ {/* Scroll to bottom button */}
588
+ {showScrollButton && !isResizing && (
589
+ <div
590
+ className="cpk:absolute cpk:inset-x-0 cpk:flex cpk:justify-center cpk:z-30 cpk:pointer-events-none"
591
+ style={{
592
+ bottom: `${inputContainerHeight + PIN_TO_SEND_FEATHER_HEIGHT + 16}px`,
593
+ }}
594
+ >
595
+ {renderSlot(
596
+ scrollToBottomButton,
597
+ CopilotChatView.ScrollToBottomButton,
598
+ {
599
+ onClick: () => scrollToBottom(),
600
+ },
601
+ )}
602
+ </div>
603
+ )}
604
+ </div>
605
+ </ScrollElementContext.Provider>
606
+ );
607
+ };
608
+
445
609
  export const ScrollView: React.FC<
446
610
  React.HTMLAttributes<HTMLDivElement> & {
447
- autoScroll?: boolean;
611
+ autoScroll?: AutoScrollMode | boolean;
448
612
  scrollToBottomButton?: SlotValue<
449
613
  React.FC<React.ButtonHTMLAttributes<HTMLButtonElement>>
450
614
  >;
@@ -454,7 +618,7 @@ export namespace CopilotChatView {
454
618
  }
455
619
  > = ({
456
620
  children,
457
- autoScroll = true,
621
+ autoScroll = "pin-to-bottom",
458
622
  scrollToBottomButton,
459
623
  feather,
460
624
  inputContainerHeight = 0,
@@ -462,8 +626,18 @@ export namespace CopilotChatView {
462
626
  className,
463
627
  ...props
464
628
  }) => {
629
+ const mode = normalizeAutoScroll(autoScroll);
465
630
  const [hasMounted, setHasMounted] = useState(false);
466
- const { scrollRef, contentRef, scrollToBottom } = useStickToBottom();
631
+ // Plain refs for the "none" and "pin-to-send" paths. Do NOT use
632
+ // useStickToBottom() here — its internal effects would attach scroll-following
633
+ // behavior to these refs and fight pin-to-send. The "pin-to-bottom" path
634
+ // gets its refs via <StickToBottom> below, scoped to that branch only.
635
+ const scrollRef = useRef<HTMLElement | null>(null);
636
+ const contentRef = useRef<HTMLElement | null>(null);
637
+ const scrollToBottom = useCallback(() => {
638
+ const el = scrollRef.current;
639
+ if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
640
+ }, []);
467
641
  const [showScrollButton, setShowScrollButton] = useState(false);
468
642
  // Tracks the scroll container element for the non-autoScroll path so the
469
643
  // context value is reactive (element state, not a ref).
@@ -478,7 +652,7 @@ export namespace CopilotChatView {
478
652
  scrollRef.current = el;
479
653
  setNonAutoScrollEl(el);
480
654
  },
481
- // scrollRef is a stable object from useStickToBottom; safe to omit.
655
+ // scrollRef is a stable ref object; safe to omit.
482
656
  // eslint-disable-next-line react-hooks/exhaustive-deps
483
657
  [],
484
658
  );
@@ -489,7 +663,7 @@ export namespace CopilotChatView {
489
663
 
490
664
  // Monitor scroll position for non-autoscroll mode
491
665
  useEffect(() => {
492
- if (autoScroll) return; // Skip for autoscroll mode
666
+ if (mode === "pin-to-bottom") return; // Skip for autoscroll mode
493
667
 
494
668
  const scrollElement = scrollRef.current;
495
669
  if (!scrollElement) return;
@@ -514,7 +688,7 @@ export namespace CopilotChatView {
514
688
  scrollElement.removeEventListener("scroll", checkScroll);
515
689
  resizeObserver.disconnect();
516
690
  };
517
- }, [scrollRef, autoScroll]);
691
+ }, [scrollRef, mode]);
518
692
 
519
693
  if (!hasMounted) {
520
694
  return (
@@ -526,8 +700,7 @@ export namespace CopilotChatView {
526
700
  );
527
701
  }
528
702
 
529
- // When autoScroll is false, we don't use StickToBottom
530
- if (!autoScroll) {
703
+ if (mode === "none") {
531
704
  const BoundFeather = renderSlot(feather, CopilotChatView.Feather, {});
532
705
 
533
706
  return (
@@ -574,6 +747,28 @@ export namespace CopilotChatView {
574
747
  );
575
748
  }
576
749
 
750
+ if (mode === "pin-to-send") {
751
+ return (
752
+ <PinToSendScrollContainer
753
+ scrollRef={scrollRef}
754
+ contentRef={contentRef}
755
+ scrollToBottom={scrollToBottom}
756
+ scrollToBottomButton={scrollToBottomButton}
757
+ feather={feather}
758
+ inputContainerHeight={inputContainerHeight}
759
+ isResizing={isResizing}
760
+ nonAutoScrollEl={nonAutoScrollEl}
761
+ nonAutoScrollRefCallback={nonAutoScrollRefCallback}
762
+ showScrollButton={showScrollButton}
763
+ className={className}
764
+ {...props}
765
+ >
766
+ {children}
767
+ </PinToSendScrollContainer>
768
+ );
769
+ }
770
+
771
+ // mode === "pin-to-bottom" (default)
577
772
  return (
578
773
  <StickToBottom
579
774
  className={cn(
@@ -0,0 +1,66 @@
1
+ import React from "react";
2
+ import { describe, it, expect, vi } from "vitest";
3
+ import { render } from "@testing-library/react";
4
+ import { EMPTY, Observable } from "rxjs";
5
+ import { type BaseEvent, type RunAgentInput } from "@ag-ui/client";
6
+ import { MockStepwiseAgent } from "../../../__tests__/utils/test-helpers";
7
+ import { CopilotKitProvider } from "../../../providers/CopilotKitProvider";
8
+ import { CopilotChatConfigurationProvider } from "../../../providers/CopilotChatConfigurationProvider";
9
+ import { CopilotChat } from "../CopilotChat";
10
+
11
+ describe("CopilotChat avoids /connect for locally-generated threadIds (ENT-314)", () => {
12
+ function buildAgentWithConnectSpy(): {
13
+ agent: MockStepwiseAgent;
14
+ connectSpy: ReturnType<typeof vi.fn>;
15
+ } {
16
+ const connectSpy = vi.fn();
17
+ class SpyAgent extends MockStepwiseAgent {
18
+ connect(input: RunAgentInput): Observable<BaseEvent> {
19
+ connectSpy(input);
20
+ return EMPTY;
21
+ }
22
+ }
23
+ return { agent: new SpyAgent(), connectSpy };
24
+ }
25
+
26
+ it("does not call connect() when no threadId is supplied", async () => {
27
+ const { agent, connectSpy } = buildAgentWithConnectSpy();
28
+
29
+ render(
30
+ <CopilotKitProvider agents__unsafe_dev_only={{ default: agent }}>
31
+ <CopilotChat welcomeScreen={false} />
32
+ </CopilotKitProvider>,
33
+ );
34
+
35
+ await new Promise((resolve) => setTimeout(resolve, 50));
36
+ expect(connectSpy).not.toHaveBeenCalled();
37
+ });
38
+
39
+ it("calls connect() when a threadId is supplied via props", async () => {
40
+ const { agent, connectSpy } = buildAgentWithConnectSpy();
41
+
42
+ render(
43
+ <CopilotKitProvider agents__unsafe_dev_only={{ default: agent }}>
44
+ <CopilotChat welcomeScreen={false} threadId="user-thread-abc" />
45
+ </CopilotKitProvider>,
46
+ );
47
+
48
+ await new Promise((resolve) => setTimeout(resolve, 50));
49
+ expect(connectSpy).toHaveBeenCalled();
50
+ });
51
+
52
+ it("calls connect() when a threadId is supplied via configuration provider", async () => {
53
+ const { agent, connectSpy } = buildAgentWithConnectSpy();
54
+
55
+ render(
56
+ <CopilotKitProvider agents__unsafe_dev_only={{ default: agent }}>
57
+ <CopilotChatConfigurationProvider threadId="config-thread-xyz">
58
+ <CopilotChat welcomeScreen={false} />
59
+ </CopilotChatConfigurationProvider>
60
+ </CopilotKitProvider>,
61
+ );
62
+
63
+ await new Promise((resolve) => setTimeout(resolve, 50));
64
+ expect(connectSpy).toHaveBeenCalled();
65
+ });
66
+ });