@copilotkit/react-core 1.56.2 → 1.56.4-canary.1777529757
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-CSJw5BG8.cjs → copilotkit-BAkj3zUc.cjs} +359 -157
- package/dist/copilotkit-BAkj3zUc.cjs.map +1 -0
- package/dist/{copilotkit-Cj2ZIxVr.mjs → copilotkit-DAatqMh2.mjs} +360 -158
- package/dist/copilotkit-DAatqMh2.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/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 +361 -163
- package/dist/v2/index.umd.js.map +1 -1
- package/package.json +8 -8
- 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 +7 -114
- package/src/v2/components/chat/CopilotChatAttachmentRenderer.tsx +26 -6
- package/src/v2/components/chat/CopilotChatInput.tsx +22 -0
- package/src/v2/components/chat/CopilotChatUserMessage.tsx +2 -2
- package/src/v2/components/chat/CopilotChatView.tsx +226 -48
- package/src/v2/components/chat/Lightbox.tsx +103 -0
- package/src/v2/components/chat/__tests__/CopilotChat.absentThreadConnect.test.tsx +66 -0
- package/src/v2/components/chat/__tests__/CopilotChat.suggestionsAlways.test.tsx +189 -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 +264 -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
|
@@ -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
|
|
|
@@ -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
|
>
|
|
@@ -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
|
);
|
|
@@ -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,13 +159,25 @@ 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,
|
|
145
167
|
className,
|
|
146
168
|
...props
|
|
147
169
|
}: CopilotChatViewProps) {
|
|
148
|
-
|
|
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);
|
|
149
181
|
const [inputContainerHeight, setInputContainerHeight] = useState(0);
|
|
150
182
|
const [isResizing, setIsResizing] = useState(false);
|
|
151
183
|
const resizeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
@@ -156,8 +188,14 @@ export function CopilotChatView({
|
|
|
156
188
|
|
|
157
189
|
// Track input container height changes
|
|
158
190
|
useEffect(() => {
|
|
159
|
-
const element =
|
|
160
|
-
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
|
+
}
|
|
161
199
|
|
|
162
200
|
const resizeObserver = new ResizeObserver((entries) => {
|
|
163
201
|
for (const entry of entries) {
|
|
@@ -196,7 +234,7 @@ export function CopilotChatView({
|
|
|
196
234
|
clearTimeout(resizeTimeoutRef.current);
|
|
197
235
|
}
|
|
198
236
|
};
|
|
199
|
-
}, []);
|
|
237
|
+
}, [inputContainerEl]);
|
|
200
238
|
|
|
201
239
|
const BoundMessageView = renderSlot(messageView, CopilotChatMessageView, {
|
|
202
240
|
messages,
|
|
@@ -217,12 +255,23 @@ export function CopilotChatView({
|
|
|
217
255
|
onAddFile,
|
|
218
256
|
positioning: "static",
|
|
219
257
|
keyboardHeight: isKeyboardOpen ? keyboardHeight : 0,
|
|
220
|
-
containerRef: inputContainerRef,
|
|
221
258
|
showDisclaimer: true,
|
|
259
|
+
// The parent overlay wrapper handles absolute bottom-0 positioning.
|
|
260
|
+
// `bottomAnchored` still triggers the license-banner offset padding
|
|
261
|
+
// inside CopilotChatInput. The welcome-screen input (below) intentionally
|
|
262
|
+
// omits this flag.
|
|
263
|
+
bottomAnchored: true,
|
|
222
264
|
...(disclaimer !== undefined ? { disclaimer } : {}),
|
|
223
265
|
} as CopilotChatInputProps);
|
|
224
266
|
|
|
225
|
-
|
|
267
|
+
// Hide suggestions while a thread is connecting (mid-replay would render
|
|
268
|
+
// against a still-assembling message tree and visibly jump as each final
|
|
269
|
+
// text chunk reflows the layout). Run-in-flight is handled by the
|
|
270
|
+
// SuggestionEngine: at run start, non-"always" suggestions are cleared and
|
|
271
|
+
// only "always" ones are restored — so a non-empty `suggestions` array
|
|
272
|
+
// here already means "this is something the user opted to keep visible."
|
|
273
|
+
const hasSuggestions =
|
|
274
|
+
!isConnecting && Array.isArray(suggestions) && suggestions.length > 0;
|
|
226
275
|
const BoundSuggestionView = hasSuggestions
|
|
227
276
|
? renderSlot(suggestionView, CopilotChatSuggestionView, {
|
|
228
277
|
suggestions,
|
|
@@ -238,8 +287,9 @@ export function CopilotChatView({
|
|
|
238
287
|
isResizing,
|
|
239
288
|
children: (
|
|
240
289
|
<div
|
|
290
|
+
data-testid="copilot-scroll-content"
|
|
241
291
|
style={{
|
|
242
|
-
paddingBottom: `${hasSuggestions ? 4 : 32}px`,
|
|
292
|
+
paddingBottom: `${inputContainerHeight + (hasSuggestions ? 4 : 32)}px`,
|
|
243
293
|
}}
|
|
244
294
|
>
|
|
245
295
|
<div className="cpk:max-w-3xl cpk:mx-auto">
|
|
@@ -258,7 +308,13 @@ export function CopilotChatView({
|
|
|
258
308
|
const isEmpty = messages.length === 0;
|
|
259
309
|
// Type assertion needed because TypeScript doesn't fully propagate `| boolean` through WithSlots
|
|
260
310
|
const welcomeScreenDisabled = (welcomeScreen as unknown) === false;
|
|
261
|
-
|
|
311
|
+
// Suppress the welcome screen (1) while the initial connect is in flight
|
|
312
|
+
// and (2) whenever the caller has picked a specific thread. The caller-
|
|
313
|
+
// managed case targets a conversation directly, so the generic welcome
|
|
314
|
+
// greeting is never the right thing to show — even for a thread that
|
|
315
|
+
// happens to have no messages yet.
|
|
316
|
+
const shouldShowWelcomeScreen =
|
|
317
|
+
isEmpty && !welcomeScreenDisabled && !isConnecting && !hasExplicitThreadId;
|
|
262
318
|
|
|
263
319
|
if (shouldShowWelcomeScreen) {
|
|
264
320
|
// Create a separate input for welcome screen with static positioning and disclaimer visible
|
|
@@ -356,17 +412,22 @@ export function CopilotChatView({
|
|
|
356
412
|
{dragOver && <DropOverlay />}
|
|
357
413
|
{BoundScrollView}
|
|
358
414
|
|
|
359
|
-
<div
|
|
415
|
+
<div
|
|
416
|
+
ref={setInputContainerEl}
|
|
417
|
+
data-testid="copilot-input-overlay"
|
|
418
|
+
className="cpk:absolute cpk:bottom-0 cpk:left-0 cpk:right-0 cpk:z-20 cpk:pointer-events-none"
|
|
419
|
+
>
|
|
360
420
|
{attachments && attachments.length > 0 && (
|
|
361
|
-
<
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
421
|
+
<div className="cpk:max-w-3xl cpk:mx-auto cpk:w-full cpk:pointer-events-auto">
|
|
422
|
+
<CopilotChatAttachmentQueue
|
|
423
|
+
attachments={attachments}
|
|
424
|
+
onRemoveAttachment={(id) => onRemoveAttachment?.(id)}
|
|
425
|
+
className="cpk:px-4"
|
|
426
|
+
/>
|
|
427
|
+
</div>
|
|
366
428
|
)}
|
|
429
|
+
{BoundInput}
|
|
367
430
|
</div>
|
|
368
|
-
|
|
369
|
-
{BoundInput}
|
|
370
431
|
</div>
|
|
371
432
|
);
|
|
372
433
|
}
|
|
@@ -417,7 +478,6 @@ export namespace CopilotChatView {
|
|
|
417
478
|
</div>
|
|
418
479
|
</StickToBottom.Content>
|
|
419
480
|
|
|
420
|
-
{/* Feather gradient overlay */}
|
|
421
481
|
{BoundFeather}
|
|
422
482
|
|
|
423
483
|
{/* Scroll to bottom button - hidden during resize */}
|
|
@@ -425,7 +485,7 @@ export namespace CopilotChatView {
|
|
|
425
485
|
<div
|
|
426
486
|
className="cpk:absolute cpk:inset-x-0 cpk:flex cpk:justify-center cpk:z-30 cpk:pointer-events-none"
|
|
427
487
|
style={{
|
|
428
|
-
bottom: `${inputContainerHeight +
|
|
488
|
+
bottom: `${inputContainerHeight + SCROLL_BUTTON_OFFSET}px`,
|
|
429
489
|
}}
|
|
430
490
|
>
|
|
431
491
|
{renderSlot(
|
|
@@ -442,9 +502,105 @@ export namespace CopilotChatView {
|
|
|
442
502
|
);
|
|
443
503
|
};
|
|
444
504
|
|
|
505
|
+
// Internal component for pin-to-send scroll behavior — not exported on CopilotChatView.
|
|
506
|
+
const PinToSendScrollContainer: React.FC<
|
|
507
|
+
React.HTMLAttributes<HTMLDivElement> & {
|
|
508
|
+
scrollRef: React.MutableRefObject<HTMLElement | null>;
|
|
509
|
+
contentRef: React.MutableRefObject<HTMLElement | null>;
|
|
510
|
+
scrollToBottom: () => void;
|
|
511
|
+
scrollToBottomButton?: SlotValue<
|
|
512
|
+
React.FC<React.ButtonHTMLAttributes<HTMLButtonElement>>
|
|
513
|
+
>;
|
|
514
|
+
feather?: SlotValue<React.FC<React.HTMLAttributes<HTMLDivElement>>>;
|
|
515
|
+
inputContainerHeight: number;
|
|
516
|
+
isResizing: boolean;
|
|
517
|
+
nonAutoScrollEl: HTMLElement | null;
|
|
518
|
+
nonAutoScrollRefCallback: (el: HTMLElement | null) => void;
|
|
519
|
+
showScrollButton: boolean;
|
|
520
|
+
}
|
|
521
|
+
> = ({
|
|
522
|
+
children,
|
|
523
|
+
scrollRef,
|
|
524
|
+
contentRef,
|
|
525
|
+
scrollToBottom,
|
|
526
|
+
scrollToBottomButton,
|
|
527
|
+
feather,
|
|
528
|
+
inputContainerHeight,
|
|
529
|
+
isResizing,
|
|
530
|
+
nonAutoScrollEl,
|
|
531
|
+
nonAutoScrollRefCallback,
|
|
532
|
+
showScrollButton,
|
|
533
|
+
className,
|
|
534
|
+
...props
|
|
535
|
+
}) => {
|
|
536
|
+
const spacerRef = useRef<HTMLDivElement>(null);
|
|
537
|
+
|
|
538
|
+
usePinToSend({
|
|
539
|
+
scrollRef,
|
|
540
|
+
contentRef,
|
|
541
|
+
spacerRef,
|
|
542
|
+
topOffset: 16,
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
// The feather and scroll-to-bottom button live OUTSIDE the scroll
|
|
546
|
+
// container. `position: absolute` children of an `overflow: auto` element
|
|
547
|
+
// are positioned relative to the scroll *content*, which means they
|
|
548
|
+
// scroll away with it. Placing them as siblings of the scroll container
|
|
549
|
+
// (inside a `relative` wrapper) keeps them pinned to the viewport bottom.
|
|
550
|
+
const BoundFeather = renderSlot(feather, CopilotChatView.Feather, {});
|
|
551
|
+
|
|
552
|
+
return (
|
|
553
|
+
<ScrollElementContext.Provider value={nonAutoScrollEl}>
|
|
554
|
+
<div
|
|
555
|
+
className={cn(
|
|
556
|
+
"cpk:h-full cpk:max-h-full cpk:flex cpk:flex-col cpk:min-h-0 cpk:relative",
|
|
557
|
+
className,
|
|
558
|
+
)}
|
|
559
|
+
>
|
|
560
|
+
<div
|
|
561
|
+
ref={nonAutoScrollRefCallback}
|
|
562
|
+
className="cpk:flex-1 cpk:min-h-0 cpk:overflow-y-auto cpk:overflow-x-hidden"
|
|
563
|
+
{...props}
|
|
564
|
+
>
|
|
565
|
+
<div
|
|
566
|
+
ref={contentRef}
|
|
567
|
+
className="cpk:px-4 cpk:sm:px-0 cpk:[div[data-sidebar-chat]_&]:px-8 cpk:[div[data-popup-chat]_&]:px-6"
|
|
568
|
+
>
|
|
569
|
+
{children}
|
|
570
|
+
</div>
|
|
571
|
+
<div
|
|
572
|
+
ref={spacerRef}
|
|
573
|
+
data-pin-to-send-spacer
|
|
574
|
+
aria-hidden="true"
|
|
575
|
+
style={{ height: 0, flex: "0 0 auto" }}
|
|
576
|
+
/>
|
|
577
|
+
</div>
|
|
578
|
+
{BoundFeather}
|
|
579
|
+
{/* Scroll to bottom button */}
|
|
580
|
+
{showScrollButton && !isResizing && (
|
|
581
|
+
<div
|
|
582
|
+
className="cpk:absolute cpk:inset-x-0 cpk:flex cpk:justify-center cpk:z-30 cpk:pointer-events-none"
|
|
583
|
+
style={{
|
|
584
|
+
bottom: `${inputContainerHeight + SCROLL_BUTTON_OFFSET}px`,
|
|
585
|
+
}}
|
|
586
|
+
>
|
|
587
|
+
{renderSlot(
|
|
588
|
+
scrollToBottomButton,
|
|
589
|
+
CopilotChatView.ScrollToBottomButton,
|
|
590
|
+
{
|
|
591
|
+
onClick: () => scrollToBottom(),
|
|
592
|
+
},
|
|
593
|
+
)}
|
|
594
|
+
</div>
|
|
595
|
+
)}
|
|
596
|
+
</div>
|
|
597
|
+
</ScrollElementContext.Provider>
|
|
598
|
+
);
|
|
599
|
+
};
|
|
600
|
+
|
|
445
601
|
export const ScrollView: React.FC<
|
|
446
602
|
React.HTMLAttributes<HTMLDivElement> & {
|
|
447
|
-
autoScroll?: boolean;
|
|
603
|
+
autoScroll?: AutoScrollMode | boolean;
|
|
448
604
|
scrollToBottomButton?: SlotValue<
|
|
449
605
|
React.FC<React.ButtonHTMLAttributes<HTMLButtonElement>>
|
|
450
606
|
>;
|
|
@@ -454,7 +610,7 @@ export namespace CopilotChatView {
|
|
|
454
610
|
}
|
|
455
611
|
> = ({
|
|
456
612
|
children,
|
|
457
|
-
autoScroll =
|
|
613
|
+
autoScroll = "pin-to-bottom",
|
|
458
614
|
scrollToBottomButton,
|
|
459
615
|
feather,
|
|
460
616
|
inputContainerHeight = 0,
|
|
@@ -462,8 +618,18 @@ export namespace CopilotChatView {
|
|
|
462
618
|
className,
|
|
463
619
|
...props
|
|
464
620
|
}) => {
|
|
621
|
+
const mode = normalizeAutoScroll(autoScroll);
|
|
465
622
|
const [hasMounted, setHasMounted] = useState(false);
|
|
466
|
-
|
|
623
|
+
// Plain refs for the "none" and "pin-to-send" paths. Do NOT use
|
|
624
|
+
// useStickToBottom() here — its internal effects would attach scroll-following
|
|
625
|
+
// behavior to these refs and fight pin-to-send. The "pin-to-bottom" path
|
|
626
|
+
// gets its refs via <StickToBottom> below, scoped to that branch only.
|
|
627
|
+
const scrollRef = useRef<HTMLElement | null>(null);
|
|
628
|
+
const contentRef = useRef<HTMLElement | null>(null);
|
|
629
|
+
const scrollToBottom = useCallback(() => {
|
|
630
|
+
const el = scrollRef.current;
|
|
631
|
+
if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
|
632
|
+
}, []);
|
|
467
633
|
const [showScrollButton, setShowScrollButton] = useState(false);
|
|
468
634
|
// Tracks the scroll container element for the non-autoScroll path so the
|
|
469
635
|
// context value is reactive (element state, not a ref).
|
|
@@ -478,7 +644,7 @@ export namespace CopilotChatView {
|
|
|
478
644
|
scrollRef.current = el;
|
|
479
645
|
setNonAutoScrollEl(el);
|
|
480
646
|
},
|
|
481
|
-
// scrollRef is a stable object
|
|
647
|
+
// scrollRef is a stable ref object; safe to omit.
|
|
482
648
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
483
649
|
[],
|
|
484
650
|
);
|
|
@@ -489,7 +655,7 @@ export namespace CopilotChatView {
|
|
|
489
655
|
|
|
490
656
|
// Monitor scroll position for non-autoscroll mode
|
|
491
657
|
useEffect(() => {
|
|
492
|
-
if (
|
|
658
|
+
if (mode === "pin-to-bottom") return; // Skip for autoscroll mode
|
|
493
659
|
|
|
494
660
|
const scrollElement = scrollRef.current;
|
|
495
661
|
if (!scrollElement) return;
|
|
@@ -514,7 +680,7 @@ export namespace CopilotChatView {
|
|
|
514
680
|
scrollElement.removeEventListener("scroll", checkScroll);
|
|
515
681
|
resizeObserver.disconnect();
|
|
516
682
|
};
|
|
517
|
-
}, [scrollRef,
|
|
683
|
+
}, [scrollRef, mode]);
|
|
518
684
|
|
|
519
685
|
if (!hasMounted) {
|
|
520
686
|
return (
|
|
@@ -526,8 +692,7 @@ export namespace CopilotChatView {
|
|
|
526
692
|
);
|
|
527
693
|
}
|
|
528
694
|
|
|
529
|
-
|
|
530
|
-
if (!autoScroll) {
|
|
695
|
+
if (mode === "none") {
|
|
531
696
|
const BoundFeather = renderSlot(feather, CopilotChatView.Feather, {});
|
|
532
697
|
|
|
533
698
|
return (
|
|
@@ -549,7 +714,6 @@ export namespace CopilotChatView {
|
|
|
549
714
|
{children}
|
|
550
715
|
</div>
|
|
551
716
|
|
|
552
|
-
{/* Feather gradient overlay */}
|
|
553
717
|
{BoundFeather}
|
|
554
718
|
|
|
555
719
|
{/* Scroll to bottom button for manual mode */}
|
|
@@ -557,7 +721,7 @@ export namespace CopilotChatView {
|
|
|
557
721
|
<div
|
|
558
722
|
className="cpk:absolute cpk:inset-x-0 cpk:flex cpk:justify-center cpk:z-30 cpk:pointer-events-none"
|
|
559
723
|
style={{
|
|
560
|
-
bottom: `${inputContainerHeight +
|
|
724
|
+
bottom: `${inputContainerHeight + SCROLL_BUTTON_OFFSET}px`,
|
|
561
725
|
}}
|
|
562
726
|
>
|
|
563
727
|
{renderSlot(
|
|
@@ -574,6 +738,28 @@ export namespace CopilotChatView {
|
|
|
574
738
|
);
|
|
575
739
|
}
|
|
576
740
|
|
|
741
|
+
if (mode === "pin-to-send") {
|
|
742
|
+
return (
|
|
743
|
+
<PinToSendScrollContainer
|
|
744
|
+
scrollRef={scrollRef}
|
|
745
|
+
contentRef={contentRef}
|
|
746
|
+
scrollToBottom={scrollToBottom}
|
|
747
|
+
scrollToBottomButton={scrollToBottomButton}
|
|
748
|
+
feather={feather}
|
|
749
|
+
inputContainerHeight={inputContainerHeight}
|
|
750
|
+
isResizing={isResizing}
|
|
751
|
+
nonAutoScrollEl={nonAutoScrollEl}
|
|
752
|
+
nonAutoScrollRefCallback={nonAutoScrollRefCallback}
|
|
753
|
+
showScrollButton={showScrollButton}
|
|
754
|
+
className={className}
|
|
755
|
+
{...props}
|
|
756
|
+
>
|
|
757
|
+
{children}
|
|
758
|
+
</PinToSendScrollContainer>
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// mode === "pin-to-bottom" (default)
|
|
577
763
|
return (
|
|
578
764
|
<StickToBottom
|
|
579
765
|
className={cn(
|
|
@@ -617,22 +803,14 @@ export namespace CopilotChatView {
|
|
|
617
803
|
</Button>
|
|
618
804
|
);
|
|
619
805
|
|
|
806
|
+
// Default renders an empty div — no visual, but the element is still in the
|
|
807
|
+
// tree so a slot override of the form `scrollView={{ feather: "my-class" }}`
|
|
808
|
+
// can apply classes (and any consumer with a full component override gets
|
|
809
|
+
// the className/style forwarding they expect).
|
|
620
810
|
export const Feather: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
|
621
811
|
className,
|
|
622
|
-
style,
|
|
623
812
|
...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
|
-
);
|
|
813
|
+
}) => <div className={className} {...props} />;
|
|
636
814
|
|
|
637
815
|
export const WelcomeMessage: React.FC<
|
|
638
816
|
React.HTMLAttributes<HTMLDivElement>
|
|
@@ -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
|
+
}
|