Skip to content

Commit 72aea5a

Browse files
Gus Hcursoragent
authored andcommitted
Fix design chat popup clipping at viewport edge.
Measure real popup height, flip above anchor when needed, and cap max-height to the viewport. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 22127ab commit 72aea5a

2 files changed

Lines changed: 78 additions & 23 deletions

File tree

src/app/globals.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -900,6 +900,7 @@
900900
display: flex;
901901
flex-direction: column;
902902
overflow: hidden;
903+
max-height: calc(100vh - 24px);
903904
border: 1px solid rgba(0, 0, 0, 0.05);
904905
border-radius: 16px;
905906
background: #ffffff;

src/components/design/ElementChatPopup.tsx

Lines changed: 77 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useCallback, useEffect, useRef, useState } from "react";
3+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
44
import {
55
sendAgentMessage,
66
streamAgentRun,
@@ -29,10 +29,64 @@ type ElementChatPopupProps = {
2929

3030
const POPUP_MIN_WIDTH = 360;
3131
const POPUP_MAX_WIDTH = 480;
32-
const POPUP_ESTIMATED_HEIGHT = 96;
32+
const POPUP_FALLBACK_HEIGHT = 132;
3333
const VIEWPORT_PADDING = 12;
3434
const ANCHOR_OVERLAP = 14;
3535

36+
type PopupLayout = { left: number; top: number; width: number };
37+
38+
function computePopupWidth(anchorRect: ElementChatPopupProps["anchorRect"]): number {
39+
if (typeof window === "undefined") {
40+
return POPUP_MIN_WIDTH;
41+
}
42+
43+
return Math.min(
44+
POPUP_MAX_WIDTH,
45+
Math.max(POPUP_MIN_WIDTH, anchorRect.width),
46+
window.innerWidth - VIEWPORT_PADDING * 2,
47+
);
48+
}
49+
50+
function computePopupLayout(
51+
anchorRect: ElementChatPopupProps["anchorRect"],
52+
popupHeight: number,
53+
): PopupLayout {
54+
if (typeof window === "undefined") {
55+
return {
56+
left: anchorRect.left,
57+
top: anchorRect.top + anchorRect.height - ANCHOR_OVERLAP,
58+
width: POPUP_MIN_WIDTH,
59+
};
60+
}
61+
62+
const width = computePopupWidth(anchorRect);
63+
const height = Math.min(
64+
popupHeight,
65+
window.innerHeight - VIEWPORT_PADDING * 2,
66+
);
67+
68+
let left = anchorRect.left;
69+
const maxLeft = window.innerWidth - width - VIEWPORT_PADDING;
70+
left = Math.max(VIEWPORT_PADDING, Math.min(left, maxLeft));
71+
72+
const belowTop = anchorRect.top + anchorRect.height - ANCHOR_OVERLAP;
73+
const aboveTop = anchorRect.top - height + ANCHOR_OVERLAP;
74+
const spaceBelow = window.innerHeight - VIEWPORT_PADDING - belowTop;
75+
const spaceAbove = anchorRect.top - VIEWPORT_PADDING;
76+
77+
let top: number;
78+
if (height <= spaceBelow || spaceBelow >= spaceAbove) {
79+
top = belowTop;
80+
} else {
81+
top = aboveTop;
82+
}
83+
84+
const maxTop = window.innerHeight - VIEWPORT_PADDING - height;
85+
top = Math.max(VIEWPORT_PADDING, Math.min(top, maxTop));
86+
87+
return { left, top, width };
88+
}
89+
3690
type SpeechRecognitionResultList = {
3791
length: number;
3892
[index: number]: {
@@ -74,26 +128,7 @@ function getSpeechRecognitionCtor(): (new () => SpeechRecognitionLike) | null {
74128
}
75129

76130
function getPopupLayout(anchorRect: ElementChatPopupProps["anchorRect"]) {
77-
if (typeof window === "undefined") {
78-
return { left: anchorRect.left, top: anchorRect.top + anchorRect.height - ANCHOR_OVERLAP, width: POPUP_MIN_WIDTH };
79-
}
80-
81-
const width = Math.min(
82-
POPUP_MAX_WIDTH,
83-
Math.max(POPUP_MIN_WIDTH, anchorRect.width),
84-
window.innerWidth - VIEWPORT_PADDING * 2,
85-
);
86-
87-
let left = anchorRect.left;
88-
let top = anchorRect.top + anchorRect.height - ANCHOR_OVERLAP;
89-
90-
const maxLeft = window.innerWidth - width - VIEWPORT_PADDING;
91-
left = Math.max(VIEWPORT_PADDING, Math.min(left, maxLeft));
92-
93-
const maxTop = window.innerHeight - POPUP_ESTIMATED_HEIGHT - VIEWPORT_PADDING;
94-
top = Math.max(VIEWPORT_PADDING, Math.min(top, maxTop));
95-
96-
return { left, top, width };
131+
return computePopupLayout(anchorRect, POPUP_FALLBACK_HEIGHT);
97132
}
98133

99134
function buildTranscript(event: SpeechRecognitionResultEvent) {
@@ -141,9 +176,28 @@ export function ElementChatPopup({
141176
getSpeechRecognitionCtor() !== null &&
142177
typeof navigator.mediaDevices?.getUserMedia === "function";
143178
const [dictationError, setDictationError] = useState<string | null>(null);
144-
const layout = getPopupLayout(anchorRect);
179+
const [layout, setLayout] = useState<PopupLayout>(() => getPopupLayout(anchorRect));
145180
const hasThread = messages.length > 0 || isThinking;
146181

182+
const syncPopupLayout = useCallback(() => {
183+
const height = popupRef.current?.offsetHeight ?? POPUP_FALLBACK_HEIGHT;
184+
setLayout(computePopupLayout(anchorRect, height));
185+
}, [anchorRect]);
186+
187+
useLayoutEffect(() => {
188+
syncPopupLayout();
189+
}, [syncPopupLayout, messages, isThinking, hasThread]);
190+
191+
useEffect(() => {
192+
const schedule = () => syncPopupLayout();
193+
window.addEventListener("resize", schedule);
194+
window.addEventListener("scroll", schedule, true);
195+
return () => {
196+
window.removeEventListener("resize", schedule);
197+
window.removeEventListener("scroll", schedule, true);
198+
};
199+
}, [syncPopupLayout]);
200+
147201
const appendMessage = useCallback((role: Message["role"], content: string) => {
148202
setMessages((prev) => [...prev, { id: crypto.randomUUID(), role, content }]);
149203
}, []);

0 commit comments

Comments
 (0)