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');
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user