Merge pull request #2863 from ChangeHow/codex/markdown-image-gallery

feat: add assistant Markdown image galleries
This commit is contained in:
Bohdan Triapitsyn
2026-08-13 14:00:05 +03:00
committed by GitHub
14 changed files with 651 additions and 49 deletions
@@ -0,0 +1,115 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import type { ToolPopupContent } from './message/types';
import {
extractMarkdownImageCandidates,
MAX_MARKDOWN_IMAGE_COUNT,
type MarkdownImageCandidate,
} from './markdown/markdownCore';
import { resolveMarkdownImageSource } from './markdown/markdownImageAssets';
const MarkdownImageThumbnail: React.FC<{
candidate: MarkdownImageCandidate;
directory: string;
onShowPopup?: (content: ToolPopupContent) => void;
}> = ({ candidate, directory, onShowPopup }) => {
const [image, setImage] = React.useState<{
url: string;
status: 'loading' | 'ready' | 'error';
}>({ url: '', status: 'loading' });
React.useEffect(() => {
const controller = new AbortController();
setImage({ url: '', status: 'loading' });
void resolveMarkdownImageSource(candidate.source, directory, controller.signal)
.then((url) => {
if (!controller.signal.aborted) setImage({ url, status: 'loading' });
})
.catch(() => {
if (!controller.signal.aborted) setImage({ url: '', status: 'error' });
});
return () => controller.abort();
}, [candidate.source, directory]);
const openPreview = React.useCallback(() => {
if (image.status !== 'ready' || !onShowPopup) return;
onShowPopup({
open: true,
title: candidate.filename,
content: '',
metadata: { tool: 'markdown-image-preview', filename: candidate.filename },
image: { url: image.url, filename: candidate.filename },
});
}, [candidate.filename, image, onShowPopup]);
return (
<button
type="button"
className="w-[100px] shrink-0 text-left outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]"
aria-label={candidate.filename}
disabled={image.status !== 'ready'}
onClick={openPreview}
data-openchamber-markdown-image-action="true"
data-openchamber-markdown-image-source={candidate.source}
data-openchamber-markdown-image-filename={candidate.filename}
>
<span className="flex h-[72px] w-[100px] items-center justify-center overflow-hidden rounded-lg border border-border/40 bg-muted/10">
{image.url && image.status !== 'error' ? (
<img
src={image.url}
alt={candidate.filename}
className="h-full w-full object-contain"
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
onLoad={() => setImage((current) => ({ ...current, status: 'ready' }))}
onError={() => setImage({ url: '', status: 'error' })}
data-openchamber-markdown-image="true"
data-openchamber-markdown-image-thumbnail="true"
data-openchamber-markdown-image-state={image.status}
/>
) : (
<Icon name="file-image" className="h-5 w-5 text-muted-foreground" />
)}
</span>
<span
className="mt-1 flex w-[100px] items-center justify-center gap-1 text-muted-foreground"
title={candidate.filename}
data-openchamber-markdown-image-caption="true"
>
<Icon name="file-image" className="h-3 w-3 shrink-0" />
<span className="min-w-0 truncate typography-meta">{candidate.filename}</span>
</span>
</button>
);
};
export const MarkdownImageGallery: React.FC<{
contents: readonly string[];
onShowPopup?: (content: ToolPopupContent) => void;
}> = ({ contents, onShowPopup }) => {
const directory = useEffectiveDirectory() ?? '';
const candidates = React.useMemo(
() => extractMarkdownImageCandidates(contents, MAX_MARKDOWN_IMAGE_COUNT),
[contents],
);
if (candidates.length === 0) return null;
return (
<div
className="mt-3 flex max-w-full gap-2 overflow-x-auto pb-1"
data-openchamber-markdown-image-gallery="true"
>
{candidates.map((candidate) => (
<MarkdownImageThumbnail
key={candidate.source}
candidate={candidate}
directory={directory}
onShowPopup={onShowPopup}
/>
))}
</div>
);
};
@@ -17,6 +17,10 @@ const SimpleMarkdownRendererLazy = lazyWithChunkRecovery(() =>
loadMarkdownRendererModule().then((m) => ({ default: m.SimpleMarkdownRenderer }))
);
const MarkdownImageGalleryLazy = lazyWithChunkRecovery(() =>
import('./MarkdownImageGallery').then((m) => ({ default: m.MarkdownImageGallery }))
);
const fallback = <div className="break-words w-full min-w-0" />;
const fallbackContentClassName = (variant: unknown): string => {
@@ -48,3 +52,9 @@ export const SimpleMarkdownRenderer: React.FC<React.ComponentPropsWithoutRef<typ
<SimpleMarkdownRendererLazy {...props} />
</React.Suspense>
);
export const MarkdownImageGallery: React.FC<React.ComponentPropsWithoutRef<typeof MarkdownImageGalleryLazy>> = (props) => (
<React.Suspense fallback={null}>
<MarkdownImageGalleryLazy {...props} />
</React.Suspense>
);
@@ -19,7 +19,8 @@ import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime } from '@/l
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants';
import { getDirectoryForFilePath, isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils';
import { renderMarkdownBlocks, renderMarkdownSync } from './markdown/markdownCore';
import { getMarkdownImageFilename, renderMarkdownBlocks, renderMarkdownSync } from './markdown/markdownCore';
import { resolveMarkdownImageSource } from './markdown/markdownImageAssets';
import { ensureMarkdownShikiTheme } from './markdown/markdownTheme';
import { getMarkdownSyntaxVars } from './markdown/markdownSyntaxVars';
import {
@@ -107,6 +108,59 @@ const useExternalLinkInteractions = ({
}, [containerRef, enabled]);
};
const useMarkdownImageLinkInteractions = ({
containerRef,
directory,
enabled,
onShowPopup,
}: {
containerRef: React.RefObject<HTMLDivElement | null>;
directory: string;
enabled: boolean;
onShowPopup?: (content: ToolPopupContent) => void;
}) => {
React.useEffect(() => {
const container = containerRef.current;
if (!enabled || !container || !onShowPopup) return;
const controller = new AbortController();
const handleClick = (event: MouseEvent) => {
if (event.defaultPrevented || event.button !== 0) return;
const target = event.target;
if (!(target instanceof Element)) return;
const link = target.closest<HTMLAnchorElement>('[data-openchamber-markdown-image-link="true"]');
if (!link || !container.contains(link)) return;
const source = link.getAttribute('data-openchamber-markdown-image-source') ?? '';
const filename = link.getAttribute('data-openchamber-markdown-image-filename')
|| getMarkdownImageFilename(source, '');
if (!source || !filename) return;
event.preventDefault();
event.stopPropagation();
void resolveMarkdownImageSource(source, directory, controller.signal)
.then((url) => {
if (controller.signal.aborted || !link.isConnected) return;
onShowPopup({
open: true,
title: filename,
content: '',
metadata: { tool: 'markdown-image-preview', filename },
image: { url, filename },
});
})
.catch(() => undefined);
};
container.addEventListener('click', handleClick);
return () => {
controller.abort();
container.removeEventListener('click', handleClick);
};
}, [containerRef, directory, enabled, onShowPopup]);
};
const DEFAULT_MERMAID_CONTROLS: MermaidControlOptions = {
download: true,
copy: true,
@@ -140,6 +194,7 @@ interface MarkdownRendererProps {
variant?: MarkdownVariant;
onShowPopup?: (content: ToolPopupContent) => void;
enableFileReferences?: boolean;
enableLocalImages?: boolean;
}
const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]';
@@ -497,6 +552,10 @@ const useFileReferenceInteractions = ({
let linkedCount = 0;
for (const candidate of Array.from(candidates)) {
if (candidate.matches('[data-openchamber-markdown-image-link="true"]')) {
clearFileLinkAttributes(candidate);
continue;
}
const rawCandidate = extractPathCandidateFromElement(candidate);
const resolved = getResolvedReference(rawCandidate, effectiveDirectory);
clearFileLinkAttributes(candidate);
@@ -831,6 +890,7 @@ const useMorphdomMarkdown = ({
text,
streaming,
cacheKey,
deferImages = false,
syntaxVars,
ctx,
}: {
@@ -838,6 +898,7 @@ const useMorphdomMarkdown = ({
text: string;
streaming: boolean;
cacheKey: string;
deferImages?: boolean;
syntaxVars: Record<string, string>;
ctx: DecorateContext;
}) => {
@@ -876,7 +937,7 @@ const useMorphdomMarkdown = ({
// `display:contents` keeps margin-collapsing/spacing identical to a flat
// HTML body — the wrapper exists only for per-block reconciliation.
block.style.display = 'contents';
block.innerHTML = renderMarkdownSync(text);
block.innerHTML = renderMarkdownSync(text, deferImages);
// Decorate synchronously too: wrap code blocks in their framed card,
// mark inline code, build table controls, etc. The async pass re-decorates
// its own DOM before morphing, so without this the first paint shows bare
@@ -888,7 +949,7 @@ const useMorphdomMarkdown = ({
refreshMermaidViewers();
}
}
}, [containerRef, text, ctx, refreshMermaidViewers]);
}, [containerRef, text, deferImages, ctx, refreshMermaidViewers]);
React.useEffect(() => () => {
mermaidViewerRef.current?.cleanup();
@@ -901,7 +962,7 @@ const useMorphdomMarkdown = ({
const target = container.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
let active = true;
void renderMarkdownBlocks(text, streaming, cacheKey).then((blocks) => {
void renderMarkdownBlocks(text, streaming, cacheKey, deferImages).then((blocks) => {
if (!active) return;
const existing = Array.from(target.children) as HTMLElement[];
@@ -952,7 +1013,7 @@ const useMorphdomMarkdown = ({
return () => {
active = false;
};
}, [containerRef, text, streaming, cacheKey, ctx, refreshMermaidViewers]);
}, [containerRef, text, streaming, cacheKey, deferImages, ctx, refreshMermaidViewers]);
React.useEffect(() => {
const container = containerRef.current;
@@ -999,6 +1060,7 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
variant = 'assistant',
onShowPopup,
enableFileReferences = true,
enableLocalImages = false,
}) => {
streamPerfCount('ui.markdown_renderer.render');
if (isStreaming) streamPerfCount('ui.markdown_renderer.render.streaming');
@@ -1030,15 +1092,33 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
enabled: enableFileReferences && !isStreaming,
});
useExternalLinkInteractions({ containerRef });
useMarkdownImageLinkInteractions({
containerRef,
directory: effectiveDirectory,
enabled: enableLocalImages && variant === 'assistant' && !isStreaming,
onShowPopup,
});
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS);
const cacheKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
useMorphdomMarkdown({ containerRef, text: content, streaming: live, cacheKey, syntaxVars, ctx });
useMorphdomMarkdown({
containerRef,
text: content,
streaming: live,
cacheKey,
deferImages: enableLocalImages && variant === 'assistant' && !isStreaming,
syntaxVars,
ctx,
});
const markdownContent = (
<div className={cn('break-words w-full min-w-0', className)} ref={containerRef}>
<div
className={cn('break-words w-full min-w-0', className)}
ref={containerRef}
data-openchamber-finalized-assistant-images={enableLocalImages && variant === 'assistant' && !isStreaming ? 'true' : undefined}
>
<div className={markdownContentClassName(variant)} data-markdown-content />
</div>
);
@@ -1065,6 +1145,7 @@ export const MarkdownRenderer = React.memo(MarkdownRendererImpl, (prev, next) =>
&& prev.messageId === next.messageId
&& prev.onShowPopup === next.onShowPopup
&& prev.enableFileReferences === next.enableFileReferences
&& prev.enableLocalImages === next.enableLocalImages
&& prev.part?.id === next.part?.id;
});
@@ -1,7 +1,21 @@
import { describe, expect, test } from 'bun:test';
import { describe, expect, mock, test } from 'bun:test';
mock.module('dompurify', () => ({
default: {
isSupported: true,
addHook: () => undefined,
sanitize: (html: string) => html,
},
}));
mock.module('./markdown-worker', () => ({
highlightCodeInWorker: async () => null,
}));
import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
const { extractMarkdownImageCandidates, renderMarkdownSync } = await import('./markdownCore');
const { resolveMarkdownImageSource } = await import('./markdownImageAssets');
describe('markdown sanitization', () => {
test('turns raw assistant HTML into inert visible text', () => {
const payload = '<style>@import url("https://example.test/theme.css");</style>';
@@ -16,3 +30,102 @@ describe('markdown sanitization', () => {
expect(MARKDOWN_FORBIDDEN_TAGS).toContain('style');
});
});
describe('Markdown images', () => {
test('keeps local image links in text and emits inert image placeholders', () => {
const html = renderMarkdownSync([
'[linked image](packages/vscode/extension.jpg)',
'![image syntax](packages/vscode/extension.jpg)',
].join('\n\n'), true);
expect(html).toContain('data-openchamber-markdown-image-link="true"');
expect(html.match(/data-openchamber-markdown-image-source="packages\/vscode\/extension.jpg"/g)).toHaveLength(1);
expect(html).toContain('data-openchamber-markdown-image-placeholder="true"');
expect(html).toContain('image syntax');
expect(html).not.toContain('src="packages/vscode/extension.jpg"');
expect(html).not.toContain('data-openchamber-markdown-image-state');
expect(html.match(/<a /g)).toHaveLength(1);
});
test('keeps HTTP links as links and defers remote image tokens to finalized rendering', () => {
const html = renderMarkdownSync([
'[remote link](https://example.test/image.png)',
'![remote image](https://example.test/image.png)',
].join('\n\n'), true);
expect(html).toContain('<a href="https://example.test/image.png"');
expect(html).not.toContain('<img');
expect(html).toContain('data-openchamber-markdown-image-placeholder="true"');
expect(html).toContain('remote image');
});
test('preserves file URLs inertly and never activates unknown schemes', () => {
const html = renderMarkdownSync([
'![file](file:///workspace/image.png)',
'![unsafe](javascript:alert(1))',
].join('\n\n'), true);
expect(html).toContain('role="img"');
expect(html).not.toContain('src="file:');
expect(html).not.toContain('src="javascript:');
});
test('collects a single ordered gallery across mixed Markdown and ignores code', () => {
const candidates = extractMarkdownImageCandidates([
[
'Before [local link](screens/first%20view.png) and `![code](ignored.png)`.',
'',
'- ![duplicate](screens/first%20view.png)',
'- ![remote](https://example.test/second.webp?size=2)',
'',
'```md',
'![fenced](ignored-too.jpg)',
'```',
].join('\n'),
'After ![third](data:image/png;base64,AAAA).',
]);
expect(candidates).toEqual([
{ source: 'screens/first%20view.png', filename: 'first view.png' },
{ source: 'https://example.test/second.webp?size=2', filename: 'second.webp' },
{ source: 'data:image/png;base64,AAAA', filename: 'third' },
]);
});
test('limits one finalized message gallery to twelve unique candidates', () => {
const markdown = Array.from({ length: 14 }, (_, index) => `![image ${index}](screens/${index}.png)`).join('\n');
const candidates = extractMarkdownImageCandidates([markdown]);
expect(candidates).toHaveLength(12);
expect(candidates.at(-1)?.source).toBe('screens/11.png');
});
test('validates embedded image bytes against the declared MIME type', async () => {
const png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==';
const signal = new AbortController().signal;
expect(await resolveMarkdownImageSource(`data:image/png;base64,${png}`, '', signal)).toBe(`data:image/png;base64,${png}`);
await resolveMarkdownImageSource(`data:image/jpeg;base64,${png}`, '', signal).then(
() => { throw new Error('Expected mismatched image data to fail'); },
(error: unknown) => expect((error as Error).message).toBe('Unsupported image data'),
);
});
test('does not resolve images after cancellation', async () => {
const controller = new AbortController();
controller.abort();
await resolveMarkdownImageSource('https://example.test/image.png', '', controller.signal).then(
() => { throw new Error('Expected an aborted image load to fail'); },
(error: unknown) => expect((error as Error).name).toBe('AbortError'),
);
});
test('keeps the existing image renderer outside finalized assistant text', () => {
const html = renderMarkdownSync('![tool image](https://example.test/image.png)');
expect(html).toContain('<img src="https://example.test/image.png"');
expect(html).not.toContain('data-openchamber-markdown-image-placeholder');
});
});
@@ -1,4 +1,4 @@
import { marked, type Tokens } from 'marked';
import { Marked, marked, type Tokens } from 'marked';
import remend from 'remend';
import katex from 'katex';
import DOMPurify from 'dompurify';
@@ -10,6 +10,95 @@ import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecuri
const escapeAttr = (value: string): string =>
value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const LOCAL_IMAGE_EXTENSION_RE = /\.(?:png|jpe?g|gif|webp)(?:[?#].*)?$/i;
const WINDOWS_ABSOLUTE_PATH_RE = /^[A-Za-z]:[\\/]/;
const URL_SCHEME_RE = /^[A-Za-z][A-Za-z\d+.-]*:/;
export interface MarkdownImageCandidate {
source: string;
filename: string;
}
export const MAX_MARKDOWN_IMAGE_COUNT = 12;
const isLocalMarkdownImageSource = (source: string): boolean => {
if (/^\/\//.test(source) || !LOCAL_IMAGE_EXTENSION_RE.test(source)) return false;
return WINDOWS_ABSOLUTE_PATH_RE.test(source)
|| /^file:\/\//i.test(source)
|| !URL_SCHEME_RE.test(source);
};
const isSupportedMarkdownImageSource = (source: string): boolean => (
/^(?:https?:)?\/\//i.test(source)
|| /^data:image\/(?:png|jpeg|gif|webp);base64,/i.test(source)
|| isLocalMarkdownImageSource(source)
);
export const getMarkdownImageFilename = (source: string, fallback: string): string => {
if (/^data:/i.test(source)) return fallback.trim();
const path = source.split(/[?#]/, 1)[0]?.replace(/\\/g, '/') ?? '';
const encodedName = path.split('/').filter(Boolean).at(-1) ?? '';
if (!encodedName) return fallback.trim();
try {
return decodeURIComponent(encodedName);
} catch {
return encodedName;
}
};
export const extractMarkdownImageCandidates = (
markdownTexts: readonly string[],
limit = MAX_MARKDOWN_IMAGE_COUNT,
): MarkdownImageCandidate[] => {
if (limit <= 0) return [];
const candidates: MarkdownImageCandidate[] = [];
const seen = new Set<string>();
for (const markdown of markdownTexts) {
if (!markdown || candidates.length >= limit) continue;
const tokens = marked.lexer(markdown);
marked.walkTokens(tokens, (token) => {
if (candidates.length >= limit) return;
if (token.type !== 'image' && token.type !== 'link') return;
if (token.type === 'link' && !isLocalMarkdownImageSource(token.href ?? '')) return;
const source = token.href ?? '';
if (!source || !isSupportedMarkdownImageSource(source) || seen.has(source)) return;
const fallback = typeof token.text === 'string' ? token.text : '';
const filename = getMarkdownImageFilename(source, fallback);
if (!filename) return;
seen.add(source);
candidates.push({ source, filename });
});
}
return candidates;
};
const renderMarkdownImage = ({
href,
title,
text,
}: {
href: string;
title?: string | null;
text: string;
}): string => {
const source = href ?? '';
const alt = escapeAttr(text ?? '');
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
const supported = isSupportedMarkdownImageSource(source);
if (!supported) {
return `<span role="img" aria-label="${alt}"${titleAttr}>${alt}</span>`;
}
return `<span role="img" aria-label="${alt}"${titleAttr} data-openchamber-markdown-image-placeholder="true">${alt}</span>`;
};
// ---------------------------------------------------------------------------
// Streaming block segmentation (port of OpenCode's markdown-stream)
// ---------------------------------------------------------------------------
@@ -162,7 +251,7 @@ const blockMathExtension = {
},
};
const parser = marked.use({
const createParser = (deferImages: boolean) => new Marked().use({
gfm: true,
breaks: false,
extensions: [inlineMathExtension, blockMathExtension],
@@ -175,6 +264,11 @@ const parser = marked.use({
},
link({ href, title, text }) {
const target = href ?? '';
if (deferImages && isLocalMarkdownImageSource(target)) {
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
const filename = getMarkdownImageFilename(target, '');
return `<a href="${escapeAttr(target)}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer" data-openchamber-markdown-image-link="true" data-openchamber-markdown-image-source="${escapeAttr(target)}" data-openchamber-markdown-image-filename="${escapeAttr(filename)}">${text}</a>`;
}
const agentName = parseAgentHref(target);
if (agentName) {
return `<a href="${escapeAttr(buildAgentMentionUrl(agentName))}" data-openchamber-agent-mention="true" class="text-primary hover:underline" target="_blank" rel="noopener noreferrer">${text}</a>`;
@@ -186,9 +280,13 @@ const parser = marked.use({
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
return `<a href="${escapeAttr(target)}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer">${text}</a>`;
},
...(deferImages ? { image: renderMarkdownImage } : {}),
},
});
const parser = createParser(false);
const imageParser = createParser(true);
// ---------------------------------------------------------------------------
// Math (KaTeX) — post-process the parsed HTML, skipping code/pre/kbd content
// ---------------------------------------------------------------------------
@@ -341,8 +439,8 @@ const touch = (key: string, entry: { hash: string; html: string }): void => {
if (oldest) htmlCache.delete(oldest);
};
const parseBlock = async (block: MarkdownBlock): Promise<string> => {
const parsed = await Promise.resolve(parser.parse(block.src));
const parseBlock = async (block: MarkdownBlock, deferImages: boolean): Promise<string> => {
const parsed = await Promise.resolve((deferImages ? imageParser : parser).parse(block.src));
const withMath = renderMathExpressions(parsed);
const highlighted = block.highlight ? await highlightCodeBlocks(withMath) : withMath;
return sanitize(highlighted);
@@ -357,9 +455,9 @@ const parseBlock = async (block: MarkdownBlock): Promise<string> => {
* is synchronous (marked is not configured `async`), so this never blocks on a
* worker round-trip.
*/
export const renderMarkdownSync = (text: string): string => {
export const renderMarkdownSync = (text: string, deferImages = false): string => {
if (!text) return '';
const parsed = parser.parse(text) as string;
const parsed = (deferImages ? imageParser : parser).parse(text) as string;
const withMath = renderMathExpressions(parsed);
return sanitize(withMath);
};
@@ -382,6 +480,7 @@ export const renderMarkdownBlocks = async (
text: string,
streaming: boolean,
cacheKey: string,
deferImages = false,
): Promise<RenderedBlock[]> => {
if (!text) return [];
@@ -389,14 +488,14 @@ export const renderMarkdownBlocks = async (
return Promise.all(
blocks.map(async (block, index) => {
const contentHash = hash(block.raw);
const id = `${contentHash}:${block.mode}:${block.highlight ? 1 : 0}`;
const key = `${cacheKey}:${index}:${block.mode}`;
const id = `${contentHash}:${block.mode}:${block.highlight ? 1 : 0}:${deferImages ? 1 : 0}`;
const key = `${cacheKey}:${index}:${block.mode}:${deferImages ? 1 : 0}`;
const cached = htmlCache.get(key);
if (cached && cached.hash === contentHash) {
touch(key, cached);
return { id, html: cached.html };
}
const html = await parseBlock(block);
const html = await parseBlock(block, deferImages);
touch(key, { hash: contentHash, html });
return { id, html };
}),
@@ -0,0 +1,132 @@
import { isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils';
import { runtimeFetch } from '@/lib/runtime-fetch';
const MAX_MARKDOWN_IMAGE_BYTES = 10 * 1024 * 1024;
const SUPPORTED_IMAGE_MIME_TYPES = new Set([
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
]);
const parseLocalImagePath = (source: string): string => {
let value = source;
if (/^file:\/\//i.test(value)) {
try {
const fileUrl = new URL(value);
if (fileUrl.protocol !== 'file:') return '';
value = fileUrl.host && fileUrl.host !== 'localhost'
? `//${fileUrl.host}${fileUrl.pathname}`
: fileUrl.pathname;
if (/^\/[A-Za-z]:\//.test(value)) value = value.slice(1);
} catch {
return '';
}
}
const path = value.split(/[?#]/, 1)[0] ?? '';
try {
return decodeURIComponent(path);
} catch {
return path;
}
};
const blobToDataUrl = (blob: Blob): Promise<string> => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => typeof reader.result === 'string'
? resolve(reader.result)
: reject(new Error('Unable to encode image'));
reader.onerror = () => reject(reader.error ?? new Error('Unable to encode image'));
reader.readAsDataURL(blob);
});
const hasImageSignature = async (blob: Blob, mimeType: string): Promise<boolean> => {
const bytes = new Uint8Array(await blob.slice(0, 12).arrayBuffer());
const ascii = (start: number, end: number) => String.fromCharCode(...bytes.slice(start, end));
if (mimeType === 'image/png') {
return bytes[0] === 0x89 && ascii(1, 4) === 'PNG'
&& bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a;
}
if (mimeType === 'image/jpeg') {
return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
}
if (mimeType === 'image/gif') {
const gif = ascii(0, 6);
return gif === 'GIF87a' || gif === 'GIF89a';
}
return mimeType === 'image/webp' && ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP';
};
const validateImageBlob = async (blob: Blob, mimeType: string): Promise<void> => {
if (!SUPPORTED_IMAGE_MIME_TYPES.has(mimeType)) throw new Error('Unsupported image type');
if (blob.size > MAX_MARKDOWN_IMAGE_BYTES) throw new Error('Image is too large');
if (!await hasImageSignature(blob, mimeType)) throw new Error('Unsupported image data');
};
const validateDataImage = async (source: string): Promise<void> => {
const match = /^data:(image\/(?:png|jpeg|gif|webp));base64,([\s\S]*)$/i.exec(source);
if (!match?.[1] || match[2] === undefined) throw new Error('Invalid image data URL');
const encoded = match[2];
if (encoded.length > Math.ceil(MAX_MARKDOWN_IMAGE_BYTES * 4 / 3) + 4) {
throw new Error('Image is too large');
}
let binary: string;
try {
binary = atob(encoded);
} catch {
throw new Error('Invalid image data URL');
}
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
await validateImageBlob(new Blob([bytes]), match[1].toLowerCase());
};
export const resolveMarkdownImageSource = async (
source: string,
directory: string,
signal: AbortSignal,
): Promise<string> => {
if (signal.aborted) throw new DOMException('Image load aborted', 'AbortError');
if (/^(?:https?:)?\/\//i.test(source)) return source;
if (/^data:image\/(?:png|jpeg|gif|webp);base64,/i.test(source)) {
await validateDataImage(source);
if (signal.aborted) throw new DOMException('Image load aborted', 'AbortError');
return source;
}
const localPath = parseLocalImagePath(source);
const absolutePath = toAbsoluteFilePath(directory, localPath);
if (!directory || !localPath || !isFilePathWithinDirectory(absolutePath, directory)) {
throw new Error('Image path is outside the active workspace');
}
const statResponse = await runtimeFetch('/api/fs/stat', {
query: { path: absolutePath, directory, optional: 'true' },
signal,
});
if (!statResponse.ok) throw new Error(`Unable to inspect image (${statResponse.status})`);
const stat = await statResponse.json() as { isFile?: boolean; size?: number };
if (!stat.isFile) throw new Error('Image path is not a file');
if (typeof stat.size === 'number' && stat.size > MAX_MARKDOWN_IMAGE_BYTES) {
throw new Error('Image is too large');
}
const response = await runtimeFetch('/api/fs/raw', {
query: { path: absolutePath, directory },
signal,
});
if (!response.ok) throw new Error(`Unable to load image (${response.status})`);
const mimeType = (response.headers.get('content-type') ?? '').split(';', 1)[0]?.toLowerCase() ?? '';
const contentLength = Number(response.headers.get('content-length'));
if (Number.isFinite(contentLength) && contentLength > MAX_MARKDOWN_IMAGE_BYTES) {
throw new Error('Image is too large');
}
const blob = await response.blob();
await validateImageBlob(blob, mimeType);
return blobToDataUrl(blob);
};
@@ -21,7 +21,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import { SimpleMarkdownRenderer } from '../MarkdownRenderer';
import { MarkdownImageGallery, SimpleMarkdownRenderer } from '../MarkdownRenderer';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useUIStore } from '@/stores/useUIStore';
import { flattenAssistantTextParts, suggestPlanTitleFromText } from '@/lib/messages/messageText';
@@ -1211,6 +1211,11 @@ const AssistantMessageBody = React.memo(({
const assistantTextParts = React.useMemo(() => {
return visibleParts.filter((part) => part.type === 'text');
}, [visibleParts]);
const finalizedAssistantMarkdownContents = React.useMemo(() => (
isMessageCompleted
? assistantTextParts.map(extractTextContent).filter((text) => text.trim().length > 0)
: []
), [assistantTextParts, isMessageCompleted]);
const assistantPlanText = React.useMemo(() => flattenAssistantTextParts(assistantTextParts), [assistantTextParts]);
const suggestedPlanTitle = React.useMemo(() => suggestPlanTitleFromText(assistantPlanText), [assistantPlanText]);
@@ -1863,6 +1868,7 @@ const AssistantMessageBody = React.memo(({
chatRenderMode={chatRenderMode}
onContentChange={onContentChange}
onShowPopup={onShowPopup}
enableMarkdownImages={isMessageCompleted}
/>
</div>
);
@@ -2017,6 +2023,7 @@ const AssistantMessageBody = React.memo(({
collapsedPreviewCount,
expandedTools,
isMobile,
isMessageCompleted,
isActivityOwnerMessage,
isSortedRenderMode,
lastRenderableTextPartIndex,
@@ -2228,6 +2235,10 @@ const AssistantMessageBody = React.memo(({
)}
</div>
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
<MarkdownImageGallery
contents={finalizedAssistantMarkdownContents}
onShowPopup={onShowPopup}
/>
{shouldRenderStandaloneActionsAfterContent && (
<div className={INLINE_MESSAGE_ACTIONS_CLASS_NAME} data-message-actions="true">
<div className="flex items-center gap-1.5" data-message-action-group="true">
@@ -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<AssistantTextPartProps> = ({
@@ -27,6 +28,7 @@ const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
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<AssistantTextPartProps> = ({
disableStreamAnimation={chatRenderMode === 'sorted'}
variant={part.type === 'reasoning' ? 'reasoning' : 'assistant'}
enableFileReferences={isFinalized}
enableLocalImages={enableMarkdownImages && !isStreaming && part.type === 'text'}
onShowPopup={onShowPopup}
/>
</div>
@@ -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.
+6
View File
@@ -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;
+1
View File
@@ -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
@@ -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<FsReadPathResolution> => {
export const resolveFileReadPath = async (targetPath: string, requestedRoot?: string): Promise<FsReadPathResolution> => {
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' };
@@ -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');
});
});
@@ -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 {