@copilotkit/react-core 1.56.4 → 1.56.5-canary.1777664617

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