@copilotkit/react-core 1.56.2 → 1.56.4
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.
- package/dist/{copilotkit-Cj2ZIxVr.mjs → copilotkit-Bd0m5HFp.mjs} +266 -81
- package/dist/copilotkit-Bd0m5HFp.mjs.map +1 -0
- package/dist/{copilotkit-CCbxm6JM.d.mts → copilotkit-DFaI4j2r.d.mts} +64 -18
- package/dist/copilotkit-DFaI4j2r.d.mts.map +1 -0
- package/dist/{copilotkit-BtP7w7cT.d.cts → copilotkit-Dg4r4Gi_.d.cts} +64 -18
- package/dist/copilotkit-Dg4r4Gi_.d.cts.map +1 -0
- package/dist/{copilotkit-CSJw5BG8.cjs → copilotkit-tb4zqaMK.cjs} +265 -80
- package/dist/copilotkit-tb4zqaMK.cjs.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +2 -1
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +2 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.umd.js +31 -44
- package/dist/index.umd.js.map +1 -1
- package/dist/v2/index.cjs +1 -1
- package/dist/v2/index.css +1 -1
- package/dist/v2/index.d.cts +2 -2
- package/dist/v2/index.d.mts +2 -2
- package/dist/v2/index.mjs +1 -1
- package/dist/v2/index.umd.js +264 -83
- package/dist/v2/index.umd.js.map +1 -1
- package/package.json +6 -6
- package/src/components/copilot-provider/__tests__/v1-explicit-threadid-bridge.test.tsx +107 -0
- package/src/components/copilot-provider/copilotkit.tsx +6 -1
- package/src/context/__tests__/threads-context.test.tsx +116 -3
- package/src/context/threads-context.tsx +18 -1
- package/src/v2/components/chat/CopilotChat.tsx +91 -4
- package/src/v2/components/chat/CopilotChatAttachmentQueue.tsx +4 -1
- package/src/v2/components/chat/CopilotChatInput.tsx +22 -0
- package/src/v2/components/chat/CopilotChatView.tsx +207 -44
- package/src/v2/components/chat/__tests__/CopilotChat.absentThreadConnect.test.tsx +66 -0
- package/src/v2/components/chat/__tests__/CopilotChat.welcomeGate.test.tsx +186 -0
- package/src/v2/components/chat/__tests__/CopilotChatActivityRendering.e2e.test.tsx +438 -4
- package/src/v2/components/chat/__tests__/CopilotChatView.connectingGate.test.tsx +56 -0
- package/src/v2/components/chat/__tests__/CopilotChatView.inputOverlay.test.tsx +172 -0
- package/src/v2/components/chat/__tests__/CopilotChatView.pinToSend.test.tsx +94 -0
- package/src/v2/components/chat/__tests__/copilot-chat-throttle.test.tsx +0 -1
- package/src/v2/components/chat/__tests__/normalize-auto-scroll.test.ts +37 -0
- package/src/v2/components/chat/index.ts +2 -0
- package/src/v2/components/chat/last-user-message-context.ts +21 -0
- package/src/v2/components/chat/normalize-auto-scroll.ts +17 -0
- package/src/v2/components/license-warning-banner.tsx +20 -1
- package/src/v2/hooks/__tests__/use-agent-stability.test.tsx +6 -0
- package/src/v2/hooks/__tests__/use-agent-thread-isolation.test.tsx +6 -0
- package/src/v2/hooks/__tests__/use-agent-throttle.test.tsx +76 -50
- package/src/v2/hooks/__tests__/use-pin-to-send.test.tsx +219 -0
- package/src/v2/hooks/__tests__/use-threads.test.tsx +68 -0
- package/src/v2/hooks/use-agent.tsx +34 -77
- package/src/v2/hooks/use-pin-to-send.ts +94 -0
- package/src/v2/hooks/use-threads.tsx +55 -12
- package/src/v2/providers/CopilotChatConfigurationProvider.tsx +29 -1
- package/src/v2/providers/CopilotKitProvider.tsx +2 -11
- package/src/v2/providers/__tests__/CopilotChatConfigurationProvider.test.tsx +106 -0
- package/dist/copilotkit-BtP7w7cT.d.cts.map +0 -1
- package/dist/copilotkit-CCbxm6JM.d.mts.map +0 -1
- package/dist/copilotkit-CSJw5BG8.cjs.map +0 -1
- package/dist/copilotkit-Cj2ZIxVr.mjs.map +0 -1
|
@@ -6,17 +6,19 @@ import React, {
|
|
|
6
6
|
useLayoutEffect,
|
|
7
7
|
} from "react";
|
|
8
8
|
import { ScrollElementContext } from "./scroll-element-context";
|
|
9
|
-
import { WithSlots, SlotValue
|
|
9
|
+
import type { WithSlots, SlotValue } from "../../lib/slots";
|
|
10
|
+
import { renderSlot } from "../../lib/slots";
|
|
10
11
|
import CopilotChatMessageView from "./CopilotChatMessageView";
|
|
11
|
-
import
|
|
12
|
+
import type {
|
|
12
13
|
CopilotChatInputProps,
|
|
13
14
|
CopilotChatInputMode,
|
|
14
15
|
} from "./CopilotChatInput";
|
|
16
|
+
import CopilotChatInput from "./CopilotChatInput";
|
|
15
17
|
import CopilotChatSuggestionView, {
|
|
16
18
|
CopilotChatSuggestionViewProps,
|
|
17
19
|
} from "./CopilotChatSuggestionView";
|
|
18
|
-
import { Suggestion } from "@copilotkit/core";
|
|
19
|
-
import { Message } from "@ag-ui/core";
|
|
20
|
+
import type { Suggestion } from "@copilotkit/core";
|
|
21
|
+
import type { Message } from "@ag-ui/core";
|
|
20
22
|
import type { Attachment } from "@copilotkit/shared";
|
|
21
23
|
import { CopilotChatAttachmentQueue } from "./CopilotChatAttachmentQueue";
|
|
22
24
|
import { twMerge } from "tailwind-merge";
|
|
@@ -33,9 +35,12 @@ import {
|
|
|
33
35
|
CopilotChatDefaultLabels,
|
|
34
36
|
} from "../../providers/CopilotChatConfigurationProvider";
|
|
35
37
|
import { useKeyboardHeight } from "../../hooks/use-keyboard-height";
|
|
38
|
+
import { normalizeAutoScroll } from "./normalize-auto-scroll";
|
|
39
|
+
import type { AutoScrollMode } from "./normalize-auto-scroll";
|
|
40
|
+
import { usePinToSend } from "../../hooks/use-pin-to-send";
|
|
36
41
|
|
|
37
|
-
//
|
|
38
|
-
const
|
|
42
|
+
// Vertical gap between the scroll-to-bottom button and the input container.
|
|
43
|
+
const SCROLL_BUTTON_OFFSET = 16;
|
|
39
44
|
|
|
40
45
|
// Forward declaration for WelcomeScreen component type
|
|
41
46
|
export type WelcomeScreenProps = WithSlots<
|
|
@@ -57,7 +62,7 @@ export type CopilotChatViewProps = WithSlots<
|
|
|
57
62
|
},
|
|
58
63
|
{
|
|
59
64
|
messages?: Message[];
|
|
60
|
-
autoScroll?: boolean;
|
|
65
|
+
autoScroll?: AutoScrollMode | boolean;
|
|
61
66
|
isRunning?: boolean;
|
|
62
67
|
suggestions?: Suggestion[];
|
|
63
68
|
suggestionLoadingIndexes?: ReadonlyArray<number>;
|
|
@@ -81,6 +86,21 @@ export type CopilotChatViewProps = WithSlots<
|
|
|
81
86
|
onDragOver?: (e: React.DragEvent) => void;
|
|
82
87
|
onDragLeave?: (e: React.DragEvent) => void;
|
|
83
88
|
onDrop?: (e: React.DragEvent) => void;
|
|
89
|
+
/**
|
|
90
|
+
* When `true`, suppresses the welcome screen while a thread's initial
|
|
91
|
+
* connect is in flight. Prevents the "How can I help you today?" flash
|
|
92
|
+
* that would otherwise appear between mounting an empty cloned agent and
|
|
93
|
+
* the bootstrap messages arriving from /connect.
|
|
94
|
+
*/
|
|
95
|
+
isConnecting?: boolean;
|
|
96
|
+
/**
|
|
97
|
+
* When `true`, the caller has explicitly picked a thread (via `threadId`
|
|
98
|
+
* prop or `CopilotChatConfigurationProvider`). Suppresses the welcome
|
|
99
|
+
* screen unconditionally — a caller-managed thread targets a specific
|
|
100
|
+
* conversation and should render its messages (or an empty panel during
|
|
101
|
+
* connect) rather than a generic "start a new chat" greeting.
|
|
102
|
+
*/
|
|
103
|
+
hasExplicitThreadId?: boolean;
|
|
84
104
|
/**
|
|
85
105
|
* @deprecated Use the `input` slot's `disclaimer` prop instead:
|
|
86
106
|
* ```tsx
|
|
@@ -139,6 +159,8 @@ export function CopilotChatView({
|
|
|
139
159
|
onDragOver,
|
|
140
160
|
onDragLeave,
|
|
141
161
|
onDrop,
|
|
162
|
+
isConnecting = false,
|
|
163
|
+
hasExplicitThreadId = false,
|
|
142
164
|
// Deprecated — forwarded to input slot
|
|
143
165
|
disclaimer,
|
|
144
166
|
children,
|
|
@@ -217,12 +239,24 @@ export function CopilotChatView({
|
|
|
217
239
|
onAddFile,
|
|
218
240
|
positioning: "static",
|
|
219
241
|
keyboardHeight: isKeyboardOpen ? keyboardHeight : 0,
|
|
220
|
-
containerRef: inputContainerRef,
|
|
221
242
|
showDisclaimer: true,
|
|
243
|
+
// The parent overlay wrapper handles absolute bottom-0 positioning.
|
|
244
|
+
// `bottomAnchored` still triggers the license-banner offset padding
|
|
245
|
+
// inside CopilotChatInput. The welcome-screen input (below) intentionally
|
|
246
|
+
// omits this flag.
|
|
247
|
+
bottomAnchored: true,
|
|
222
248
|
...(disclaimer !== undefined ? { disclaimer } : {}),
|
|
223
249
|
} as CopilotChatInputProps);
|
|
224
250
|
|
|
225
|
-
|
|
251
|
+
// Hide suggestions while a thread is connecting or a run is in flight.
|
|
252
|
+
// Otherwise, mid-replay (bootstrap stream from /connect) or mid-run, the
|
|
253
|
+
// suggestions would render against a still-assembling message tree and
|
|
254
|
+
// visibly jump as each final text chunk reflows the layout.
|
|
255
|
+
const hasSuggestions =
|
|
256
|
+
!isConnecting &&
|
|
257
|
+
!isRunning &&
|
|
258
|
+
Array.isArray(suggestions) &&
|
|
259
|
+
suggestions.length > 0;
|
|
226
260
|
const BoundSuggestionView = hasSuggestions
|
|
227
261
|
? renderSlot(suggestionView, CopilotChatSuggestionView, {
|
|
228
262
|
suggestions,
|
|
@@ -238,8 +272,9 @@ export function CopilotChatView({
|
|
|
238
272
|
isResizing,
|
|
239
273
|
children: (
|
|
240
274
|
<div
|
|
275
|
+
data-testid="copilot-scroll-content"
|
|
241
276
|
style={{
|
|
242
|
-
paddingBottom: `${hasSuggestions ? 4 : 32}px`,
|
|
277
|
+
paddingBottom: `${inputContainerHeight + (hasSuggestions ? 4 : 32)}px`,
|
|
243
278
|
}}
|
|
244
279
|
>
|
|
245
280
|
<div className="cpk:max-w-3xl cpk:mx-auto">
|
|
@@ -258,7 +293,13 @@ export function CopilotChatView({
|
|
|
258
293
|
const isEmpty = messages.length === 0;
|
|
259
294
|
// Type assertion needed because TypeScript doesn't fully propagate `| boolean` through WithSlots
|
|
260
295
|
const welcomeScreenDisabled = (welcomeScreen as unknown) === false;
|
|
261
|
-
|
|
296
|
+
// Suppress the welcome screen (1) while the initial connect is in flight
|
|
297
|
+
// and (2) whenever the caller has picked a specific thread. The caller-
|
|
298
|
+
// managed case targets a conversation directly, so the generic welcome
|
|
299
|
+
// greeting is never the right thing to show — even for a thread that
|
|
300
|
+
// happens to have no messages yet.
|
|
301
|
+
const shouldShowWelcomeScreen =
|
|
302
|
+
isEmpty && !welcomeScreenDisabled && !isConnecting && !hasExplicitThreadId;
|
|
262
303
|
|
|
263
304
|
if (shouldShowWelcomeScreen) {
|
|
264
305
|
// Create a separate input for welcome screen with static positioning and disclaimer visible
|
|
@@ -356,17 +397,22 @@ export function CopilotChatView({
|
|
|
356
397
|
{dragOver && <DropOverlay />}
|
|
357
398
|
{BoundScrollView}
|
|
358
399
|
|
|
359
|
-
<div
|
|
400
|
+
<div
|
|
401
|
+
ref={inputContainerRef}
|
|
402
|
+
data-testid="copilot-input-overlay"
|
|
403
|
+
className="cpk:absolute cpk:bottom-0 cpk:left-0 cpk:right-0 cpk:z-20 cpk:pointer-events-none"
|
|
404
|
+
>
|
|
360
405
|
{attachments && attachments.length > 0 && (
|
|
361
|
-
<
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
406
|
+
<div className="cpk:max-w-3xl cpk:mx-auto cpk:w-full cpk:pointer-events-auto">
|
|
407
|
+
<CopilotChatAttachmentQueue
|
|
408
|
+
attachments={attachments}
|
|
409
|
+
onRemoveAttachment={(id) => onRemoveAttachment?.(id)}
|
|
410
|
+
className="cpk:px-4"
|
|
411
|
+
/>
|
|
412
|
+
</div>
|
|
366
413
|
)}
|
|
414
|
+
{BoundInput}
|
|
367
415
|
</div>
|
|
368
|
-
|
|
369
|
-
{BoundInput}
|
|
370
416
|
</div>
|
|
371
417
|
);
|
|
372
418
|
}
|
|
@@ -417,7 +463,6 @@ export namespace CopilotChatView {
|
|
|
417
463
|
</div>
|
|
418
464
|
</StickToBottom.Content>
|
|
419
465
|
|
|
420
|
-
{/* Feather gradient overlay */}
|
|
421
466
|
{BoundFeather}
|
|
422
467
|
|
|
423
468
|
{/* Scroll to bottom button - hidden during resize */}
|
|
@@ -425,7 +470,7 @@ export namespace CopilotChatView {
|
|
|
425
470
|
<div
|
|
426
471
|
className="cpk:absolute cpk:inset-x-0 cpk:flex cpk:justify-center cpk:z-30 cpk:pointer-events-none"
|
|
427
472
|
style={{
|
|
428
|
-
bottom: `${inputContainerHeight +
|
|
473
|
+
bottom: `${inputContainerHeight + SCROLL_BUTTON_OFFSET}px`,
|
|
429
474
|
}}
|
|
430
475
|
>
|
|
431
476
|
{renderSlot(
|
|
@@ -442,9 +487,105 @@ export namespace CopilotChatView {
|
|
|
442
487
|
);
|
|
443
488
|
};
|
|
444
489
|
|
|
490
|
+
// Internal component for pin-to-send scroll behavior — not exported on CopilotChatView.
|
|
491
|
+
const PinToSendScrollContainer: React.FC<
|
|
492
|
+
React.HTMLAttributes<HTMLDivElement> & {
|
|
493
|
+
scrollRef: React.MutableRefObject<HTMLElement | null>;
|
|
494
|
+
contentRef: React.MutableRefObject<HTMLElement | null>;
|
|
495
|
+
scrollToBottom: () => void;
|
|
496
|
+
scrollToBottomButton?: SlotValue<
|
|
497
|
+
React.FC<React.ButtonHTMLAttributes<HTMLButtonElement>>
|
|
498
|
+
>;
|
|
499
|
+
feather?: SlotValue<React.FC<React.HTMLAttributes<HTMLDivElement>>>;
|
|
500
|
+
inputContainerHeight: number;
|
|
501
|
+
isResizing: boolean;
|
|
502
|
+
nonAutoScrollEl: HTMLElement | null;
|
|
503
|
+
nonAutoScrollRefCallback: (el: HTMLElement | null) => void;
|
|
504
|
+
showScrollButton: boolean;
|
|
505
|
+
}
|
|
506
|
+
> = ({
|
|
507
|
+
children,
|
|
508
|
+
scrollRef,
|
|
509
|
+
contentRef,
|
|
510
|
+
scrollToBottom,
|
|
511
|
+
scrollToBottomButton,
|
|
512
|
+
feather,
|
|
513
|
+
inputContainerHeight,
|
|
514
|
+
isResizing,
|
|
515
|
+
nonAutoScrollEl,
|
|
516
|
+
nonAutoScrollRefCallback,
|
|
517
|
+
showScrollButton,
|
|
518
|
+
className,
|
|
519
|
+
...props
|
|
520
|
+
}) => {
|
|
521
|
+
const spacerRef = useRef<HTMLDivElement>(null);
|
|
522
|
+
|
|
523
|
+
usePinToSend({
|
|
524
|
+
scrollRef,
|
|
525
|
+
contentRef,
|
|
526
|
+
spacerRef,
|
|
527
|
+
topOffset: 16,
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// The feather and scroll-to-bottom button live OUTSIDE the scroll
|
|
531
|
+
// container. `position: absolute` children of an `overflow: auto` element
|
|
532
|
+
// are positioned relative to the scroll *content*, which means they
|
|
533
|
+
// scroll away with it. Placing them as siblings of the scroll container
|
|
534
|
+
// (inside a `relative` wrapper) keeps them pinned to the viewport bottom.
|
|
535
|
+
const BoundFeather = renderSlot(feather, CopilotChatView.Feather, {});
|
|
536
|
+
|
|
537
|
+
return (
|
|
538
|
+
<ScrollElementContext.Provider value={nonAutoScrollEl}>
|
|
539
|
+
<div
|
|
540
|
+
className={cn(
|
|
541
|
+
"cpk:h-full cpk:max-h-full cpk:flex cpk:flex-col cpk:min-h-0 cpk:relative",
|
|
542
|
+
className,
|
|
543
|
+
)}
|
|
544
|
+
>
|
|
545
|
+
<div
|
|
546
|
+
ref={nonAutoScrollRefCallback}
|
|
547
|
+
className="cpk:flex-1 cpk:min-h-0 cpk:overflow-y-auto cpk:overflow-x-hidden"
|
|
548
|
+
{...props}
|
|
549
|
+
>
|
|
550
|
+
<div
|
|
551
|
+
ref={contentRef}
|
|
552
|
+
className="cpk:px-4 cpk:sm:px-0 cpk:[div[data-sidebar-chat]_&]:px-8 cpk:[div[data-popup-chat]_&]:px-6"
|
|
553
|
+
>
|
|
554
|
+
{children}
|
|
555
|
+
</div>
|
|
556
|
+
<div
|
|
557
|
+
ref={spacerRef}
|
|
558
|
+
data-pin-to-send-spacer
|
|
559
|
+
aria-hidden="true"
|
|
560
|
+
style={{ height: 0, flex: "0 0 auto" }}
|
|
561
|
+
/>
|
|
562
|
+
</div>
|
|
563
|
+
{BoundFeather}
|
|
564
|
+
{/* Scroll to bottom button */}
|
|
565
|
+
{showScrollButton && !isResizing && (
|
|
566
|
+
<div
|
|
567
|
+
className="cpk:absolute cpk:inset-x-0 cpk:flex cpk:justify-center cpk:z-30 cpk:pointer-events-none"
|
|
568
|
+
style={{
|
|
569
|
+
bottom: `${inputContainerHeight + SCROLL_BUTTON_OFFSET}px`,
|
|
570
|
+
}}
|
|
571
|
+
>
|
|
572
|
+
{renderSlot(
|
|
573
|
+
scrollToBottomButton,
|
|
574
|
+
CopilotChatView.ScrollToBottomButton,
|
|
575
|
+
{
|
|
576
|
+
onClick: () => scrollToBottom(),
|
|
577
|
+
},
|
|
578
|
+
)}
|
|
579
|
+
</div>
|
|
580
|
+
)}
|
|
581
|
+
</div>
|
|
582
|
+
</ScrollElementContext.Provider>
|
|
583
|
+
);
|
|
584
|
+
};
|
|
585
|
+
|
|
445
586
|
export const ScrollView: React.FC<
|
|
446
587
|
React.HTMLAttributes<HTMLDivElement> & {
|
|
447
|
-
autoScroll?: boolean;
|
|
588
|
+
autoScroll?: AutoScrollMode | boolean;
|
|
448
589
|
scrollToBottomButton?: SlotValue<
|
|
449
590
|
React.FC<React.ButtonHTMLAttributes<HTMLButtonElement>>
|
|
450
591
|
>;
|
|
@@ -454,7 +595,7 @@ export namespace CopilotChatView {
|
|
|
454
595
|
}
|
|
455
596
|
> = ({
|
|
456
597
|
children,
|
|
457
|
-
autoScroll =
|
|
598
|
+
autoScroll = "pin-to-bottom",
|
|
458
599
|
scrollToBottomButton,
|
|
459
600
|
feather,
|
|
460
601
|
inputContainerHeight = 0,
|
|
@@ -462,8 +603,18 @@ export namespace CopilotChatView {
|
|
|
462
603
|
className,
|
|
463
604
|
...props
|
|
464
605
|
}) => {
|
|
606
|
+
const mode = normalizeAutoScroll(autoScroll);
|
|
465
607
|
const [hasMounted, setHasMounted] = useState(false);
|
|
466
|
-
|
|
608
|
+
// Plain refs for the "none" and "pin-to-send" paths. Do NOT use
|
|
609
|
+
// useStickToBottom() here — its internal effects would attach scroll-following
|
|
610
|
+
// behavior to these refs and fight pin-to-send. The "pin-to-bottom" path
|
|
611
|
+
// gets its refs via <StickToBottom> below, scoped to that branch only.
|
|
612
|
+
const scrollRef = useRef<HTMLElement | null>(null);
|
|
613
|
+
const contentRef = useRef<HTMLElement | null>(null);
|
|
614
|
+
const scrollToBottom = useCallback(() => {
|
|
615
|
+
const el = scrollRef.current;
|
|
616
|
+
if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
|
617
|
+
}, []);
|
|
467
618
|
const [showScrollButton, setShowScrollButton] = useState(false);
|
|
468
619
|
// Tracks the scroll container element for the non-autoScroll path so the
|
|
469
620
|
// context value is reactive (element state, not a ref).
|
|
@@ -478,7 +629,7 @@ export namespace CopilotChatView {
|
|
|
478
629
|
scrollRef.current = el;
|
|
479
630
|
setNonAutoScrollEl(el);
|
|
480
631
|
},
|
|
481
|
-
// scrollRef is a stable object
|
|
632
|
+
// scrollRef is a stable ref object; safe to omit.
|
|
482
633
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
483
634
|
[],
|
|
484
635
|
);
|
|
@@ -489,7 +640,7 @@ export namespace CopilotChatView {
|
|
|
489
640
|
|
|
490
641
|
// Monitor scroll position for non-autoscroll mode
|
|
491
642
|
useEffect(() => {
|
|
492
|
-
if (
|
|
643
|
+
if (mode === "pin-to-bottom") return; // Skip for autoscroll mode
|
|
493
644
|
|
|
494
645
|
const scrollElement = scrollRef.current;
|
|
495
646
|
if (!scrollElement) return;
|
|
@@ -514,7 +665,7 @@ export namespace CopilotChatView {
|
|
|
514
665
|
scrollElement.removeEventListener("scroll", checkScroll);
|
|
515
666
|
resizeObserver.disconnect();
|
|
516
667
|
};
|
|
517
|
-
}, [scrollRef,
|
|
668
|
+
}, [scrollRef, mode]);
|
|
518
669
|
|
|
519
670
|
if (!hasMounted) {
|
|
520
671
|
return (
|
|
@@ -526,8 +677,7 @@ export namespace CopilotChatView {
|
|
|
526
677
|
);
|
|
527
678
|
}
|
|
528
679
|
|
|
529
|
-
|
|
530
|
-
if (!autoScroll) {
|
|
680
|
+
if (mode === "none") {
|
|
531
681
|
const BoundFeather = renderSlot(feather, CopilotChatView.Feather, {});
|
|
532
682
|
|
|
533
683
|
return (
|
|
@@ -549,7 +699,6 @@ export namespace CopilotChatView {
|
|
|
549
699
|
{children}
|
|
550
700
|
</div>
|
|
551
701
|
|
|
552
|
-
{/* Feather gradient overlay */}
|
|
553
702
|
{BoundFeather}
|
|
554
703
|
|
|
555
704
|
{/* Scroll to bottom button for manual mode */}
|
|
@@ -557,7 +706,7 @@ export namespace CopilotChatView {
|
|
|
557
706
|
<div
|
|
558
707
|
className="cpk:absolute cpk:inset-x-0 cpk:flex cpk:justify-center cpk:z-30 cpk:pointer-events-none"
|
|
559
708
|
style={{
|
|
560
|
-
bottom: `${inputContainerHeight +
|
|
709
|
+
bottom: `${inputContainerHeight + SCROLL_BUTTON_OFFSET}px`,
|
|
561
710
|
}}
|
|
562
711
|
>
|
|
563
712
|
{renderSlot(
|
|
@@ -574,6 +723,28 @@ export namespace CopilotChatView {
|
|
|
574
723
|
);
|
|
575
724
|
}
|
|
576
725
|
|
|
726
|
+
if (mode === "pin-to-send") {
|
|
727
|
+
return (
|
|
728
|
+
<PinToSendScrollContainer
|
|
729
|
+
scrollRef={scrollRef}
|
|
730
|
+
contentRef={contentRef}
|
|
731
|
+
scrollToBottom={scrollToBottom}
|
|
732
|
+
scrollToBottomButton={scrollToBottomButton}
|
|
733
|
+
feather={feather}
|
|
734
|
+
inputContainerHeight={inputContainerHeight}
|
|
735
|
+
isResizing={isResizing}
|
|
736
|
+
nonAutoScrollEl={nonAutoScrollEl}
|
|
737
|
+
nonAutoScrollRefCallback={nonAutoScrollRefCallback}
|
|
738
|
+
showScrollButton={showScrollButton}
|
|
739
|
+
className={className}
|
|
740
|
+
{...props}
|
|
741
|
+
>
|
|
742
|
+
{children}
|
|
743
|
+
</PinToSendScrollContainer>
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// mode === "pin-to-bottom" (default)
|
|
577
748
|
return (
|
|
578
749
|
<StickToBottom
|
|
579
750
|
className={cn(
|
|
@@ -617,22 +788,14 @@ export namespace CopilotChatView {
|
|
|
617
788
|
</Button>
|
|
618
789
|
);
|
|
619
790
|
|
|
791
|
+
// Default renders an empty div — no visual, but the element is still in the
|
|
792
|
+
// tree so a slot override of the form `scrollView={{ feather: "my-class" }}`
|
|
793
|
+
// can apply classes (and any consumer with a full component override gets
|
|
794
|
+
// the className/style forwarding they expect).
|
|
620
795
|
export const Feather: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
|
621
796
|
className,
|
|
622
|
-
style,
|
|
623
797
|
...props
|
|
624
|
-
}) =>
|
|
625
|
-
<div
|
|
626
|
-
className={cn(
|
|
627
|
-
"cpk:absolute cpk:bottom-0 cpk:left-0 cpk:right-4 cpk:h-24 cpk:pointer-events-none cpk:z-10 cpk:bg-gradient-to-t",
|
|
628
|
-
"cpk:from-white cpk:via-white cpk:to-transparent",
|
|
629
|
-
"cpk:dark:from-[rgb(33,33,33)] cpk:dark:via-[rgb(33,33,33)]",
|
|
630
|
-
className,
|
|
631
|
-
)}
|
|
632
|
-
style={style}
|
|
633
|
-
{...props}
|
|
634
|
-
/>
|
|
635
|
-
);
|
|
798
|
+
}) => <div className={className} {...props} />;
|
|
636
799
|
|
|
637
800
|
export const WelcomeMessage: React.FC<
|
|
638
801
|
React.HTMLAttributes<HTMLDivElement>
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
3
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
4
|
+
import { CopilotKitProvider } from "../../../providers/CopilotKitProvider";
|
|
5
|
+
import { CopilotChatConfigurationProvider } from "../../../providers/CopilotChatConfigurationProvider";
|
|
6
|
+
import { CopilotChat } from "../CopilotChat";
|
|
7
|
+
import { MockStepwiseAgent } from "../../../__tests__/utils/test-helpers";
|
|
8
|
+
import { DEFAULT_AGENT_ID } from "@copilotkit/shared";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Mock agent that records every connectAgent() invocation and resolves
|
|
12
|
+
* immediately with an empty run result. Tracking lives on the class so
|
|
13
|
+
* per-thread clones (from useAgent's WeakMap) share the counter.
|
|
14
|
+
*/
|
|
15
|
+
class TrackingAgent extends MockStepwiseAgent {
|
|
16
|
+
static connectCalls: Array<{
|
|
17
|
+
threadId: string | undefined;
|
|
18
|
+
agentId: string | undefined;
|
|
19
|
+
}> = [];
|
|
20
|
+
|
|
21
|
+
static reset() {
|
|
22
|
+
TrackingAgent.connectCalls = [];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async connectAgent(
|
|
26
|
+
_params: unknown,
|
|
27
|
+
_subscriber: unknown,
|
|
28
|
+
): Promise<{ result: unknown; newMessages: [] }> {
|
|
29
|
+
TrackingAgent.connectCalls.push({
|
|
30
|
+
threadId: this.threadId,
|
|
31
|
+
agentId: this.agentId,
|
|
32
|
+
});
|
|
33
|
+
return { result: undefined, newMessages: [] };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function renderWithKit(ui: React.ReactNode, agent: TrackingAgent) {
|
|
38
|
+
return render(
|
|
39
|
+
<CopilotKitProvider agents__unsafe_dev_only={{ [DEFAULT_AGENT_ID]: agent }}>
|
|
40
|
+
<div style={{ height: 400 }}>{ui}</div>
|
|
41
|
+
</CopilotKitProvider>,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Regression coverage for fix/welcome-not-showing-at-all.
|
|
47
|
+
*
|
|
48
|
+
* The underlying bug: the v1 <CopilotKit> wrapper pipes a ThreadsProvider-
|
|
49
|
+
* minted UUID through to CopilotChatConfigurationProvider as `threadId`.
|
|
50
|
+
* CopilotChat previously treated any non-empty providedThreadId as "caller
|
|
51
|
+
* supplied a real backend thread" and (a) fired /connect (→ 404 for an
|
|
52
|
+
* auto-minted UUID) and (b) suppressed the welcome screen forever. The
|
|
53
|
+
* fix threads an `hasExplicitThreadId` signal through the provider chain;
|
|
54
|
+
* these tests pin the contract that /connect and welcome-screen gating
|
|
55
|
+
* now follow that signal rather than `!!threadId`.
|
|
56
|
+
*/
|
|
57
|
+
describe("CopilotChat welcome / connect integration", () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
TrackingAgent.reset();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("v1 bridge scenario (config provider marks threadId as non-explicit)", () => {
|
|
63
|
+
it("does not call connectAgent and shows the welcome screen", async () => {
|
|
64
|
+
const agent = new TrackingAgent();
|
|
65
|
+
agent.agentId = DEFAULT_AGENT_ID;
|
|
66
|
+
|
|
67
|
+
renderWithKit(
|
|
68
|
+
<CopilotChatConfigurationProvider
|
|
69
|
+
threadId="auto-minted-uuid"
|
|
70
|
+
hasExplicitThreadId={false}
|
|
71
|
+
>
|
|
72
|
+
<CopilotChat />
|
|
73
|
+
</CopilotChatConfigurationProvider>,
|
|
74
|
+
agent,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Give the connect-effect a chance to misfire.
|
|
78
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
79
|
+
|
|
80
|
+
expect(TrackingAgent.connectCalls).toHaveLength(0);
|
|
81
|
+
expect(screen.getByTestId("copilot-welcome-screen")).toBeDefined();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("plain CopilotChat (no threadId anywhere)", () => {
|
|
86
|
+
it("does not call connectAgent and shows the welcome screen", async () => {
|
|
87
|
+
const agent = new TrackingAgent();
|
|
88
|
+
agent.agentId = DEFAULT_AGENT_ID;
|
|
89
|
+
|
|
90
|
+
renderWithKit(<CopilotChat />, agent);
|
|
91
|
+
|
|
92
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
93
|
+
|
|
94
|
+
expect(TrackingAgent.connectCalls).toHaveLength(0);
|
|
95
|
+
expect(screen.getByTestId("copilot-welcome-screen")).toBeDefined();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("explicit threadId via CopilotChat prop", () => {
|
|
100
|
+
it("calls connectAgent with that threadId and suppresses the welcome screen", async () => {
|
|
101
|
+
const agent = new TrackingAgent();
|
|
102
|
+
agent.agentId = DEFAULT_AGENT_ID;
|
|
103
|
+
|
|
104
|
+
renderWithKit(<CopilotChat threadId="real-thread" />, agent);
|
|
105
|
+
|
|
106
|
+
await waitFor(() => {
|
|
107
|
+
expect(TrackingAgent.connectCalls.length).toBeGreaterThan(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// The per-thread clone carries threadId; agentId is the default.
|
|
111
|
+
expect(
|
|
112
|
+
TrackingAgent.connectCalls.some((c) => c.threadId === "real-thread"),
|
|
113
|
+
).toBe(true);
|
|
114
|
+
|
|
115
|
+
// Welcome screen is suppressed even after connect resolves, because the
|
|
116
|
+
// thread was caller-picked (hasExplicitThreadId=true).
|
|
117
|
+
expect(screen.queryByTestId("copilot-welcome-screen")).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("explicit threadId via wrapping CopilotChatConfigurationProvider", () => {
|
|
122
|
+
it("inherits explicitness from the provider and connects", async () => {
|
|
123
|
+
const agent = new TrackingAgent();
|
|
124
|
+
agent.agentId = DEFAULT_AGENT_ID;
|
|
125
|
+
|
|
126
|
+
renderWithKit(
|
|
127
|
+
<CopilotChatConfigurationProvider threadId="from-config">
|
|
128
|
+
<CopilotChat />
|
|
129
|
+
</CopilotChatConfigurationProvider>,
|
|
130
|
+
agent,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
await waitFor(() => {
|
|
134
|
+
expect(TrackingAgent.connectCalls.length).toBeGreaterThan(0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
expect(
|
|
138
|
+
TrackingAgent.connectCalls.some((c) => c.threadId === "from-config"),
|
|
139
|
+
).toBe(true);
|
|
140
|
+
expect(screen.queryByTestId("copilot-welcome-screen")).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("thread switch between two explicit threads", () => {
|
|
145
|
+
it("keeps the welcome screen hidden across the switch", async () => {
|
|
146
|
+
const agent = new TrackingAgent();
|
|
147
|
+
agent.agentId = DEFAULT_AGENT_ID;
|
|
148
|
+
|
|
149
|
+
const { rerender } = renderWithKit(
|
|
150
|
+
<CopilotChat threadId="thread-a" />,
|
|
151
|
+
agent,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
await waitFor(() => {
|
|
155
|
+
expect(
|
|
156
|
+
TrackingAgent.connectCalls.some((c) => c.threadId === "thread-a"),
|
|
157
|
+
).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
// After thread-a's connect resolves, welcome must still be hidden
|
|
160
|
+
// because the thread is caller-picked.
|
|
161
|
+
expect(screen.queryByTestId("copilot-welcome-screen")).toBeNull();
|
|
162
|
+
|
|
163
|
+
rerender(
|
|
164
|
+
<CopilotKitProvider
|
|
165
|
+
agents__unsafe_dev_only={{ [DEFAULT_AGENT_ID]: agent }}
|
|
166
|
+
>
|
|
167
|
+
<div style={{ height: 400 }}>
|
|
168
|
+
<CopilotChat threadId="thread-b" />
|
|
169
|
+
</div>
|
|
170
|
+
</CopilotKitProvider>,
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
// During the switch (lastConnected="thread-a" !== "thread-b") isConnecting
|
|
174
|
+
// is true — welcome must not flash.
|
|
175
|
+
expect(screen.queryByTestId("copilot-welcome-screen")).toBeNull();
|
|
176
|
+
|
|
177
|
+
await waitFor(() => {
|
|
178
|
+
expect(
|
|
179
|
+
TrackingAgent.connectCalls.some((c) => c.threadId === "thread-b"),
|
|
180
|
+
).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
// And after thread-b's connect resolves.
|
|
183
|
+
expect(screen.queryByTestId("copilot-welcome-screen")).toBeNull();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
});
|