fix: reuse existing image preview for galleries
This commit is contained in:
@@ -1212,10 +1212,10 @@ const AssistantMessageBody = React.memo(({
|
||||
return visibleParts.filter((part) => part.type === 'text');
|
||||
}, [visibleParts]);
|
||||
const finalizedAssistantMarkdownContents = React.useMemo(() => (
|
||||
isMessageCompleted && !isMobile
|
||||
isMessageCompleted
|
||||
? assistantTextParts.map(extractTextContent).filter((text) => text.trim().length > 0)
|
||||
: []
|
||||
), [assistantTextParts, isMessageCompleted, isMobile]);
|
||||
), [assistantTextParts, isMessageCompleted]);
|
||||
const assistantPlanText = React.useMemo(() => flattenAssistantTextParts(assistantTextParts), [assistantTextParts]);
|
||||
const suggestedPlanTitle = React.useMemo(() => suggestPlanTitleFromText(assistantPlanText), [assistantPlanText]);
|
||||
|
||||
@@ -1868,7 +1868,7 @@ const AssistantMessageBody = React.memo(({
|
||||
chatRenderMode={chatRenderMode}
|
||||
onContentChange={onContentChange}
|
||||
onShowPopup={onShowPopup}
|
||||
enableMarkdownImages={isMessageCompleted && !isMobile}
|
||||
enableMarkdownImages={isMessageCompleted}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { getImagePreviewBounds, getImagePreviewDialogLayout } from './imagePreviewSizing';
|
||||
import { MermaidLoadFailure, getMermaidDataUrlSourcePromise, isCurrentMermaidLoadRequest, nextMermaidLoadRequestId } from './toolOutputDialogMermaid';
|
||||
|
||||
describe('getMermaidDataUrlSourcePromise', () => {
|
||||
@@ -30,43 +29,3 @@ describe('Mermaid load request ids', () => {
|
||||
expect(isCurrentMermaidLoadRequest(secondRequest, secondRequest)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Markdown image preview bounds', () => {
|
||||
test('uses sixty percent of the viewport width with vertical containment', () => {
|
||||
expect(getImagePreviewBounds({ width: 1200, height: 800 }, false, true)).toEqual({
|
||||
maxWidth: 720,
|
||||
maxHeight: 640,
|
||||
});
|
||||
});
|
||||
|
||||
test('preserves existing attachment preview bounds', () => {
|
||||
expect(getImagePreviewBounds({ width: 1200, height: 800 }, false, false)).toEqual({
|
||||
maxWidth: 900,
|
||||
maxHeight: 600,
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps a readable modal width for narrow portrait images', () => {
|
||||
expect(getImagePreviewDialogLayout(
|
||||
{ width: 29, height: 576 },
|
||||
{ width: 1280, height: 720 },
|
||||
false,
|
||||
)).toEqual({
|
||||
dialogWidth: 320,
|
||||
imageWidth: 29,
|
||||
imageHeight: 576,
|
||||
});
|
||||
});
|
||||
|
||||
test('fits image content inside the mobile dialog chrome without cropping', () => {
|
||||
expect(getImagePreviewDialogLayout(
|
||||
{ width: 275, height: 500 },
|
||||
{ width: 320, height: 700 },
|
||||
true,
|
||||
)).toEqual({
|
||||
dialogWidth: 304,
|
||||
imageWidth: 270,
|
||||
imageHeight: 491,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||
import { File as PierreFile, PatchDiff } from '@pierre/diffs/react';
|
||||
import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode';
|
||||
import { createPortal } from 'react-dom';
|
||||
@@ -29,11 +29,6 @@ import { Icon } from "@/components/icon/Icon";
|
||||
import { useI18n, type I18nKey, type I18nParams } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { MermaidLoadFailure, getMermaidDataUrlSourcePromise, isCurrentMermaidLoadRequest, isMermaidLoadFailure, nextMermaidLoadRequestId } from './toolOutputDialogMermaid';
|
||||
import {
|
||||
getImagePreviewBounds,
|
||||
getImagePreviewDialogLayout,
|
||||
type ImagePreviewViewport,
|
||||
} from './imagePreviewSizing';
|
||||
|
||||
interface ToolOutputDialogProps {
|
||||
popup: ToolPopupContent;
|
||||
@@ -163,7 +158,7 @@ const usePierreThemeConfig = (): PierreThemeConfig => {
|
||||
};
|
||||
};
|
||||
|
||||
type ViewportSize = ImagePreviewViewport;
|
||||
type ViewportSize = { width: number; height: number };
|
||||
|
||||
const getWindowViewport = (): ViewportSize => ({
|
||||
width: typeof window !== 'undefined' ? window.innerWidth : 0,
|
||||
@@ -336,11 +331,8 @@ const ImagePreviewDialog: React.FC<{
|
||||
}, [popup.image]);
|
||||
|
||||
const [currentIndex, setCurrentIndex] = React.useState(0);
|
||||
const [loadedImageSize, setLoadedImageSize] = React.useState<{
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const [imageNaturalSize, setImageNaturalSize] = React.useState<{ width: number; height: number } | null>(null);
|
||||
const { isRendered, isVisible, isTransitioning } = usePreviewOverlayState(popup.open);
|
||||
const viewport = usePreviewViewport(popup.open);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -360,10 +352,9 @@ const ImagePreviewDialog: React.FC<{
|
||||
setCurrentIndex(matchingIndex >= 0 ? matchingIndex : 0);
|
||||
}, [gallery, popup.image?.index, popup.image?.url, popup.open]);
|
||||
|
||||
const currentImage = gallery[currentIndex] ?? gallery[0];
|
||||
const currentImage = gallery[currentIndex] ?? gallery[0] ?? popup.image;
|
||||
const imageTitle = currentImage?.filename || popup.title || 'Image preview';
|
||||
const hasMultipleImages = gallery.length > 1;
|
||||
const markdownImage = popup.metadata?.tool === 'markdown-image-preview';
|
||||
|
||||
const showPrevious = React.useCallback(() => {
|
||||
if (gallery.length <= 1) return;
|
||||
@@ -381,6 +372,11 @@ const ImagePreviewDialog: React.FC<{
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowLeft' && hasMultipleImages) {
|
||||
event.preventDefault();
|
||||
showPrevious();
|
||||
@@ -397,108 +393,126 @@ const ImagePreviewDialog: React.FC<{
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [hasMultipleImages, popup.open, showNext, showPrevious]);
|
||||
}, [hasMultipleImages, onOpenChange, popup.open, showNext, showPrevious]);
|
||||
|
||||
const imageNaturalSize = loadedImageSize?.url === currentImage?.url
|
||||
? loadedImageSize
|
||||
: null;
|
||||
React.useEffect(() => {
|
||||
setImageNaturalSize(null);
|
||||
}, [currentImage?.url]);
|
||||
|
||||
const imageDisplaySize = React.useMemo(() => {
|
||||
const maxWidth = Math.max(160, viewport.width * (isMobile ? 0.86 : 0.75));
|
||||
const maxHeight = Math.max(160, viewport.height * (isMobile ? 0.72 : 0.75));
|
||||
|
||||
if (!imageNaturalSize) {
|
||||
return {
|
||||
width: Math.round(maxWidth),
|
||||
height: Math.round(maxHeight),
|
||||
};
|
||||
}
|
||||
|
||||
const { maxWidth, maxHeight } = getImagePreviewBounds(viewport, isMobile, markdownImage);
|
||||
let imageDisplaySize = {
|
||||
width: Math.round(maxWidth),
|
||||
height: Math.round(maxHeight),
|
||||
};
|
||||
if (imageNaturalSize) {
|
||||
const widthScale = maxWidth / imageNaturalSize.width;
|
||||
const heightScale = maxHeight / imageNaturalSize.height;
|
||||
const scale = Math.min(widthScale, heightScale);
|
||||
imageDisplaySize = {
|
||||
|
||||
return {
|
||||
width: Math.max(1, Math.round(imageNaturalSize.width * scale)),
|
||||
height: Math.max(1, Math.round(imageNaturalSize.height * scale)),
|
||||
};
|
||||
}
|
||||
}, [imageNaturalSize, isMobile, viewport.height, viewport.width]);
|
||||
|
||||
const handleImageLoad = React.useCallback((event: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
const element = event.currentTarget;
|
||||
const width = element.naturalWidth;
|
||||
const height = element.naturalHeight;
|
||||
if (width <= 0 || height <= 0) return;
|
||||
|
||||
const url = element.getAttribute('src') ?? '';
|
||||
setLoadedImageSize((previous) => {
|
||||
if (previous && previous.url === url && previous.width === width && previous.height === height) {
|
||||
return previous;
|
||||
}
|
||||
return { url, width, height };
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!currentImage) {
|
||||
if (!isRendered || !currentImage || typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dialogLayout = getImagePreviewDialogLayout(imageDisplaySize, viewport, isMobile);
|
||||
|
||||
return (
|
||||
<Dialog open={popup.open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
const content = (
|
||||
<div className={cn('fixed inset-0 z-50', popup.open ? 'pointer-events-auto' : 'pointer-events-none')}>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'max-w-none gap-3 overflow-hidden p-4',
|
||||
'[&>button]:right-3 [&>button]:top-3',
|
||||
'absolute inset-0 bg-black/40',
|
||||
isTransitioning && 'transition-opacity duration-150 ease-out',
|
||||
isVisible ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
style={{
|
||||
width: `${dialogLayout.dialogWidth}px`,
|
||||
maxWidth: isMobile ? 'calc(100vw - 1rem)' : 'calc(100vw - 2rem)',
|
||||
maxHeight: isMobile ? 'calc(100vh - 1rem)' : 'calc(100vh - 2rem)',
|
||||
}}
|
||||
data-openchamber-image-preview-dialog="true"
|
||||
data-openchamber-markdown-image-dialog={markdownImage ? 'true' : undefined}
|
||||
aria-modal="true"
|
||||
>
|
||||
<DialogHeader className="min-w-0 pr-8">
|
||||
<DialogTitle className="flex min-w-0 items-center gap-2 text-left">
|
||||
<Icon name="file-image" className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 truncate" title={imageTitle}>{imageTitle}</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
onMouseDown={() => onOpenChange(false)}
|
||||
/>
|
||||
|
||||
{hasMultipleImages && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={showPrevious}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 h-10 w-10 flex items-center justify-center rounded-full bg-black/40 text-foreground/90 hover:bg-black/55 focus:outline-none focus:ring-2 focus:ring-primary/60"
|
||||
aria-label={t('chat.toolOutputDialog.image.previousAria')}
|
||||
>
|
||||
<Icon name="arrow-left-s" className="h-6 w-6" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={showNext}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 h-10 w-10 flex items-center justify-center rounded-full bg-black/40 text-foreground/90 hover:bg-black/55 focus:outline-none focus:ring-2 focus:ring-primary/60"
|
||||
aria-label={t('chat.toolOutputDialog.image.nextAria')}
|
||||
>
|
||||
<Icon name="arrow-right-s" className="h-6 w-6" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-center justify-center pointer-events-none',
|
||||
isMobile ? 'p-2.5' : 'p-4'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="relative flex min-h-0 max-w-full self-center items-center justify-center overflow-hidden rounded-lg bg-muted/20"
|
||||
style={{ width: `${dialogLayout.imageWidth}px`, height: `${dialogLayout.imageHeight}px` }}
|
||||
className={cn(
|
||||
'pointer-events-auto flex flex-col gap-2',
|
||||
isTransitioning && 'transition-opacity duration-150 ease-out',
|
||||
isVisible ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
style={{ width: `${imageDisplaySize.width}px` }}
|
||||
>
|
||||
{hasMultipleImages ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={showPrevious}
|
||||
className="absolute left-2 top-1/2 z-10 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-background/85 text-foreground shadow-sm hover:bg-background focus:outline-none focus:ring-2 focus:ring-primary/60"
|
||||
aria-label={t('chat.toolOutputDialog.image.previousAria')}
|
||||
>
|
||||
<Icon name="arrow-left-s" className="h-6 w-6" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={showNext}
|
||||
className="absolute right-2 top-1/2 z-10 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-background/85 text-foreground shadow-sm hover:bg-background focus:outline-none focus:ring-2 focus:ring-primary/60"
|
||||
aria-label={t('chat.toolOutputDialog.image.nextAria')}
|
||||
>
|
||||
<Icon name="arrow-right-s" className="h-6 w-6" />
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex-1 text-foreground typography-ui-header font-semibold truncate" title={imageTitle}>
|
||||
{imageTitle}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 w-8 flex items-center justify-center rounded-lg text-muted-foreground/80 hover:text-foreground focus:outline-none focus:ring-2 focus:ring-primary/60"
|
||||
onClick={() => onOpenChange(false)}
|
||||
aria-label={t('chat.toolOutputDialog.image.closeAria')}
|
||||
>
|
||||
<Icon name="close" className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<img
|
||||
src={currentImage.url}
|
||||
alt={imageTitle}
|
||||
className="block h-full w-full object-contain"
|
||||
className="block object-contain"
|
||||
style={{ width: `${imageDisplaySize.width}px`, height: `${imageDisplaySize.height}px` }}
|
||||
loading="lazy"
|
||||
onLoad={handleImageLoad}
|
||||
data-openchamber-markdown-image-preview={markdownImage ? 'true' : undefined}
|
||||
onLoad={(event) => {
|
||||
const element = event.currentTarget;
|
||||
const width = element.naturalWidth;
|
||||
const height = element.naturalHeight;
|
||||
if (width > 0 && height > 0) {
|
||||
setImageNaturalSize((previous) => {
|
||||
if (previous && previous.width === width && previous.height === height) {
|
||||
return previous;
|
||||
}
|
||||
return { width, height };
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div aria-hidden="true" className="h-4 shrink-0" />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(content, document.body);
|
||||
};
|
||||
|
||||
// ── PERF-007: Virtualised sub-components for dialog ──────────────────
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
export type ImagePreviewViewport = { width: number; height: number };
|
||||
type ImagePreviewSize = { width: number; height: number };
|
||||
|
||||
export const getImagePreviewBounds = (
|
||||
viewport: ImagePreviewViewport,
|
||||
isMobile: boolean,
|
||||
markdownImage: boolean,
|
||||
): { maxWidth: number; maxHeight: number } => ({
|
||||
maxWidth: Math.max(160, viewport.width * (markdownImage ? 0.6 : (isMobile ? 0.86 : 0.75))),
|
||||
maxHeight: Math.max(160, viewport.height * (markdownImage ? 0.8 : (isMobile ? 0.72 : 0.75))),
|
||||
});
|
||||
|
||||
const IMAGE_DIALOG_MIN_WIDTH = 320;
|
||||
const IMAGE_DIALOG_CHROME_WIDTH = 34;
|
||||
|
||||
export const getImagePreviewDialogLayout = (
|
||||
image: ImagePreviewSize,
|
||||
viewport: ImagePreviewViewport,
|
||||
isMobile: boolean,
|
||||
): { dialogWidth: number; imageWidth: number; imageHeight: number } => {
|
||||
const viewportInset = isMobile ? 16 : 32;
|
||||
const maxDialogWidth = Math.max(160, viewport.width - viewportInset);
|
||||
const minDialogWidth = Math.min(IMAGE_DIALOG_MIN_WIDTH, maxDialogWidth);
|
||||
const dialogWidth = Math.min(
|
||||
maxDialogWidth,
|
||||
Math.max(minDialogWidth, image.width + IMAGE_DIALOG_CHROME_WIDTH),
|
||||
);
|
||||
const availableImageWidth = Math.max(1, dialogWidth - IMAGE_DIALOG_CHROME_WIDTH);
|
||||
const scale = Math.min(1, availableImageWidth / Math.max(1, image.width));
|
||||
|
||||
return {
|
||||
dialogWidth: Math.round(dialogWidth),
|
||||
imageWidth: Math.max(1, Math.round(image.width * scale)),
|
||||
imageHeight: Math.max(1, Math.round(image.height * scale)),
|
||||
};
|
||||
};
|
||||
@@ -63,14 +63,13 @@ Use this doc when you ask an agent to change tool/header/description behavior.
|
||||
are limited to 10 MiB, validated as PNG/JPEG/GIF/WebP, and local paths are
|
||||
fetched through the active runtime before conversion to data URLs. Local
|
||||
Markdown links whose target has one of
|
||||
those image suffixes stay links in the text and open the same standard modal
|
||||
preview as the gallery; image syntax does not insert a large inline image. A
|
||||
those image suffixes stay links in the text and open the same existing
|
||||
full-screen image preview as the gallery; image syntax does not insert a
|
||||
large inline image. A
|
||||
completed assistant message hydrates at most 12 unique image candidates,
|
||||
including persisted text parts that omit their optional part-level end time.
|
||||
The image modal reserves readable title width even for narrow portrait media.
|
||||
This gallery and modal-preview enhancement is desktop-only. Mobile keeps the
|
||||
standard Markdown rendering path until a dedicated, non-modal full-screen
|
||||
image viewer can own safe areas, orientation, and touch gestures coherently.
|
||||
Gallery clicks do not introduce or alter preview chrome: desktop and mobile
|
||||
both reuse the pre-existing attachment image preview overlay.
|
||||
- `read` and `skill` are **static navigation tools** and render via `StaticToolRow`.
|
||||
- Every other tool, including search/fetch, OpenCode built-ins, custom tools, plugins, and MCP tools, is **expandable** and renders through `ToolPart`.
|
||||
- The managed `openchamber` plugin tool uses the expandable path and hides its broad protocol input. The plugin supplies the selected action's human description as the native tool title; the UI renders that metadata without owning an action map. The full versioned result envelope renders through the same neutral JSON summary/tree/raw views as other tools, without a tool-specific output card.
|
||||
|
||||
Reference in New Issue
Block a user