Merge branch 'main' into SaaS

This commit is contained in:
Anthony Stirling
2026-06-10 15:51:43 +01:00
committed by GitHub
16 changed files with 1152 additions and 8 deletions
@@ -0,0 +1,60 @@
/* Overlay layer ─────────────────────────────────────────────────────────── */
.chat-fab-overlay {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
}
/* FAB trigger button ─────────────────────────────────────────────────────── */
.chat-fab-trigger {
position: absolute;
right: 16px;
bottom: calc(var(--footer-height, 2rem) + 16px);
pointer-events: auto;
opacity: 1;
/* Include box-shadow so the ChatFABButton hover shadow still animates */
transition:
opacity 160ms ease,
transform 180ms cubic-bezier(0.32, 0.72, 0, 1),
box-shadow 180ms ease;
}
@media (prefers-reduced-motion: reduce) {
.chat-fab-trigger {
transition: none;
}
}
.chat-fab-trigger--hidden {
opacity: 0;
transform: scale(0.78);
pointer-events: none;
}
/* Panel layout ───────────────────────────────────────────────────────────── */
/* ChatPanel fills remaining height when inside the FAB window */
.chat-fab-panel-rnd .chat-panel--embedded {
flex: 1;
height: auto;
min-height: 0;
}
/* Header is the drag handle — grab cursor on background areas */
.chat-fab-panel-rnd .chat-panel__header {
cursor: grab;
user-select: none;
}
.chat-fab-panel-rnd .chat-panel__header:active {
cursor: grabbing;
}
/* Buttons inside the header keep their pointer cursor */
.chat-fab-panel-rnd .chat-panel__header button,
.chat-fab-panel-rnd .chat-panel__header [role="button"] {
cursor: pointer;
}
@@ -0,0 +1,198 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createTheme, MantineProvider, Popover } from "@mantine/core";
import { Rnd } from "react-rnd";
import { useTranslation } from "react-i18next";
import { ChatFABButton } from "@shared/components/ChatFABButton";
import { ChatFABWindow } from "@shared/components/ChatFABWindow";
import { ChatPanel } from "@app/components/chat/ChatPanel";
import { useChat } from "@app/components/chat/ChatContext";
import { useAgentsEnabled } from "@app/components/agents/AgentsPanel";
import { Z_INDEX_CHAT_FAB_OVERLAY } from "@app/styles/zIndex";
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;
// footer-height (2rem = 32px) + gap so the panel clears the footer
const FAB_BOTTOM_OFFSET_PX = 32 + 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: {
Popover: Popover.extend({
defaultProps: { zIndex: Z_INDEX_CHAT_FAB_OVERLAY + 300 },
}),
},
});
// 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
// open state so it doesn't interact with the right-rail chat panel.
const [isOpen, setIsOpen] = useState(false);
const [hasUnviewedResult, setHasUnviewedResult] = useState(false);
const { isLoading } = useChat();
const enabled = useAgentsEnabled();
// Detect loading → done transition. If the FAB is closed when the agent
// finishes, show the tick badge until the user opens the panel.
const isOpenRef = useRef(isOpen);
isOpenRef.current = isOpen;
// Initialize to false, not isLoading: if loading is already in-flight at
// mount we never showed the spinner, so we shouldn't show the tick on completion.
const prevIsLoadingRef = useRef(false);
useEffect(() => {
const wasLoading = prevIsLoadingRef.current;
prevIsLoadingRef.current = isLoading;
if (wasLoading && !isLoading && !isOpenRef.current) {
setHasUnviewedResult(true);
}
}, [isLoading]);
const overlayRef = useRef<HTMLDivElement>(null);
const [rndPos, setRndPos] = useState<{ x: number; y: number } | null>(null);
const [rndSize, setRndSize] = useState({
width: PANEL_WIDTH_PX,
height: PANEL_HEIGHT_PX,
});
const [isAnimatingReset, setIsAnimatingReset] = useState(false);
const resetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
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,
};
};
useLayoutEffect(() => {
const pos = getDefaultPos();
if (pos) setRndPos(pos);
}, []);
// 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(() => {
return () => {
if (resetTimerRef.current !== null) clearTimeout(resetTimerRef.current);
document.body.style.removeProperty("user-select");
document.body.style.removeProperty("-webkit-user-select");
};
}, []);
const cancelResetAnimation = () => {
if (resetTimerRef.current !== null) {
clearTimeout(resetTimerRef.current);
resetTimerRef.current = null;
}
setIsAnimatingReset(false);
};
const handleHeaderDoubleClick = (e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
if (target.closest(".chat-panel__header") && !target.closest("button")) {
const pos = getDefaultPos();
if (!pos) return;
if (resetTimerRef.current !== null) clearTimeout(resetTimerRef.current);
setIsAnimatingReset(true);
setRndPos(pos);
setRndSize({ width: PANEL_WIDTH_PX, height: PANEL_HEIGHT_PX });
resetTimerRef.current = setTimeout(() => {
setIsAnimatingReset(false);
resetTimerRef.current = null;
}, RESET_MS + 60);
}
};
if (!enabled) return null;
return (
<div
ref={overlayRef}
className="chat-fab-overlay"
style={{ zIndex: Z_INDEX_CHAT_FAB_OVERLAY }}
>
{/* Trigger button — fades out while panel is open */}
<ChatFABButton
className={`chat-fab-trigger${isOpen ? " chat-fab-trigger--hidden" : ""}`}
onClick={() => {
setIsOpen(true);
setHasUnviewedResult(false);
}}
aria-label={t("chat.fab.open", "Open Stirling AI assistant")}
aria-expanded={isOpen}
isLoading={isLoading}
showTick={hasUnviewedResult && !isLoading}
/>
{/* Draggable / resizable panel */}
{rndPos !== null && (
<Rnd
className="chat-fab-panel-rnd"
position={rndPos}
size={rndSize}
minWidth={PANEL_MIN_WIDTH_PX}
minHeight={PANEL_MIN_HEIGHT_PX}
bounds="parent"
enableResizing={true}
// Drag from the header; cancel keeps buttons inside it clickable
dragHandleClassName="chat-panel__header"
cancel="button, [role='button']"
onDragStart={cancelResetAnimation}
onDragStop={(_e, d) => setRndPos({ x: d.x, y: d.y })}
onResizeStart={() => {
cancelResetAnimation();
document.body.style.setProperty("user-select", "none");
document.body.style.setProperty("-webkit-user-select", "none");
}}
onResizeStop={(_e, _dir, ref, _delta, pos) => {
document.body.style.removeProperty("user-select");
document.body.style.removeProperty("-webkit-user-select");
setRndSize({ width: ref.offsetWidth, height: ref.offsetHeight });
setRndPos(pos);
}}
// Invisible strips centred on the border — cursor change is the affordance.
// zIndex: 1 lifts them above ChatFABWindow's stacking context.
resizeHandleStyles={RESIZE_HANDLES}
style={{
pointerEvents: isOpen ? "auto" : "none",
transition: isAnimatingReset ? RESET_TRANSITION : undefined,
}}
>
<ChatFABWindow open={isOpen} onDoubleClick={handleHeaderDoubleClick}>
<MantineProvider theme={FAB_PANEL_THEME}>
<ChatPanel
onBack={() => setIsOpen(false)}
backLabel={t("chat.fab.close", "Close chat")}
/>
</MantineProvider>
</ChatFABWindow>
</Rnd>
)}
</div>
);
}