diff --git a/packages/ui/src/components/chat/message/ToolOutputDialog.test.ts b/packages/ui/src/components/chat/message/ToolOutputDialog.test.ts
index 08584eb8..a0d7f916 100644
--- a/packages/ui/src/components/chat/message/ToolOutputDialog.test.ts
+++ b/packages/ui/src/components/chat/message/ToolOutputDialog.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, test } from 'bun:test';
+import { getImagePreviewBounds, getImagePreviewDialogLayout } from './imagePreviewSizing';
import { MermaidLoadFailure, getMermaidDataUrlSourcePromise, isCurrentMermaidLoadRequest, nextMermaidLoadRequestId } from './toolOutputDialogMermaid';
describe('getMermaidDataUrlSourcePromise', () => {
@@ -29,3 +30,43 @@ 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 15298662..ce01e48b 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 } from '@/components/ui/dialog';
+import { Dialog, DialogContent, DialogHeader, DialogTitle } 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,6 +29,11 @@ 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;
@@ -158,7 +163,7 @@ const usePierreThemeConfig = (): PierreThemeConfig => {
};
};
-type ViewportSize = { width: number; height: number };
+type ViewportSize = ImagePreviewViewport;
const getWindowViewport = (): ViewportSize => ({
width: typeof window !== 'undefined' ? window.innerWidth : 0,
@@ -331,8 +336,11 @@ const ImagePreviewDialog: React.FC<{
}, [popup.image]);
const [currentIndex, setCurrentIndex] = React.useState(0);
- const [imageNaturalSize, setImageNaturalSize] = React.useState<{ width: number; height: number } | null>(null);
- const { isRendered, isVisible, isTransitioning } = usePreviewOverlayState(popup.open);
+ const [loadedImageSize, setLoadedImageSize] = React.useState<{
+ url: string;
+ width: number;
+ height: number;
+ } | null>(null);
const viewport = usePreviewViewport(popup.open);
React.useEffect(() => {
@@ -352,9 +360,10 @@ const ImagePreviewDialog: React.FC<{
setCurrentIndex(matchingIndex >= 0 ? matchingIndex : 0);
}, [gallery, popup.image?.index, popup.image?.url, popup.open]);
- const currentImage = gallery[currentIndex] ?? gallery[0] ?? popup.image;
+ const currentImage = gallery[currentIndex] ?? gallery[0];
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;
@@ -372,11 +381,6 @@ const ImagePreviewDialog: React.FC<{
}
const onKeyDown = (event: KeyboardEvent) => {
- if (event.key === 'Escape') {
- onOpenChange(false);
- return;
- }
-
if (event.key === 'ArrowLeft' && hasMultipleImages) {
event.preventDefault();
showPrevious();
@@ -393,126 +397,108 @@ const ImagePreviewDialog: React.FC<{
return () => {
window.removeEventListener('keydown', onKeyDown);
};
- }, [hasMultipleImages, onOpenChange, popup.open, showNext, showPrevious]);
+ }, [hasMultipleImages, popup.open, showNext, showPrevious]);
- 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 imageNaturalSize = loadedImageSize?.url === currentImage?.url
+ ? loadedImageSize
+ : null;
+ 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);
-
- return {
+ imageDisplaySize = {
width: Math.max(1, Math.round(imageNaturalSize.width * scale)),
height: Math.max(1, Math.round(imageNaturalSize.height * scale)),
};
- }, [imageNaturalSize, isMobile, viewport.height, viewport.width]);
+ }
- if (!isRendered || !currentImage || typeof document === 'undefined') {
+ 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) {
return null;
}
- const content = (
-
-
onOpenChange(false)}
- />
+ const dialogLayout = getImagePreviewDialogLayout(imageDisplaySize, viewport, isMobile);
- {hasMultipleImages && (
- <>
-
-
- >
- )}
-
-
+
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"
>
-
-
-
- {imageTitle}
-
-
-
+
+
+
+ {imageTitle}
+
+
+
+ {hasMultipleImages ? (
+ <>
+
+
+ >
+ ) : null}

