fix: reuse existing image preview for galleries

This commit is contained in:
ChangeHow
2026-08-13 16:19:06 +08:00
parent efba4f33a7
commit 5d29ef15d3
5 changed files with 114 additions and 178 deletions
@@ -1212,10 +1212,10 @@ const AssistantMessageBody = React.memo(({
return visibleParts.filter((part) => part.type === 'text'); return visibleParts.filter((part) => part.type === 'text');
}, [visibleParts]); }, [visibleParts]);
const finalizedAssistantMarkdownContents = React.useMemo(() => ( const finalizedAssistantMarkdownContents = React.useMemo(() => (
isMessageCompleted && !isMobile isMessageCompleted
? assistantTextParts.map(extractTextContent).filter((text) => text.trim().length > 0) ? assistantTextParts.map(extractTextContent).filter((text) => text.trim().length > 0)
: [] : []
), [assistantTextParts, isMessageCompleted, isMobile]); ), [assistantTextParts, isMessageCompleted]);
const assistantPlanText = React.useMemo(() => flattenAssistantTextParts(assistantTextParts), [assistantTextParts]); const assistantPlanText = React.useMemo(() => flattenAssistantTextParts(assistantTextParts), [assistantTextParts]);
const suggestedPlanTitle = React.useMemo(() => suggestPlanTitleFromText(assistantPlanText), [assistantPlanText]); const suggestedPlanTitle = React.useMemo(() => suggestPlanTitleFromText(assistantPlanText), [assistantPlanText]);
@@ -1868,7 +1868,7 @@ const AssistantMessageBody = React.memo(({
chatRenderMode={chatRenderMode} chatRenderMode={chatRenderMode}
onContentChange={onContentChange} onContentChange={onContentChange}
onShowPopup={onShowPopup} onShowPopup={onShowPopup}
enableMarkdownImages={isMessageCompleted && !isMobile} enableMarkdownImages={isMessageCompleted}
/> />
</div> </div>
); );
@@ -1,6 +1,5 @@
import { describe, expect, test } from 'bun:test'; import { describe, expect, test } from 'bun:test';
import { getImagePreviewBounds, getImagePreviewDialogLayout } from './imagePreviewSizing';
import { MermaidLoadFailure, getMermaidDataUrlSourcePromise, isCurrentMermaidLoadRequest, nextMermaidLoadRequestId } from './toolOutputDialogMermaid'; import { MermaidLoadFailure, getMermaidDataUrlSourcePromise, isCurrentMermaidLoadRequest, nextMermaidLoadRequestId } from './toolOutputDialogMermaid';
describe('getMermaidDataUrlSourcePromise', () => { describe('getMermaidDataUrlSourcePromise', () => {
@@ -30,43 +29,3 @@ describe('Mermaid load request ids', () => {
expect(isCurrentMermaidLoadRequest(secondRequest, secondRequest)).toBe(true); 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 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 { File as PierreFile, PatchDiff } from '@pierre/diffs/react';
import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode'; import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode';
import { createPortal } from 'react-dom'; 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 { useI18n, type I18nKey, type I18nParams } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch'; import { runtimeFetch } from '@/lib/runtime-fetch';
import { MermaidLoadFailure, getMermaidDataUrlSourcePromise, isCurrentMermaidLoadRequest, isMermaidLoadFailure, nextMermaidLoadRequestId } from './toolOutputDialogMermaid'; import { MermaidLoadFailure, getMermaidDataUrlSourcePromise, isCurrentMermaidLoadRequest, isMermaidLoadFailure, nextMermaidLoadRequestId } from './toolOutputDialogMermaid';
import {
getImagePreviewBounds,
getImagePreviewDialogLayout,
type ImagePreviewViewport,
} from './imagePreviewSizing';
interface ToolOutputDialogProps { interface ToolOutputDialogProps {
popup: ToolPopupContent; popup: ToolPopupContent;
@@ -163,7 +158,7 @@ const usePierreThemeConfig = (): PierreThemeConfig => {
}; };
}; };
type ViewportSize = ImagePreviewViewport; type ViewportSize = { width: number; height: number };
const getWindowViewport = (): ViewportSize => ({ const getWindowViewport = (): ViewportSize => ({
width: typeof window !== 'undefined' ? window.innerWidth : 0, width: typeof window !== 'undefined' ? window.innerWidth : 0,
@@ -336,11 +331,8 @@ const ImagePreviewDialog: React.FC<{
}, [popup.image]); }, [popup.image]);
const [currentIndex, setCurrentIndex] = React.useState(0); const [currentIndex, setCurrentIndex] = React.useState(0);
const [loadedImageSize, setLoadedImageSize] = React.useState<{ const [imageNaturalSize, setImageNaturalSize] = React.useState<{ width: number; height: number } | null>(null);
url: string; const { isRendered, isVisible, isTransitioning } = usePreviewOverlayState(popup.open);
width: number;
height: number;
} | null>(null);
const viewport = usePreviewViewport(popup.open); const viewport = usePreviewViewport(popup.open);
React.useEffect(() => { React.useEffect(() => {
@@ -360,10 +352,9 @@ const ImagePreviewDialog: React.FC<{
setCurrentIndex(matchingIndex >= 0 ? matchingIndex : 0); setCurrentIndex(matchingIndex >= 0 ? matchingIndex : 0);
}, [gallery, popup.image?.index, popup.image?.url, popup.open]); }, [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 imageTitle = currentImage?.filename || popup.title || 'Image preview';
const hasMultipleImages = gallery.length > 1; const hasMultipleImages = gallery.length > 1;
const markdownImage = popup.metadata?.tool === 'markdown-image-preview';
const showPrevious = React.useCallback(() => { const showPrevious = React.useCallback(() => {
if (gallery.length <= 1) return; if (gallery.length <= 1) return;
@@ -381,6 +372,11 @@ const ImagePreviewDialog: React.FC<{
} }
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onOpenChange(false);
return;
}
if (event.key === 'ArrowLeft' && hasMultipleImages) { if (event.key === 'ArrowLeft' && hasMultipleImages) {
event.preventDefault(); event.preventDefault();
showPrevious(); showPrevious();
@@ -397,108 +393,126 @@ const ImagePreviewDialog: React.FC<{
return () => { return () => {
window.removeEventListener('keydown', onKeyDown); window.removeEventListener('keydown', onKeyDown);
}; };
}, [hasMultipleImages, popup.open, showNext, showPrevious]); }, [hasMultipleImages, onOpenChange, popup.open, showNext, showPrevious]);
const imageNaturalSize = loadedImageSize?.url === currentImage?.url React.useEffect(() => {
? loadedImageSize setImageNaturalSize(null);
: null; }, [currentImage?.url]);
const { maxWidth, maxHeight } = getImagePreviewBounds(viewport, isMobile, markdownImage); const imageDisplaySize = React.useMemo(() => {
let imageDisplaySize = { 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), width: Math.round(maxWidth),
height: Math.round(maxHeight), height: Math.round(maxHeight),
}; };
if (imageNaturalSize) { }
const widthScale = maxWidth / imageNaturalSize.width; const widthScale = maxWidth / imageNaturalSize.width;
const heightScale = maxHeight / imageNaturalSize.height; const heightScale = maxHeight / imageNaturalSize.height;
const scale = Math.min(widthScale, heightScale); const scale = Math.min(widthScale, heightScale);
imageDisplaySize = {
return {
width: Math.max(1, Math.round(imageNaturalSize.width * scale)), width: Math.max(1, Math.round(imageNaturalSize.width * scale)),
height: Math.max(1, Math.round(imageNaturalSize.height * scale)), height: Math.max(1, Math.round(imageNaturalSize.height * scale)),
}; };
} }, [imageNaturalSize, isMobile, viewport.height, viewport.width]);
const handleImageLoad = React.useCallback((event: React.SyntheticEvent<HTMLImageElement>) => { if (!isRendered || !currentImage || typeof document === 'undefined') {
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) {
return null; return null;
} }
const dialogLayout = getImagePreviewDialogLayout(imageDisplaySize, viewport, isMobile); const content = (
<div className={cn('fixed inset-0 z-50', popup.open ? 'pointer-events-auto' : 'pointer-events-none')}>
return (
<Dialog open={popup.open} onOpenChange={onOpenChange}>
<DialogContent
className={cn(
'max-w-none gap-3 overflow-hidden p-4',
'[&>button]:right-3 [&>button]:top-3',
)}
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>
<div <div
className="relative flex min-h-0 max-w-full self-center items-center justify-center overflow-hidden rounded-lg bg-muted/20" aria-hidden="true"
style={{ width: `${dialogLayout.imageWidth}px`, height: `${dialogLayout.imageHeight}px` }} className={cn(
> 'absolute inset-0 bg-black/40',
{hasMultipleImages ? ( isTransitioning && 'transition-opacity duration-150 ease-out',
isVisible ? 'opacity-100' : 'opacity-0'
)}
onMouseDown={() => onOpenChange(false)}
/>
{hasMultipleImages && (
<> <>
<button <button
type="button" type="button"
onMouseDown={(event) => event.stopPropagation()}
onClick={showPrevious} 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" 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')} aria-label={t('chat.toolOutputDialog.image.previousAria')}
> >
<Icon name="arrow-left-s" className="h-6 w-6" /> <Icon name="arrow-left-s" className="h-6 w-6" />
</button> </button>
<button <button
type="button" type="button"
onMouseDown={(event) => event.stopPropagation()}
onClick={showNext} 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" 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')} aria-label={t('chat.toolOutputDialog.image.nextAria')}
> >
<Icon name="arrow-right-s" className="h-6 w-6" /> <Icon name="arrow-right-s" className="h-6 w-6" />
</button> </button>
</> </>
) : null} )}
<div
className={cn(
'absolute inset-0 flex items-center justify-center pointer-events-none',
isMobile ? 'p-2.5' : 'p-4'
)}
>
<div
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` }}
>
<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 <img
src={currentImage.url} src={currentImage.url}
alt={imageTitle} 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" loading="lazy"
onLoad={handleImageLoad} onLoad={(event) => {
data-openchamber-markdown-image-preview={markdownImage ? 'true' : undefined} 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>
<div aria-hidden="true" className="h-4 shrink-0" /> </div>
</DialogContent> </div>
</Dialog>
); );
return createPortal(content, document.body);
}; };
// ── PERF-007: Virtualised sub-components for dialog ────────────────── // ── 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 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 fetched through the active runtime before conversion to data URLs. Local
Markdown links whose target has one of Markdown links whose target has one of
those image suffixes stay links in the text and open the same standard modal those image suffixes stay links in the text and open the same existing
preview as the gallery; image syntax does not insert a large inline image. A 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, completed assistant message hydrates at most 12 unique image candidates,
including persisted text parts that omit their optional part-level end time. including persisted text parts that omit their optional part-level end time.
The image modal reserves readable title width even for narrow portrait media. Gallery clicks do not introduce or alter preview chrome: desktop and mobile
This gallery and modal-preview enhancement is desktop-only. Mobile keeps the both reuse the pre-existing attachment image preview overlay.
standard Markdown rendering path until a dedicated, non-modal full-screen
image viewer can own safe areas, orientation, and touch gestures coherently.
- `read` and `skill` are **static navigation tools** and render via `StaticToolRow`. - `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`. - 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. - 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.