Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 60 additions & 8 deletions web/src/embedded/chat/components/markdown/overrides/img.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<string | null>(null);
const [naturalSize, setNaturalSize] = useState<{
width: number;
height: number;
} | null>(null);

const handleContextMenu = useCallback(
(event: React.MouseEvent<HTMLImageElement>) => {
Expand All @@ -31,6 +45,43 @@ export const CueStreamdownImg = ({
[src]
);

const handleLoad = useCallback(
(event: SyntheticEvent<HTMLImageElement>) => {
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<HTMLImageElement>) => {
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 (
Expand All @@ -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}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
49 changes: 46 additions & 3 deletions web/src/embedded/chat/components/messages/attachment-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -95,6 +107,8 @@ export const AttachmentImage = ({
sourcePath,
environmentId,
className,
style,
previewBounds = imagePreviewBounds,
...props
}: {
className?: string;
Expand All @@ -103,13 +117,15 @@ export const AttachmentImage = ({
mimeType?: string;
sourcePath?: string;
environmentId?: string;
previewBounds?: ImagePreviewBounds;
} & React.HTMLAttributes<HTMLElement>) => {
const [status, setStatus] = useState<'loading' | 'loaded' | 'error'>(
'loading'
);
const [resolvedSrc, setResolvedSrc] = useState<string | null>(null);
const [previewButtonTone, setPreviewButtonTone] =
useState<PreviewButtonTone>('dark');
const [naturalSize, setNaturalSize] = useState<NaturalImageSize | null>(null);
const figureRef = useRef<HTMLElement | null>(null);
const imageRef = useRef<HTMLImageElement | null>(null);

Expand Down Expand Up @@ -229,6 +245,7 @@ export const AttachmentImage = ({
setStatus('loading');
setResolvedSrc(null);
setPreviewButtonTone('dark');
setNaturalSize(null);
resolveUrl();

return () => {
Expand All @@ -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);
Expand Down Expand Up @@ -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 (
<figure
ref={node => {
Expand All @@ -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}
>
Expand Down Expand Up @@ -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}
Expand Down
68 changes: 68 additions & 0 deletions web/src/embedded/chat/components/messages/image-preview-size.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 2 additions & 0 deletions web/src/embedded/chat/components/messages/user-message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -233,6 +234,7 @@ export const UserMessage = ({
sourcePath={image.fileRef?.path}
environmentId={image.fileRef?.environmentId ?? undefined}
className="h-24 shrink-0"
previewBounds={imageThumbnailPreviewBounds}
/>
) : null
)}
Expand Down
45 changes: 45 additions & 0 deletions web/tests/embedded/image-preview-size.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
Loading