@copilotkit/react-core 1.56.4-canary.1777529757 → 1.56.4-canary.1777538870

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@copilotkit/react-core",
3
- "version": "1.56.4-canary.1777529757",
3
+ "version": "1.56.4-canary.1777538870",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "ai",
@@ -52,8 +52,8 @@
52
52
  "access": "public"
53
53
  },
54
54
  "dependencies": {
55
- "@ag-ui/client": "0.0.53",
56
- "@ag-ui/core": "0.0.53",
55
+ "@ag-ui/client": "0.0.52",
56
+ "@ag-ui/core": "0.0.52",
57
57
  "@jetbrains/websandbox": "^1.1.3",
58
58
  "@lit-labs/react": "^2.0.2",
59
59
  "@radix-ui/react-dropdown-menu": "^2.1.15",
@@ -73,11 +73,11 @@
73
73
  "untruncate-json": "^0.0.1",
74
74
  "use-stick-to-bottom": "^1.1.1",
75
75
  "zod-to-json-schema": "^3.24.5",
76
- "@copilotkit/a2ui-renderer": "1.56.4-canary.1777529757",
77
- "@copilotkit/shared": "1.56.4-canary.1777529757",
78
- "@copilotkit/web-inspector": "1.56.4-canary.1777529757",
79
- "@copilotkit/runtime-client-gql": "1.56.4-canary.1777529757",
80
- "@copilotkit/core": "1.56.4-canary.1777529757"
76
+ "@copilotkit/core": "1.56.4-canary.1777538870",
77
+ "@copilotkit/runtime-client-gql": "1.56.4-canary.1777538870",
78
+ "@copilotkit/shared": "1.56.4-canary.1777538870",
79
+ "@copilotkit/a2ui-renderer": "1.56.4-canary.1777538870",
80
+ "@copilotkit/web-inspector": "1.56.4-canary.1777538870"
81
81
  },
