diff --git a/client/src/components/Chat/Messages/Content/Files.tsx b/client/src/components/Chat/Messages/Content/Files.tsx index 8997d5e822..c55148891a 100644 --- a/client/src/components/Chat/Messages/Content/Files.tsx +++ b/client/src/components/Chat/Messages/Content/Files.tsx @@ -21,15 +21,7 @@ const Files = ({ message }: { message?: TMessage }) => { ))} diff --git a/client/src/components/Chat/Messages/Content/Image.tsx b/client/src/components/Chat/Messages/Content/Image.tsx index cd72733298..502a1c8e02 100644 --- a/client/src/components/Chat/Messages/Content/Image.tsx +++ b/client/src/components/Chat/Messages/Content/Image.tsx @@ -2,26 +2,20 @@ import React, { useState, useRef, useMemo } from 'react'; import { Skeleton } from '@librechat/client'; import { LazyLoadImage } from 'react-lazy-load-image-component'; import { apiBaseUrl } from 'librechat-data-provider'; -import { cn, scaleImage } from '~/utils'; +import { cn } from '~/utils'; import DialogImage from './DialogImage'; +/** Max display height for chat images (Tailwind JIT class) */ +const IMAGE_MAX_H = 'max-h-[45vh]' as const; + const Image = ({ imagePath, altText, - height, - width, - placeholderDimensions, className, args, }: { imagePath: string; altText: string; - height: number; - width: number; - placeholderDimensions?: { - height?: string; - width?: string; - }; className?: string; args?: { prompt?: string; @@ -33,7 +27,6 @@ const Image = ({ }) => { const [isOpen, setIsOpen] = useState(false); const [isLoaded, setIsLoaded] = useState(false); - const containerRef = useRef(null); const triggerRef = useRef(null); const handleImageLoad = () => setIsLoaded(true); @@ -56,16 +49,6 @@ const Image = ({ return `${baseURL}${imagePath}`; }, [imagePath]); - const { width: scaledWidth, height: scaledHeight } = useMemo( - () => - scaleImage({ - originalWidth: Number(placeholderDimensions?.width?.split('px')[0] ?? width), - originalHeight: Number(placeholderDimensions?.height?.split('px')[0] ?? height), - containerRef, - }), - [placeholderDimensions, height, width], - ); - const downloadImage = async () => { try { const response = await fetch(absoluteImageUrl); @@ -96,7 +79,7 @@ const Image = ({ }; return ( -
+
diff --git a/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx b/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx index da2a8f175e..ce6750b048 100644 --- a/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx @@ -12,11 +12,7 @@ interface LogContentProps { attachments?: TAttachment[]; } -type ImageAttachment = TFile & - TAttachmentMetadata & { - height: number; - width: number; - }; +type ImageAttachment = TFile & TAttachmentMetadata; const LogContent: React.FC = ({ output = '', renderImages, attachments }) => { const localize = useLocalize(); @@ -35,12 +31,8 @@ const LogContent: React.FC = ({ output = '', renderImages, atta const nonImageAtts: TAttachment[] = []; attachments?.forEach((attachment) => { - const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata; - const isImage = - imageExtRegex.test(attachment.filename ?? '') && - width != null && - height != null && - filepath != null; + const { filepath = null } = attachment as TFile & TAttachmentMetadata; + const isImage = imageExtRegex.test(attachment.filename ?? '') && filepath != null; if (isImage) { imageAtts.push(attachment as ImageAttachment); } else { @@ -100,18 +92,13 @@ const LogContent: React.FC = ({ output = '', renderImages, atta ))}
)} - {imageAttachments?.map((attachment, index) => { - const { width, height, filepath } = attachment; - return ( - - ); - })} + {imageAttachments?.map((attachment) => ( + + ))} ); }; diff --git a/client/src/components/Chat/Messages/Content/Parts/OpenAIImageGen/OpenAIImageGen.tsx b/client/src/components/Chat/Messages/Content/Parts/OpenAIImageGen/OpenAIImageGen.tsx index aa1b07827b..e0205e4957 100644 --- a/client/src/components/Chat/Messages/Content/Parts/OpenAIImageGen/OpenAIImageGen.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/OpenAIImageGen/OpenAIImageGen.tsx @@ -1,9 +1,12 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { PixelCard } from '@librechat/client'; import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider'; import Image from '~/components/Chat/Messages/Content/Image'; import ProgressText from './ProgressText'; -import { scaleImage } from '~/utils'; +import { cn } from '~/utils'; + +const IMAGE_MAX_H = 'max-h-[45vh]' as const; +const IMAGE_FULL_H = 'h-[45vh]' as const; export default function OpenAIImageGen({ initialProgress = 0.1, @@ -28,8 +31,6 @@ export default function OpenAIImageGen({ const cancelled = (!isSubmitting && initialProgress < 1) || error === true; - let width: number | undefined; - let height: number | undefined; let quality: 'low' | 'medium' | 'high' = 'high'; // Parse args if it's a string @@ -41,61 +42,15 @@ export default function OpenAIImageGen({ parsedArgs = {}; } - try { - const argsObj = parsedArgs; - - if (argsObj && typeof argsObj.size === 'string') { - const [w, h] = argsObj.size.split('x').map((v: string) => parseInt(v, 10)); - if (!isNaN(w) && !isNaN(h)) { - width = w; - height = h; - } - } else if (argsObj && (typeof argsObj.size !== 'string' || !argsObj.size)) { - width = undefined; - height = undefined; + if (parsedArgs && typeof parsedArgs.quality === 'string') { + const q = parsedArgs.quality.toLowerCase(); + if (q === 'low' || q === 'medium' || q === 'high') { + quality = q; } - - if (argsObj && typeof argsObj.quality === 'string') { - const q = argsObj.quality.toLowerCase(); - if (q === 'low' || q === 'medium' || q === 'high') { - quality = q; - } - } - } catch (e) { - width = undefined; - height = undefined; } - // Default to 1024x1024 if width and height are still undefined after parsing args and attachment metadata const attachment = attachments?.[0]; - const { - width: imageWidth, - height: imageHeight, - filepath = null, - filename = '', - } = (attachment as TFile & TAttachmentMetadata) || {}; - - let origWidth = width ?? imageWidth; - let origHeight = height ?? imageHeight; - - if (origWidth === undefined || origHeight === undefined) { - origWidth = 1024; - origHeight = 1024; - } - - const [dimensions, setDimensions] = useState({ width: 'auto', height: 'auto' }); - const containerRef = useRef(null); - - const updateDimensions = useCallback(() => { - if (origWidth && origHeight && containerRef.current) { - const scaled = scaleImage({ - originalWidth: origWidth, - originalHeight: origHeight, - containerRef, - }); - setDimensions(scaled); - } - }, [origWidth, origHeight]); + const { filepath = null, filename = '' } = (attachment as TFile & TAttachmentMetadata) || {}; useEffect(() => { if (isSubmitting) { @@ -156,45 +111,19 @@ export default function OpenAIImageGen({ } }, [initialProgress, cancelled]); - useEffect(() => { - updateDimensions(); - - const resizeObserver = new ResizeObserver(() => { - updateDimensions(); - }); - - if (containerRef.current) { - resizeObserver.observe(containerRef.current); - } - - return () => { - resizeObserver.disconnect(); - }; - }, [updateDimensions]); - return ( <>
-
-
- {dimensions.width !== 'auto' && progress < 1 && ( - - )} +
+
+ {progress < 1 && }
diff --git a/client/src/components/Chat/Messages/Content/__tests__/Image.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/Image.test.tsx new file mode 100644 index 0000000000..3654d8e075 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/__tests__/Image.test.tsx @@ -0,0 +1,200 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import Image from '../Image'; + +jest.mock('~/utils', () => ({ + cn: (...classes: unknown[]) => + classes + .flat(Infinity) + .filter((c) => typeof c === 'string' && c.length > 0) + .join(' '), +})); + +jest.mock('librechat-data-provider', () => ({ + apiBaseUrl: () => '', +})); + +jest.mock('react-lazy-load-image-component', () => ({ + LazyLoadImage: ({ + alt, + src, + className, + onLoad, + placeholder, + visibleByDefault: _visibleByDefault, + ...rest + }: { + alt: string; + src: string; + className: string; + onLoad: () => void; + placeholder: React.ReactNode; + visibleByDefault?: boolean; + [key: string]: unknown; + }) => ( +
+ {alt} +
{placeholder}
+
+ ), +})); + +jest.mock('@librechat/client', () => ({ + Skeleton: ({ className, ...props }: React.HTMLAttributes) => ( +
+ ), +})); + +jest.mock('../DialogImage', () => ({ + __esModule: true, + default: ({ isOpen, src }: { isOpen: boolean; src: string }) => + isOpen ?
: null, +})); + +describe('Image', () => { + const defaultProps = { + imagePath: '/images/test.png', + altText: 'Test image', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('rendering', () => { + it('renders with max-h-[45vh] height constraint', () => { + render(); + const img = screen.getByTestId('lazy-image'); + expect(img.className).toContain('max-h-[45vh]'); + }); + + it('renders with max-w-full to prevent landscape clipping', () => { + render(); + const img = screen.getByTestId('lazy-image'); + expect(img.className).toContain('max-w-full'); + }); + + it('renders with w-auto and h-auto for natural aspect ratio', () => { + render(); + const img = screen.getByTestId('lazy-image'); + expect(img.className).toContain('w-auto'); + expect(img.className).toContain('h-auto'); + }); + + it('starts with opacity-0 before image loads', () => { + render(); + const img = screen.getByTestId('lazy-image'); + expect(img.className).toContain('opacity-0'); + expect(img.className).not.toContain('opacity-100'); + }); + + it('transitions to opacity-100 after image loads', () => { + render(); + const img = screen.getByTestId('lazy-image'); + + fireEvent.load(img); + + expect(img.className).toContain('opacity-100'); + }); + + it('applies custom className to the button wrapper', () => { + render(); + const button = screen.getByRole('button'); + expect(button.className).toContain('mb-4'); + }); + + it('sets correct alt text', () => { + render(); + const img = screen.getByTestId('lazy-image'); + expect(img).toHaveAttribute('alt', 'Test image'); + }); + }); + + describe('skeleton placeholder', () => { + it('renders skeleton with non-zero dimensions', () => { + render(); + const skeleton = screen.getByTestId('skeleton'); + expect(skeleton.className).toContain('h-48'); + expect(skeleton.className).toContain('w-full'); + expect(skeleton.className).toContain('max-w-lg'); + }); + + it('renders skeleton with max-h constraint', () => { + render(); + const skeleton = screen.getByTestId('skeleton'); + expect(skeleton.className).toContain('max-h-[45vh]'); + }); + + it('has accessible loading attributes', () => { + render(); + const skeleton = screen.getByTestId('skeleton'); + expect(skeleton).toHaveAttribute('aria-label', 'Loading image'); + expect(skeleton).toHaveAttribute('aria-busy', 'true'); + }); + }); + + describe('dialog interaction', () => { + it('opens dialog on button click after image loads', () => { + render(); + + const img = screen.getByTestId('lazy-image'); + fireEvent.load(img); + + expect(screen.queryByTestId('dialog-image')).not.toBeInTheDocument(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + expect(screen.getByTestId('dialog-image')).toBeInTheDocument(); + }); + + it('does not render dialog before image loads', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + expect(screen.queryByTestId('dialog-image')).not.toBeInTheDocument(); + }); + + it('has correct accessibility attributes on button', () => { + render(); + const button = screen.getByRole('button'); + expect(button).toHaveAttribute('aria-label', 'View Test image in dialog'); + expect(button).toHaveAttribute('aria-haspopup', 'dialog'); + }); + }); + + describe('image URL resolution', () => { + it('passes /images/ paths through with base URL', () => { + render(); + const img = screen.getByTestId('lazy-image'); + expect(img).toHaveAttribute('src', '/images/test.png'); + }); + + it('passes absolute http URLs through unchanged', () => { + render(); + const img = screen.getByTestId('lazy-image'); + expect(img).toHaveAttribute('src', 'https://example.com/photo.jpg'); + }); + + it('passes data URIs through unchanged', () => { + render(); + const img = screen.getByTestId('lazy-image'); + expect(img).toHaveAttribute('src', 'data:image/png;base64,abc'); + }); + + it('passes non-/images/ paths through unchanged', () => { + render(); + const img = screen.getByTestId('lazy-image'); + expect(img).toHaveAttribute('src', '/other/path.png'); + }); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/__tests__/OpenAIImageGen.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/OpenAIImageGen.test.tsx new file mode 100644 index 0000000000..886b1b6294 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/__tests__/OpenAIImageGen.test.tsx @@ -0,0 +1,182 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import OpenAIImageGen from '../Parts/OpenAIImageGen/OpenAIImageGen'; + +jest.mock('~/utils', () => ({ + cn: (...classes: unknown[]) => + classes + .flat(Infinity) + .filter((c) => typeof c === 'string' && c.length > 0) + .join(' '), +})); + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => key, +})); + +jest.mock('~/components/Chat/Messages/Content/Image', () => ({ + __esModule: true, + default: ({ + altText, + imagePath, + className, + }: { + altText: string; + imagePath: string; + className?: string; + }) => ( +
+ ), +})); + +jest.mock('@librechat/client', () => ({ + PixelCard: ({ progress }: { progress: number }) => ( +
+ ), +})); + +jest.mock('../Parts/OpenAIImageGen/ProgressText', () => ({ + __esModule: true, + default: ({ progress, error }: { progress: number; error: boolean }) => ( +
+ ), +})); + +describe('OpenAIImageGen', () => { + const defaultProps = { + initialProgress: 0.1, + isSubmitting: true, + toolName: 'image_gen_oai', + args: '{"prompt":"a cat","quality":"high","size":"1024x1024"}', + output: null as string | null, + attachments: undefined, + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('image preloading', () => { + it('keeps Image mounted during generation (progress < 1)', () => { + render(); + expect(screen.getByTestId('image-component')).toBeInTheDocument(); + }); + + it('hides Image with invisible absolute while progress < 1', () => { + render(); + const image = screen.getByTestId('image-component'); + expect(image.className).toContain('invisible'); + expect(image.className).toContain('absolute'); + }); + + it('shows Image without hiding classes when progress >= 1', () => { + render( + , + ); + const image = screen.getByTestId('image-component'); + expect(image.className).not.toContain('invisible'); + expect(image.className).not.toContain('absolute'); + }); + }); + + describe('PixelCard visibility', () => { + it('shows PixelCard when progress < 1', () => { + render(); + expect(screen.getByTestId('pixel-card')).toBeInTheDocument(); + }); + + it('hides PixelCard when progress >= 1', () => { + render(); + expect(screen.queryByTestId('pixel-card')).not.toBeInTheDocument(); + }); + }); + + describe('layout classes', () => { + it('applies max-h-[45vh] to the outer container', () => { + const { container } = render(); + const outerDiv = container.querySelector('[class*="max-h-"]'); + expect(outerDiv?.className).toContain('max-h-[45vh]'); + }); + + it('applies h-[45vh] w-full to inner container during loading', () => { + const { container } = render(); + const innerDiv = container.querySelector('[class*="h-[45vh]"]'); + expect(innerDiv).not.toBeNull(); + expect(innerDiv?.className).toContain('w-full'); + }); + + it('applies w-auto to inner container when complete', () => { + const { container } = render( + , + ); + const overflowDiv = container.querySelector('[class*="overflow-hidden"]'); + expect(overflowDiv?.className).toContain('w-auto'); + }); + }); + + describe('args parsing', () => { + it('parses quality from args', () => { + render(); + expect(screen.getByTestId('progress-text')).toBeInTheDocument(); + }); + + it('handles invalid JSON args gracefully', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + render(); + expect(screen.getByTestId('image-component')).toBeInTheDocument(); + consoleSpy.mockRestore(); + }); + + it('handles object args', () => { + render( + , + ); + expect(screen.getByTestId('image-component')).toBeInTheDocument(); + }); + }); + + describe('cancellation', () => { + it('shows error state when output contains error', () => { + render( + , + ); + const progressText = screen.getByTestId('progress-text'); + expect(progressText).toHaveAttribute('data-error', 'true'); + }); + + it('shows cancelled state when not submitting and incomplete', () => { + render(); + const progressText = screen.getByTestId('progress-text'); + expect(progressText).toHaveAttribute('data-error', 'true'); + }); + }); +}); diff --git a/client/src/utils/index.ts b/client/src/utils/index.ts index 8946951ed8..2a7dfc4a88 100644 --- a/client/src/utils/index.ts +++ b/client/src/utils/index.ts @@ -29,7 +29,6 @@ export * from './share'; export * from './timestamps'; export { default as cn } from './cn'; export { default as logger } from './logger'; -export { default as scaleImage } from './scaleImage'; export { default as getLoginError } from './getLoginError'; export { default as cleanupPreset } from './cleanupPreset'; export { default as buildDefaultConvo } from './buildDefaultConvo'; diff --git a/client/src/utils/scaleImage.ts b/client/src/utils/scaleImage.ts deleted file mode 100644 index 11e051fbd9..0000000000 --- a/client/src/utils/scaleImage.ts +++ /dev/null @@ -1,21 +0,0 @@ -export default function scaleImage({ - originalWidth, - originalHeight, - containerRef, -}: { - originalWidth?: number; - originalHeight?: number; - containerRef: React.RefObject; -}) { - const containerWidth = containerRef.current?.offsetWidth ?? 0; - - if (containerWidth === 0 || originalWidth == null || originalHeight == null) { - return { width: 'auto', height: 'auto' }; - } - - const aspectRatio = originalWidth / originalHeight; - const scaledWidth = Math.min(containerWidth, originalWidth); - const scaledHeight = scaledWidth / aspectRatio; - - return { width: `${scaledWidth}px`, height: `${scaledHeight}px` }; -}