From 10ec58785a18938b670d1e329042b0b258ea3dd4 Mon Sep 17 00:00:00 2001 From: Managed via Tart Date: Wed, 20 May 2026 02:41:23 +0000 Subject: [PATCH 1/2] Resize chat image previews naturally Co-authored-by: 3720 --- .../components/markdown/overrides/img.tsx | 68 ++++++++++++++++--- .../components/messages/assistant-message.tsx | 2 +- .../components/messages/attachment-image.tsx | 41 ++++++++++- .../components/messages/image-preview-size.ts | 61 +++++++++++++++++ .../chat/components/messages/user-message.tsx | 2 +- web/tests/embedded/image-preview-size.test.ts | 33 +++++++++ 6 files changed, 194 insertions(+), 13 deletions(-) create mode 100644 web/src/embedded/chat/components/messages/image-preview-size.ts create mode 100644 web/tests/embedded/image-preview-size.test.ts 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..2131f00 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,10 @@ import { previewAttachmentSource, previewSourceRectForElement, } from './file-reference-actions'; +import { fitImagePreviewSize } 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 +103,7 @@ export const AttachmentImage = ({ sourcePath, environmentId, className, + style, ...props }: { className?: string; @@ -110,6 +119,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 +239,7 @@ export const AttachmentImage = ({ setStatus('loading'); setResolvedSrc(null); setPreviewButtonTone('dark'); + setNaturalSize(null); resolveUrl(); return () => { @@ -247,6 +258,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 +347,22 @@ export const AttachmentImage = ({ ] ); + const previewSize = naturalSize + ? fitImagePreviewSize(naturalSize.width, naturalSize.height) + : null; + const imageStyle = + previewSize && naturalSize + ? ({ + width: previewSize.width, + maxHeight: previewSize.height, + aspectRatio: `${naturalSize.width} / ${naturalSize.height}`, + } satisfies CSSProperties) + : undefined; + const figureStyle = { + ...style, + ...(previewSize ? { width: previewSize.width } : {}), + } satisfies CSSProperties; + return (
{ @@ -338,9 +371,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 min-h-[120px] min-w-[160px] max-w-full items-center justify-center bg-black/5 dark:bg-white/5', className )} + style={figureStyle} data-source-path={sourcePath} {...props} > @@ -403,9 +437,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..c169d4a --- /dev/null +++ b/web/src/embedded/chat/components/messages/image-preview-size.ts @@ -0,0 +1,61 @@ +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 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..913304d 100644 --- a/web/src/embedded/chat/components/messages/user-message.tsx +++ b/web/src/embedded/chat/components/messages/user-message.tsx @@ -232,7 +232,7 @@ export const UserMessage = ({ mimeType={image.mimeType ?? undefined} sourcePath={image.fileRef?.path} environmentId={image.fileRef?.environmentId ?? undefined} - className="h-24 shrink-0" + className="shrink-0" /> ) : 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..61da3d2 --- /dev/null +++ b/web/tests/embedded/image-preview-size.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { fitImagePreviewSize } 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, + }); + }); +}); From 335c9e9dd36f261c07a7d9b6a4ee046083b4eb08 Mon Sep 17 00:00:00 2001 From: Managed via Tart Date: Wed, 20 May 2026 02:54:15 +0000 Subject: [PATCH 2/2] Keep user image thumbnails compact Co-authored-by: 3720 --- .../chat/components/messages/attachment-image.tsx | 14 +++++++++++--- .../chat/components/messages/image-preview-size.ts | 7 +++++++ .../chat/components/messages/user-message.tsx | 4 +++- web/tests/embedded/image-preview-size.test.ts | 14 +++++++++++++- 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/web/src/embedded/chat/components/messages/attachment-image.tsx b/web/src/embedded/chat/components/messages/attachment-image.tsx index 2131f00..1a60fa9 100644 --- a/web/src/embedded/chat/components/messages/attachment-image.tsx +++ b/web/src/embedded/chat/components/messages/attachment-image.tsx @@ -20,7 +20,11 @@ import { previewAttachmentSource, previewSourceRectForElement, } from './file-reference-actions'; -import { fitImagePreviewSize } from './image-preview-size'; +import { + fitImagePreviewSize, + imagePreviewBounds, + type ImagePreviewBounds, +} from './image-preview-size'; type PreviewButtonTone = 'dark' | 'light'; type NaturalImageSize = { width: number; height: number }; @@ -104,6 +108,7 @@ export const AttachmentImage = ({ environmentId, className, style, + previewBounds = imagePreviewBounds, ...props }: { className?: string; @@ -112,6 +117,7 @@ export const AttachmentImage = ({ mimeType?: string; sourcePath?: string; environmentId?: string; + previewBounds?: ImagePreviewBounds; } & React.HTMLAttributes) => { const [status, setStatus] = useState<'loading' | 'loaded' | 'error'>( 'loading' @@ -348,7 +354,7 @@ export const AttachmentImage = ({ ); const previewSize = naturalSize - ? fitImagePreviewSize(naturalSize.width, naturalSize.height) + ? fitImagePreviewSize(naturalSize.width, naturalSize.height, previewBounds) : null; const imageStyle = previewSize && naturalSize @@ -359,6 +365,8 @@ export const AttachmentImage = ({ } satisfies CSSProperties) : undefined; const figureStyle = { + minWidth: previewBounds.minWidth, + minHeight: previewBounds.minHeight, ...style, ...(previewSize ? { width: previewSize.width } : {}), } satisfies CSSProperties; @@ -371,7 +379,7 @@ export const AttachmentImage = ({ onContextMenu={handleContextMenu} className={cn( 'group/image overflow-hidden rounded-lg border border-black/10 dark:border-white/20 relative', - 'inline-flex min-h-[120px] min-w-[160px] max-w-full items-center justify-center bg-black/5 dark:bg-white/5', + 'inline-flex max-w-full items-center justify-center bg-black/5 dark:bg-white/5', className )} style={figureStyle} diff --git a/web/src/embedded/chat/components/messages/image-preview-size.ts b/web/src/embedded/chat/components/messages/image-preview-size.ts index c169d4a..e03ea76 100644 --- a/web/src/embedded/chat/components/messages/image-preview-size.ts +++ b/web/src/embedded/chat/components/messages/image-preview-size.ts @@ -18,6 +18,13 @@ export const imagePreviewBounds: ImagePreviewBounds = { maxHeight: 520, }; +export const imageThumbnailPreviewBounds: ImagePreviewBounds = { + minWidth: 96, + minHeight: 96, + maxWidth: 240, + maxHeight: 96, +}; + export function fitImagePreviewSize( naturalWidth: number, naturalHeight: number, diff --git a/web/src/embedded/chat/components/messages/user-message.tsx b/web/src/embedded/chat/components/messages/user-message.tsx index 913304d..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'; @@ -232,7 +233,8 @@ export const UserMessage = ({ mimeType={image.mimeType ?? undefined} sourcePath={image.fileRef?.path} environmentId={image.fileRef?.environmentId ?? undefined} - className="shrink-0" + 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 index 61da3d2..97c70d5 100644 --- a/web/tests/embedded/image-preview-size.test.ts +++ b/web/tests/embedded/image-preview-size.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { fitImagePreviewSize } from '../../src/embedded/chat/components/messages/image-preview-size'; +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', () => { @@ -30,4 +33,13 @@ describe('fitImagePreviewSize', () => { height: 520, }); }); + + it('supports smaller thumbnail bounds for user-uploaded images', () => { + expect( + fitImagePreviewSize(1436, 1240, imageThumbnailPreviewBounds) + ).toMatchObject({ + width: 111, + height: 96, + }); + }); });