diff --git a/web/src/embedded/chat/components/markdown/overrides/img.tsx b/web/src/embedded/chat/components/markdown/overrides/img.tsx index 0f99e93..34fdf01 100644 --- a/web/src/embedded/chat/components/markdown/overrides/img.tsx +++ b/web/src/embedded/chat/components/markdown/overrides/img.tsx @@ -1,5 +1,12 @@ import { Menu } from '@/utils/webview-context-menu'; -import { useCallback, useState } from 'react'; +import { cn } from '@/utils/cn'; +import { + useCallback, + useState, + type CSSProperties, + type SyntheticEvent, +} from 'react'; +import { fitImagePreviewSize } from '../../messages/image-preview-size'; /** * Custom image component with loading animation and error handling. @@ -8,10 +15,17 @@ export const CueStreamdownImg = ({ src, alt, className, + onLoad, + onError, + style, ...props }: React.ComponentProps<'img'>) => { const [imageLoaded, setImageLoaded] = useState(false); const [error, setError] = useState(null); + const [naturalSize, setNaturalSize] = useState<{ + width: number; + height: number; + } | null>(null); const handleContextMenu = useCallback( (event: React.MouseEvent) => { @@ -31,6 +45,43 @@ export const CueStreamdownImg = ({ [src] ); + const handleLoad = useCallback( + (event: SyntheticEvent) => { + const image = event.currentTarget; + if (image.naturalWidth > 0 && image.naturalHeight > 0) { + setNaturalSize({ + width: image.naturalWidth, + height: image.naturalHeight, + }); + } + setImageLoaded(true); + onLoad?.(event); + }, + [onLoad] + ); + + const handleError = useCallback( + (event: SyntheticEvent) => { + setError('Failed to load image'); + onError?.(event); + }, + [onError] + ); + + const previewSize = naturalSize + ? fitImagePreviewSize(naturalSize.width, naturalSize.height) + : null; + const imageStyle = { + ...style, + ...(previewSize && naturalSize + ? { + width: previewSize.width, + maxHeight: previewSize.height, + aspectRatio: `${naturalSize.width} / ${naturalSize.height}`, + } + : {}), + } satisfies CSSProperties; + // Error state if (error) { return ( @@ -48,13 +99,14 @@ export const CueStreamdownImg = ({ src={src} alt={alt || 'Generated image'} onContextMenu={handleContextMenu} - className={` - ${className || ''} - transition-opacity duration-500 ease-out - ${imageLoaded ? 'opacity-100' : 'opacity-0'} - `} - onLoad={() => setImageLoaded(true)} - onError={() => setError('Failed to load image')} + className={cn( + 'inline-block h-auto max-w-full rounded-lg object-contain transition-opacity duration-500 ease-out', + className, + imageLoaded ? 'opacity-100' : 'opacity-0' + )} + style={imageStyle} + onLoad={handleLoad} + onError={handleError} {...props} /> ); diff --git a/web/src/embedded/chat/components/messages/assistant-message.tsx b/web/src/embedded/chat/components/messages/assistant-message.tsx index 28ff0b2..4bb5b30 100644 --- a/web/src/embedded/chat/components/messages/assistant-message.tsx +++ b/web/src/embedded/chat/components/messages/assistant-message.tsx @@ -255,7 +255,7 @@ function renderContentBlock( mimeType={content.mimeType ?? undefined} sourcePath={content.fileRef?.path} environmentId={content.fileRef?.environmentId ?? undefined} - className="max-h-80 w-fit" + className="w-fit" data-artifact={key} /> ) : null; diff --git a/web/src/embedded/chat/components/messages/attachment-image.tsx b/web/src/embedded/chat/components/messages/attachment-image.tsx index a4670b4..1a60fa9 100644 --- a/web/src/embedded/chat/components/messages/attachment-image.tsx +++ b/web/src/embedded/chat/components/messages/attachment-image.tsx @@ -5,7 +5,13 @@ import { resolveAttachmentDisplayURL, } from '@/utils/agent-file-url'; import { hasNativeJSBridge } from '@/utils/bridge-runtime'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { + useCallback, + useEffect, + useRef, + useState, + type CSSProperties, +} from 'react'; import { Spinner } from '../loading/spinner'; import { Menu } from '@/utils/webview-context-menu'; import { EyeSFSymbolMedium } from '@/assets/sf-symbols/medium/eye'; @@ -14,8 +20,14 @@ import { previewAttachmentSource, previewSourceRectForElement, } from './file-reference-actions'; +import { + fitImagePreviewSize, + imagePreviewBounds, + type ImagePreviewBounds, +} from './image-preview-size'; type PreviewButtonTone = 'dark' | 'light'; +type NaturalImageSize = { width: number; height: number }; function detectPreviewButtonTone(image: HTMLImageElement): PreviewButtonTone { const naturalWidth = image.naturalWidth; @@ -95,6 +107,8 @@ export const AttachmentImage = ({ sourcePath, environmentId, className, + style, + previewBounds = imagePreviewBounds, ...props }: { className?: string; @@ -103,6 +117,7 @@ export const AttachmentImage = ({ mimeType?: string; sourcePath?: string; environmentId?: string; + previewBounds?: ImagePreviewBounds; } & React.HTMLAttributes) => { const [status, setStatus] = useState<'loading' | 'loaded' | 'error'>( 'loading' @@ -110,6 +125,7 @@ export const AttachmentImage = ({ const [resolvedSrc, setResolvedSrc] = useState(null); const [previewButtonTone, setPreviewButtonTone] = useState('dark'); + const [naturalSize, setNaturalSize] = useState(null); const figureRef = useRef(null); const imageRef = useRef(null); @@ -229,6 +245,7 @@ export const AttachmentImage = ({ setStatus('loading'); setResolvedSrc(null); setPreviewButtonTone('dark'); + setNaturalSize(null); resolveUrl(); return () => { @@ -247,6 +264,12 @@ export const AttachmentImage = ({ return; } + if (image.naturalWidth > 0 && image.naturalHeight > 0) { + setNaturalSize({ + width: image.naturalWidth, + height: image.naturalHeight, + }); + } setPreviewButtonTone(detectPreviewButtonTone(image)); if (resolvedSrc) { preparePreviewAsset(resolvedSrc, fileName, mimeType); @@ -330,6 +353,24 @@ export const AttachmentImage = ({ ] ); + const previewSize = naturalSize + ? fitImagePreviewSize(naturalSize.width, naturalSize.height, previewBounds) + : null; + const imageStyle = + previewSize && naturalSize + ? ({ + width: previewSize.width, + maxHeight: previewSize.height, + aspectRatio: `${naturalSize.width} / ${naturalSize.height}`, + } satisfies CSSProperties) + : undefined; + const figureStyle = { + minWidth: previewBounds.minWidth, + minHeight: previewBounds.minHeight, + ...style, + ...(previewSize ? { width: previewSize.width } : {}), + } satisfies CSSProperties; + return (
{ @@ -338,9 +379,10 @@ export const AttachmentImage = ({ onContextMenu={handleContextMenu} className={cn( 'group/image overflow-hidden rounded-lg border border-black/10 dark:border-white/20 relative', - 'min-h-24 w-fit', + 'inline-flex max-w-full items-center justify-center bg-black/5 dark:bg-white/5', className )} + style={figureStyle} data-source-path={sourcePath} {...props} > @@ -403,9 +445,10 @@ export const AttachmentImage = ({ alt="Uploaded attachment" className={cn( 'attachment-image', - 'h-full max-h-[inherit] w-auto object-contain transition-opacity duration-200', + 'block h-auto max-w-full object-contain transition-opacity duration-200', status !== 'loaded' && 'opacity-0' )} + style={imageStyle} loading={isDataUrl ? undefined : 'lazy'} decoding="async" onLoad={handleLoad} diff --git a/web/src/embedded/chat/components/messages/image-preview-size.ts b/web/src/embedded/chat/components/messages/image-preview-size.ts new file mode 100644 index 0000000..e03ea76 --- /dev/null +++ b/web/src/embedded/chat/components/messages/image-preview-size.ts @@ -0,0 +1,68 @@ +export type ImagePreviewBounds = { + minWidth: number; + minHeight: number; + maxWidth: number; + maxHeight: number; +}; + +export type ImagePreviewSize = { + width: number; + height: number; + scale: number; +}; + +export const imagePreviewBounds: ImagePreviewBounds = { + minWidth: 160, + minHeight: 120, + maxWidth: 640, + maxHeight: 520, +}; + +export const imageThumbnailPreviewBounds: ImagePreviewBounds = { + minWidth: 96, + minHeight: 96, + maxWidth: 240, + maxHeight: 96, +}; + +export function fitImagePreviewSize( + naturalWidth: number, + naturalHeight: number, + bounds: ImagePreviewBounds = imagePreviewBounds +): ImagePreviewSize | null { + if ( + !isPositiveFiniteNumber(naturalWidth) || + !isPositiveFiniteNumber(naturalHeight) + ) { + return null; + } + + const maxScale = Math.min( + bounds.maxWidth / naturalWidth, + bounds.maxHeight / naturalHeight + ); + let scale = 1; + + if (maxScale < 1) { + scale = maxScale; + } else if ( + naturalWidth < bounds.minWidth || + naturalHeight < bounds.minHeight + ) { + const minScale = Math.max( + bounds.minWidth / naturalWidth, + bounds.minHeight / naturalHeight + ); + scale = Math.min(minScale, maxScale); + } + + return { + width: Math.max(1, Math.round(naturalWidth * scale)), + height: Math.max(1, Math.round(naturalHeight * scale)), + scale, + }; +} + +function isPositiveFiniteNumber(value: number): boolean { + return Number.isFinite(value) && value > 0; +} diff --git a/web/src/embedded/chat/components/messages/user-message.tsx b/web/src/embedded/chat/components/messages/user-message.tsx index c966079..8570af9 100644 --- a/web/src/embedded/chat/components/messages/user-message.tsx +++ b/web/src/embedded/chat/components/messages/user-message.tsx @@ -13,6 +13,7 @@ import { cn } from '@/utils/cn'; import { animate } from 'motion'; import { FileAttachmentCard } from './file-attachment'; import { AttachmentImage } from './attachment-image'; +import { imageThumbnailPreviewBounds } from './image-preview-size'; import { LinkifiedText } from '../linkified-text'; import { MaskedScrollArea } from '../masked-scrollarea'; import { collectUserMessageContent, CopyButton, copyUserMessage } from './copy'; @@ -233,6 +234,7 @@ export const UserMessage = ({ sourcePath={image.fileRef?.path} environmentId={image.fileRef?.environmentId ?? undefined} className="h-24 shrink-0" + previewBounds={imageThumbnailPreviewBounds} /> ) : null )} diff --git a/web/tests/embedded/image-preview-size.test.ts b/web/tests/embedded/image-preview-size.test.ts new file mode 100644 index 0000000..97c70d5 --- /dev/null +++ b/web/tests/embedded/image-preview-size.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { + fitImagePreviewSize, + imageThumbnailPreviewBounds, +} from '../../src/embedded/chat/components/messages/image-preview-size'; + +describe('fitImagePreviewSize', () => { + it('keeps images at their natural size when they are inside the preview bounds', () => { + expect(fitImagePreviewSize(400, 300)).toMatchObject({ + width: 400, + height: 300, + scale: 1, + }); + }); + + it('scales oversized images down to fit the maximum preview bounds', () => { + expect(fitImagePreviewSize(1436, 1240)).toMatchObject({ + width: 602, + height: 520, + }); + }); + + it('scales tiny images up to satisfy the minimum preview bounds', () => { + expect(fitImagePreviewSize(80, 60)).toMatchObject({ + width: 160, + height: 120, + }); + }); + + it('preserves aspect ratio and lets max bounds win for very tall images', () => { + expect(fitImagePreviewSize(300, 1200)).toMatchObject({ + width: 130, + height: 520, + }); + }); + + it('supports smaller thumbnail bounds for user-uploaded images', () => { + expect( + fitImagePreviewSize(1436, 1240, imageThumbnailPreviewBounds) + ).toMatchObject({ + width: 111, + height: 96, + }); + }); +});