From 3750111ffcff058d5ec6d59ee09f545ee7534ed9 Mon Sep 17 00:00:00 2001
From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Date: Wed, 17 Jun 2026 16:54:09 +0100
Subject: [PATCH] fix agent overlay chat position when workbench size changes
(#6682)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../proprietary/components/chat/ChatFAB.tsx | 90 ++++++++++++-------
.../components/chat/chatFabLayout.ts | 52 +++++++++++
2 files changed, 110 insertions(+), 32 deletions(-)
create mode 100644 frontend/editor/src/proprietary/components/chat/chatFabLayout.ts
diff --git a/frontend/editor/src/proprietary/components/chat/ChatFAB.tsx b/frontend/editor/src/proprietary/components/chat/ChatFAB.tsx
index 8c0a426a8..5541284f6 100644
--- a/frontend/editor/src/proprietary/components/chat/ChatFAB.tsx
+++ b/frontend/editor/src/proprietary/components/chat/ChatFAB.tsx
@@ -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 | 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.
diff --git a/frontend/editor/src/proprietary/components/chat/chatFabLayout.ts b/frontend/editor/src/proprietary/components/chat/chatFabLayout.ts
new file mode 100644
index 000000000..db2fd76d6
--- /dev/null
+++ b/frontend/editor/src/proprietary/components/chat/chatFabLayout.ts
@@ -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)),
+ };
+}