82
82
  "devDependencies": {
83
83
  "@tailwindcss/cli": "^4.1.11",
@@ -1,13 +1,13 @@
1
- import React, { useEffect, useState } from "react";
1
+ import React, { useCallback, useEffect, useId, useRef, useState } from "react";
2
+ import { createPortal, flushSync } from "react-dom";
2
3
  import type { Attachment } from "@copilotkit/shared";
3
4
  import {
4
5
  formatFileSize,
5
6
  getSourceUrl,
6
7
  getDocumentIcon,
7
8
  } from "@copilotkit/shared";
8
- import { Play } from "lucide-react";
9
+ import { Play, X } from "lucide-react";
9
10
  import { cn } from "../../lib/utils";
10
- import { Lightbox, useLightbox } from "./Lightbox";
11
11
 
12
12
  interface CopilotChatAttachmentQueueProps {
13
13
  attachments: Attachment[];
@@ -88,6 +88,116 @@ function AttachmentPreview({ attachment }: { attachment: Attachment }) {
88
88
  }
89
89
  }
90
90
 
91
+ // ---------------------------------------------------------------------------
92
+ // Lightbox – fullscreen overlay for images and videos (portal to body)
93
+ // Uses the View Transition API when available for a smooth thumbnail-to-
94
+ // fullscreen morph; falls back to a simple opacity fade.
95
+ // ---------------------------------------------------------------------------
96
+
97
+ interface LightboxProps {
98
+ onClose: () => void;
99
+ children: React.ReactNode;
100
+ }
101
+
102
+ function Lightbox({ onClose, children }: LightboxProps) {
103
+ useEffect(() => {
104
+ const handleKey = (e: KeyboardEvent) => {
105
+ if (e.key === "Escape") onClose();
106
+ };
107
+ document.addEventListener("keydown", handleKey);
108
+ return () => document.removeEventListener("keydown", handleKey);
109
+ }, [onClose]);
110
+
111
+ if (typeof document === "undefined") return null;
112
+
113
+ return createPortal(
114
+ <div
115
+ className="cpk:fixed cpk:inset-0 cpk:z-[9999] cpk:flex cpk:items-center cpk:justify-center cpk:bg-black/80 cpk:animate-fade-in"
116
+ onClick={onClose}
117
+ >
118
+ <button
119
+ onClick={onClose}
120
+ className="cpk:absolute cpk:top-4 cpk:right-4 cpk:text-white cpk:bg-white/10 cpk:hover:bg-white/20 cpk:rounded-full cpk:w-10 cpk:h-10 cpk:flex cpk:items-center cpk:justify-center cpk:cursor-pointer cpk:border-none cpk:z-10"
121
+ aria-label="Close preview"
122
+ >
123
+ <X className="cpk:w-5 cpk:h-5" />
124
+ </button>
125
+
126
+ <div onClick={(e) => e.stopPropagation()}>{children}</div>
127
+ </div>,
128
+ document.body,
129
+ );
130
+ }
131
+
132
+ type DocWithVT = Document & {
133
+ startViewTransition?: (cb: () => void) => { finished: Promise<void> };
134
+ };
135
+
136
+ /**
137
+ * Hook that manages lightbox open/close and uses the View Transition API to
138
+ * morph the thumbnail into fullscreen content.
139
+ *
140
+ * The trick: `view-transition-name` must live on exactly ONE element at a time.
141
+ * - Old state (thumbnail visible): name is on the thumbnail.
142
+ * - New state (lightbox visible): name moves to the lightbox content.
143
+ * `flushSync` ensures React commits the DOM change synchronously inside the
144
+ * `startViewTransition` callback so the API can snapshot old → new correctly.
145
+ */
146
+ function useLightbox() {
147
+ const thumbnailRef = useRef<HTMLElement>(null);
148
+ const [open, setOpen] = useState(false);
149
+ const vtName = useId();
150
+
151
+ const openLightbox = useCallback(() => {
152
+ const thumb = thumbnailRef.current;
153
+ const doc = document as DocWithVT;
154
+
155
+ if (doc.startViewTransition && thumb) {
156
+ // Old snapshot: name on the thumbnail
157
+ thumb.style.viewTransitionName = vtName;
158
+
159
+ doc.startViewTransition(() => {
160
+ // New snapshot: remove from thumb (lightbox content will have it)
161
+ thumb.style.viewTransitionName = "";
162
+ flushSync(() => setOpen(true));
163
+ });
164
+ } else {
165
+ setOpen(true);
166
+ }
167
+ }, []);
168
+
169
+ const closeLightbox = useCallback(() => {
170
+ const thumb = thumbnailRef.current;
171
+ const doc = document as DocWithVT;
172
+
173
+ if (doc.startViewTransition && thumb) {
174
+ const transition = doc.startViewTransition(() => {
175
+ // New snapshot: name back on thumbnail
176
+ flushSync(() => setOpen(false));
177
+ thumb.style.viewTransitionName = vtName;
178
+ });
179
+ // Clean up the name after animation finishes (or fails)
180
+ transition.finished
181
+ .then(() => {
182
+ thumb.style.viewTransitionName = "";
183
+ })
184
+ .catch(() => {
185
+ thumb.style.viewTransitionName = "";
186
+ });
187
+ } else {
188
+ setOpen(false);
189
+ }
190
+ }, []);
191
+
192
+ return {
193
+ thumbnailRef,
194
+ vtName,
195
+ open,
196
+ openLightbox,
197
+ closeLightbox,
198
+ };
199
+ }
200
+
91
201
  // ---------------------------------------------------------------------------
92
202
  // Image
93
203
  // ---------------------------------------------------------------------------
@@ -2,7 +2,6 @@ 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";
6
5
 
7
6
  interface CopilotChatAttachmentRendererProps {
8
7
  type: "image" | "audio" | "video" | "document";
@@ -19,8 +18,6 @@ const ImageAttachment = memo(function ImageAttachment({
19
18
  className?: string;
20
19
  }) {
21
20
  const [error, setError] = useState(false);
22
- const { thumbnailRef, vtName, open, openLightbox, closeLightbox } =
23
- useLightbox();
24
21
 
25
22
  if (error) {
26
23
  return (
@@ -36,29 +33,12 @@ const ImageAttachment = memo(function ImageAttachment({
36
33
  }
37
34
 
38
35
  return (
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
- </>
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
+ />
62
42
  );
63
43
  });
64
44
 
@@ -217,8 +217,9 @@ export function CopilotChatUserMessage({
217
217
  data-message-id={message.id}
218
218
  {...props}
219
219
  >
220
+ {BoundMessageRenderer}
220
221
  {mediaParts.length > 0 && (
221
- <div className="cpk:flex cpk:flex-row cpk:flex-wrap cpk:justify-end cpk:gap-2 cpk:mb-2">
222
+ <div className="cpk:flex cpk:flex-col cpk:items-end cpk:gap-2 cpk:mt-2">
222
223
  {mediaParts.map((part, index) => (
223
224
  <CopilotChatAttachmentRenderer
224
225
  key={index}
@@ -229,7 +230,6 @@ export function CopilotChatUserMessage({
229
230
  ))}
230
231
  </div>
231
232
  )}
232
- {BoundMessageRenderer}
233
233
  {BoundToolbar}
234
234
  </div>
235
235
  );
@@ -167,17 +167,7 @@ export function CopilotChatView({
167
167
  className,
168
168
  ...props
169
169
  }: CopilotChatViewProps) {
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);
170
+ const inputContainerRef = useRef<HTMLDivElement>(null);
181
171
  const [inputContainerHeight, setInputContainerHeight] = useState(0);
182
172
  const [isResizing, setIsResizing] = useState(false);
183
173
  const resizeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -188,14 +178,8 @@ export function CopilotChatView({
188
178
 
189
179
  // Track input container height changes
190
180
  useEffect(() => {
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
- }
181
+ const element = inputContainerRef.current;
182
+ if (!element) return;
199
183
 
200
184
  const resizeObserver = new ResizeObserver((entries) => {
201
185
  for (const entry of entries) {
@@ -234,7 +218,7 @@ export function CopilotChatView({
234
218
  clearTimeout(resizeTimeoutRef.current);
235
219
  }
236
220
  };
237
- }, [inputContainerEl]);
221
+ }, []);
238
222
 
239
223
  const BoundMessageView = renderSlot(messageView, CopilotChatMessageView, {
240
224
  messages,
@@ -264,14 +248,15 @@ export function CopilotChatView({
264
248
  ...(disclaimer !== undefined ? { disclaimer } : {}),
265
249
  } as CopilotChatInputProps);
266
250
 
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."
251
+ // Hide suggestions while a thread is connecting or a run is in flight.
252
+ // Otherwise, mid-replay (bootstrap stream from /connect) or mid-run, the
253
+ // suggestions would render against a still-assembling message tree and
254
+ // visibly jump as each final text chunk reflows the layout.
273
255
  const hasSuggestions =
274
- !isConnecting && Array.isArray(suggestions) && suggestions.length > 0;
256
+ !isConnecting &&
257
+ !isRunning &&
258
+ Array.isArray(suggestions) &&
259
+ suggestions.length > 0;
275
260
  const BoundSuggestionView = hasSuggestions
276
261
  ? renderSlot(suggestionView, CopilotChatSuggestionView, {
277
262
  suggestions,
@@ -413,7 +398,7 @@ export function CopilotChatView({
413
398
  {BoundScrollView}
414
399
 
415
400
  <div
416
- ref={setInputContainerEl}
401
+ ref={inputContainerRef}
417
402
  data-testid="copilot-input-overlay"
418
403
  className="cpk:absolute cpk:bottom-0 cpk:left-0 cpk:right-0 cpk:z-20 cpk:pointer-events-none"
419
404
  >
@@ -6,7 +6,6 @@ import { CopilotChatConfigurationProvider } from "../../../providers/CopilotChat
6
6
  import { CopilotChatView } from "../CopilotChatView";
7
7
  import { LastUserMessageContext } from "../last-user-message-context";
8
8
  import type { Attachment } from "@copilotkit/shared";
9
- import type { Message } from "@ag-ui/core";
10
9
 
11
10
  beforeEach(() => {
12
11
  HTMLElement.prototype.scrollTo = vi.fn();
@@ -170,95 +169,4 @@ describe("CopilotChatView input overlay layout", () => {
170
169
  (global as any).ResizeObserver = OriginalRO;
171
170
  }
172
171
  });
173
-
174
- it("attaches the resize observer when transitioning from welcome to chat view", async () => {
175
- // Regression: a `[]`-deps useEffect captured `inputContainerRef.current`
176
- // as null when mounted on the welcome screen and never re-ran after the
177
- // user sent their first message. The overlay rendered without a measured
178
- // height, so paddingBottom stayed at 32 and the last messages slid
179
- // underneath the absolute-positioned input pill. Verify the observer
180
- // attaches reactively when the overlay mounts post-transition.
181
- const callbacks: Array<{
182
- cb: ResizeObserverCallback;
183
- target: Element | null;
184
- }> = [];
185
- const OriginalRO = global.ResizeObserver;
186
- class MockResizeObserver {
187
- private cb: ResizeObserverCallback;
188
- constructor(cb: ResizeObserverCallback) {
189
- this.cb = cb;
190
- }
191
- observe(target: Element) {
192
- callbacks.push({ cb: this.cb, target });
193
- }
194
- unobserve() {}
195
- disconnect() {}
196
- }
197
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
198
- (global as any).ResizeObserver = MockResizeObserver as any;
199
-
200
- try {
201
- // Render with no messages to start on the welcome screen branch — the
202
- // overlay wrapper does not exist in this DOM, so the observer cannot
203
- // attach yet.
204
- const initialMessages: Message[] = [];
205
- const screen = render(
206
- <TestWrapper>
207
- <LastUserMessageContext.Provider value={{ id: null, sendNonce: 0 }}>
208
- <CopilotChatView messages={initialMessages} />
209
- </LastUserMessageContext.Provider>
210
- </TestWrapper>,
211
- );
212
-
213
- await screen.findByTestId("copilot-welcome-screen");
214
- expect(screen.queryByTestId("copilot-input-overlay")).toBeNull();
215
-
216
- // Transition to the chat view by re-rendering with messages — mirrors
217
- // what happens when CopilotChat re-renders after the user submits.
218
- screen.rerender(
219
- <TestWrapper>
220
- <LastUserMessageContext.Provider value={{ id: null, sendNonce: 0 }}>
221
- <CopilotChatView messages={sampleMessages} />
222
- </LastUserMessageContext.Provider>
223
- </TestWrapper>,
224
- );
225
-
226
- await waitForMount(screen);
227
- const overlay = screen.getByTestId("copilot-input-overlay");
228
-
229
- // The bug: observer was attached at mount when the overlay element was
230
- // null, so it never re-attached after the transition. Verify it now
231
- // observes the overlay specifically.
232
- await waitFor(() =>
233
- expect(callbacks.some(({ target }) => target === overlay)).toBe(true),
234
- );
235
-
236
- const scrollContent = screen.getByTestId("copilot-scroll-content");
237
- // Simulate the overlay reporting a real height (e.g. 88px input pill).
238
- // Only fire on the overlay's own observer — other components (e.g. the
239
- // textarea autosize) also use ResizeObserver and would corrupt the
240
- // assertion if we fed all observers a 88px contentRect.
241
- for (const { cb, target } of callbacks) {
242
- if (target !== overlay) continue;
243
- cb(
244
- [
245
- {
246
- contentRect: { height: 88 } as DOMRectReadOnly,
247
- } as ResizeObserverEntry,
248
- ],
249
- {} as ResizeObserver,
250
- );
251
- }
252
-
253
- // 88 (input) + 32 (no suggestions baseline) = 120px. Without the fix,
254
- // paddingBottom would be stuck at 32px because the observer never
255
- // attached.
256
- await waitFor(() =>
257
- expect(scrollContent.style.paddingBottom).toBe("120px"),
258
- );
259
- } finally {
260
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
261
- (global as any).ResizeObserver = OriginalRO;
262
- }
263
- });
264
172
  });