feat: improve mobile markdown image previews
This commit is contained in:
@@ -1,6 +1,13 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
import { getImagePreviewBounds, getImagePreviewDialogLayout } from './imagePreviewSizing';
|
import {
|
||||||
|
clampImagePreviewTransform,
|
||||||
|
getContainedImagePreviewSize,
|
||||||
|
getImagePreviewBounds,
|
||||||
|
getImagePreviewDialogLayout,
|
||||||
|
getImagePreviewGestureTransform,
|
||||||
|
getLocalImagePreviewPoints,
|
||||||
|
} from './imagePreviewSizing';
|
||||||
import { MermaidLoadFailure, getMermaidDataUrlSourcePromise, isCurrentMermaidLoadRequest, nextMermaidLoadRequestId } from './toolOutputDialogMermaid';
|
import { MermaidLoadFailure, getMermaidDataUrlSourcePromise, isCurrentMermaidLoadRequest, nextMermaidLoadRequestId } from './toolOutputDialogMermaid';
|
||||||
|
|
||||||
describe('getMermaidDataUrlSourcePromise', () => {
|
describe('getMermaidDataUrlSourcePromise', () => {
|
||||||
@@ -70,3 +77,52 @@ describe('Markdown image preview bounds', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Mobile image preview gestures', () => {
|
||||||
|
test('zooms around the midpoint of a two-finger gesture', () => {
|
||||||
|
expect(getImagePreviewGestureTransform(
|
||||||
|
{ scale: 1, x: 0, y: 0 },
|
||||||
|
[{ x: 100, y: 200 }, { x: 200, y: 200 }],
|
||||||
|
[{ x: 50, y: 200 }, { x: 250, y: 200 }],
|
||||||
|
{ width: 300, height: 500 },
|
||||||
|
{ width: 300, height: 500 },
|
||||||
|
)).toEqual({ scale: 2, x: 0, y: 50 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supports panning after zoom and clamps the image to the viewport', () => {
|
||||||
|
expect(getImagePreviewGestureTransform(
|
||||||
|
{ scale: 2, x: 0, y: 0 },
|
||||||
|
[{ x: 100, y: 100 }],
|
||||||
|
[{ x: 400, y: -400 }],
|
||||||
|
{ width: 300, height: 500 },
|
||||||
|
{ width: 300, height: 500 },
|
||||||
|
)).toEqual({ scale: 2, x: 150, y: -250 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('limits pinch zoom to four times', () => {
|
||||||
|
expect(clampImagePreviewTransform(
|
||||||
|
{ scale: 8, x: 1000, y: -1000 },
|
||||||
|
{ width: 300, height: 500 },
|
||||||
|
{ width: 300, height: 500 },
|
||||||
|
)).toEqual({ scale: 4, x: 450, y: -750 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('converts page coordinates into the image viewport coordinate system', () => {
|
||||||
|
expect(getLocalImagePreviewPoints(
|
||||||
|
[{ x: 129, y: 257 }, { x: 229, y: 257 }],
|
||||||
|
{ x: 29, y: 57 },
|
||||||
|
)).toEqual([{ x: 100, y: 200 }, { x: 200, y: 200 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clamps letterboxed wide images against their visible content', () => {
|
||||||
|
const content = getContainedImagePreviewSize(
|
||||||
|
{ width: 2908, height: 1686 },
|
||||||
|
{ width: 332, height: 758 },
|
||||||
|
);
|
||||||
|
expect(clampImagePreviewTransform(
|
||||||
|
{ scale: 2.5, x: 1000, y: 1000 },
|
||||||
|
{ width: 332, height: 758 },
|
||||||
|
content,
|
||||||
|
)).toEqual({ scale: 2.5, x: 249, y: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -30,8 +30,14 @@ 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 {
|
import {
|
||||||
|
clampImagePreviewTransform,
|
||||||
|
getContainedImagePreviewSize,
|
||||||
|
getImagePreviewGestureTransform,
|
||||||
getImagePreviewBounds,
|
getImagePreviewBounds,
|
||||||
getImagePreviewDialogLayout,
|
getImagePreviewDialogLayout,
|
||||||
|
getLocalImagePreviewPoints,
|
||||||
|
IDENTITY_IMAGE_PREVIEW_TRANSFORM,
|
||||||
|
type ImagePreviewPoint,
|
||||||
type ImagePreviewViewport,
|
type ImagePreviewViewport,
|
||||||
} from './imagePreviewSizing';
|
} from './imagePreviewSizing';
|
||||||
|
|
||||||
@@ -364,6 +370,76 @@ const ImagePreviewDialog: React.FC<{
|
|||||||
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 markdownImage = popup.metadata?.tool === 'markdown-image-preview';
|
||||||
|
const mobileMarkdownViewer = isMobile && markdownImage;
|
||||||
|
const imageViewportRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
const activePointersRef = React.useRef(new Map<number, ImagePreviewPoint>());
|
||||||
|
const previousPointsRef = React.useRef<ImagePreviewPoint[]>([]);
|
||||||
|
const [imageTransform, setImageTransform] = React.useState(IDENTITY_IMAGE_PREVIEW_TRANSFORM);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
activePointersRef.current.clear();
|
||||||
|
previousPointsRef.current = [];
|
||||||
|
setImageTransform(IDENTITY_IMAGE_PREVIEW_TRANSFORM);
|
||||||
|
}, [currentImage?.url, popup.open]);
|
||||||
|
|
||||||
|
const handleImagePointerDown = React.useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!mobileMarkdownViewer || event.pointerType !== 'touch') return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||||
|
previousPointsRef.current = Array.from(activePointersRef.current.values());
|
||||||
|
}, [mobileMarkdownViewer]);
|
||||||
|
|
||||||
|
const handleImagePointerMove = React.useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!mobileMarkdownViewer || !activePointersRef.current.has(event.pointerId)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||||
|
const currentPoints = Array.from(activePointersRef.current.values());
|
||||||
|
const previousPoints = previousPointsRef.current;
|
||||||
|
const imageViewport = imageViewportRef.current;
|
||||||
|
if (imageViewport) {
|
||||||
|
const bounds = imageViewport.getBoundingClientRect();
|
||||||
|
const viewportSize = { width: bounds.width, height: bounds.height };
|
||||||
|
const image = imageViewport.querySelector('img');
|
||||||
|
const contentSize = getContainedImagePreviewSize({
|
||||||
|
width: image?.naturalWidth || bounds.width,
|
||||||
|
height: image?.naturalHeight || bounds.height,
|
||||||
|
}, viewportSize);
|
||||||
|
setImageTransform((current) => getImagePreviewGestureTransform(
|
||||||
|
current,
|
||||||
|
getLocalImagePreviewPoints(previousPoints, { x: bounds.left, y: bounds.top }),
|
||||||
|
getLocalImagePreviewPoints(currentPoints, { x: bounds.left, y: bounds.top }),
|
||||||
|
viewportSize,
|
||||||
|
contentSize,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
previousPointsRef.current = currentPoints;
|
||||||
|
}, [mobileMarkdownViewer]);
|
||||||
|
|
||||||
|
const handleImagePointerEnd = React.useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!mobileMarkdownViewer) return;
|
||||||
|
activePointersRef.current.delete(event.pointerId);
|
||||||
|
previousPointsRef.current = Array.from(activePointersRef.current.values());
|
||||||
|
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||||
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
}, [mobileMarkdownViewer]);
|
||||||
|
|
||||||
|
const adjustImageScale = React.useCallback((delta: number) => {
|
||||||
|
const imageViewport = imageViewportRef.current;
|
||||||
|
if (!imageViewport) return;
|
||||||
|
const bounds = imageViewport.getBoundingClientRect();
|
||||||
|
const viewportSize = { width: bounds.width, height: bounds.height };
|
||||||
|
const image = imageViewport.querySelector('img');
|
||||||
|
const contentSize = getContainedImagePreviewSize({
|
||||||
|
width: image?.naturalWidth || bounds.width,
|
||||||
|
height: image?.naturalHeight || bounds.height,
|
||||||
|
}, viewportSize);
|
||||||
|
setImageTransform((current) => clampImagePreviewTransform({
|
||||||
|
...current,
|
||||||
|
scale: current.scale + delta,
|
||||||
|
}, viewportSize, contentSize));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const showPrevious = React.useCallback(() => {
|
const showPrevious = React.useCallback(() => {
|
||||||
if (gallery.length <= 1) return;
|
if (gallery.length <= 1) return;
|
||||||
@@ -443,10 +519,17 @@ const ImagePreviewDialog: React.FC<{
|
|||||||
<Dialog open={popup.open} onOpenChange={onOpenChange}>
|
<Dialog open={popup.open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className={cn(
|
className={cn(
|
||||||
'max-w-none gap-3 overflow-hidden p-4',
|
'max-w-none overflow-hidden',
|
||||||
|
mobileMarkdownViewer
|
||||||
|
? 'h-[calc(100dvh-2rem)] gap-3 p-3'
|
||||||
|
: 'gap-3 p-4',
|
||||||
'[&>button]:right-3 [&>button]:top-3',
|
'[&>button]:right-3 [&>button]:top-3',
|
||||||
)}
|
)}
|
||||||
style={{
|
style={mobileMarkdownViewer ? {
|
||||||
|
width: 'calc(100vw - 2rem)',
|
||||||
|
maxWidth: 'none',
|
||||||
|
maxHeight: 'none',
|
||||||
|
} : {
|
||||||
width: `${dialogLayout.dialogWidth}px`,
|
width: `${dialogLayout.dialogWidth}px`,
|
||||||
maxWidth: isMobile ? 'calc(100vw - 1rem)' : 'calc(100vw - 2rem)',
|
maxWidth: isMobile ? 'calc(100vw - 1rem)' : 'calc(100vw - 2rem)',
|
||||||
maxHeight: isMobile ? 'calc(100vh - 1rem)' : 'calc(100vh - 2rem)',
|
maxHeight: isMobile ? 'calc(100vh - 1rem)' : 'calc(100vh - 2rem)',
|
||||||
@@ -463,8 +546,19 @@ const ImagePreviewDialog: React.FC<{
|
|||||||
</DialogHeader>
|
</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"
|
ref={imageViewportRef}
|
||||||
style={{ width: `${dialogLayout.imageWidth}px`, height: `${dialogLayout.imageHeight}px` }}
|
className={cn(
|
||||||
|
'relative flex min-h-0 max-w-full self-center items-center justify-center overflow-hidden rounded-lg bg-muted/20',
|
||||||
|
mobileMarkdownViewer && 'w-full flex-1 touch-none',
|
||||||
|
)}
|
||||||
|
style={mobileMarkdownViewer
|
||||||
|
? { touchAction: 'none' }
|
||||||
|
: { width: `${dialogLayout.imageWidth}px`, height: `${dialogLayout.imageHeight}px` }}
|
||||||
|
onPointerDown={handleImagePointerDown}
|
||||||
|
onPointerMove={handleImagePointerMove}
|
||||||
|
onPointerUp={handleImagePointerEnd}
|
||||||
|
onPointerCancel={handleImagePointerEnd}
|
||||||
|
onLostPointerCapture={handleImagePointerEnd}
|
||||||
>
|
>
|
||||||
{hasMultipleImages ? (
|
{hasMultipleImages ? (
|
||||||
<>
|
<>
|
||||||
@@ -490,12 +584,51 @@ const ImagePreviewDialog: React.FC<{
|
|||||||
src={currentImage.url}
|
src={currentImage.url}
|
||||||
alt={imageTitle}
|
alt={imageTitle}
|
||||||
className="block h-full w-full object-contain"
|
className="block h-full w-full object-contain"
|
||||||
|
style={mobileMarkdownViewer ? {
|
||||||
|
transform: `translate3d(${imageTransform.x}px, ${imageTransform.y}px, 0) scale(${imageTransform.scale})`,
|
||||||
|
transformOrigin: 'center',
|
||||||
|
} : undefined}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onLoad={handleImageLoad}
|
onLoad={handleImageLoad}
|
||||||
data-openchamber-markdown-image-preview={markdownImage ? 'true' : undefined}
|
data-openchamber-markdown-image-preview={markdownImage ? 'true' : undefined}
|
||||||
|
data-openchamber-image-preview-scale={mobileMarkdownViewer ? imageTransform.scale : undefined}
|
||||||
/>
|
/>
|
||||||
|
{mobileMarkdownViewer ? (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-3 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1 rounded-lg border border-border/60 bg-background/90 p-1"
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-md text-foreground hover:bg-interactive-hover focus:outline-none focus:ring-2 focus:ring-primary/60"
|
||||||
|
onClick={() => adjustImageScale(-0.5)}
|
||||||
|
aria-label={t('markdownRenderer.mermaid.actions.zoomOutTitle')}
|
||||||
|
data-openchamber-image-preview-zoom="out"
|
||||||
|
>
|
||||||
|
<Icon name="subtract" className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-md text-foreground hover:bg-interactive-hover focus:outline-none focus:ring-2 focus:ring-primary/60"
|
||||||
|
onClick={() => setImageTransform(IDENTITY_IMAGE_PREVIEW_TRANSFORM)}
|
||||||
|
aria-label={t('markdownRenderer.mermaid.actions.resetViewTitle')}
|
||||||
|
data-openchamber-image-preview-zoom="reset"
|
||||||
|
>
|
||||||
|
<Icon name="restart" className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-md text-foreground hover:bg-interactive-hover focus:outline-none focus:ring-2 focus:ring-primary/60"
|
||||||
|
onClick={() => adjustImageScale(0.5)}
|
||||||
|
aria-label={t('markdownRenderer.mermaid.actions.zoomInTitle')}
|
||||||
|
data-openchamber-image-preview-zoom="in"
|
||||||
|
>
|
||||||
|
<Icon name="add" className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div aria-hidden="true" className="h-4 shrink-0" />
|
{!mobileMarkdownViewer ? <div aria-hidden="true" className="h-4 shrink-0" /> : null}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
export type ImagePreviewViewport = { width: number; height: number };
|
export type ImagePreviewViewport = { width: number; height: number };
|
||||||
type ImagePreviewSize = { width: number; height: number };
|
type ImagePreviewSize = { width: number; height: number };
|
||||||
|
export type ImagePreviewPoint = { x: number; y: number };
|
||||||
|
type ImagePreviewTransform = { scale: number; x: number; y: number };
|
||||||
|
|
||||||
|
export const IDENTITY_IMAGE_PREVIEW_TRANSFORM: ImagePreviewTransform = { scale: 1, x: 0, y: 0 };
|
||||||
|
const MAX_IMAGE_PREVIEW_SCALE = 4;
|
||||||
|
|
||||||
export const getImagePreviewBounds = (
|
export const getImagePreviewBounds = (
|
||||||
viewport: ImagePreviewViewport,
|
viewport: ImagePreviewViewport,
|
||||||
@@ -34,3 +39,88 @@ export const getImagePreviewDialogLayout = (
|
|||||||
imageHeight: Math.max(1, Math.round(image.height * scale)),
|
imageHeight: Math.max(1, Math.round(image.height * scale)),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const midpoint = (first: ImagePreviewPoint, second: ImagePreviewPoint): ImagePreviewPoint => ({
|
||||||
|
x: (first.x + second.x) / 2,
|
||||||
|
y: (first.y + second.y) / 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const distance = (first: ImagePreviewPoint, second: ImagePreviewPoint): number => (
|
||||||
|
Math.hypot(second.x - first.x, second.y - first.y)
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getContainedImagePreviewSize = (
|
||||||
|
image: ImagePreviewSize,
|
||||||
|
viewport: ImagePreviewViewport,
|
||||||
|
): ImagePreviewSize => {
|
||||||
|
const scale = Math.min(
|
||||||
|
viewport.width / Math.max(1, image.width),
|
||||||
|
viewport.height / Math.max(1, image.height),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
width: image.width * scale,
|
||||||
|
height: image.height * scale,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLocalImagePreviewPoints = (
|
||||||
|
points: ImagePreviewPoint[],
|
||||||
|
origin: ImagePreviewPoint,
|
||||||
|
): ImagePreviewPoint[] => points.map((point) => ({
|
||||||
|
x: point.x - origin.x,
|
||||||
|
y: point.y - origin.y,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const clampImagePreviewTransform = (
|
||||||
|
transform: ImagePreviewTransform,
|
||||||
|
viewport: ImagePreviewViewport,
|
||||||
|
content: ImagePreviewSize,
|
||||||
|
): ImagePreviewTransform => {
|
||||||
|
const scale = Math.min(MAX_IMAGE_PREVIEW_SCALE, Math.max(1, transform.scale));
|
||||||
|
const maxX = Math.max(0, (content.width * scale - viewport.width) / 2);
|
||||||
|
const maxY = Math.max(0, (content.height * scale - viewport.height) / 2);
|
||||||
|
|
||||||
|
return {
|
||||||
|
scale,
|
||||||
|
x: Math.min(maxX, Math.max(-maxX, scale === 1 ? 0 : transform.x)),
|
||||||
|
y: Math.min(maxY, Math.max(-maxY, scale === 1 ? 0 : transform.y)),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getImagePreviewGestureTransform = (
|
||||||
|
transform: ImagePreviewTransform,
|
||||||
|
previousPoints: ImagePreviewPoint[],
|
||||||
|
currentPoints: ImagePreviewPoint[],
|
||||||
|
viewport: ImagePreviewViewport,
|
||||||
|
content: ImagePreviewSize,
|
||||||
|
): ImagePreviewTransform => {
|
||||||
|
if (previousPoints.length >= 2 && currentPoints.length >= 2) {
|
||||||
|
const previousDistance = distance(previousPoints[0], previousPoints[1]);
|
||||||
|
if (previousDistance <= 0) return transform;
|
||||||
|
|
||||||
|
const previousMidpoint = midpoint(previousPoints[0], previousPoints[1]);
|
||||||
|
const currentMidpoint = midpoint(currentPoints[0], currentPoints[1]);
|
||||||
|
const scale = Math.min(
|
||||||
|
MAX_IMAGE_PREVIEW_SCALE,
|
||||||
|
Math.max(1, transform.scale * distance(currentPoints[0], currentPoints[1]) / previousDistance),
|
||||||
|
);
|
||||||
|
const ratio = scale / transform.scale;
|
||||||
|
const center = { x: viewport.width / 2, y: viewport.height / 2 };
|
||||||
|
|
||||||
|
return clampImagePreviewTransform({
|
||||||
|
scale,
|
||||||
|
x: currentMidpoint.x - center.x - (previousMidpoint.x - center.x - transform.x) * ratio,
|
||||||
|
y: currentMidpoint.y - center.y - (previousMidpoint.y - center.y - transform.y) * ratio,
|
||||||
|
}, viewport, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousPoints.length === 1 && currentPoints.length === 1 && transform.scale > 1) {
|
||||||
|
return clampImagePreviewTransform({
|
||||||
|
...transform,
|
||||||
|
x: transform.x + currentPoints[0].x - previousPoints[0].x,
|
||||||
|
y: transform.y + currentPoints[0].y - previousPoints[0].y,
|
||||||
|
}, viewport, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return transform;
|
||||||
|
};
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ Use this doc when you ask an agent to change tool/header/description behavior.
|
|||||||
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.
|
The image modal reserves readable title width even for narrow portrait media.
|
||||||
|
Markdown image previews use the 60vw contained dialog on desktop and a
|
||||||
|
full-height mobile dialog with 1x-4x pinch zoom, zoom controls, and panning
|
||||||
|
when zoomed.
|
||||||
- `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