@copilotkit/react-core 1.55.0-next.9 → 1.55.0
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/CHANGELOG.md +36 -6
- package/dist/{copilotkit-DeOzjPsb.mjs → copilotkit-BY5S1-0P.mjs} +2402 -552
- package/dist/copilotkit-BY5S1-0P.mjs.map +1 -0
- package/dist/{copilotkit-BqcyhQjT.d.mts → copilotkit-BuhSUZHb.d.mts} +228 -17
- package/dist/copilotkit-BuhSUZHb.d.mts.map +1 -0
- package/dist/{copilotkit-BDNjFNmk.cjs → copilotkit-Bz5-ImDl.cjs} +2421 -541
- package/dist/copilotkit-Bz5-ImDl.cjs.map +1 -0
- package/dist/{copilotkit-l-IBF4Xp.d.cts → copilotkit-dwDWYpya.d.cts} +228 -17
- package/dist/copilotkit-dwDWYpya.d.cts.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.umd.js +1400 -238
- package/dist/index.umd.js.map +1 -1
- package/dist/v2/index.cjs +13 -1
- package/dist/v2/index.css +1 -1
- package/dist/v2/index.d.cts +3 -3
- package/dist/v2/index.d.mts +3 -3
- package/dist/v2/index.mjs +3 -2
- package/dist/v2/index.umd.js +2442 -552
- package/dist/v2/index.umd.js.map +1 -1
- package/package.json +62 -54
- package/scripts/scope-preflight.mjs +1 -2
- package/src/components/CopilotListeners.tsx +41 -8
- package/src/components/copilot-provider/copilotkit-props.tsx +4 -2
- package/src/components/toast/toast-provider.tsx +269 -194
- package/src/v2/__tests__/A2UIMessageRenderer.test.tsx +86 -22
- package/src/v2/__tests__/utils/test-helpers.tsx +67 -0
- package/src/v2/a2ui/A2UICatalogContext.tsx +79 -0
- package/src/v2/a2ui/A2UIMessageRenderer.tsx +125 -37
- package/src/v2/a2ui/A2UIToolCallRenderer.tsx +290 -0
- package/src/v2/components/CopilotKitInspector.tsx +2 -0
- package/src/v2/components/OpenGenerativeUIRenderer.tsx +598 -0
- package/src/v2/components/__tests__/OpenGenerativeUIRenderer.test.tsx +665 -0
- package/src/v2/components/chat/CopilotChat.tsx +193 -50
- package/src/v2/components/chat/CopilotChatAssistantMessage.tsx +17 -2
- package/src/v2/components/chat/CopilotChatAttachmentQueue.tsx +481 -0
- package/src/v2/components/chat/CopilotChatAttachmentRenderer.tsx +139 -0
- package/src/v2/components/chat/CopilotChatInput.tsx +146 -77
- package/src/v2/components/chat/CopilotChatMessageView.tsx +253 -149
- package/src/v2/components/chat/CopilotChatSuggestionView.tsx +1 -0
- package/src/v2/components/chat/CopilotChatUserMessage.tsx +54 -0
- package/src/v2/components/chat/CopilotChatView.tsx +179 -66
- package/src/v2/components/chat/__tests__/CopilotChat.attachments.test.tsx +168 -0
- package/src/v2/components/chat/__tests__/CopilotChatActivityRendering.e2e.test.tsx +63 -2
- package/src/v2/components/chat/__tests__/CopilotChatInput.test.tsx +544 -1
- package/src/v2/components/chat/__tests__/CopilotChatPerf.e2e.test.tsx +268 -0
- package/src/v2/components/chat/__tests__/CopilotChatPropsRerender.e2e.test.tsx +249 -0
- package/src/v2/components/chat/__tests__/MCPAppsActivityRenderer.e2e.test.tsx +43 -2
- package/src/v2/components/chat/__tests__/copilot-chat-throttle.test.tsx +138 -0
- package/src/v2/components/chat/index.ts +9 -0
- package/src/v2/components/chat/scroll-element-context.ts +13 -0
- package/src/v2/hooks/__tests__/use-agent-throttle.test.tsx +1003 -0
- package/src/v2/hooks/__tests__/use-attachments.test.tsx +169 -0
- package/src/v2/hooks/__tests__/use-threads.test.tsx +54 -0
- package/src/v2/hooks/index.ts +5 -0
- package/src/v2/hooks/use-agent.tsx +95 -10
- package/src/v2/hooks/use-attachments.tsx +269 -0
- package/src/v2/hooks/use-frontend-tool.tsx +5 -2
- package/src/v2/hooks/use-render-activity-message.tsx +9 -2
- package/src/v2/hooks/use-threads.tsx +35 -15
- package/src/v2/index.ts +5 -1
- package/src/v2/lib/__tests__/processPartialHtml.test.ts +112 -0
- package/src/v2/lib/__tests__/slots.test.ts +56 -0
- package/src/v2/lib/processPartialHtml.ts +45 -0
- package/src/v2/lib/slots.tsx +42 -1
- package/src/v2/providers/CopilotChatConfigurationProvider.tsx +9 -3
- package/src/v2/providers/CopilotKitProvider.tsx +268 -32
- package/src/v2/providers/SandboxFunctionsContext.ts +10 -0
- package/src/v2/providers/__tests__/CopilotKitProvider.sandboxFunctions.test.tsx +198 -0
- package/src/v2/providers/__tests__/CopilotKitProvider.test.tsx +71 -0
- package/src/v2/providers/index.ts +7 -0
- package/src/v2/styles/globals.css +2 -1
- package/src/v2/types/index.ts +1 -0
- package/src/v2/types/sandbox-function.ts +11 -0
- package/dist/copilotkit-BDNjFNmk.cjs.map +0 -1
- package/dist/copilotkit-BqcyhQjT.d.mts.map +0 -1
- package/dist/copilotkit-DeOzjPsb.mjs.map +0 -1
- package/dist/copilotkit-l-IBF4Xp.d.cts.map +0 -1
- package/src/v2/components/__tests__/license-warning-banner.test.tsx +0 -46
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, {
|
|
2
|
+
useCallback,
|
|
3
|
+
useRef,
|
|
4
|
+
useState,
|
|
5
|
+
useEffect,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
} from "react";
|
|
8
|
+
import { ScrollElementContext } from "./scroll-element-context";
|
|
2
9
|
import { WithSlots, SlotValue, renderSlot } from "../../lib/slots";
|
|
3
10
|
import CopilotChatMessageView from "./CopilotChatMessageView";
|
|
4
11
|
import CopilotChatInput, {
|
|
@@ -10,13 +17,15 @@ import CopilotChatSuggestionView, {
|
|
|
10
17
|
} from "./CopilotChatSuggestionView";
|
|
11
18
|
import { Suggestion } from "@copilotkit/core";
|
|
12
19
|
import { Message } from "@ag-ui/core";
|
|
20
|
+
import type { Attachment } from "@copilotkit/shared";
|
|
21
|
+
import { CopilotChatAttachmentQueue } from "./CopilotChatAttachmentQueue";
|
|
13
22
|
import { twMerge } from "tailwind-merge";
|
|
14
23
|
import {
|
|
15
24
|
StickToBottom,
|
|
16
25
|
useStickToBottom,
|
|
17
26
|
useStickToBottomContext,
|
|
18
27
|
} from "use-stick-to-bottom";
|
|
19
|
-
import { ChevronDown } from "lucide-react";
|
|
28
|
+
import { ChevronDown, Upload } from "lucide-react";
|
|
20
29
|
import { Button } from "../../components/ui/button";
|
|
21
30
|
import { cn } from "../../lib/utils";
|
|
22
31
|
import {
|
|
@@ -64,6 +73,14 @@ export type CopilotChatViewProps = WithSlots<
|
|
|
64
73
|
onCancelTranscribe?: () => void;
|
|
65
74
|
onFinishTranscribe?: () => void;
|
|
66
75
|
onFinishTranscribeWithAudio?: (audioBlob: Blob) => Promise<void>;
|
|
76
|
+
// Attachment props
|
|
77
|
+
attachments?: Attachment[];
|
|
78
|
+
onRemoveAttachment?: (id: string) => void;
|
|
79
|
+
onAddFile?: () => void;
|
|
80
|
+
dragOver?: boolean;
|
|
81
|
+
onDragOver?: (e: React.DragEvent) => void;
|
|
82
|
+
onDragLeave?: (e: React.DragEvent) => void;
|
|
83
|
+
onDrop?: (e: React.DragEvent) => void;
|
|
67
84
|
/**
|
|
68
85
|
* @deprecated Use the `input` slot's `disclaimer` prop instead:
|
|
69
86
|
* ```tsx
|
|
@@ -74,6 +91,24 @@ export type CopilotChatViewProps = WithSlots<
|
|
|
74
91
|
} & React.HTMLAttributes<HTMLDivElement>
|
|
75
92
|
>;
|
|
76
93
|
|
|
94
|
+
function DropOverlay() {
|
|
95
|
+
return (
|
|
96
|
+
<div
|
|
97
|
+
className={cn(
|
|
98
|
+
"cpk:absolute cpk:inset-0 cpk:z-50 cpk:pointer-events-none",
|
|
99
|
+
"cpk:flex cpk:items-center cpk:justify-center",
|
|
100
|
+
"cpk:bg-primary/5 cpk:backdrop-blur-[2px]",
|
|
101
|
+
"cpk:border-2 cpk:border-dashed cpk:border-primary/40 cpk:rounded-lg cpk:m-2",
|
|
102
|
+
)}
|
|
103
|
+
>
|
|
104
|
+
<div className="cpk:flex cpk:flex-col cpk:items-center cpk:gap-2 cpk:text-primary/70">
|
|
105
|
+
<Upload className="cpk:w-8 cpk:h-8" />
|
|
106
|
+
<span className="cpk:text-sm cpk:font-medium">Drop files here</span>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
77
112
|
export function CopilotChatView({
|
|
78
113
|
messageView,
|
|
79
114
|
input,
|
|
@@ -96,6 +131,14 @@ export function CopilotChatView({
|
|
|
96
131
|
onCancelTranscribe,
|
|
97
132
|
onFinishTranscribe,
|
|
98
133
|
onFinishTranscribeWithAudio,
|
|
134
|
+
// Attachment props
|
|
135
|
+
attachments,
|
|
136
|
+
onRemoveAttachment,
|
|
137
|
+
onAddFile,
|
|
138
|
+
dragOver,
|
|
139
|
+
onDragOver,
|
|
140
|
+
onDragLeave,
|
|
141
|
+
onDrop,
|
|
99
142
|
// Deprecated — forwarded to input slot
|
|
100
143
|
disclaimer,
|
|
101
144
|
children,
|
|
@@ -171,6 +214,7 @@ export function CopilotChatView({
|
|
|
171
214
|
onCancelTranscribe,
|
|
172
215
|
onFinishTranscribe,
|
|
173
216
|
onFinishTranscribeWithAudio,
|
|
217
|
+
onAddFile,
|
|
174
218
|
positioning: "static",
|
|
175
219
|
keyboardHeight: isKeyboardOpen ? keyboardHeight : 0,
|
|
176
220
|
containerRef: inputContainerRef,
|
|
@@ -229,6 +273,7 @@ export function CopilotChatView({
|
|
|
229
273
|
onCancelTranscribe,
|
|
230
274
|
onFinishTranscribe,
|
|
231
275
|
onFinishTranscribeWithAudio,
|
|
276
|
+
onAddFile,
|
|
232
277
|
positioning: "static",
|
|
233
278
|
showDisclaimer: true,
|
|
234
279
|
...(disclaimer !== undefined ? { disclaimer } : {}),
|
|
@@ -238,11 +283,25 @@ export function CopilotChatView({
|
|
|
238
283
|
const welcomeScreenSlot = (
|
|
239
284
|
welcomeScreen === true ? undefined : welcomeScreen
|
|
240
285
|
) as SlotValue<React.FC<WelcomeScreenProps>> | undefined;
|
|
286
|
+
// Wrap the input with attachment queue above it
|
|
287
|
+
const inputWithAttachments = (
|
|
288
|
+
<div className="cpk:w-full">
|
|
289
|
+
{attachments && attachments.length > 0 && (
|
|
290
|
+
<CopilotChatAttachmentQueue
|
|
291
|
+
attachments={attachments}
|
|
292
|
+
onRemoveAttachment={(id) => onRemoveAttachment?.(id)}
|
|
293
|
+
className="cpk:mb-2"
|
|
294
|
+
/>
|
|
295
|
+
)}
|
|
296
|
+
{BoundInputForWelcome}
|
|
297
|
+
</div>
|
|
298
|
+
);
|
|
299
|
+
|
|
241
300
|
const BoundWelcomeScreen = renderSlot(
|
|
242
301
|
welcomeScreenSlot,
|
|
243
302
|
CopilotChatView.WelcomeScreen,
|
|
244
303
|
{
|
|
245
|
-
input:
|
|
304
|
+
input: inputWithAttachments,
|
|
246
305
|
suggestionView: BoundSuggestionView ?? <></>,
|
|
247
306
|
},
|
|
248
307
|
);
|
|
@@ -252,12 +311,16 @@ export function CopilotChatView({
|
|
|
252
311
|
data-copilotkit
|
|
253
312
|
data-testid="copilot-chat"
|
|
254
313
|
data-copilot-running={isRunning ? "true" : "false"}
|
|
255
|
-
|
|
314
|
+
onDragOver={onDragOver}
|
|
315
|
+
onDragLeave={onDragLeave}
|
|
316
|
+
onDrop={onDrop}
|
|
317
|
+
className={cn(
|
|
256
318
|
"copilotKitChat cpk:relative cpk:h-full cpk:flex cpk:flex-col",
|
|
257
319
|
className,
|
|
258
320
|
)}
|
|
259
321
|
{...props}
|
|
260
322
|
>
|
|
323
|
+
{dragOver && <DropOverlay />}
|
|
261
324
|
{BoundWelcomeScreen}
|
|
262
325
|
</div>
|
|
263
326
|
);
|
|
@@ -281,14 +344,28 @@ export function CopilotChatView({
|
|
|
281
344
|
data-copilotkit
|
|
282
345
|
data-testid="copilot-chat"
|
|
283
346
|
data-copilot-running={isRunning ? "true" : "false"}
|
|
284
|
-
|
|
347
|
+
onDragOver={onDragOver}
|
|
348
|
+
onDragLeave={onDragLeave}
|
|
349
|
+
onDrop={onDrop}
|
|
350
|
+
className={cn(
|
|
285
351
|
"copilotKitChat cpk:relative cpk:h-full cpk:flex cpk:flex-col",
|
|
286
352
|
className,
|
|
287
353
|
)}
|
|
288
354
|
{...props}
|
|
289
355
|
>
|
|
356
|
+
{dragOver && <DropOverlay />}
|
|
290
357
|
{BoundScrollView}
|
|
291
358
|
|
|
359
|
+
<div className="cpk:max-w-3xl cpk:mx-auto cpk:w-full">
|
|
360
|
+
{attachments && attachments.length > 0 && (
|
|
361
|
+
<CopilotChatAttachmentQueue
|
|
362
|
+
attachments={attachments}
|
|
363
|
+
onRemoveAttachment={(id) => onRemoveAttachment?.(id)}
|
|
364
|
+
className="cpk:px-4"
|
|
365
|
+
/>
|
|
366
|
+
)}
|
|
367
|
+
</div>
|
|
368
|
+
|
|
292
369
|
{BoundInput}
|
|
293
370
|
</div>
|
|
294
371
|
);
|
|
@@ -311,42 +388,57 @@ export namespace CopilotChatView {
|
|
|
311
388
|
inputContainerHeight,
|
|
312
389
|
isResizing,
|
|
313
390
|
}) => {
|
|
314
|
-
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
|
391
|
+
const { isAtBottom, scrollToBottom, scrollRef } = useStickToBottomContext();
|
|
392
|
+
|
|
393
|
+
// Capture the scroll element in state so the context value is reactive —
|
|
394
|
+
// consumers re-render when the element is first set rather than reading a
|
|
395
|
+
// ref that silently stays null until after their own layout effects fire.
|
|
396
|
+
const [scrollEl, setScrollEl] = useState<HTMLElement | null>(null);
|
|
397
|
+
useLayoutEffect(() => {
|
|
398
|
+
setScrollEl(scrollRef.current ?? null);
|
|
399
|
+
// scrollRef is a stable object; omitting from deps is intentional.
|
|
400
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
401
|
+
}, []);
|
|
315
402
|
|
|
316
403
|
const BoundFeather = renderSlot(feather, CopilotChatView.Feather, {});
|
|
317
404
|
|
|
318
405
|
return (
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
<
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
406
|
+
// Provide the scroll element so CopilotChatMessageView can feed it to
|
|
407
|
+
// useVirtualizer's getScrollElement. Using state (not the raw ref) means
|
|
408
|
+
// the context value updates reactively when the element mounts.
|
|
409
|
+
<ScrollElementContext.Provider value={scrollEl}>
|
|
410
|
+
<>
|
|
411
|
+
<StickToBottom.Content
|
|
412
|
+
className="cpk:overflow-y-auto cpk:overflow-x-hidden"
|
|
413
|
+
style={{ flex: "1 1 0%", minHeight: 0 }}
|
|
414
|
+
>
|
|
415
|
+
<div className="cpk:px-4 cpk:sm:px-0 cpk:[div[data-sidebar-chat]_&]:px-8 cpk:[div[data-popup-chat]_&]:px-6">
|
|
416
|
+
{children}
|
|
417
|
+
</div>
|
|
418
|
+
</StickToBottom.Content>
|
|
328
419
|
|
|
329
|
-
|
|
330
|
-
|
|
420
|
+
{/* Feather gradient overlay */}
|
|
421
|
+
{BoundFeather}
|
|
331
422
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
423
|
+
{/* Scroll to bottom button - hidden during resize */}
|
|
424
|
+
{!isAtBottom && !isResizing && (
|
|
425
|
+
<div
|
|
426
|
+
className="cpk:absolute cpk:inset-x-0 cpk:flex cpk:justify-center cpk:z-30 cpk:pointer-events-none"
|
|
427
|
+
style={{
|
|
428
|
+
bottom: `${inputContainerHeight + FEATHER_HEIGHT + 16}px`,
|
|
429
|
+
}}
|
|
430
|
+
>
|
|
431
|
+
{renderSlot(
|
|
432
|
+
scrollToBottomButton,
|
|
433
|
+
CopilotChatView.ScrollToBottomButton,
|
|
434
|
+
{
|
|
435
|
+
onClick: () => scrollToBottom(),
|
|
436
|
+
},
|
|
437
|
+
)}
|
|
438
|
+
</div>
|
|
439
|
+
)}
|
|
440
|
+
</>
|
|
441
|
+
</ScrollElementContext.Provider>
|
|
350
442
|
);
|
|
351
443
|
};
|
|
352
444
|
|
|
@@ -373,6 +465,23 @@ export namespace CopilotChatView {
|
|
|
373
465
|
const [hasMounted, setHasMounted] = useState(false);
|
|
374
466
|
const { scrollRef, contentRef, scrollToBottom } = useStickToBottom();
|
|
375
467
|
const [showScrollButton, setShowScrollButton] = useState(false);
|
|
468
|
+
// Tracks the scroll container element for the non-autoScroll path so the
|
|
469
|
+
// context value is reactive (element state, not a ref).
|
|
470
|
+
const [nonAutoScrollEl, setNonAutoScrollEl] = useState<HTMLElement | null>(
|
|
471
|
+
null,
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
// Callback ref that keeps scrollRef in sync with the DOM element while also
|
|
475
|
+
// updating context state — eliminates the need for a useLayoutEffect.
|
|
476
|
+
const nonAutoScrollRefCallback = useCallback(
|
|
477
|
+
(el: HTMLElement | null) => {
|
|
478
|
+
scrollRef.current = el;
|
|
479
|
+
setNonAutoScrollEl(el);
|
|
480
|
+
},
|
|
481
|
+
// scrollRef is a stable object from useStickToBottom; safe to omit.
|
|
482
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
483
|
+
[],
|
|
484
|
+
);
|
|
376
485
|
|
|
377
486
|
useEffect(() => {
|
|
378
487
|
setHasMounted(true);
|
|
@@ -422,42 +531,46 @@ export namespace CopilotChatView {
|
|
|
422
531
|
const BoundFeather = renderSlot(feather, CopilotChatView.Feather, {});
|
|
423
532
|
|
|
424
533
|
return (
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
"cpk:h-full cpk:max-h-full cpk:flex cpk:flex-col cpk:min-h-0 cpk:overflow-y-auto cpk:overflow-x-hidden cpk:relative",
|
|
429
|
-
className,
|
|
430
|
-
)}
|
|
431
|
-
{...props}
|
|
432
|
-
>
|
|
534
|
+
// Provide the scroll element so CopilotChatMessageView can use it for
|
|
535
|
+
// useVirtualizer. Element state (not a ref) keeps the context reactive.
|
|
536
|
+
<ScrollElementContext.Provider value={nonAutoScrollEl}>
|
|
433
537
|
<div
|
|
434
|
-
ref={
|
|
435
|
-
className=
|
|
538
|
+
ref={nonAutoScrollRefCallback}
|
|
539
|
+
className={cn(
|
|
540
|
+
"cpk:h-full cpk:max-h-full cpk:flex cpk:flex-col cpk:min-h-0 cpk:overflow-y-auto cpk:overflow-x-hidden cpk:relative",
|
|
541
|
+
className,
|
|
542
|
+
)}
|
|
543
|
+
{...props}
|
|
436
544
|
>
|
|
437
|
-
{children}
|
|
438
|
-
</div>
|
|
439
|
-
|
|
440
|
-
{/* Feather gradient overlay */}
|
|
441
|
-
{BoundFeather}
|
|
442
|
-
|
|
443
|
-
{/* Scroll to bottom button for manual mode */}
|
|
444
|
-
{showScrollButton && !isResizing && (
|
|
445
545
|
<div
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
bottom: `${inputContainerHeight + FEATHER_HEIGHT + 16}px`,
|
|
449
|
-
}}
|
|
546
|
+
ref={contentRef}
|
|
547
|
+
className="cpk:px-4 cpk:sm:px-0 cpk:[div[data-sidebar-chat]_&]:px-8 cpk:[div[data-popup-chat]_&]:px-6"
|
|
450
548
|
>
|
|
451
|
-
{
|
|
452
|
-
scrollToBottomButton,
|
|
453
|
-
CopilotChatView.ScrollToBottomButton,
|
|
454
|
-
{
|
|
455
|
-
onClick: () => scrollToBottom(),
|
|
456
|
-
},
|
|
457
|
-
)}
|
|
549
|
+
{children}
|
|
458
550
|
</div>
|
|
459
|
-
|
|
460
|
-
|
|
551
|
+
|
|
552
|
+
{/* Feather gradient overlay */}
|
|
553
|
+
{BoundFeather}
|
|
554
|
+
|
|
555
|
+
{/* Scroll to bottom button for manual mode */}
|
|
556
|
+
{showScrollButton && !isResizing && (
|
|
557
|
+
<div
|
|
558
|
+
className="cpk:absolute cpk:inset-x-0 cpk:flex cpk:justify-center cpk:z-30 cpk:pointer-events-none"
|
|
559
|
+
style={{
|
|
560
|
+
bottom: `${inputContainerHeight + FEATHER_HEIGHT + 16}px`,
|
|
561
|
+
}}
|
|
562
|
+
>
|
|
563
|
+
{renderSlot(
|
|
564
|
+
scrollToBottomButton,
|
|
565
|
+
CopilotChatView.ScrollToBottomButton,
|
|
566
|
+
{
|
|
567
|
+
onClick: () => scrollToBottom(),
|
|
568
|
+
},
|
|
569
|
+
)}
|
|
570
|
+
</div>
|
|
571
|
+
)}
|
|
572
|
+
</div>
|
|
573
|
+
</ScrollElementContext.Provider>
|
|
461
574
|
);
|
|
462
575
|
}
|
|
463
576
|
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { describe, it, expect, vi } from "vitest";
|
|
3
|
+
import { screen, fireEvent, waitFor } from "@testing-library/react";
|
|
4
|
+
import {
|
|
5
|
+
MockStepwiseAgent,
|
|
6
|
+
renderWithCopilotKit,
|
|
7
|
+
} from "../../../__tests__/utils/test-helpers";
|
|
8
|
+
import { CopilotChat } from "../CopilotChat";
|
|
9
|
+
import type { AttachmentUploadError } from "@copilotkit/shared";
|
|
10
|
+
import { type BaseEvent, type RunAgentInput } from "@ag-ui/client";
|
|
11
|
+
import { Observable, EMPTY } from "rxjs";
|
|
12
|
+
|
|
13
|
+
class NoopAgent extends MockStepwiseAgent {
|
|
14
|
+
run(_input: RunAgentInput): Observable<BaseEvent> {
|
|
15
|
+
return EMPTY;
|
|
16
|
+
}
|
|
17
|
+
connect(_input: RunAgentInput): Observable<BaseEvent> {
|
|
18
|
+
return EMPTY;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function createFile(name: string, size: number, type: string): File {
|
|
23
|
+
const content = new Uint8Array(size);
|
|
24
|
+
return new File([content], name, { type });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function renderChat(props: {
|
|
28
|
+
onUploadFailed?: (error: AttachmentUploadError) => void;
|
|
29
|
+
accept?: string;
|
|
30
|
+
maxSize?: number;
|
|
31
|
+
onUpload?: (file: File) => any;
|
|
32
|
+
}) {
|
|
33
|
+
const agent = new NoopAgent();
|
|
34
|
+
return renderWithCopilotKit({
|
|
35
|
+
agent,
|
|
36
|
+
children: (
|
|
37
|
+
<CopilotChat
|
|
38
|
+
welcomeScreen={false}
|
|
39
|
+
attachments={{
|
|
40
|
+
enabled: true,
|
|
41
|
+
accept: props.accept,
|
|
42
|
+
maxSize: props.maxSize,
|
|
43
|
+
onUploadFailed: props.onUploadFailed,
|
|
44
|
+
onUpload: props.onUpload,
|
|
45
|
+
}}
|
|
46
|
+
/>
|
|
47
|
+
),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function dropFiles(container: HTMLElement, files: File[]) {
|
|
52
|
+
const dropTarget = container.querySelector('[data-testid="copilot-chat"]');
|
|
53
|
+
if (!dropTarget) {
|
|
54
|
+
throw new Error("Could not find copilot-chat drop target");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
fireEvent.dragOver(dropTarget, {
|
|
58
|
+
dataTransfer: { files, types: ["Files"] },
|
|
59
|
+
});
|
|
60
|
+
fireEvent.drop(dropTarget, {
|
|
61
|
+
dataTransfer: { files, types: ["Files"] },
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("CopilotChat attachments", () => {
|
|
66
|
+
describe("onUploadFailed", () => {
|
|
67
|
+
it("fires with 'invalid-type' when file does not match accept filter", async () => {
|
|
68
|
+
const onUploadFailed = vi.fn();
|
|
69
|
+
const { container } = renderChat({
|
|
70
|
+
accept: "image/*",
|
|
71
|
+
onUploadFailed,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const pdfFile = createFile("document.pdf", 1024, "application/pdf");
|
|
75
|
+
await dropFiles(container, [pdfFile]);
|
|
76
|
+
|
|
77
|
+
await waitFor(() => {
|
|
78
|
+
expect(onUploadFailed).toHaveBeenCalledTimes(1);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const error: AttachmentUploadError = onUploadFailed.mock.calls[0][0];
|
|
82
|
+
expect(error.reason).toBe("invalid-type");
|
|
83
|
+
expect(error.file).toBe(pdfFile);
|
|
84
|
+
expect(error.message).toContain("document.pdf");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("fires with 'file-too-large' when file exceeds maxSize", async () => {
|
|
88
|
+
const onUploadFailed = vi.fn();
|
|
89
|
+
const { container } = renderChat({
|
|
90
|
+
maxSize: 100,
|
|
91
|
+
onUploadFailed,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const largeFile = createFile("big.png", 200, "image/png");
|
|
95
|
+
await dropFiles(container, [largeFile]);
|
|
96
|
+
|
|
97
|
+
await waitFor(() => {
|
|
98
|
+
expect(onUploadFailed).toHaveBeenCalledTimes(1);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const error: AttachmentUploadError = onUploadFailed.mock.calls[0][0];
|
|
102
|
+
expect(error.reason).toBe("file-too-large");
|
|
103
|
+
expect(error.file).toBe(largeFile);
|
|
104
|
+
expect(error.message).toContain("big.png");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("fires with 'upload-failed' when onUpload throws", async () => {
|
|
108
|
+
const onUploadFailed = vi.fn();
|
|
109
|
+
const { container } = renderChat({
|
|
110
|
+
onUpload: () => {
|
|
111
|
+
throw new Error("S3 upload failed");
|
|
112
|
+
},
|
|
113
|
+
onUploadFailed,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const file = createFile("photo.png", 50, "image/png");
|
|
117
|
+
await dropFiles(container, [file]);
|
|
118
|
+
|
|
119
|
+
await waitFor(() => {
|
|
120
|
+
expect(onUploadFailed).toHaveBeenCalledTimes(1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const error: AttachmentUploadError = onUploadFailed.mock.calls[0][0];
|
|
124
|
+
expect(error.reason).toBe("upload-failed");
|
|
125
|
+
expect(error.file).toBe(file);
|
|
126
|
+
expect(error.message).toBe("S3 upload failed");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("fires multiple times for multiple rejected files", async () => {
|
|
130
|
+
const onUploadFailed = vi.fn();
|
|
131
|
+
const { container } = renderChat({
|
|
132
|
+
accept: "image/*",
|
|
133
|
+
onUploadFailed,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const pdf = createFile("a.pdf", 100, "application/pdf");
|
|
137
|
+
const doc = createFile(
|
|
138
|
+
"b.docx",
|
|
139
|
+
100,
|
|
140
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
141
|
+
);
|
|
142
|
+
await dropFiles(container, [pdf, doc]);
|
|
143
|
+
|
|
144
|
+
await waitFor(() => {
|
|
145
|
+
expect(onUploadFailed).toHaveBeenCalledTimes(2);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(onUploadFailed.mock.calls[0][0].reason).toBe("invalid-type");
|
|
149
|
+
expect(onUploadFailed.mock.calls[1][0].reason).toBe("invalid-type");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("does not fire for valid files", async () => {
|
|
153
|
+
const onUploadFailed = vi.fn();
|
|
154
|
+
const { container } = renderChat({
|
|
155
|
+
accept: "image/*",
|
|
156
|
+
maxSize: 10000,
|
|
157
|
+
onUploadFailed,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const validFile = createFile("photo.png", 500, "image/png");
|
|
161
|
+
await dropFiles(container, [validFile]);
|
|
162
|
+
|
|
163
|
+
// Give time for any async processing
|
|
164
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
165
|
+
expect(onUploadFailed).not.toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
} from "../../../__tests__/utils/test-helpers";
|
|
12
12
|
import { ReactActivityMessageRenderer } from "../../../types";
|
|
13
13
|
import { useCopilotKit } from "../../../providers";
|
|
14
|
+
import { AbstractAgent } from "@ag-ui/client";
|
|
15
|
+
import { getThreadClone } from "../../../hooks/use-agent";
|
|
14
16
|
|
|
15
17
|
describe("CopilotChat activity message rendering", () => {
|
|
16
18
|
it("renders custom components for activity snapshots", async () => {
|
|
@@ -24,9 +26,9 @@ describe("CopilotChat activity message rendering", () => {
|
|
|
24
26
|
}> = {
|
|
25
27
|
activityType: "search-progress",
|
|
26
28
|
content: z.object({ status: z.string(), percent: z.number() }),
|
|
27
|
-
render: ({ content, agent }) => (
|
|
29
|
+
render: ({ content, agent: rendererAgent }) => (
|
|
28
30
|
<div data-testid="activity-card">
|
|
29
|
-
{content.status} · {content.percent}% · {
|
|
31
|
+
{content.status} · {content.percent}% · {rendererAgent?.agentId}
|
|
30
32
|
</div>
|
|
31
33
|
),
|
|
32
34
|
};
|
|
@@ -147,4 +149,63 @@ describe("CopilotChat activity message rendering", () => {
|
|
|
147
149
|
expect(capturedCopilotkit).not.toBeNull();
|
|
148
150
|
expect(capturedCopilotkit).toBeDefined();
|
|
149
151
|
});
|
|
152
|
+
|
|
153
|
+
it("passes the per-thread clone (not the registry agent) to activity message renderers", async () => {
|
|
154
|
+
// Regression test for: A2UI button clicks firing runAgent on the registry
|
|
155
|
+
// agent instead of the per-thread clone that CopilotChat renders from.
|
|
156
|
+
// Caused by useRenderActivityMessage calling copilotkit.getAgent() directly
|
|
157
|
+
// instead of getThreadClone(registryAgent, threadId) ?? registryAgent.
|
|
158
|
+
const agent = new MockStepwiseAgent();
|
|
159
|
+
const agentId = "action-agent";
|
|
160
|
+
agent.agentId = agentId;
|
|
161
|
+
const threadId = "thread-for-action-test";
|
|
162
|
+
|
|
163
|
+
let capturedAgent: AbstractAgent | undefined;
|
|
164
|
+
|
|
165
|
+
const activityRenderer: ReactActivityMessageRenderer<{ label: string }> = {
|
|
166
|
+
activityType: "button-action",
|
|
167
|
+
content: z.object({ label: z.string() }),
|
|
168
|
+
render: ({ content, agent: renderedAgent }) => {
|
|
169
|
+
capturedAgent = renderedAgent;
|
|
170
|
+
return <button data-testid="action-button">{content.label}</button>;
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
renderWithCopilotKit({
|
|
175
|
+
agents: { [agentId]: agent },
|
|
176
|
+
agentId,
|
|
177
|
+
threadId,
|
|
178
|
+
renderActivityMessages: [activityRenderer],
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const input = await screen.findByRole("textbox");
|
|
182
|
+
fireEvent.change(input, { target: { value: "show me buttons" } });
|
|
183
|
+
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
|
184
|
+
|
|
185
|
+
await waitFor(() => {
|
|
186
|
+
expect(screen.getByText("show me buttons")).toBeDefined();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
agent.emit(runStartedEvent());
|
|
190
|
+
agent.emit(
|
|
191
|
+
activitySnapshotEvent({
|
|
192
|
+
messageId: testId("activity-action"),
|
|
193
|
+
activityType: "button-action",
|
|
194
|
+
content: { label: "Click Me" },
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
agent.emit(runFinishedEvent());
|
|
198
|
+
|
|
199
|
+
await waitFor(() => {
|
|
200
|
+
expect(screen.getByTestId("action-button")).toBeDefined();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// CopilotChat creates a per-thread clone via useAgent. The activity renderer
|
|
204
|
+
// must receive that clone so that handleAction → runAgent targets the same
|
|
205
|
+
// instance chat is rendering from.
|
|
206
|
+
const clone = getThreadClone(agent, threadId);
|
|
207
|
+
expect(clone).toBeDefined();
|
|
208
|
+
expect(capturedAgent).toBe(clone);
|
|
209
|
+
expect(capturedAgent).not.toBe(agent); // must NOT be the registry agent
|
|
210
|
+
});
|
|
150
211
|
});
|