diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index 97679a1327..cf706ef89c 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -264,6 +264,14 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit isNewConvo && !wasAbortedBeforeComplete; + // Save user message BEFORE sending final event to avoid race condition + // where client refetch happens before database is updated + if (!client.skipSaveUserMessage && userMessage) { + await saveMessage(req, userMessage, { + context: 'api/server/controllers/agents/request.js - resumable user message', + }); + } + if (!wasAbortedBeforeComplete) { const finalEvent = { final: true, @@ -298,12 +306,6 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit await decrementPendingRequest(userId); } - if (!client.skipSaveUserMessage && userMessage) { - await saveMessage(req, userMessage, { - context: 'api/server/controllers/agents/request.js - resumable user message', - }); - } - if (shouldGenerateTitle) { addTitle(req, { text, diff --git a/client/src/components/Chat/Input/Files/ImagePreview.tsx b/client/src/components/Chat/Input/Files/ImagePreview.tsx index 943de4a970..c675c9326c 100644 --- a/client/src/components/Chat/Input/Files/ImagePreview.tsx +++ b/client/src/components/Chat/Input/Files/ImagePreview.tsx @@ -1,7 +1,8 @@ import { useState, useEffect, useCallback, useRef } from 'react'; -import { Maximize2 } from 'lucide-react'; +import { Button } from '@librechat/client'; +import { Maximize2, X } from 'lucide-react'; import { FileSources } from 'librechat-data-provider'; -import { OGDialog, OGDialogContent } from '@librechat/client'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; import ProgressCircle from './ProgressCircle'; import SourceIcon from './SourceIcon'; import { cn } from '~/utils'; @@ -31,6 +32,8 @@ const ImagePreview = ({ const [isModalOpen, setIsModalOpen] = useState(false); const [isHovered, setIsHovered] = useState(false); const triggerRef = useRef(null); + const imageRef = useRef(null); + const closeButtonRef = useRef(null); const openModal = useCallback(() => { setIsModalOpen(true); @@ -45,6 +48,16 @@ const ImagePreview = ({ } }, []); + // Handle click on background areas to close (only if clicking the overlay/content directly) + const handleBackgroundClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + handleOpenChange(false); + } + }, + [handleOpenChange], + ); + useEffect(() => { if (isModalOpen) { document.body.style.overflow = 'hidden'; @@ -57,6 +70,18 @@ const ImagePreview = ({ }; }, [isModalOpen]); + // Handle escape key + useEffect(() => { + if (!isModalOpen) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + handleOpenChange(false); + } + }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [isModalOpen, handleOpenChange]); + const baseStyle: styleProps = { backgroundSize: 'cover', backgroundPosition: 'center', @@ -85,24 +110,25 @@ const ImagePreview = ({ return ( <> -
{ + e.preventDefault(); + e.stopPropagation(); + openModal(); + }} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > -
+ - - - {alt} + + - - + { + e.preventDefault(); + closeButtonRef.current?.focus(); + }} + onCloseAutoFocus={(e) => { + e.preventDefault(); + triggerRef.current?.focus(); + }} + onPointerDownOutside={(e) => e.preventDefault()} + onClick={handleBackgroundClick} + > + {/* Close button */} + + + {/* Image container */} +
e.stopPropagation()}> + {alt} +
+
+ + ); }; diff --git a/client/src/components/Chat/Messages/Content/DialogImage.tsx b/client/src/components/Chat/Messages/Content/DialogImage.tsx index 723dc42f59..cb496de646 100644 --- a/client/src/components/Chat/Messages/Content/DialogImage.tsx +++ b/client/src/components/Chat/Messages/Content/DialogImage.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback, useRef } from 'react'; -import { Button, OGDialog, OGDialogContent, TooltipAnchor } from '@librechat/client'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { Button, TooltipAnchor } from '@librechat/client'; import { X, ArrowDownToLine, PanelLeftOpen, PanelLeftClose, RotateCcw } from 'lucide-react'; import { useLocalize } from '~/hooks'; @@ -13,7 +14,26 @@ const getQualityStyles = (quality: string): string => { return 'bg-gray-100 text-gray-800'; }; -export default function DialogImage({ isOpen, onOpenChange, src = '', downloadImage, args }) { +export default function DialogImage({ + isOpen, + onOpenChange, + src = '', + downloadImage, + args, + triggerRef, +}: { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + src?: string; + downloadImage: () => void; + args?: { + prompt?: string; + quality?: string; + size?: string; + [key: string]: unknown; + }; + triggerRef?: React.RefObject; +}) { const localize = useLocalize(); const [isPromptOpen, setIsPromptOpen] = useState(false); const [imageSize, setImageSize] = useState(null); @@ -26,6 +46,8 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); const containerRef = useRef(null); + const imageRef = useRef(null); + const closeButtonRef = useRef(null); const getImageSize = useCallback(async (url: string) => { try { @@ -56,15 +78,6 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; - const getImageMaxWidth = () => { - // On mobile (when panel overlays), use full width minus padding - // On desktop, account for the side panel width - if (isPromptOpen) { - return window.innerWidth >= 640 ? 'calc(100vw - 22rem)' : 'calc(100vw - 2rem)'; - } - return 'calc(100vw - 2rem)'; - }; - const resetZoom = useCallback(() => { setZoom(1); setPanX(0); @@ -80,7 +93,6 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm if (zoom > 1) { resetZoom(); } else { - // Zoom in to 2x on double click when at normal zoom setZoom(2); } }, [zoom, resetZoom]); @@ -94,13 +106,11 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; - // Calculate zoom factor const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; const newZoom = Math.min(Math.max(zoom * zoomFactor, 1), 5); if (newZoom === zoom) return; - // If zooming back to 1, reset pan to center the image if (newZoom === 1) { setZoom(1); setPanX(0); @@ -108,11 +118,9 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm return; } - // Calculate the zoom center relative to the current viewport const containerCenterX = rect.width / 2; const containerCenterY = rect.height / 2; - // Calculate new pan position to zoom towards mouse cursor const zoomRatio = newZoom / zoom; const deltaX = (mouseX - containerCenterX - panX) * (zoomRatio - 1); const deltaY = (mouseY - containerCenterY - panY) * (zoomRatio - 1); @@ -147,15 +155,41 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm }, [isDragging, dragStart, zoom], ); + const handleMouseUp = useCallback(() => { setIsDragging(false); }, []); + // Handle click on empty areas to close (only if clicking overlay/content directly, not children) + const handleBackgroundClick = useCallback( + (e: React.MouseEvent) => { + // Only close if clicking directly on overlay/content background + if (e.target !== e.currentTarget) { + return; + } + // Don't close if zoomed (user might be panning) + if (zoom > 1) { + return; + } + onOpenChange(false); + }, + [onOpenChange, zoom], + ); + useEffect(() => { - const onKey = (e: KeyboardEvent) => e.key === 'Escape' && resetZoom(); + if (!isOpen) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + if (zoom > 1) { + resetZoom(); + } else { + onOpenChange(false); + } + } + }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); - }, [resetZoom]); + }, [resetZoom, onOpenChange, isOpen, zoom]); useEffect(() => { if (isOpen && src) { @@ -164,7 +198,6 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm } }, [isOpen, src, getImageSize, resetZoom]); - // Ensure image is centered when zoom changes to 1 useEffect(() => { if (zoom === 1) { setPanX(0); @@ -172,7 +205,6 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm } }, [zoom]); - // Reset pan when panel opens/closes to maintain centering useEffect(() => { if (zoom === 1) { setPanX(0); @@ -180,35 +212,75 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm } }, [isPromptOpen, zoom]); + // Lock body scroll when dialog is open + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + return () => { + document.body.style.overflow = ''; + }; + }, [isOpen]); + const imageDetailsLabel = isPromptOpen ? localize('com_ui_hide_image_details') : localize('com_ui_show_image_details'); + // Calculate image max dimensions accounting for side panel (w-80 = 320px) + const getImageMaxWidth = () => { + if (isPromptOpen) { + // On mobile, panel overlays so use full width; on desktop, subtract panel width + return typeof window !== 'undefined' && window.innerWidth >= 640 + ? 'calc(90vw - 320px)' + : '90vw'; + } + return '90vw'; + }; + return ( - - -
+ + + { + e.preventDefault(); + closeButtonRef.current?.focus(); + }} + onCloseAutoFocus={(e) => { + e.preventDefault(); + triggerRef?.current?.focus(); + }} + onPointerDownOutside={(e) => e.preventDefault()} + onClick={handleBackgroundClick} > - onOpenChange(false)} - variant="ghost" - className="h-10 w-10 p-0 hover:bg-surface-hover" - aria-label={localize('com_ui_close')} - > -