{
- 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 };
- });
- }
- }}
+ onLoad={handleImageLoad}
+ data-openchamber-markdown-image-preview={markdownImage ? 'true' : undefined}
/>
-
-
+
+
+
);
-
- 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
new file mode 100644
index 00000000..9ef842ee
--- /dev/null
+++ b/packages/ui/src/components/chat/message/imagePreviewSizing.ts
@@ -0,0 +1,36 @@
+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/AssistantTextPart.tsx b/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx
index 74b34b24..cfcbbaa7 100644
--- a/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx
+++ b/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx
@@ -19,6 +19,7 @@ interface AssistantTextPartProps {
chatRenderMode?: 'sorted' | 'live';
onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void;
onShowPopup?: (content: ToolPopupContent) => void;
+ enableMarkdownImages?: boolean;
}
const AssistantTextPart: React.FC
= ({
@@ -27,6 +28,7 @@ const AssistantTextPart: React.FC = ({
streamPhase,
chatRenderMode = 'live',
onShowPopup,
+ enableMarkdownImages = false,
}) => {
// Use part directly from props — parent provides the latest version from the store.
// No store subscription here to avoid re-render cascade from unrelated delta events.
@@ -101,6 +103,7 @@ const AssistantTextPart: React.FC = ({
disableStreamAnimation={chatRenderMode === 'sorted'}
variant={part.type === 'reasoning' ? 'reasoning' : 'assistant'}
enableFileReferences={isFinalized}
+ enableLocalImages={enableMarkdownImages && !isStreaming && part.type === 'text'}
onShowPopup={onShowPopup}
/>
diff --git a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md
index d28450ea..e8dc194e 100644
--- a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md
+++ b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md
@@ -55,6 +55,19 @@ Use this doc when you ask an agent to change tool/header/description behavior.
HTML is sanitized as defense in depth, with script and style elements
forbidden, so message content cannot inject active DOM or application-wide
CSS into any runtime surface.
+- Final assistant Markdown collects HTTP(S), embedded, and workspace-local
+ PNG/JPEG/GIF/WebP image candidates into one 100px thumbnail gallery in the
+ message-completion area after all message text and above the turn's changed
+ files. Each muted filename caption includes the shared image-file icon.
+ HTTP(S) images keep their browser URL. Embedded and workspace-local images
+ 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
+ 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.
- `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.
diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css
index b5969d69..585cddba 100644
--- a/packages/ui/src/index.css
+++ b/packages/ui/src/index.css
@@ -1151,6 +1151,12 @@ html:not(.dark) .chat-scroll {
color: var(--markdown-link-hover, var(--primary));
}
+[data-openchamber-finalized-assistant-images="true"] [data-openchamber-markdown-image-placeholder="true"],
+[data-openchamber-finalized-assistant-images="true"] p:has(> [data-openchamber-markdown-image-placeholder="true"]:only-child),
+[data-openchamber-finalized-assistant-images="true"] li:has(> [data-openchamber-markdown-image-placeholder="true"]:only-child) {
+ display: none;
+}
+
.markdown-content [data-openchamber-file-link="true"] {
color: var(--markdown-link, var(--primary));
cursor: pointer;
diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md
index d611986c..fc9f7bb7 100644
--- a/packages/vscode/src/DOCUMENTATION.md
+++ b/packages/vscode/src/DOCUMENTATION.md
@@ -37,6 +37,7 @@ Keep `bridge.ts` as a thin orchestration layer that delegates message handling t
- directory listing
- file search
- file read path safety checks
+ - active-directory selection across multi-root workspaces
- dropped-file parsing and attachment reading
- models metadata fetch helper
diff --git a/packages/vscode/src/bridge-fs-helpers-runtime.ts b/packages/vscode/src/bridge-fs-helpers-runtime.ts
index f506b8f4..b304778d 100644
--- a/packages/vscode/src/bridge-fs-helpers-runtime.ts
+++ b/packages/vscode/src/bridge-fs-helpers-runtime.ts
@@ -538,7 +538,13 @@ export const fetchModelsMetadata = async () => {
}
};
-const getFsAccessRoot = (): string => vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
+const getFsAccessRoot = (requestedRoot?: string): string => {
+ const workspaceRoots = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath) ?? [];
+ const requested = requestedRoot ? path.resolve(requestedRoot) : '';
+ return workspaceRoots.find((root) => path.resolve(root) === requested)
+ || workspaceRoots[0]
+ || os.homedir();
+};
export const getFsMimeType = (filePath: string): string => {
const ext = path.extname(filePath).toLowerCase();
@@ -564,13 +570,13 @@ export type FsReadPathResolution =
| { ok: true; resolvedPath: string }
| { ok: false; status: number; error: string };
-export const resolveFileReadPath = async (targetPath: string): Promise
=> {
+export const resolveFileReadPath = async (targetPath: string, requestedRoot?: string): Promise => {
const trimmed = targetPath.trim();
if (!trimmed) {
return { ok: false, status: 400, error: 'Path is required' };
}
- const baseRoot = getFsAccessRoot();
+ const baseRoot = getFsAccessRoot(requestedRoot);
const resolved = resolveUserPath(trimmed, baseRoot);
if (!resolved) {
return { ok: false, status: 400, error: 'Path is required' };
diff --git a/packages/vscode/src/bridge-localfs-proxy-runtime.test.js b/packages/vscode/src/bridge-localfs-proxy-runtime.test.js
index 54eb8bad..02cdf654 100644
--- a/packages/vscode/src/bridge-localfs-proxy-runtime.test.js
+++ b/packages/vscode/src/bridge-localfs-proxy-runtime.test.js
@@ -1,32 +1,25 @@
import { describe, expect, it, mock } from 'bun:test';
+const existingFiles = new Set();
+const fsPromises = {
+ realpath: mock(async (filePath) => {
+ if (existingFiles.has(filePath)) return filePath;
+ const error = new Error('missing');
+ error.code = 'ENOENT';
+ throw error;
+ }),
+ stat: mock(async (filePath) => {
+ if (existingFiles.has(filePath)) return { isFile: () => true, size: 4, mtimeMs: 1 };
+ const error = new Error('missing');
+ error.code = 'ENOENT';
+ throw error;
+ }),
+ readFile: mock(async () => Buffer.from('test')),
+};
+
mock.module('fs', () => ({
- promises: {
- realpath: mock(async () => {
- const error = new Error('missing');
- error.code = 'ENOENT';
- throw error;
- }),
- stat: mock(async () => {
- const error = new Error('missing');
- error.code = 'ENOENT';
- throw error;
- }),
- },
- default: {
- promises: {
- realpath: mock(async () => {
- const error = new Error('missing');
- error.code = 'ENOENT';
- throw error;
- }),
- stat: mock(async () => {
- const error = new Error('missing');
- error.code = 'ENOENT';
- throw error;
- }),
- },
- },
+ promises: fsPromises,
+ default: { promises: fsPromises },
}));
mock.module('vscode', () => ({
@@ -34,7 +27,10 @@ mock.module('vscode', () => ({
file: (fsPath) => ({ fsPath }),
},
workspace: {
- workspaceFolders: [{ uri: { fsPath: '/workspace' } }],
+ workspaceFolders: [
+ { uri: { fsPath: '/workspace' } },
+ { uri: { fsPath: '/workspace-two' } },
+ ],
},
}));
@@ -56,4 +52,15 @@ describe('bridge local fs proxy', () => {
expect(response?.status).toBe(404);
});
+
+ it('reads from the active directory when it is the second workspace root', async () => {
+ existingFiles.add('/workspace-two/image.png');
+ const response = await tryHandleLocalFsProxy(
+ 'GET',
+ '/api/fs/raw?path=%2Fworkspace-two%2Fimage.png&directory=%2Fworkspace-two',
+ );
+
+ expect(response?.status).toBe(200);
+ expect(Buffer.from(response?.bodyBase64 ?? '', 'base64').toString()).toBe('test');
+ });
});
diff --git a/packages/vscode/src/bridge-localfs-proxy-runtime.ts b/packages/vscode/src/bridge-localfs-proxy-runtime.ts
index a372181c..d0439071 100644
--- a/packages/vscode/src/bridge-localfs-proxy-runtime.ts
+++ b/packages/vscode/src/bridge-localfs-proxy-runtime.ts
@@ -66,7 +66,10 @@ export const tryHandleLocalFsProxy = async (method: string, requestPath: string)
const targetPath = parsed.searchParams.get('path') || '';
const optional = parsed.searchParams.get('optional') === 'true';
- const resolution: FsReadPathResolution = await resolveFileReadPath(targetPath);
+ const resolution: FsReadPathResolution = await resolveFileReadPath(
+ targetPath,
+ parsed.searchParams.get('directory') || undefined,
+ );
if (!resolution.ok) {
if (fsProxyPath === '/api/fs/stat' && optional && resolution.status === 404) {
return {