Skip to content

Commit cff40fd

Browse files
tylerslatonclaude
andcommitted
fix(react-core): re-attach overlay observer when leaving welcome screen
`CopilotChatView` mounts on the welcome-screen branch, where the absolute- positioned input overlay (and its `inputContainerRef`) does not exist. The ResizeObserver useEffect ran once with an empty `[]` dep array, found `ref.current === null`, and bailed. Submitting the first message swapped to the chat-view branch and attached the overlay element — but the effect never re-ran, so `inputContainerHeight` stayed at 0 and the scroll content's reserved bottom padding sat at 32px instead of ~input height. Late messages and any "always" suggestion strip slid underneath the input pill, invisible to the user. Hold the overlay element in state via a callback ref and key the effect on the element. Same pattern already used by `nonAutoScrollRefCallback` in this file. Effect now attaches and detaches reactively as the overlay mounts/unmounts (e.g. clearing messages and falling back to the welcome screen also resets the measured height instead of holding stale data). Add a test that mounts on the welcome screen, re-renders with messages, and asserts the observer attaches to the new overlay element and feeds the correct paddingBottom. Reverting the fix makes it fail on the post-transition padding assertion. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0a7e1be commit cff40fd

2 files changed

Lines changed: 113 additions & 5 deletions

File tree

packages/react-core/src/v2/components/chat/CopilotChatView.tsx

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,17 @@ export function CopilotChatView({
167167
className,
168168
...props
169169
}: 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);
171181
const [inputContainerHeight, setInputContainerHeight] = useState(0);
172182
const [isResizing, setIsResizing] = useState(false);
173183
const resizeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -178,8 +188,14 @@ export function CopilotChatView({
178188

179189
// Track input container height changes
180190
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+
}
183199

184200
const resizeObserver = new ResizeObserver((entries) => {
185201
for (const entry of entries) {
@@ -218,7 +234,7 @@ export function CopilotChatView({
218234
clearTimeout(resizeTimeoutRef.current);
219235
}
220236
};
221-
}, []);
237+
}, [inputContainerEl]);
222238

223239
const BoundMessageView = renderSlot(messageView, CopilotChatMessageView, {
224240
messages,
@@ -398,7 +414,7 @@ export function CopilotChatView({
398414
{BoundScrollView}
399415

400416
<div
401-
ref={inputContainerRef}
417+
ref={setInputContainerEl}
402418
data-testid="copilot-input-overlay"
403419
className="cpk:absolute cpk:bottom-0 cpk:left-0 cpk:right-0 cpk:z-20 cpk:pointer-events-none"
404420
>

packages/react-core/src/v2/components/chat/__tests__/CopilotChatView.inputOverlay.test.tsx

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { CopilotChatConfigurationProvider } from "../../../providers/CopilotChat
66
import { CopilotChatView } from "../CopilotChatView";
77
import { LastUserMessageContext } from "../last-user-message-context";
88
import type { Attachment } from "@copilotkit/shared";
9+
import type { Message } from "@ag-ui/core";
910

1011
beforeEach(() => {
1112
HTMLElement.prototype.scrollTo = vi.fn();
@@ -169,4 +170,95 @@ describe("CopilotChatView input overlay layout", () => {
169170
(global as any).ResizeObserver = OriginalRO;
170171
}
171172
});
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+
});
172264
});

0 commit comments

Comments
 (0)