@copilotkit/react-core 1.56.4 → 1.56.5-canary.1777664617
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-Bd0m5HFp.mjs → copilotkit-CPe2-340.mjs} +130 -80
- package/dist/copilotkit-CPe2-340.mjs.map +1 -0
- package/dist/copilotkit-DFaI4j2r.d.mts.map +1 -1
- package/dist/{copilotkit-tb4zqaMK.cjs → copilotkit-DGbvw8n2.cjs} +130 -80
- package/dist/copilotkit-DGbvw8n2.cjs.map +1 -0
- package/dist/copilotkit-Dg4r4Gi_.d.cts.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.umd.js +24 -0
- 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.mjs +1 -1
- package/dist/v2/index.umd.js +132 -82
- package/dist/v2/index.umd.js.map +1 -1
- package/package.json +8 -8
- package/src/v2/components/chat/CopilotChatAttachmentQueue.tsx +3 -113
- package/src/v2/components/chat/CopilotChatAttachmentRenderer.tsx +26 -6
- package/src/v2/components/chat/CopilotChatUserMessage.tsx +2 -2
- package/src/v2/components/chat/CopilotChatView.tsx +21 -5
- package/src/v2/components/chat/Lightbox.tsx +103 -0
- package/src/v2/components/chat/__tests__/CopilotChat.suggestionsAlways.test.tsx +183 -0
- package/src/v2/components/chat/__tests__/CopilotChatView.inputOverlay.test.tsx +92 -0
- package/src/v2/hooks/__tests__/use-threads.test.tsx +229 -27
- package/src/v2/hooks/use-configure-suggestions.tsx +50 -1
- package/src/v2/hooks/use-threads.tsx +7 -1
- package/dist/copilotkit-Bd0m5HFp.mjs.map +0 -1
- package/dist/copilotkit-tb4zqaMK.cjs.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@copilotkit/react-core",
|
|
3
|
-
"version": "1.56.
|
|
3
|
+
"version": "1.56.5-canary.1777664617",
|
|
4
4
|
"private": false,
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -52,8 +52,8 @@
|
|
|
52
52
|
"access": "public"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@ag-ui/client": "0.0.
|
|
56
|
-
"@ag-ui/core": "0.0.
|
|
55
|
+
"@ag-ui/client": "0.0.53",
|
|
56
|
+
"@ag-ui/core": "0.0.53",
|
|
57
57
|
"@jetbrains/websandbox": "^1.1.3",
|
|
58
58
|
"@lit-labs/react": "^2.0.2",
|
|
59
59
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
|
@@ -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.
|
|
77
|
-
"@copilotkit/core": "1.56.
|
|
78
|
-
"@copilotkit/runtime-client-gql": "1.56.
|
|
79
|
-
"@copilotkit/web-inspector": "1.56.
|
|
80
|
-
"@copilotkit/shared": "1.56.
|
|
76
|
+
"@copilotkit/a2ui-renderer": "1.56.5-canary.1777664617",
|
|
77
|
+
"@copilotkit/core": "1.56.5-canary.1777664617",
|
|
78
|
+
"@copilotkit/runtime-client-gql": "1.56.5-canary.1777664617",
|
|
79
|
+
"@copilotkit/web-inspector": "1.56.5-canary.1777664617",
|
|
80
|
+
"@copilotkit/shared": "1.56.5-canary.1777664617"
|
|
81
81
|
},
|
|
82
82
|
"devDependencies": {
|
|
83
83
|
"@tailwindcss/cli": "^4.1.11",
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import React, {
|
|
2
|
-
import { createPortal, flushSync } from "react-dom";
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
3
2
|
import type { Attachment } from "@copilotkit/shared";
|
|
4
3
|
import {
|
|
5
4
|
formatFileSize,
|
|
6
5
|
getSourceUrl,
|
|
7
6
|
getDocumentIcon,
|
|
8
7
|
} from "@copilotkit/shared";
|
|
9
|
-
import { Play
|
|
8
|
+
import { Play } from "lucide-react";
|
|
10
9
|
import { cn } from "../../lib/utils";
|
|
10
|
+
import { Lightbox, useLightbox } from "./Lightbox";
|
|
11
11
|
|
|
12
12
|
interface CopilotChatAttachmentQueueProps {
|
|
13
13
|
attachments: Attachment[];
|
|
@@ -88,116 +88,6 @@ function AttachmentPreview({ attachment }: { attachment: Attachment }) {
|
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
// ---------------------------------------------------------------------------
|
|
92
|
-
// Lightbox – fullscreen overlay for images and videos (portal to body)
|
|
93
|
-
// Uses the View Transition API when available for a smooth thumbnail-to-
|
|
94
|
-
// fullscreen morph; falls back to a simple opacity fade.
|
|
95
|
-
// ---------------------------------------------------------------------------
|
|
96
|
-
|
|
97
|
-
interface LightboxProps {
|
|
98
|
-
onClose: () => void;
|
|
99
|
-
children: React.ReactNode;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function Lightbox({ onClose, children }: LightboxProps) {
|
|
103
|
-
useEffect(() => {
|
|
104
|
-
const handleKey = (e: KeyboardEvent) => {
|
|
105
|
-
if (e.key === "Escape") onClose();
|
|
106
|
-
};
|
|
107
|
-
document.addEventListener("keydown", handleKey);
|
|
108
|
-
return () => document.removeEventListener("keydown", handleKey);
|
|
109
|
-
}, [onClose]);
|
|
110
|
-
|
|
111
|
-
if (typeof document === "undefined") return null;
|
|
112
|
-
|
|
113
|
-
return createPortal(
|
|
114
|
-
<div
|
|
115
|
-
className="cpk:fixed cpk:inset-0 cpk:z-[9999] cpk:flex cpk:items-center cpk:justify-center cpk:bg-black/80 cpk:animate-fade-in"
|
|
116
|
-
onClick={onClose}
|
|
117
|
-
>
|
|
118
|
-
<button
|
|
119
|
-
onClick={onClose}
|
|
120
|
-
className="cpk:absolute cpk:top-4 cpk:right-4 cpk:text-white cpk:bg-white/10 cpk:hover:bg-white/20 cpk:rounded-full cpk:w-10 cpk:h-10 cpk:flex cpk:items-center cpk:justify-center cpk:cursor-pointer cpk:border-none cpk:z-10"
|
|
121
|
-
aria-label="Close preview"
|
|
122
|
-
>
|
|
123
|
-
<X className="cpk:w-5 cpk:h-5" />
|
|
124
|
-
</button>
|
|
125
|
-
|
|
126
|
-
<div onClick={(e) => e.stopPropagation()}>{children}</div>
|
|
127
|
-
</div>,
|
|
128
|
-
document.body,
|
|
129
|
-
);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
type DocWithVT = Document & {
|
|
133
|
-
startViewTransition?: (cb: () => void) => { finished: Promise<void> };
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Hook that manages lightbox open/close and uses the View Transition API to
|
|
138
|
-
* morph the thumbnail into fullscreen content.
|
|
139
|
-
*
|
|
140
|
-
* The trick: `view-transition-name` must live on exactly ONE element at a time.
|
|
141
|
-
* - Old state (thumbnail visible): name is on the thumbnail.
|
|
142
|
-
* - New state (lightbox visible): name moves to the lightbox content.
|
|
143
|
-
* `flushSync` ensures React commits the DOM change synchronously inside the
|
|
144
|
-
* `startViewTransition` callback so the API can snapshot old → new correctly.
|
|
145
|
-
*/
|
|
146
|
-
function useLightbox() {
|
|
147
|
-
const thumbnailRef = useRef<HTMLElement>(null);
|
|
148
|
-
const [open, setOpen] = useState(false);
|
|
149
|
-
const vtName = useId();
|
|
150
|
-
|
|
151
|
-
const openLightbox = useCallback(() => {
|
|
152
|
-
const thumb = thumbnailRef.current;
|
|
153
|
-
const doc = document as DocWithVT;
|
|
154
|
-
|
|
155
|
-
if (doc.startViewTransition && thumb) {
|
|
156
|
-
// Old snapshot: name on the thumbnail
|
|
157
|
-
thumb.style.viewTransitionName = vtName;
|
|
158
|
-
|
|
159
|
-
doc.startViewTransition(() => {
|
|
160
|
-
// New snapshot: remove from thumb (lightbox content will have it)
|
|
161
|
-
thumb.style.viewTransitionName = "";
|
|
162
|
-
flushSync(() => setOpen(true));
|
|
163
|
-
});
|
|
164
|
-
} else {
|
|
165
|
-
setOpen(true);
|
|
166
|
-
}
|
|
167
|
-
}, []);
|
|
168
|
-
|
|
169
|
-
const closeLightbox = useCallback(() => {
|
|
170
|
-
const thumb = thumbnailRef.current;
|
|
171
|
-
const doc = document as DocWithVT;
|
|
172
|
-
|
|
173
|
-
if (doc.startViewTransition && thumb) {
|
|
174
|
-
const transition = doc.startViewTransition(() => {
|
|
175
|
-
// New snapshot: name back on thumbnail
|
|
176
|
-
flushSync(() => setOpen(false));
|
|
177
|
-
thumb.style.viewTransitionName = vtName;
|
|
178
|
-
});
|
|
179
|
-
// Clean up the name after animation finishes (or fails)
|
|
180
|
-
transition.finished
|
|
181
|
-
.then(() => {
|
|
182
|
-
thumb.style.viewTransitionName = "";
|
|
183
|
-
})
|
|
184
|
-
.catch(() => {
|
|
185
|
-
thumb.style.viewTransitionName = "";
|
|
186
|
-
});
|
|
187
|
-
} else {
|
|
188
|
-
setOpen(false);
|
|
189
|
-
}
|
|
190
|
-
}, []);
|
|
191
|
-
|
|
192
|
-
return {
|
|
193
|
-
thumbnailRef,
|
|
194
|
-
vtName,
|
|
195
|
-
open,
|
|
196
|
-
openLightbox,
|
|
197
|
-
closeLightbox,
|
|
198
|
-
};
|
|
199
|
-
}
|
|
200
|
-
|
|
201
91
|
// ---------------------------------------------------------------------------
|
|
202
92
|
// Image
|
|
203
93
|
// ---------------------------------------------------------------------------
|
|
@@ -2,6 +2,7 @@ import React, { memo, useState } from "react";
|
|
|
2
2
|
import type { InputContentSource } from "@copilotkit/shared";
|
|
3
3
|
import { getSourceUrl, getDocumentIcon } from "@copilotkit/shared";
|
|
4
4
|
import { cn } from "../../lib/utils";
|
|
5
|
+
import { Lightbox, useLightbox } from "./Lightbox";
|
|
5
6
|
|
|
6
7
|
interface CopilotChatAttachmentRendererProps {
|
|
7
8
|
type: "image" | "audio" | "video" | "document";
|
|
@@ -18,6 +19,8 @@ const ImageAttachment = memo(function ImageAttachment({
|
|
|
18
19
|
className?: string;
|
|
19
20
|
}) {
|
|
20
21
|
const [error, setError] = useState(false);
|
|
22
|
+
const { thumbnailRef, vtName, open, openLightbox, closeLightbox } =
|
|
23
|
+
useLightbox();
|
|
21
24
|
|
|
22
25
|
if (error) {
|
|
23
26
|
return (
|
|
@@ -33,12 +36,29 @@ const ImageAttachment = memo(function ImageAttachment({
|
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
return (
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
<>
|
|
40
|
+
<img
|
|
41
|
+
ref={thumbnailRef as React.Ref<HTMLImageElement>}
|
|
42
|
+
src={src}
|
|
43
|
+
alt="Image attachment"
|
|
44
|
+
className={cn(
|
|
45
|
+
"cpk:max-w-[80px] cpk:max-h-[80px] cpk:w-auto cpk:h-auto cpk:rounded-xl cpk:object-cover cpk:cursor-pointer cpk:bg-muted",
|
|
46
|
+
className,
|
|
47
|
+
)}
|
|
48
|
+
onClick={openLightbox}
|
|
49
|
+
onError={() => setError(true)}
|
|
50
|
+
/>
|
|
51
|
+
{open && (
|
|
52
|
+
<Lightbox onClose={closeLightbox}>
|
|
53
|
+
<img
|
|
54
|
+
style={{ viewTransitionName: vtName }}
|
|
55
|
+
src={src}
|
|
56
|
+
alt="Image attachment"
|
|
57
|
+
className="cpk:max-w-[90vw] cpk:max-h-[90vh] cpk:object-contain cpk:rounded-lg"
|
|
58
|
+
/>
|
|
59
|
+
</Lightbox>
|
|
60
|
+
)}
|
|
61
|
+
</>
|
|
42
62
|
);
|
|
43
63
|
});
|
|
44
64
|
|
|
@@ -217,9 +217,8 @@ export function CopilotChatUserMessage({
|
|
|
217
217
|
data-message-id={message.id}
|
|
218
218
|
{...props}
|
|
219
219
|
>
|
|
220
|
-
{BoundMessageRenderer}
|
|
221
220
|
{mediaParts.length > 0 && (
|
|
222
|
-
<div className="cpk:flex cpk:flex-
|
|
221
|
+
<div className="cpk:flex cpk:flex-row cpk:flex-wrap cpk:justify-end cpk:gap-2 cpk:mb-2">
|
|
223
222
|
{mediaParts.map((part, index) => (
|
|
224
223
|
<CopilotChatAttachmentRenderer
|
|
225
224
|
key={index}
|
|
@@ -230,6 +229,7 @@ export function CopilotChatUserMessage({
|
|
|
230
229
|
))}
|
|
231
230
|
</div>
|
|
232
231
|
)}
|
|
232
|
+
{BoundMessageRenderer}
|
|
233
233
|
{BoundToolbar}
|
|
234
234
|
</div>
|
|
235
235
|
);
|
|
@@ -167,7 +167,17 @@ export function CopilotChatView({
|
|
|
167
167
|
className,
|
|
168
168
|
...props
|
|
169
169
|
}: CopilotChatViewProps) {
|
|
170
|
-
|
|
170
|
+
// Element-as-state via callback ref. The overlay wrapper only renders on the
|
|
171
|
+
// chat-view branch (the welcome-screen branch omits it), so a plain
|
|
172
|
+
// useRef + `[]` useEffect would observe `null` on mount whenever the chat
|
|
173
|
+
// starts on the welcome screen and never re-attach after the user sends
|
|
174
|
+
// their first message — leaving inputContainerHeight at 0 and the scroll
|
|
175
|
+
// content's reserved bottom padding at 32px instead of ~input height. The
|
|
176
|
+
// result is the last messages scrolling underneath the absolute-positioned
|
|
177
|
+
// input pill. Subscribing to element state lets the observer attach (and
|
|
178
|
+
// detach) reactively as the overlay mounts/unmounts.
|
|
179
|
+
const [inputContainerEl, setInputContainerEl] =
|
|
180
|
+
useState<HTMLDivElement | null>(null);
|
|
171
181
|
const [inputContainerHeight, setInputContainerHeight] = useState(0);
|
|
172
182
|
const [isResizing, setIsResizing] = useState(false);
|
|
173
183
|
const resizeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
@@ -178,8 +188,14 @@ export function CopilotChatView({
|
|
|
178
188
|
|
|
179
189
|
// Track input container height changes
|
|
180
190
|
useEffect(() => {
|
|
181
|
-
const element =
|
|
182
|
-
if (!element)
|
|
191
|
+
const element = inputContainerEl;
|
|
192
|
+
if (!element) {
|
|
193
|
+
// Reset measured height so the scroll content's paddingBottom doesn't
|
|
194
|
+
// hold a stale value if the overlay unmounts (e.g. messages cleared
|
|
195
|
+
// and the welcome screen returns).
|
|
196
|
+
setInputContainerHeight(0);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
183
199
|
|
|
184
200
|
const resizeObserver = new ResizeObserver((entries) => {
|
|
185
201
|
for (const entry of entries) {
|
|
@@ -218,7 +234,7 @@ export function CopilotChatView({
|
|
|
218
234
|
clearTimeout(resizeTimeoutRef.current);
|
|
219
235
|
}
|
|
220
236
|
};
|
|
221
|
-
}, []);
|
|
237
|
+
}, [inputContainerEl]);
|
|
222
238
|
|
|
223
239
|
const BoundMessageView = renderSlot(messageView, CopilotChatMessageView, {
|
|
224
240
|
messages,
|
|
@@ -398,7 +414,7 @@ export function CopilotChatView({
|
|
|
398
414
|
{BoundScrollView}
|
|
399
415
|
|
|
400
416
|
<div
|
|
401
|
-
ref={
|
|
417
|
+
ref={setInputContainerEl}
|
|
402
418
|
data-testid="copilot-input-overlay"
|
|
403
419
|
className="cpk:absolute cpk:bottom-0 cpk:left-0 cpk:right-0 cpk:z-20 cpk:pointer-events-none"
|
|
404
420
|
>
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useId, useRef, useState } from "react";
|
|
2
|
+
import { createPortal, flushSync } from "react-dom";
|
|
3
|
+
import { X } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
interface LightboxProps {
|
|
6
|
+
onClose: () => void;
|
|
7
|
+
children: React.ReactNode;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Lightbox({ onClose, children }: LightboxProps) {
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const handleKey = (e: KeyboardEvent) => {
|
|
13
|
+
if (e.key === "Escape") onClose();
|
|
14
|
+
};
|
|
15
|
+
document.addEventListener("keydown", handleKey);
|
|
16
|
+
return () => document.removeEventListener("keydown", handleKey);
|
|
17
|
+
}, [onClose]);
|
|
18
|
+
|
|
19
|
+
if (typeof document === "undefined") return null;
|
|
20
|
+
|
|
21
|
+
return createPortal(
|
|
22
|
+
<div
|
|
23
|
+
className="cpk:fixed cpk:inset-0 cpk:z-[9999] cpk:flex cpk:items-center cpk:justify-center cpk:bg-black/80 cpk:animate-fade-in"
|
|
24
|
+
onClick={onClose}
|
|
25
|
+
>
|
|
26
|
+
<button
|
|
27
|
+
onClick={onClose}
|
|
28
|
+
className="cpk:absolute cpk:top-4 cpk:right-4 cpk:text-white cpk:bg-white/10 cpk:hover:bg-white/20 cpk:rounded-full cpk:w-10 cpk:h-10 cpk:flex cpk:items-center cpk:justify-center cpk:cursor-pointer cpk:border-none cpk:z-10"
|
|
29
|
+
aria-label="Close preview"
|
|
30
|
+
>
|
|
31
|
+
<X className="cpk:w-5 cpk:h-5" />
|
|
32
|
+
</button>
|
|
33
|
+
|
|
34
|
+
<div onClick={(e) => e.stopPropagation()}>{children}</div>
|
|
35
|
+
</div>,
|
|
36
|
+
document.body,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type DocWithVT = Document & {
|
|
41
|
+
startViewTransition?: (cb: () => void) => { finished: Promise<void> };
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Hook that manages lightbox open/close and uses the View Transition API to
|
|
46
|
+
* morph the thumbnail into fullscreen content.
|
|
47
|
+
*
|
|
48
|
+
* The trick: `view-transition-name` must live on exactly ONE element at a time.
|
|
49
|
+
* - Old state (thumbnail visible): name is on the thumbnail.
|
|
50
|
+
* - New state (lightbox visible): name moves to the lightbox content.
|
|
51
|
+
* `flushSync` ensures React commits the DOM change synchronously inside the
|
|
52
|
+
* `startViewTransition` callback so the API can snapshot old → new correctly.
|
|
53
|
+
*/
|
|
54
|
+
export function useLightbox() {
|
|
55
|
+
const thumbnailRef = useRef<HTMLElement>(null);
|
|
56
|
+
const [open, setOpen] = useState(false);
|
|
57
|
+
const vtName = useId();
|
|
58
|
+
|
|
59
|
+
const openLightbox = useCallback(() => {
|
|
60
|
+
const thumb = thumbnailRef.current;
|
|
61
|
+
const doc = document as DocWithVT;
|
|
62
|
+
|
|
63
|
+
if (doc.startViewTransition && thumb) {
|
|
64
|
+
thumb.style.viewTransitionName = vtName;
|
|
65
|
+
|
|
66
|
+
doc.startViewTransition(() => {
|
|
67
|
+
thumb.style.viewTransitionName = "";
|
|
68
|
+
flushSync(() => setOpen(true));
|
|
69
|
+
});
|
|
70
|
+
} else {
|
|
71
|
+
setOpen(true);
|
|
72
|
+
}
|
|
73
|
+
}, [vtName]);
|
|
74
|
+
|
|
75
|
+
const closeLightbox = useCallback(() => {
|
|
76
|
+
const thumb = thumbnailRef.current;
|
|
77
|
+
const doc = document as DocWithVT;
|
|
78
|
+
|
|
79
|
+
if (doc.startViewTransition && thumb) {
|
|
80
|
+
const transition = doc.startViewTransition(() => {
|
|
81
|
+
flushSync(() => setOpen(false));
|
|
82
|
+
thumb.style.viewTransitionName = vtName;
|
|
83
|
+
});
|
|
84
|
+
transition.finished
|
|
85
|
+
.then(() => {
|
|
86
|
+
thumb.style.viewTransitionName = "";
|
|
87
|
+
})
|
|
88
|
+
.catch(() => {
|
|
89
|
+
thumb.style.viewTransitionName = "";
|
|
90
|
+
});
|
|
91
|
+
} else {
|
|
92
|
+
setOpen(false);
|
|
93
|
+
}
|
|
94
|
+
}, [vtName]);
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
thumbnailRef,
|
|
98
|
+
vtName,
|
|
99
|
+
open,
|
|
100
|
+
openLightbox,
|
|
101
|
+
closeLightbox,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
3
|
+
import { beforeEach, vi } from "vitest";
|
|
4
|
+
import { useConfigureSuggestions } from "../../../hooks/use-configure-suggestions";
|
|
5
|
+
import { CopilotChat } from "../CopilotChat";
|
|
6
|
+
import { CopilotKitProvider } from "../../../providers/CopilotKitProvider";
|
|
7
|
+
import {
|
|
8
|
+
MockStepwiseAgent,
|
|
9
|
+
runStartedEvent,
|
|
10
|
+
runFinishedEvent,
|
|
11
|
+
textChunkEvent,
|
|
12
|
+
testId,
|
|
13
|
+
} from "../../../__tests__/utils/test-helpers";
|
|
14
|
+
import type { AutoScrollMode } from "../normalize-auto-scroll";
|
|
15
|
+
|
|
16
|
+
// jsdom doesn't implement scrollTo; pin-to-send mode calls it from a rAF
|
|
17
|
+
// callback, so without this stub the cleanup throws an unhandled error.
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
HTMLElement.prototype.scrollTo = vi.fn();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const STATIC_SUGGESTIONS = [
|
|
23
|
+
{ title: "Say hello", message: "Hello there!" },
|
|
24
|
+
{ title: "Get help", message: "Can you help me?" },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const ChatWithStaticAlwaysSuggestions: React.FC<{
|
|
28
|
+
autoScroll?: AutoScrollMode | boolean;
|
|
29
|
+
consumerAgentId?: string;
|
|
30
|
+
}> = ({ autoScroll, consumerAgentId }) => {
|
|
31
|
+
useConfigureSuggestions({
|
|
32
|
+
suggestions: STATIC_SUGGESTIONS,
|
|
33
|
+
available: "always",
|
|
34
|
+
...(consumerAgentId ? { consumerAgentId } : {}),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return <CopilotChat autoScroll={autoScroll} />;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function renderChat({
|
|
41
|
+
agent,
|
|
42
|
+
autoScroll,
|
|
43
|
+
consumerAgentId,
|
|
44
|
+
}: {
|
|
45
|
+
agent: MockStepwiseAgent;
|
|
46
|
+
autoScroll?: AutoScrollMode | boolean;
|
|
47
|
+
consumerAgentId?: string;
|
|
48
|
+
}) {
|
|
49
|
+
return render(
|
|
50
|
+
<CopilotKitProvider agents__unsafe_dev_only={{ default: agent }}>
|
|
51
|
+
<div style={{ height: 400 }}>
|
|
52
|
+
<ChatWithStaticAlwaysSuggestions
|
|
53
|
+
autoScroll={autoScroll}
|
|
54
|
+
consumerAgentId={consumerAgentId}
|
|
55
|
+
/>
|
|
56
|
+
</div>
|
|
57
|
+
</CopilotKitProvider>,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe("CopilotChat - static suggestions with available:'always'", () => {
|
|
62
|
+
it("should show suggestions on the welcome screen", async () => {
|
|
63
|
+
const agent = new MockStepwiseAgent();
|
|
64
|
+
renderChat({ agent, consumerAgentId: "default" });
|
|
65
|
+
|
|
66
|
+
await waitFor(() => {
|
|
67
|
+
expect(screen.getByTestId("copilot-welcome-screen")).toBeDefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await waitFor(() => {
|
|
71
|
+
expect(screen.getByText("Say hello")).toBeDefined();
|
|
72
|
+
expect(screen.getByText("Get help")).toBeDefined();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("should show suggestions on the welcome screen with global config (no consumerAgentId)", async () => {
|
|
77
|
+
const agent = new MockStepwiseAgent();
|
|
78
|
+
renderChat({ agent });
|
|
79
|
+
|
|
80
|
+
await waitFor(() => {
|
|
81
|
+
expect(screen.getByTestId("copilot-welcome-screen")).toBeDefined();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
await waitFor(() => {
|
|
85
|
+
expect(screen.getByText("Say hello")).toBeDefined();
|
|
86
|
+
expect(screen.getByText("Get help")).toBeDefined();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("should hide suggestions during a run and restore them after", async () => {
|
|
91
|
+
const agent = new MockStepwiseAgent();
|
|
92
|
+
renderChat({ agent, consumerAgentId: "default" });
|
|
93
|
+
|
|
94
|
+
await waitFor(() => {
|
|
95
|
+
expect(screen.getByTestId("copilot-welcome-screen")).toBeDefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
await waitFor(() => {
|
|
99
|
+
expect(screen.getByText("Say hello")).toBeDefined();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const input = await screen.findByRole("textbox");
|
|
103
|
+
fireEvent.change(input, { target: { value: "Hi!" } });
|
|
104
|
+
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
|
105
|
+
|
|
106
|
+
await waitFor(() => {
|
|
107
|
+
expect(screen.getByText("Hi!")).toBeDefined();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const messageId = testId("msg");
|
|
111
|
+
agent.emit(runStartedEvent());
|
|
112
|
+
agent.emit(textChunkEvent(messageId, "Hello! How can I help?"));
|
|
113
|
+
|
|
114
|
+
// While the run is in flight, suggestions should be hidden — every run
|
|
115
|
+
// changes the conversation context, so we wait for the end-of-run reload
|
|
116
|
+
// before showing them again.
|
|
117
|
+
await waitFor(() => {
|
|
118
|
+
expect(screen.queryByText("Say hello")).toBeNull();
|
|
119
|
+
expect(screen.queryByText("Get help")).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
agent.emit(runFinishedEvent());
|
|
123
|
+
agent.complete();
|
|
124
|
+
|
|
125
|
+
await waitFor(() => {
|
|
126
|
+
expect(screen.getByText("Hello! How can I help?")).toBeDefined();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// After the run, the static "always" config repopulates them.
|
|
130
|
+
await waitFor(
|
|
131
|
+
() => {
|
|
132
|
+
expect(screen.getByText("Say hello")).toBeDefined();
|
|
133
|
+
expect(screen.getByText("Get help")).toBeDefined();
|
|
134
|
+
},
|
|
135
|
+
{ timeout: 3000 },
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("should hide suggestions during a run in pin-to-send mode", async () => {
|
|
140
|
+
const agent = new MockStepwiseAgent();
|
|
141
|
+
renderChat({ agent, autoScroll: "pin-to-send" });
|
|
142
|
+
|
|
143
|
+
await waitFor(() => {
|
|
144
|
+
expect(screen.getByTestId("copilot-welcome-screen")).toBeDefined();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await waitFor(() => {
|
|
148
|
+
expect(screen.getByText("Say hello")).toBeDefined();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const input = await screen.findByRole("textbox");
|
|
152
|
+
fireEvent.change(input, { target: { value: "Hi!" } });
|
|
153
|
+
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
|
154
|
+
|
|
155
|
+
await waitFor(() => {
|
|
156
|
+
expect(screen.getByText("Hi!")).toBeDefined();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const messageId = testId("msg");
|
|
160
|
+
agent.emit(runStartedEvent());
|
|
161
|
+
agent.emit(textChunkEvent(messageId, "Hello! How can I help?"));
|
|
162
|
+
|
|
163
|
+
await waitFor(() => {
|
|
164
|
+
expect(screen.queryByText("Say hello")).toBeNull();
|
|
165
|
+
expect(screen.queryByText("Get help")).toBeNull();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
agent.emit(runFinishedEvent());
|
|
169
|
+
agent.complete();
|
|
170
|
+
|
|
171
|
+
await waitFor(() => {
|
|
172
|
+
expect(screen.getByText("Hello! How can I help?")).toBeDefined();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
await waitFor(
|
|
176
|
+
() => {
|
|
177
|
+
expect(screen.getByText("Say hello")).toBeDefined();
|
|
178
|
+
expect(screen.getByText("Get help")).toBeDefined();
|
|
179
|
+
},
|
|
180
|
+
{ timeout: 3000 },
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -6,6 +6,7 @@ import { CopilotChatConfigurationProvider } from "../../../providers/CopilotChat
|
|
|
6
6
|
import { CopilotChatView } from "../CopilotChatView";
|
|
7
7
|
import { LastUserMessageContext } from "../last-user-message-context";
|
|
8
8
|
import type { Attachment } from "@copilotkit/shared";
|
|
9
|
+
import type { Message } from "@ag-ui/core";
|
|
9
10
|
|
|
10
11
|
beforeEach(() => {
|
|
11
12
|
HTMLElement.prototype.scrollTo = vi.fn();
|
|
@@ -169,4 +170,95 @@ describe("CopilotChatView input overlay layout", () => {
|
|
|
169
170
|
(global as any).ResizeObserver = OriginalRO;
|
|
170
171
|
}
|
|
171
172
|
});
|
|
173
|
+
|
|
174
|
+
it("attaches the resize observer when transitioning from welcome to chat view", async () => {
|
|
175
|
+
// Regression: a `[]`-deps useEffect captured `inputContainerRef.current`
|
|
176
|
+
// as null when mounted on the welcome screen and never re-ran after the
|
|
177
|
+
// user sent their first message. The overlay rendered without a measured
|
|
178
|
+
// height, so paddingBottom stayed at 32 and the last messages slid
|
|
179
|
+
// underneath the absolute-positioned input pill. Verify the observer
|
|
180
|
+
// attaches reactively when the overlay mounts post-transition.
|
|
181
|
+
const callbacks: Array<{
|
|
182
|
+
cb: ResizeObserverCallback;
|
|
183
|
+
target: Element | null;
|
|
184
|
+
}> = [];
|
|
185
|
+
const OriginalRO = global.ResizeObserver;
|
|
186
|
+
class MockResizeObserver {
|
|
187
|
+
private cb: ResizeObserverCallback;
|
|
188
|
+
constructor(cb: ResizeObserverCallback) {
|
|
189
|
+
this.cb = cb;
|
|
190
|
+
}
|
|
191
|
+
observe(target: Element) {
|
|
192
|
+
callbacks.push({ cb: this.cb, target });
|
|
193
|
+
}
|
|
194
|
+
unobserve() {}
|
|
195
|
+
disconnect() {}
|
|
196
|
+
}
|
|
197
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
198
|
+
(global as any).ResizeObserver = MockResizeObserver as any;
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
// Render with no messages to start on the welcome screen branch — the
|
|
202
|
+
// overlay wrapper does not exist in this DOM, so the observer cannot
|
|
203
|
+
// attach yet.
|
|
204
|
+
const initialMessages: Message[] = [];
|
|
205
|
+
const screen = render(
|
|
206
|
+
<TestWrapper>
|
|
207
|
+
<LastUserMessageContext.Provider value={{ id: null, sendNonce: 0 }}>
|
|
208
|
+
<CopilotChatView messages={initialMessages} />
|
|
209
|
+
</LastUserMessageContext.Provider>
|
|
210
|
+
</TestWrapper>,
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
await screen.findByTestId("copilot-welcome-screen");
|
|
214
|
+
expect(screen.queryByTestId("copilot-input-overlay")).toBeNull();
|
|
215
|
+
|
|
216
|
+
// Transition to the chat view by re-rendering with messages — mirrors
|
|
217
|
+
// what happens when CopilotChat re-renders after the user submits.
|
|
218
|
+
screen.rerender(
|
|
219
|
+
<TestWrapper>
|
|
220
|
+
<LastUserMessageContext.Provider value={{ id: null, sendNonce: 0 }}>
|
|
221
|
+
<CopilotChatView messages={sampleMessages} />
|
|
222
|
+
</LastUserMessageContext.Provider>
|
|
223
|
+
</TestWrapper>,
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
await waitForMount(screen);
|
|
227
|
+
const overlay = screen.getByTestId("copilot-input-overlay");
|
|
228
|
+
|
|
229
|
+
// The bug: observer was attached at mount when the overlay element was
|
|
230
|
+
// null, so it never re-attached after the transition. Verify it now
|
|
231
|
+
// observes the overlay specifically.
|
|
232
|
+
await waitFor(() =>
|
|
233
|
+
expect(callbacks.some(({ target }) => target === overlay)).toBe(true),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const scrollContent = screen.getByTestId("copilot-scroll-content");
|
|
237
|
+
// Simulate the overlay reporting a real height (e.g. 88px input pill).
|
|
238
|
+
// Only fire on the overlay's own observer — other components (e.g. the
|
|
239
|
+
// textarea autosize) also use ResizeObserver and would corrupt the
|
|
240
|
+
// assertion if we fed all observers a 88px contentRect.
|
|
241
|
+
for (const { cb, target } of callbacks) {
|
|
242
|
+
if (target !== overlay) continue;
|
|
243
|
+
cb(
|
|
244
|
+
[
|
|
245
|
+
{
|
|
246
|
+
contentRect: { height: 88 } as DOMRectReadOnly,
|
|
247
|
+
} as ResizeObserverEntry,
|
|
248
|
+
],
|
|
249
|
+
{} as ResizeObserver,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 88 (input) + 32 (no suggestions baseline) = 120px. Without the fix,
|
|
254
|
+
// paddingBottom would be stuck at 32px because the observer never
|
|
255
|
+
// attached.
|
|
256
|
+
await waitFor(() =>
|
|
257
|
+
expect(scrollContent.style.paddingBottom).toBe("120px"),
|
|
258
|
+
);
|
|
259
|
+
} finally {
|
|
260
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
261
|
+
(global as any).ResizeObserver = OriginalRO;
|
|
262
|
+
}
|
|
263
|
+
});
|
|
172
264
|
});
|