diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index 8963e5fb..a697c165 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -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} /> ); diff --git a/packages/ui/src/components/chat/message/ToolOutputDialog.test.ts b/packages/ui/src/components/chat/message/ToolOutputDialog.test.ts index a0d7f916..08584eb8 100644 --- a/packages/ui/src/components/chat/message/ToolOutputDialog.test.ts +++ b/packages/ui/src/components/chat/message/ToolOutputDialog.test.ts @@ -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, - }); - }); -}); diff --git a/packages/ui/src/components/chat/message/ToolOutputDialog.tsx b/packages/ui/src/components/chat/message/ToolOutputDialog.tsx index ce01e48b..15298662 100644 --- a/packages/ui/src/components/chat/message/ToolOutputDialog.tsx +++ b/packages/ui/src/components/chat/message/ToolOutputDialog.tsx @@ -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) => { - 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 ( - - + + + ); + + return createPortal(content, document.body); }; // ── PERF-007: Virtualised sub-components for dialog ────────────────── diff --git a/packages/ui/src/components/chat/message/imagePreviewSizing.ts b/packages/ui/src/components/chat/message/imagePreviewSizing.ts deleted file mode 100644 index 9ef842ee..00000000 --- a/packages/ui/src/components/chat/message/imagePreviewSizing.ts +++ /dev/null @@ -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)), - }; -}; diff --git a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md index f709d590..d5215518 100644 --- a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md @@ -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.