fix agent overlay chat position when workbench size changes (#6682)

<img width="2056" height="1047" alt="Screenshot 2026-06-16 at 12 42
05 AM"
src="https://github.com/user-attachments/assets/74a38b93-f31f-4263-bb62-24c2334a22e8"
/>
<img width="1443" height="1051" alt="Screenshot 2026-06-16 at 12 42
33 AM"
src="https://github.com/user-attachments/assets/adb3ba47-f3e6-44a7-bbc3-2097e15843b6"
/>
This commit is contained in:
EthanHealy01
2026-06-17 15:54:09 +00:00
committed by GitHub
parent 20c88feabb
commit 3750111ffc
2 changed files with 110 additions and 32 deletions
@@ -8,19 +8,19 @@ import { ChatPanel } from "@app/components/chat/ChatPanel";
import { useChat } from "@app/components/chat/ChatContext";
import { useAiEngineEnabled } from "@app/hooks/useAiEngineEnabled";
import { Z_INDEX_CHAT_FAB_OVERLAY } from "@app/styles/zIndex";
import {
PANEL_WIDTH_PX,
PANEL_HEIGHT_PX,
PANEL_MIN_WIDTH_PX,
PANEL_MIN_HEIGHT_PX,
RESET_MS,
RESET_TRANSITION,
RESIZE_HANDLES,
clampToOverlay,
defaultPanelPos,
} from "@app/components/chat/chatFabLayout";
import "@app/components/chat/ChatFAB.css";
const PANEL_WIDTH_PX = 390;
const PANEL_HEIGHT_PX = 520;
const PANEL_MIN_WIDTH_PX = 300;
const PANEL_MIN_HEIGHT_PX = 380;
const FAB_GAP_PX = 16;
const FAB_BOTTOM_OFFSET_PX = FAB_GAP_PX;
const RESET_MS = 380;
const RESET_EASING = "cubic-bezier(0.32, 0.72, 0, 1)";
const RESET_TRANSITION = `transform ${RESET_MS}ms ${RESET_EASING}, width ${RESET_MS}ms ${RESET_EASING}, height ${RESET_MS}ms ${RESET_EASING}`;
// Raise Mantine popup z-index so Menu/Popover portals appear above the FAB overlay.
const FAB_PANEL_THEME = createTheme({
components: {
@@ -30,22 +30,6 @@ const FAB_PANEL_THEME = createTheme({
},
});
// Resize handle strips sit half-inside / half-outside the 1px border.
// zIndex: 1 ensures they appear above ChatFABWindow's stacking context
// (which is created by the CSS open/close transform), so the resize cursor
// is visible on hover — not just during active drag.
// Corners get a 14×14 zone; edges get a 6px-wide strip.
const RESIZE_HANDLES = {
top: { top: -3, left: 14, right: 14, height: 6, zIndex: 1 },
bottom: { bottom: -3, left: 14, right: 14, height: 6, zIndex: 1 },
left: { left: -3, top: 14, bottom: 14, width: 6, zIndex: 1 },
right: { right: -3, top: 14, bottom: 14, width: 6, zIndex: 1 },
topLeft: { top: -4, left: -4, width: 14, height: 14, zIndex: 1 },
topRight: { top: -4, right: -4, width: 14, height: 14, zIndex: 1 },
bottomLeft: { bottom: -4, left: -4, width: 14, height: 14, zIndex: 1 },
bottomRight: { bottom: -4, right: -4, width: 14, height: 14, zIndex: 1 },
};
export function ChatFAB() {
const { t } = useTranslation();
// Intentionally separate from useChat().isOpen — the FAB tracks its own
@@ -81,13 +65,22 @@ export function ChatFAB() {
const [isAnimatingReset, setIsAnimatingReset] = useState(false);
const resetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Anchored = at default home; re-homes on overlay resize. Drag/resize breaks anchor; double-click restores it.
const [isAnchored, setIsAnchored] = useState(true);
// Mirror the latest position/size/anchor into refs so the ResizeObserver below —
// set up once — always reads current values without re-subscribing on every drag/resize.
const rndPosRef = useRef(rndPos);
rndPosRef.current = rndPos;
const rndSizeRef = useRef(rndSize);
rndSizeRef.current = rndSize;
const isAnchoredRef = useRef(isAnchored);
isAnchoredRef.current = isAnchored;
const getDefaultPos = () => {
const el = overlayRef.current;
if (!el) return null;
return {
x: el.offsetWidth - PANEL_WIDTH_PX - FAB_GAP_PX,
y: el.offsetHeight - PANEL_HEIGHT_PX - FAB_BOTTOM_OFFSET_PX,
};
return defaultPanelPos(el.offsetWidth, el.offsetHeight);
};
useLayoutEffect(() => {
@@ -99,6 +92,29 @@ export function ChatFAB() {
if (pos) setRndPos(pos);
}, [enabled]);
// bounds="parent" only clamps during active drag/resize; ResizeObserver keeps position valid when the overlay changes size.
useEffect(() => {
if (!enabled) return;
const el = overlayRef.current;
if (!el) return;
const observer = new ResizeObserver(() => {
const pos = rndPosRef.current;
if (!pos) return;
const base = isAnchoredRef.current
? defaultPanelPos(el.offsetWidth, el.offsetHeight)
: pos;
const next = clampToOverlay(
base,
rndSizeRef.current,
el.offsetWidth,
el.offsetHeight,
);
if (next.x !== pos.x || next.y !== pos.y) setRndPos(next);
});
observer.observe(el);
return () => observer.disconnect();
}, [enabled]);
// Clear the reset timer on unmount to avoid state updates on dead components.
// Also ensure body user-select is restored if we unmount mid-resize.
useEffect(() => {
@@ -126,6 +142,8 @@ export function ChatFAB() {
setIsAnimatingReset(true);
setRndPos(pos);
setRndSize({ width: PANEL_WIDTH_PX, height: PANEL_HEIGHT_PX });
// Back at the default home — re-enable corner-hugging on resize.
setIsAnchored(true);
resetTimerRef.current = setTimeout(() => {
setIsAnimatingReset(false);
resetTimerRef.current = null;
@@ -174,7 +192,14 @@ export function ChatFAB() {
dragHandleClassName="chat-panel__header"
cancel="button, [role='button']"
onDragStart={cancelResetAnimation}
onDragStop={(_e, d) => setRndPos({ x: d.x, y: d.y })}
onDragStop={(_e, d) => {
setRndPos({ x: d.x, y: d.y });
// A bare click on the header fires drag start/stop without movement;
// only break the anchor when the panel actually moved.
if (rndPos && (d.x !== rndPos.x || d.y !== rndPos.y)) {
setIsAnchored(false);
}
}}
onResizeStart={() => {
cancelResetAnimation();
document.body.style.setProperty("user-select", "none");
@@ -185,6 +210,7 @@ export function ChatFAB() {
document.body.style.removeProperty("-webkit-user-select");
setRndSize({ width: ref.offsetWidth, height: ref.offsetHeight });
setRndPos(pos);
setIsAnchored(false);
}}
// Invisible strips centred on the border — cursor change is the affordance.
// zIndex: 1 lifts them above ChatFABWindow's stacking context.
@@ -0,0 +1,52 @@
// Pure layout/geometry for the draggable ChatFAB panel. Kept free of React so
// the constants and clamping math stay readable and unit-testable; ChatFAB.tsx
// owns the stateful wiring (refs, effects, the ResizeObserver) that consumes them.
export const PANEL_WIDTH_PX = 390;
export const PANEL_HEIGHT_PX = 520;
export const PANEL_MIN_WIDTH_PX = 300;
export const PANEL_MIN_HEIGHT_PX = 380;
export const FAB_GAP_PX = 16;
const FAB_BOTTOM_OFFSET_PX = FAB_GAP_PX;
export const RESET_MS = 380;
const RESET_EASING = "cubic-bezier(0.32, 0.72, 0, 1)";
export const RESET_TRANSITION = `transform ${RESET_MS}ms ${RESET_EASING}, width ${RESET_MS}ms ${RESET_EASING}, height ${RESET_MS}ms ${RESET_EASING}`;
// Handle strips straddle the border; zIndex:1 lifts them above ChatFABWindow's transform stacking context.
export const RESIZE_HANDLES = {
top: { top: -3, left: 14, right: 14, height: 6, zIndex: 1 },
bottom: { bottom: -3, left: 14, right: 14, height: 6, zIndex: 1 },
left: { left: -3, top: 14, bottom: 14, width: 6, zIndex: 1 },
right: { right: -3, top: 14, bottom: 14, width: 6, zIndex: 1 },
topLeft: { top: -4, left: -4, width: 14, height: 14, zIndex: 1 },
topRight: { top: -4, right: -4, width: 14, height: 14, zIndex: 1 },
bottomLeft: { bottom: -4, left: -4, width: 14, height: 14, zIndex: 1 },
bottomRight: { bottom: -4, right: -4, width: 14, height: 14, zIndex: 1 },
};
// Default resting position: bottom-right of the overlay, inset by the gap.
export function defaultPanelPos(
overlayWidth: number,
overlayHeight: number,
): { x: number; y: number } {
return {
x: overlayWidth - PANEL_WIDTH_PX - FAB_GAP_PX,
y: overlayHeight - PANEL_HEIGHT_PX - FAB_BOTTOM_OFFSET_PX,
};
}
// Clamps pos to [gap, max] on each axis; pins to top-left gap when the overlay is too small so the header stays reachable.
export function clampToOverlay(
pos: { x: number; y: number },
size: { width: number; height: number },
overlayWidth: number,
overlayHeight: number,
): { x: number; y: number } {
const maxX = overlayWidth - size.width - FAB_GAP_PX;
const maxY = overlayHeight - size.height - FAB_GAP_PX;
return {
x: Math.max(FAB_GAP_PX, Math.min(pos.x, maxX)),
y: Math.max(FAB_GAP_PX, Math.min(pos.y, maxY)),
};
}