@copilotkit/react-core 1.56.1 → 1.56.2-canary.pin-to-send
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{copilotkit-Cj2ZIxVr.mjs → copilotkit-BBYbekCa.mjs} +234 -60
- package/dist/copilotkit-BBYbekCa.mjs.map +1 -0
- package/dist/{copilotkit-CSJw5BG8.cjs → copilotkit-D5JT2Pu3.cjs} +233 -59
- package/dist/copilotkit-D5JT2Pu3.cjs.map +1 -0
- package/dist/{copilotkit-CCbxm6JM.d.mts → copilotkit-DArT2Iuw.d.mts} +62 -18
- package/dist/copilotkit-DArT2Iuw.d.mts.map +1 -0
- package/dist/{copilotkit-BtP7w7cT.d.cts → copilotkit-KEc28l8G.d.cts} +62 -18
- package/dist/copilotkit-KEc28l8G.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 +16 -40
- 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 +232 -62
- package/dist/v2/index.umd.js.map +1 -1
- package/package.json +6 -6
- package/src/v2/components/chat/CopilotChat.tsx +80 -4
- package/src/v2/components/chat/CopilotChatInput.tsx +22 -0
- package/src/v2/components/chat/CopilotChatView.tsx +206 -11
- package/src/v2/components/chat/__tests__/CopilotChat.absentThreadConnect.test.tsx +66 -0
- package/src/v2/components/chat/__tests__/CopilotChatActivityRendering.e2e.test.tsx +300 -2
- package/src/v2/components/chat/__tests__/CopilotChatView.connectingGate.test.tsx +56 -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/CopilotKitProvider.tsx +2 -11
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@copilotkit/react-core",
|
|
3
|
-
"version": "1.56.
|
|
3
|
+
"version": "1.56.2-canary.pin-to-send",
|
|
4
4
|
"private": false,
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -73,11 +73,11 @@
|
|
|
73
73
|
"untruncate-json": "^0.0.1",
|
|
74
74
|
"use-stick-to-bottom": "^1.1.1",
|
|
75
75
|
"zod-to-json-schema": "^3.24.5",
|
|
76
|
-
"@copilotkit/
|
|
77
|
-
"@copilotkit/
|
|
78
|
-
"@copilotkit/
|
|
79
|
-
"@copilotkit/web-inspector": "1.56.
|
|
80
|
-
"@copilotkit/a2ui-renderer": "1.56.
|
|
76
|
+
"@copilotkit/core": "1.56.2-canary.pin-to-send",
|
|
77
|
+
"@copilotkit/shared": "1.56.2-canary.pin-to-send",
|
|
78
|
+
"@copilotkit/runtime-client-gql": "1.56.2-canary.pin-to-send",
|
|
79
|
+
"@copilotkit/web-inspector": "1.56.2-canary.pin-to-send",
|
|
80
|
+
"@copilotkit/a2ui-renderer": "1.56.2-canary.pin-to-send"
|
|
81
81
|
},
|
|
82
82
|
"devDependencies": {
|
|
83
83
|
"@tailwindcss/cli": "^4.1.11",
|
|
@@ -33,6 +33,10 @@ import {
|
|
|
33
33
|
transcribeAudio,
|
|
34
34
|
TranscriptionError,
|
|
35
35
|
} from "../../lib/transcription-client";
|
|
36
|
+
import {
|
|
37
|
+
LastUserMessageContext,
|
|
38
|
+
type LastUserMessageState,
|
|
39
|
+
} from "./last-user-message-context";
|
|
36
40
|
|
|
37
41
|
export type CopilotChatProps = Omit<
|
|
38
42
|
CopilotChatViewProps,
|
|
@@ -97,9 +101,10 @@ export function CopilotChat({
|
|
|
97
101
|
// Apply priority: props > existing config > defaults
|
|
98
102
|
const resolvedAgentId =
|
|
99
103
|
agentId ?? existingConfig?.agentId ?? DEFAULT_AGENT_ID;
|
|
104
|
+
const providedThreadId = threadId ?? existingConfig?.threadId;
|
|
100
105
|
const resolvedThreadId = useMemo(
|
|
101
|
-
() =>
|
|
102
|
-
[
|
|
106
|
+
() => providedThreadId ?? randomUUID(),
|
|
107
|
+
[providedThreadId],
|
|
103
108
|
);
|
|
104
109
|
|
|
105
110
|
const { agent } = useAgent({
|
|
@@ -191,7 +196,24 @@ export function CopilotChat({
|
|
|
191
196
|
...restProps
|
|
192
197
|
} = props;
|
|
193
198
|
|
|
199
|
+
// Tracks the last threadId for which connectAgent has completed (success or
|
|
200
|
+
// failure). When the user supplies a threadId, we're in "resume existing
|
|
201
|
+
// thread" mode — the welcome screen should be suppressed until the connect
|
|
202
|
+
// resolves, otherwise switching threads flashes the welcome screen while the
|
|
203
|
+
// new thread's messages are still en route.
|
|
204
|
+
const [lastConnectedThreadId, setLastConnectedThreadId] = useState<
|
|
205
|
+
string | null
|
|
206
|
+
>(null);
|
|
207
|
+
const isConnecting =
|
|
208
|
+
!!providedThreadId && lastConnectedThreadId !== resolvedThreadId;
|
|
209
|
+
|
|
194
210
|
useEffect(() => {
|
|
211
|
+
// When no threadId was supplied by the caller, resolvedThreadId is a UUID
|
|
212
|
+
// minted in this browser tab. The backend has never seen it, so /connect
|
|
213
|
+
// would always 404. Skip the call — a real thread is only created once
|
|
214
|
+
// the user runs the agent for the first time.
|
|
215
|
+
if (!providedThreadId) return;
|
|
216
|
+
|
|
195
217
|
let detached = false;
|
|
196
218
|
|
|
197
219
|
// Create a fresh AbortController so we can cancel the HTTP request on cleanup.
|
|
@@ -212,6 +234,25 @@ export function CopilotChat({
|
|
|
212
234
|
// connectAgent already emits via the subscriber system, but catch
|
|
213
235
|
// here to prevent unhandled rejections from unexpected errors.
|
|
214
236
|
console.error("CopilotChat: connectAgent failed", error);
|
|
237
|
+
} finally {
|
|
238
|
+
// Whether the connect succeeded or failed, we're no longer in the
|
|
239
|
+
// transitional "connecting" state for this thread — unblock the
|
|
240
|
+
// welcome-screen-suppression so the view can settle.
|
|
241
|
+
//
|
|
242
|
+
// Defer one animation frame so any trailing React commits from the
|
|
243
|
+
// bootstrap replay (final assistant message content) paint before
|
|
244
|
+
// isConnecting flips off. Without this, suggestions + copy button
|
|
245
|
+
// can briefly appear against an incompletely-laid-out message tree
|
|
246
|
+
// and visibly snap once the last text chunk lands.
|
|
247
|
+
if (!detached) {
|
|
248
|
+
const raf =
|
|
249
|
+
typeof requestAnimationFrame === "function"
|
|
250
|
+
? requestAnimationFrame
|
|
251
|
+
: (cb: () => void) => setTimeout(cb, 16);
|
|
252
|
+
raf(() => {
|
|
253
|
+
if (!detached) setLastConnectedThreadId(resolvedThreadId);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
215
256
|
}
|
|
216
257
|
};
|
|
217
258
|
connect(agent);
|
|
@@ -229,7 +270,7 @@ export function CopilotChat({
|
|
|
229
270
|
};
|
|
230
271
|
// copilotkit is intentionally excluded — it is a stable ref that never changes.
|
|
231
272
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
232
|
-
}, [resolvedThreadId, agent, resolvedAgentId]);
|
|
273
|
+
}, [resolvedThreadId, agent, resolvedAgentId, providedThreadId]);
|
|
233
274
|
|
|
234
275
|
const onSubmitInput = useCallback(
|
|
235
276
|
async (value: string) => {
|
|
@@ -497,6 +538,37 @@ export function CopilotChat({
|
|
|
497
538
|
[messagesMemoKey],
|
|
498
539
|
);
|
|
499
540
|
|
|
541
|
+
// Compute the ID of the last user message for scroll-pinning logic.
|
|
542
|
+
const lastUserMessageId = useMemo(() => {
|
|
543
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
544
|
+
if (messages[i].role === "user") return messages[i].id;
|
|
545
|
+
}
|
|
546
|
+
return null;
|
|
547
|
+
}, [messages]);
|
|
548
|
+
|
|
549
|
+
// Track a nonce that increments each time a new user message ID appears.
|
|
550
|
+
// Using useState ensures the context value propagates correctly on the
|
|
551
|
+
// render that follows the state update (approach b from the design doc).
|
|
552
|
+
const [sendNonce, setSendNonce] = useState(0);
|
|
553
|
+
// Seed with the current value so restoring a thread with existing messages
|
|
554
|
+
// does not count as a new send. Only later-render id transitions bump.
|
|
555
|
+
const prevLastUserMessageIdRef = useRef<string | null>(lastUserMessageId);
|
|
556
|
+
|
|
557
|
+
useEffect(() => {
|
|
558
|
+
if (
|
|
559
|
+
lastUserMessageId &&
|
|
560
|
+
lastUserMessageId !== prevLastUserMessageIdRef.current
|
|
561
|
+
) {
|
|
562
|
+
setSendNonce((n) => n + 1);
|
|
563
|
+
prevLastUserMessageIdRef.current = lastUserMessageId;
|
|
564
|
+
}
|
|
565
|
+
}, [lastUserMessageId]);
|
|
566
|
+
|
|
567
|
+
const lastUserMessageState = useMemo<LastUserMessageState>(
|
|
568
|
+
() => ({ id: lastUserMessageId, sendNonce }),
|
|
569
|
+
[lastUserMessageId, sendNonce],
|
|
570
|
+
);
|
|
571
|
+
|
|
500
572
|
const finalProps: CopilotChatViewProps = {
|
|
501
573
|
...mergedProps,
|
|
502
574
|
messages,
|
|
@@ -521,6 +593,8 @@ export function CopilotChat({
|
|
|
521
593
|
onDragOver: handleDragOver,
|
|
522
594
|
onDragLeave: handleDragLeave,
|
|
523
595
|
onDrop: handleDrop,
|
|
596
|
+
isConnecting,
|
|
597
|
+
hasExplicitThreadId: !!providedThreadId,
|
|
524
598
|
};
|
|
525
599
|
|
|
526
600
|
// Always create a provider with merged values
|
|
@@ -564,7 +638,9 @@ export function CopilotChat({
|
|
|
564
638
|
{transcriptionError}
|
|
565
639
|
</div>
|
|
566
640
|
)}
|
|
567
|
-
{
|
|
641
|
+
<LastUserMessageContext.Provider value={lastUserMessageState}>
|
|
642
|
+
{RenderedChatView}
|
|
643
|
+
</LastUserMessageContext.Provider>
|
|
568
644
|
</div>
|
|
569
645
|
</CopilotChatConfigurationProvider>
|
|
570
646
|
);
|
|
@@ -87,6 +87,19 @@ type CopilotChatInputRestProps = {
|
|
|
87
87
|
containerRef?: React.Ref<HTMLDivElement>;
|
|
88
88
|
/** Whether to show the disclaimer. Default: true for absolute positioning, false for static */
|
|
89
89
|
showDisclaimer?: boolean;
|
|
90
|
+
/**
|
|
91
|
+
* Set to `true` when the input sits at the bottom of its container as a
|
|
92
|
+
* flex-last-child (visible position is driven by layout, not CSS
|
|
93
|
+
* positioning). Triggers reservation of bottom space for the fixed
|
|
94
|
+
* CopilotKit license banner via the
|
|
95
|
+
* `--copilotkit-license-banner-offset` CSS var so the two don't overlap.
|
|
96
|
+
*
|
|
97
|
+
* Not needed when `positioning === "absolute"`; that mode already pins the
|
|
98
|
+
* input to the bottom and picks up the same reservation automatically.
|
|
99
|
+
* Leave unset (default `false`) for inputs rendered mid-layout such as the
|
|
100
|
+
* welcome screen, where the banner offset would push the input off-center.
|
|
101
|
+
*/
|
|
102
|
+
bottomAnchored?: boolean;
|
|
90
103
|
} & Omit<React.HTMLAttributes<HTMLDivElement>, "onChange">;
|
|
91
104
|
|
|
92
105
|
type CopilotChatInputBaseProps = WithSlots<
|
|
@@ -130,6 +143,7 @@ export function CopilotChatInput({
|
|
|
130
143
|
keyboardHeight = 0,
|
|
131
144
|
containerRef,
|
|
132
145
|
showDisclaimer,
|
|
146
|
+
bottomAnchored = false,
|
|
133
147
|
textArea,
|
|
134
148
|
sendButton,
|
|
135
149
|
startTranscribeButton,
|
|
@@ -1097,6 +1111,14 @@ export function CopilotChatInput({
|
|
|
1097
1111
|
transform:
|
|
1098
1112
|
keyboardHeight > 0 ? `translateY(-${keyboardHeight}px)` : undefined,
|
|
1099
1113
|
transition: "transform 0.2s ease-out",
|
|
1114
|
+
// Reserve room when the fixed license banner is visible so it doesn't
|
|
1115
|
+
// overlap the input. Applied only for bottom-anchored inputs (either
|
|
1116
|
+
// `positioning === "absolute"`, or an explicitly-flagged flex-last-child
|
|
1117
|
+
// input in run state). The welcome-screen input sits mid-layout and
|
|
1118
|
+
// must stay still when the banner is present.
|
|
1119
|
+
...(positioning === "absolute" || bottomAnchored
|
|
1120
|
+
? { paddingBottom: "var(--copilotkit-license-banner-offset, 0px)" }
|
|
1121
|
+
: {}),
|
|
1100
1122
|
}}
|
|
1101
1123
|
{...props}
|
|
1102
1124
|
>
|
|
@@ -33,10 +33,34 @@ import {
|
|
|
33
33
|
CopilotChatDefaultLabels,
|
|
34
34
|
} from "../../providers/CopilotChatConfigurationProvider";
|
|
35
35
|
import { useKeyboardHeight } from "../../hooks/use-keyboard-height";
|
|
36
|
+
import { normalizeAutoScroll } from "./normalize-auto-scroll";
|
|
37
|
+
import type { AutoScrollMode } from "./normalize-auto-scroll";
|
|
38
|
+
import { usePinToSend } from "../../hooks/use-pin-to-send";
|
|
36
39
|
|
|
37
40
|
// Height of the feather gradient overlay (h-24 = 6rem = 96px)
|
|
38
41
|
const FEATHER_HEIGHT = 96;
|
|
39
42
|
|
|
43
|
+
// Pin-to-send uses a softer, shorter feather than pin-to-bottom so readable
|
|
44
|
+
// content isn't obscured (h-12 = 3rem = 48px).
|
|
45
|
+
const PIN_TO_SEND_FEATHER_HEIGHT = 48;
|
|
46
|
+
|
|
47
|
+
const PinToSendSoftFeather: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
|
48
|
+
className,
|
|
49
|
+
style,
|
|
50
|
+
...props
|
|
51
|
+
}) => (
|
|
52
|
+
<div
|
|
53
|
+
className={cn(
|
|
54
|
+
"cpk:absolute cpk:bottom-0 cpk:left-0 cpk:right-4 cpk:h-12 cpk:pointer-events-none cpk:z-10 cpk:bg-gradient-to-t",
|
|
55
|
+
"cpk:from-white cpk:to-transparent",
|
|
56
|
+
"cpk:dark:from-[rgb(33,33,33)]",
|
|
57
|
+
className,
|
|
58
|
+
)}
|
|
59
|
+
style={style}
|
|
60
|
+
{...props}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
|
|
40
64
|
// Forward declaration for WelcomeScreen component type
|
|
41
65
|
export type WelcomeScreenProps = WithSlots<
|
|
42
66
|
{
|
|
@@ -57,7 +81,7 @@ export type CopilotChatViewProps = WithSlots<
|
|
|
57
81
|
},
|
|
58
82
|
{
|
|
59
83
|
messages?: Message[];
|
|
60
|
-
autoScroll?: boolean;
|
|
84
|
+
autoScroll?: AutoScrollMode | boolean;
|
|
61
85
|
isRunning?: boolean;
|
|
62
86
|
suggestions?: Suggestion[];
|
|
63
87
|
suggestionLoadingIndexes?: ReadonlyArray<number>;
|
|
@@ -81,6 +105,21 @@ export type CopilotChatViewProps = WithSlots<
|
|
|
81
105
|
onDragOver?: (e: React.DragEvent) => void;
|
|
82
106
|
onDragLeave?: (e: React.DragEvent) => void;
|
|
83
107
|
onDrop?: (e: React.DragEvent) => void;
|
|
108
|
+
/**
|
|
109
|
+
* When `true`, suppresses the welcome screen while a thread's initial
|
|
110
|
+
* connect is in flight. Prevents the "How can I help you today?" flash
|
|
111
|
+
* that would otherwise appear between mounting an empty cloned agent and
|
|
112
|
+
* the bootstrap messages arriving from /connect.
|
|
113
|
+
*/
|
|
114
|
+
isConnecting?: boolean;
|
|
115
|
+
/**
|
|
116
|
+
* When `true`, the caller has explicitly picked a thread (via `threadId`
|
|
117
|
+
* prop or `CopilotChatConfigurationProvider`). Suppresses the welcome
|
|
118
|
+
* screen unconditionally — a caller-managed thread targets a specific
|
|
119
|
+
* conversation and should render its messages (or an empty panel during
|
|
120
|
+
* connect) rather than a generic "start a new chat" greeting.
|
|
121
|
+
*/
|
|
122
|
+
hasExplicitThreadId?: boolean;
|
|
84
123
|
/**
|
|
85
124
|
* @deprecated Use the `input` slot's `disclaimer` prop instead:
|
|
86
125
|
* ```tsx
|
|
@@ -139,6 +178,8 @@ export function CopilotChatView({
|
|
|
139
178
|
onDragOver,
|
|
140
179
|
onDragLeave,
|
|
141
180
|
onDrop,
|
|
181
|
+
isConnecting = false,
|
|
182
|
+
hasExplicitThreadId = false,
|
|
142
183
|
// Deprecated — forwarded to input slot
|
|
143
184
|
disclaimer,
|
|
144
185
|
children,
|
|
@@ -219,10 +260,22 @@ export function CopilotChatView({
|
|
|
219
260
|
keyboardHeight: isKeyboardOpen ? keyboardHeight : 0,
|
|
220
261
|
containerRef: inputContainerRef,
|
|
221
262
|
showDisclaimer: true,
|
|
263
|
+
// This input is the last flex child of the chat column, so it sits at
|
|
264
|
+
// the bottom where the license banner would overlap. The welcome-screen
|
|
265
|
+
// input (below) intentionally omits this flag.
|
|
266
|
+
bottomAnchored: true,
|
|
222
267
|
...(disclaimer !== undefined ? { disclaimer } : {}),
|
|
223
268
|
} as CopilotChatInputProps);
|
|
224
269
|
|
|
225
|
-
|
|
270
|
+
// Hide suggestions while a thread is connecting or a run is in flight.
|
|
271
|
+
// Otherwise, mid-replay (bootstrap stream from /connect) or mid-run, the
|
|
272
|
+
// suggestions would render against a still-assembling message tree and
|
|
273
|
+
// visibly jump as each final text chunk reflows the layout.
|
|
274
|
+
const hasSuggestions =
|
|
275
|
+
!isConnecting &&
|
|
276
|
+
!isRunning &&
|
|
277
|
+
Array.isArray(suggestions) &&
|
|
278
|
+
suggestions.length > 0;
|
|
226
279
|
const BoundSuggestionView = hasSuggestions
|
|
227
280
|
? renderSlot(suggestionView, CopilotChatSuggestionView, {
|
|
228
281
|
suggestions,
|
|
@@ -258,7 +311,13 @@ export function CopilotChatView({
|
|
|
258
311
|
const isEmpty = messages.length === 0;
|
|
259
312
|
// Type assertion needed because TypeScript doesn't fully propagate `| boolean` through WithSlots
|
|
260
313
|
const welcomeScreenDisabled = (welcomeScreen as unknown) === false;
|
|
261
|
-
|
|
314
|
+
// Suppress the welcome screen (1) while the initial connect is in flight
|
|
315
|
+
// and (2) whenever the caller has picked a specific thread. The caller-
|
|
316
|
+
// managed case targets a conversation directly, so the generic welcome
|
|
317
|
+
// greeting is never the right thing to show — even for a thread that
|
|
318
|
+
// happens to have no messages yet.
|
|
319
|
+
const shouldShowWelcomeScreen =
|
|
320
|
+
isEmpty && !welcomeScreenDisabled && !isConnecting && !hasExplicitThreadId;
|
|
262
321
|
|
|
263
322
|
if (shouldShowWelcomeScreen) {
|
|
264
323
|
// Create a separate input for welcome screen with static positioning and disclaimer visible
|
|
@@ -442,9 +501,114 @@ export namespace CopilotChatView {
|
|
|
442
501
|
);
|
|
443
502
|
};
|
|
444
503
|
|
|
504
|
+
// Internal component for pin-to-send scroll behavior — not exported on CopilotChatView.
|
|
505
|
+
const PinToSendScrollContainer: React.FC<
|
|
506
|
+
React.HTMLAttributes<HTMLDivElement> & {
|
|
507
|
+
scrollRef: React.MutableRefObject<HTMLElement | null>;
|
|
508
|
+
contentRef: React.MutableRefObject<HTMLElement | null>;
|
|
509
|
+
scrollToBottom: () => void;
|
|
510
|
+
scrollToBottomButton?: SlotValue<
|
|
511
|
+
React.FC<React.ButtonHTMLAttributes<HTMLButtonElement>>
|
|
512
|
+
>;
|
|
513
|
+
feather?: SlotValue<React.FC<React.HTMLAttributes<HTMLDivElement>>>;
|
|
514
|
+
inputContainerHeight: number;
|
|
515
|
+
isResizing: boolean;
|
|
516
|
+
nonAutoScrollEl: HTMLElement | null;
|
|
517
|
+
nonAutoScrollRefCallback: (el: HTMLElement | null) => void;
|
|
518
|
+
showScrollButton: boolean;
|
|
519
|
+
}
|
|
520
|
+
> = ({
|
|
521
|
+
children,
|
|
522
|
+
scrollRef,
|
|
523
|
+
contentRef,
|
|
524
|
+
scrollToBottom,
|
|
525
|
+
scrollToBottomButton,
|
|
526
|
+
feather,
|
|
527
|
+
inputContainerHeight,
|
|
528
|
+
isResizing,
|
|
529
|
+
nonAutoScrollEl,
|
|
530
|
+
nonAutoScrollRefCallback,
|
|
531
|
+
showScrollButton,
|
|
532
|
+
className,
|
|
533
|
+
...props
|
|
534
|
+
}) => {
|
|
535
|
+
const spacerRef = useRef<HTMLDivElement>(null);
|
|
536
|
+
|
|
537
|
+
usePinToSend({
|
|
538
|
+
scrollRef,
|
|
539
|
+
contentRef,
|
|
540
|
+
spacerRef,
|
|
541
|
+
topOffset: 16,
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// Pin-to-send uses a SOFTER feather than pin-to-bottom:
|
|
545
|
+
// - default: h-24 + from-white via-white to-transparent (fully opaque
|
|
546
|
+
// bottom half, aggressive). Good for streaming-to-bottom where
|
|
547
|
+
// the edge is always churning.
|
|
548
|
+
// - pin-to-send: h-12 + from-white to-transparent (gradual fade,
|
|
549
|
+
// no opaque midline). Gives a visual soft edge above the input
|
|
550
|
+
// without obscuring otherwise-readable content.
|
|
551
|
+
// Consumers can still override with the `feather` slot.
|
|
552
|
+
const BoundFeather = renderSlot(feather, PinToSendSoftFeather, {});
|
|
553
|
+
|
|
554
|
+
// Feather and scroll-to-bottom button live OUTSIDE the scroll container.
|
|
555
|
+
// `position: absolute` children of an `overflow: auto` element are
|
|
556
|
+
// positioned relative to the scroll *content*, which means they scroll
|
|
557
|
+
// away with it. Placing them as siblings of the scroll container
|
|
558
|
+
// (inside a `relative` wrapper) keeps them pinned to the viewport bottom.
|
|
559
|
+
return (
|
|
560
|
+
<ScrollElementContext.Provider value={nonAutoScrollEl}>
|
|
561
|
+
<div
|
|
562
|
+
className={cn(
|
|
563
|
+
"cpk:h-full cpk:max-h-full cpk:flex cpk:flex-col cpk:min-h-0 cpk:relative",
|
|
564
|
+
className,
|
|
565
|
+
)}
|
|
566
|
+
>
|
|
567
|
+
<div
|
|
568
|
+
ref={nonAutoScrollRefCallback}
|
|
569
|
+
className="cpk:flex-1 cpk:min-h-0 cpk:overflow-y-auto cpk:overflow-x-hidden"
|
|
570
|
+
{...props}
|
|
571
|
+
>
|
|
572
|
+
<div
|
|
573
|
+
ref={contentRef}
|
|
574
|
+
className="cpk:px-4 cpk:sm:px-0 cpk:[div[data-sidebar-chat]_&]:px-8 cpk:[div[data-popup-chat]_&]:px-6"
|
|
575
|
+
>
|
|
576
|
+
{children}
|
|
577
|
+
</div>
|
|
578
|
+
<div
|
|
579
|
+
ref={spacerRef}
|
|
580
|
+
data-pin-to-send-spacer
|
|
581
|
+
aria-hidden="true"
|
|
582
|
+
style={{ height: 0, flex: "0 0 auto" }}
|
|
583
|
+
/>
|
|
584
|
+
</div>
|
|
585
|
+
{/* Soft feather — pinned to wrapper bottom */}
|
|
586
|
+
{BoundFeather}
|
|
587
|
+
{/* Scroll to bottom button */}
|
|
588
|
+
{showScrollButton && !isResizing && (
|
|
589
|
+
<div
|
|
590
|
+
className="cpk:absolute cpk:inset-x-0 cpk:flex cpk:justify-center cpk:z-30 cpk:pointer-events-none"
|
|
591
|
+
style={{
|
|
592
|
+
bottom: `${inputContainerHeight + PIN_TO_SEND_FEATHER_HEIGHT + 16}px`,
|
|
593
|
+
}}
|
|
594
|
+
>
|
|
595
|
+
{renderSlot(
|
|
596
|
+
scrollToBottomButton,
|
|
597
|
+
CopilotChatView.ScrollToBottomButton,
|
|
598
|
+
{
|
|
599
|
+
onClick: () => scrollToBottom(),
|
|
600
|
+
},
|
|
601
|
+
)}
|
|
602
|
+
</div>
|
|
603
|
+
)}
|
|
604
|
+
</div>
|
|
605
|
+
</ScrollElementContext.Provider>
|
|
606
|
+
);
|
|
607
|
+
};
|
|
608
|
+
|
|
445
609
|
export const ScrollView: React.FC<
|
|
446
610
|
React.HTMLAttributes<HTMLDivElement> & {
|
|
447
|
-
autoScroll?: boolean;
|
|
611
|
+
autoScroll?: AutoScrollMode | boolean;
|
|
448
612
|
scrollToBottomButton?: SlotValue<
|
|
449
613
|
React.FC<React.ButtonHTMLAttributes<HTMLButtonElement>>
|
|
450
614
|
>;
|
|
@@ -454,7 +618,7 @@ export namespace CopilotChatView {
|
|
|
454
618
|
}
|
|
455
619
|
> = ({
|
|
456
620
|
children,
|
|
457
|
-
autoScroll =
|
|
621
|
+
autoScroll = "pin-to-bottom",
|
|
458
622
|
scrollToBottomButton,
|
|
459
623
|
feather,
|
|
460
624
|
inputContainerHeight = 0,
|
|
@@ -462,8 +626,18 @@ export namespace CopilotChatView {
|
|
|
462
626
|
className,
|
|
463
627
|
...props
|
|
464
628
|
}) => {
|
|
629
|
+
const mode = normalizeAutoScroll(autoScroll);
|
|
465
630
|
const [hasMounted, setHasMounted] = useState(false);
|
|
466
|
-
|
|
631
|
+
// Plain refs for the "none" and "pin-to-send" paths. Do NOT use
|
|
632
|
+
// useStickToBottom() here — its internal effects would attach scroll-following
|
|
633
|
+
// behavior to these refs and fight pin-to-send. The "pin-to-bottom" path
|
|
634
|
+
// gets its refs via <StickToBottom> below, scoped to that branch only.
|
|
635
|
+
const scrollRef = useRef<HTMLElement | null>(null);
|
|
636
|
+
const contentRef = useRef<HTMLElement | null>(null);
|
|
637
|
+
const scrollToBottom = useCallback(() => {
|
|
638
|
+
const el = scrollRef.current;
|
|
639
|
+
if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
|
640
|
+
}, []);
|
|
467
641
|
const [showScrollButton, setShowScrollButton] = useState(false);
|
|
468
642
|
// Tracks the scroll container element for the non-autoScroll path so the
|
|
469
643
|
// context value is reactive (element state, not a ref).
|
|
@@ -478,7 +652,7 @@ export namespace CopilotChatView {
|
|
|
478
652
|
scrollRef.current = el;
|
|
479
653
|
setNonAutoScrollEl(el);
|
|
480
654
|
},
|
|
481
|
-
// scrollRef is a stable object
|
|
655
|
+
// scrollRef is a stable ref object; safe to omit.
|
|
482
656
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
483
657
|
[],
|
|
484
658
|
);
|
|
@@ -489,7 +663,7 @@ export namespace CopilotChatView {
|
|
|
489
663
|
|
|
490
664
|
// Monitor scroll position for non-autoscroll mode
|
|
491
665
|
useEffect(() => {
|
|
492
|
-
if (
|
|
666
|
+
if (mode === "pin-to-bottom") return; // Skip for autoscroll mode
|
|
493
667
|
|
|
494
668
|
const scrollElement = scrollRef.current;
|
|
495
669
|
if (!scrollElement) return;
|
|
@@ -514,7 +688,7 @@ export namespace CopilotChatView {
|
|
|
514
688
|
scrollElement.removeEventListener("scroll", checkScroll);
|
|
515
689
|
resizeObserver.disconnect();
|
|
516
690
|
};
|
|
517
|
-
}, [scrollRef,
|
|
691
|
+
}, [scrollRef, mode]);
|
|
518
692
|
|
|
519
693
|
if (!hasMounted) {
|
|
520
694
|
return (
|
|
@@ -526,8 +700,7 @@ export namespace CopilotChatView {
|
|
|
526
700
|
);
|
|
527
701
|
}
|
|
528
702
|
|
|
529
|
-
|
|
530
|
-
if (!autoScroll) {
|
|
703
|
+
if (mode === "none") {
|
|
531
704
|
const BoundFeather = renderSlot(feather, CopilotChatView.Feather, {});
|
|
532
705
|
|
|
533
706
|
return (
|
|
@@ -574,6 +747,28 @@ export namespace CopilotChatView {
|
|
|
574
747
|
);
|
|
575
748
|
}
|
|
576
749
|
|
|
750
|
+
if (mode === "pin-to-send") {
|
|
751
|
+
return (
|
|
752
|
+
<PinToSendScrollContainer
|
|
753
|
+
scrollRef={scrollRef}
|
|
754
|
+
contentRef={contentRef}
|
|
755
|
+
scrollToBottom={scrollToBottom}
|
|
756
|
+
scrollToBottomButton={scrollToBottomButton}
|
|
757
|
+
feather={feather}
|
|
758
|
+
inputContainerHeight={inputContainerHeight}
|
|
759
|
+
isResizing={isResizing}
|
|
760
|
+
nonAutoScrollEl={nonAutoScrollEl}
|
|
761
|
+
nonAutoScrollRefCallback={nonAutoScrollRefCallback}
|
|
762
|
+
showScrollButton={showScrollButton}
|
|
763
|
+
className={className}
|
|
764
|
+
{...props}
|
|
765
|
+
>
|
|
766
|
+
{children}
|
|
767
|
+
</PinToSendScrollContainer>
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// mode === "pin-to-bottom" (default)
|
|
577
772
|
return (
|
|
578
773
|
<StickToBottom
|
|
579
774
|
className={cn(
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { describe, it, expect, vi } from "vitest";
|
|
3
|
+
import { render } from "@testing-library/react";
|
|
4
|
+
import { EMPTY, Observable } from "rxjs";
|
|
5
|
+
import { type BaseEvent, type RunAgentInput } from "@ag-ui/client";
|
|
6
|
+
import { MockStepwiseAgent } from "../../../__tests__/utils/test-helpers";
|
|
7
|
+
import { CopilotKitProvider } from "../../../providers/CopilotKitProvider";
|
|
8
|
+
import { CopilotChatConfigurationProvider } from "../../../providers/CopilotChatConfigurationProvider";
|
|
9
|
+
import { CopilotChat } from "../CopilotChat";
|
|
10
|
+
|
|
11
|
+
describe("CopilotChat avoids /connect for locally-generated threadIds (ENT-314)", () => {
|
|
12
|
+
function buildAgentWithConnectSpy(): {
|
|
13
|
+
agent: MockStepwiseAgent;
|
|
14
|
+
connectSpy: ReturnType<typeof vi.fn>;
|
|
15
|
+
} {
|
|
16
|
+
const connectSpy = vi.fn();
|
|
17
|
+
class SpyAgent extends MockStepwiseAgent {
|
|
18
|
+
connect(input: RunAgentInput): Observable<BaseEvent> {
|
|
19
|
+
connectSpy(input);
|
|
20
|
+
return EMPTY;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return { agent: new SpyAgent(), connectSpy };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
it("does not call connect() when no threadId is supplied", async () => {
|
|
27
|
+
const { agent, connectSpy } = buildAgentWithConnectSpy();
|
|
28
|
+
|
|
29
|
+
render(
|
|
30
|
+
<CopilotKitProvider agents__unsafe_dev_only={{ default: agent }}>
|
|
31
|
+
<CopilotChat welcomeScreen={false} />
|
|
32
|
+
</CopilotKitProvider>,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
36
|
+
expect(connectSpy).not.toHaveBeenCalled();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("calls connect() when a threadId is supplied via props", async () => {
|
|
40
|
+
const { agent, connectSpy } = buildAgentWithConnectSpy();
|
|
41
|
+
|
|
42
|
+
render(
|
|
43
|
+
<CopilotKitProvider agents__unsafe_dev_only={{ default: agent }}>
|
|
44
|
+
<CopilotChat welcomeScreen={false} threadId="user-thread-abc" />
|
|
45
|
+
</CopilotKitProvider>,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
49
|
+
expect(connectSpy).toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("calls connect() when a threadId is supplied via configuration provider", async () => {
|
|
53
|
+
const { agent, connectSpy } = buildAgentWithConnectSpy();
|
|
54
|
+
|
|
55
|
+
render(
|
|
56
|
+
<CopilotKitProvider agents__unsafe_dev_only={{ default: agent }}>
|
|
57
|
+
<CopilotChatConfigurationProvider threadId="config-thread-xyz">
|
|
58
|
+
<CopilotChat welcomeScreen={false} />
|
|
59
|
+
</CopilotChatConfigurationProvider>
|
|
60
|
+
</CopilotKitProvider>,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
64
|
+
expect(connectSpy).toHaveBeenCalled();
|
|
65
|
+
});
|
|
66
|
+
});
|