mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
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:
@@ -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)),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user