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 { useChat } from "@app/components/chat/ChatContext";
|
||||||
import { useAiEngineEnabled } from "@app/hooks/useAiEngineEnabled";
|
import { useAiEngineEnabled } from "@app/hooks/useAiEngineEnabled";
|
||||||
import { Z_INDEX_CHAT_FAB_OVERLAY } from "@app/styles/zIndex";
|
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";
|
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.
|
// Raise Mantine popup z-index so Menu/Popover portals appear above the FAB overlay.
|
||||||
const FAB_PANEL_THEME = createTheme({
|
const FAB_PANEL_THEME = createTheme({
|
||||||
components: {
|
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() {
|
export function ChatFAB() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Intentionally separate from useChat().isOpen — the FAB tracks its own
|
// Intentionally separate from useChat().isOpen — the FAB tracks its own
|
||||||
@@ -81,13 +65,22 @@ export function ChatFAB() {
|
|||||||
const [isAnimatingReset, setIsAnimatingReset] = useState(false);
|
const [isAnimatingReset, setIsAnimatingReset] = useState(false);
|
||||||
const resetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
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 getDefaultPos = () => {
|
||||||
const el = overlayRef.current;
|
const el = overlayRef.current;
|
||||||
if (!el) return null;
|
if (!el) return null;
|
||||||
return {
|
return defaultPanelPos(el.offsetWidth, el.offsetHeight);
|
||||||
x: el.offsetWidth - PANEL_WIDTH_PX - FAB_GAP_PX,
|
|
||||||
y: el.offsetHeight - PANEL_HEIGHT_PX - FAB_BOTTOM_OFFSET_PX,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -99,6 +92,29 @@ export function ChatFAB() {
|
|||||||
if (pos) setRndPos(pos);
|
if (pos) setRndPos(pos);
|
||||||
}, [enabled]);
|
}, [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.
|
// 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.
|
// Also ensure body user-select is restored if we unmount mid-resize.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -126,6 +142,8 @@ export function ChatFAB() {
|
|||||||
setIsAnimatingReset(true);
|
setIsAnimatingReset(true);
|
||||||
setRndPos(pos);
|
setRndPos(pos);
|
||||||
setRndSize({ width: PANEL_WIDTH_PX, height: PANEL_HEIGHT_PX });
|
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(() => {
|
resetTimerRef.current = setTimeout(() => {
|
||||||
setIsAnimatingReset(false);
|
setIsAnimatingReset(false);
|
||||||
resetTimerRef.current = null;
|
resetTimerRef.current = null;
|
||||||
@@ -174,7 +192,14 @@ export function ChatFAB() {
|
|||||||
dragHandleClassName="chat-panel__header"
|
dragHandleClassName="chat-panel__header"
|
||||||
cancel="button, [role='button']"
|
cancel="button, [role='button']"
|
||||||
onDragStart={cancelResetAnimation}
|
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={() => {
|
onResizeStart={() => {
|
||||||
cancelResetAnimation();
|
cancelResetAnimation();
|
||||||
document.body.style.setProperty("user-select", "none");
|
document.body.style.setProperty("user-select", "none");
|
||||||
@@ -185,6 +210,7 @@ export function ChatFAB() {
|
|||||||
document.body.style.removeProperty("-webkit-user-select");
|
document.body.style.removeProperty("-webkit-user-select");
|
||||||
setRndSize({ width: ref.offsetWidth, height: ref.offsetHeight });
|
setRndSize({ width: ref.offsetWidth, height: ref.offsetHeight });
|
||||||
setRndPos(pos);
|
setRndPos(pos);
|
||||||
|
setIsAnchored(false);
|
||||||
}}
|
}}
|
||||||
// Invisible strips centred on the border — cursor change is the affordance.
|
// Invisible strips centred on the border — cursor change is the affordance.
|
||||||
// zIndex: 1 lifts them above ChatFABWindow's stacking context.
|
// 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