@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.
Files changed (63) hide show
  1. package/dist/{copilotkit-CSJw5BG8.cjs → copilotkit-BAkj3zUc.cjs} +359 -157
  2. package/dist/copilotkit-BAkj3zUc.cjs.map +1 -0
  3. package/dist/{copilotkit-Cj2ZIxVr.mjs → copilotkit-DAatqMh2.mjs} +360 -158
  4. package/dist/copilotkit-DAatqMh2.mjs.map +1 -0
  5. package/dist/{copilotkit-CCbxm6JM.d.mts → copilotkit-DFaI4j2r.d.mts} +64 -18
  6. package/dist/copilotkit-DFaI4j2r.d.mts.map +1 -0
  7. package/dist/{copilotkit-BtP7w7cT.d.cts → copilotkit-Dg4r4Gi_.d.cts} +64 -18
  8. package/dist/copilotkit-Dg4r4Gi_.d.cts.map +1 -0
  9. package/dist/index.cjs +1 -1
  10. package/dist/index.d.cts +2 -1
  11. package/dist/index.d.cts.map +1 -1
  12. package/dist/index.d.mts +2 -1
  13. package/dist/index.d.mts.map +1 -1
  14. package/dist/index.mjs +1 -1
  15. package/dist/index.umd.js +31 -44
  16. package/dist/index.umd.js.map +1 -1
  17. package/dist/v2/index.cjs +1 -1
  18. package/dist/v2/index.css +1 -1
  19. package/dist/v2/index.d.cts +2 -2
  20. package/dist/v2/index.d.mts +2 -2
  21. package/dist/v2/index.mjs +1 -1
  22. package/dist/v2/index.umd.js +361 -163
  23. package/dist/v2/index.umd.js.map +1 -1
  24. package/package.json +8 -8
  25. package/src/components/copilot-provider/__tests__/v1-explicit-threadid-bridge.test.tsx +107 -0
  26. package/src/components/copilot-provider/copilotkit.tsx +6 -1
  27. package/src/context/__tests__/threads-context.test.tsx +116 -3
  28. package/src/context/threads-context.tsx +18 -1
  29. package/src/v2/components/chat/CopilotChat.tsx +91 -4
  30. package/src/v2/components/chat/CopilotChatAttachmentQueue.tsx +7 -114
  31. package/src/v2/components/chat/CopilotChatAttachmentRenderer.tsx +26 -6
  32. package/src/v2/components/chat/CopilotChatInput.tsx +22 -0
  33. package/src/v2/components/chat/CopilotChatUserMessage.tsx +2 -2
  34. package/src/v2/components/chat/CopilotChatView.tsx +226 -48
  35. package/src/v2/components/chat/Lightbox.tsx +103 -0
  36. package/src/v2/components/chat/__tests__/CopilotChat.absentThreadConnect.test.tsx +66 -0
  37. package/src/v2/components/chat/__tests__/CopilotChat.suggestionsAlways.test.tsx +189 -0
  38. package/src/v2/components/chat/__tests__/CopilotChat.welcomeGate.test.tsx +186 -0
  39. package/src/v2/components/chat/__tests__/CopilotChatActivityRendering.e2e.test.tsx +438 -4
  40. package/src/v2/components/chat/__tests__/CopilotChatView.connectingGate.test.tsx +56 -0
  41. package/src/v2/components/chat/__tests__/CopilotChatView.inputOverlay.test.tsx +264 -0
  42. package/src/v2/components/chat/__tests__/CopilotChatView.pinToSend.test.tsx +94 -0
  43. package/src/v2/components/chat/__tests__/copilot-chat-throttle.test.tsx +0 -1
  44. package/src/v2/components/chat/__tests__/normalize-auto-scroll.test.ts +37 -0
  45. package/src/v2/components/chat/index.ts +2 -0
  46. package/src/v2/components/chat/last-user-message-context.ts +21 -0
  47. package/src/v2/components/chat/normalize-auto-scroll.ts +17 -0
  48. package/src/v2/components/license-warning-banner.tsx +20 -1
  49. package/src/v2/hooks/__tests__/use-agent-stability.test.tsx +6 -0
  50. package/src/v2/hooks/__tests__/use-agent-thread-isolation.test.tsx +6 -0
  51. package/src/v2/hooks/__tests__/use-agent-throttle.test.tsx +76 -50
  52. package/src/v2/hooks/__tests__/use-pin-to-send.test.tsx +219 -0
  53. package/src/v2/hooks/__tests__/use-threads.test.tsx +68 -0
  54. package/src/v2/hooks/use-agent.tsx +34 -77
  55. package/src/v2/hooks/use-pin-to-send.ts +94 -0
  56. package/src/v2/hooks/use-threads.tsx +55 -12
  57. package/src/v2/providers/CopilotChatConfigurationProvider.tsx +29 -1
  58. package/src/v2/providers/CopilotKitProvider.tsx +2 -11
  59. package/src/v2/providers/__tests__/CopilotChatConfigurationProvider.test.tsx +106 -0
  60. package/dist/copilotkit-BtP7w7cT.d.cts.map +0 -1
  61. package/dist/copilotkit-CCbxm6JM.d.mts.map +0 -1
  62. package/dist/copilotkit-CSJw5BG8.cjs.map +0 -1
  63. 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
- <img
37
- src={src}
38
- alt="Image attachment"
39
- className={cn("cpk:max-w-full cpk:h-auto cpk:rounded-lg", className)}
40
- onError={() => setError(true)}
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-col cpk:items-end cpk:gap-2 cpk:mt-2">
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, renderSlot } from "../../lib/slots";
9
+ import type { WithSlots, SlotValue } from "../../lib/slots";
10
+ import { renderSlot } from "../../lib/slots";
10
11
  import CopilotChatMessageView from "./CopilotChatMessageView";
11
- import CopilotChatInput, {
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
- // Height of the feather gradient overlay (h-24 = 6rem = 96px)
38
- const FEATHER_HEIGHT = 96;
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
- const inputContainerRef = useRef<HTMLDivElement>(null);
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 = inputContainerRef.current;
160
- if (!element) return;
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
- const hasSuggestions = Array.isArray(suggestions) && suggestions.length > 0;
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
- const shouldShowWelcomeScreen = isEmpty && !welcomeScreenDisabled;
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 className="cpk:max-w-3xl cpk:mx-auto cpk:w-full">
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
- <CopilotChatAttachmentQueue
362
- attachments={attachments}
363
- onRemoveAttachment={(id) => onRemoveAttachment?.(id)}
364
- className="cpk:px-4"
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 + FEATHER_HEIGHT + 16}px`,
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 = true,
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
- const { scrollRef, contentRef, scrollToBottom } = useStickToBottom();
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 from useStickToBottom; safe to omit.
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 (autoScroll) return; // Skip for autoscroll mode
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, autoScroll]);
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
- // When autoScroll is false, we don't use StickToBottom
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 + FEATHER_HEIGHT + 16}px`,
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
+ }