From d21dfba2aceb63ae64133cd63c72b30972f65d53 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 5 Jan 2026 16:31:35 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=8F=9E=EF=B8=8F=20fix:=20Image=20Preview?= =?UTF-8?q?=20Refactor=20with=20Accessibility=20Enhancements=20(#11217)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🔧 fix: Prevent race condition by saving user messages before final event in ResumableAgentController - Updated the ResumableAgentController to save user messages prior to sending the final event. This change addresses a potential race condition where the client might refetch data before the database is updated. - Removed redundant message saving logic that was previously located after the final event handling, ensuring a more reliable message processing flow. * style: improve image preview dialogs with ChatGPT-like UX and accessibility Refactored image preview dialogs (DialogImage and ImagePreview) to provide a cleaner, more intuitive user experience similar to ChatGPT's implementation. ## DialogImage.tsx (generated images) - Replaced OGDialog/OGDialogContent with direct Radix Dialog primitives for finer control over behavior - Full-screen dark overlay (bg-black/90) that closes on click outside image - Restructured component so all interactive elements (close, download, details panel buttons) are inside DialogPrimitive.Content for proper focus trap and keyboard navigation - Added onOpenAutoFocus to focus close button when dialog opens - Added onCloseAutoFocus to return focus to trigger element on close - Added triggerRef prop to enable focus restoration - Removed animate-in/animate-out classes that caused stuttering on open - Changed transition-all to transition-[margin] to prevent animation jank - Added proper TypeScript types for component props ## ImagePreview.tsx (uploaded file thumbnails) - Same Radix Dialog primitive refactor for consistent behavior - Click-outside-to-close functionality - Proper focus management with closeButtonRef and triggerRef - Made button the container element to prevent focus ring clipping - Added focus-visible ring styling for keyboard navigation visibility ## Image.tsx (image display component) - Restructured so button is the outer container instead of being nested inside a div with overflow-hidden (which was clipping focus ring) - Added visible focus-visible:ring styling with ring-offset - Added aria-haspopup="dialog" for screen reader context - Added triggerRef and passed to DialogImage for focus restoration ## Accessibility improvements - Keyboard navigation now works properly (Tab cycles through buttons) - Escape key closes dialog (or resets zoom if zoomed in) - Focus is trapped within dialog when open - Focus returns to trigger element when dialog closes - Visible focus indicators on image buttons when focused via keyboard - Proper ARIA attributes (aria-label, aria-haspopup, aria-hidden) ## UX improvements - Click anywhere outside the image to close (not just specific regions) - No more weird scroll/navigation issues - Instant dialog open without stuttering animations - Clean, minimal overlay without container/header chrome * refactor: Improve click handling in image preview dialogs Updated the click handling logic in ImagePreview and DialogImage components to ensure that the dialog only closes when clicking directly on the overlay or content background, enhancing user experience by preventing unintended closures when interacting with child elements. Additionally, clarified comments to reflect the new behavior. * chore: import order --- api/server/controllers/agents/request.js | 14 +- .../Chat/Input/Files/ImagePreview.tsx | 120 ++++-- .../Chat/Messages/Content/DialogImage.tsx | 378 ++++++++++-------- .../Chat/Messages/Content/Image.tsx | 85 ++-- 4 files changed, 355 insertions(+), 242 deletions(-) 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')} - > -