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..d5215518 100644
--- a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md
+++ b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md
@@ -55,6 +55,21 @@ 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 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.
+ 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.
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 {