feat: add markdown image gallery previews

This commit is contained in:
ChangeHow
2026-08-13 16:19:06 +08:00
parent 8a6eca5597
commit f790b58d83
17 changed files with 818 additions and 155 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 })) 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 fallback = <div className="break-words w-full min-w-0" />;
const fallbackContentClassName = (variant: unknown): string => { const fallbackContentClassName = (variant: unknown): string => {
@@ -48,3 +52,9 @@ export const SimpleMarkdownRenderer: React.FC<React.ComponentPropsWithoutRef<typ
<SimpleMarkdownRendererLazy {...props} /> <SimpleMarkdownRendererLazy {...props} />
</React.Suspense> </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 { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants'; import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants';
import { getDirectoryForFilePath, isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils'; 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 { ensureMarkdownShikiTheme } from './markdown/markdownTheme';
import { getMarkdownSyntaxVars } from './markdown/markdownSyntaxVars'; import { getMarkdownSyntaxVars } from './markdown/markdownSyntaxVars';
import { import {
@@ -107,6 +108,59 @@ const useExternalLinkInteractions = ({
}, [containerRef, enabled]); }, [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 = { const DEFAULT_MERMAID_CONTROLS: MermaidControlOptions = {
download: true, download: true,
copy: true, copy: true,
@@ -140,6 +194,7 @@ interface MarkdownRendererProps {
variant?: MarkdownVariant; variant?: MarkdownVariant;
onShowPopup?: (content: ToolPopupContent) => void; onShowPopup?: (content: ToolPopupContent) => void;
enableFileReferences?: boolean; enableFileReferences?: boolean;
enableLocalImages?: boolean;
} }
const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]'; const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]';
@@ -497,6 +552,10 @@ const useFileReferenceInteractions = ({
let linkedCount = 0; let linkedCount = 0;
for (const candidate of Array.from(candidates)) { for (const candidate of Array.from(candidates)) {
if (candidate.matches('[data-openchamber-markdown-image-link="true"]')) {
clearFileLinkAttributes(candidate);
continue;
}
const rawCandidate = extractPathCandidateFromElement(candidate); const rawCandidate = extractPathCandidateFromElement(candidate);
const resolved = getResolvedReference(rawCandidate, effectiveDirectory); const resolved = getResolvedReference(rawCandidate, effectiveDirectory);
clearFileLinkAttributes(candidate); clearFileLinkAttributes(candidate);
@@ -831,6 +890,7 @@ const useMorphdomMarkdown = ({
text, text,
streaming, streaming,
cacheKey, cacheKey,
deferImages = false,
syntaxVars, syntaxVars,
ctx, ctx,
}: { }: {
@@ -838,6 +898,7 @@ const useMorphdomMarkdown = ({
text: string; text: string;
streaming: boolean; streaming: boolean;
cacheKey: string; cacheKey: string;
deferImages?: boolean;
syntaxVars: Record<string, string>; syntaxVars: Record<string, string>;
ctx: DecorateContext; ctx: DecorateContext;
}) => { }) => {
@@ -876,7 +937,7 @@ const useMorphdomMarkdown = ({
// `display:contents` keeps margin-collapsing/spacing identical to a flat // `display:contents` keeps margin-collapsing/spacing identical to a flat
// HTML body — the wrapper exists only for per-block reconciliation. // HTML body — the wrapper exists only for per-block reconciliation.
block.style.display = 'contents'; block.style.display = 'contents';
block.innerHTML = renderMarkdownSync(text); block.innerHTML = renderMarkdownSync(text, deferImages);
// Decorate synchronously too: wrap code blocks in their framed card, // Decorate synchronously too: wrap code blocks in their framed card,
// mark inline code, build table controls, etc. The async pass re-decorates // 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 // its own DOM before morphing, so without this the first paint shows bare
@@ -888,7 +949,7 @@ const useMorphdomMarkdown = ({
refreshMermaidViewers(); refreshMermaidViewers();
} }
} }
}, [containerRef, text, ctx, refreshMermaidViewers]); }, [containerRef, text, deferImages, ctx, refreshMermaidViewers]);
React.useEffect(() => () => { React.useEffect(() => () => {
mermaidViewerRef.current?.cleanup(); mermaidViewerRef.current?.cleanup();
@@ -901,7 +962,7 @@ const useMorphdomMarkdown = ({
const target = container.querySelector<HTMLElement>('[data-markdown-content]') ?? container; const target = container.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
let active = true; let active = true;
void renderMarkdownBlocks(text, streaming, cacheKey).then((blocks) => { void renderMarkdownBlocks(text, streaming, cacheKey, deferImages).then((blocks) => {
if (!active) return; if (!active) return;
const existing = Array.from(target.children) as HTMLElement[]; const existing = Array.from(target.children) as HTMLElement[];
@@ -952,7 +1013,7 @@ const useMorphdomMarkdown = ({
return () => { return () => {
active = false; active = false;
}; };
}, [containerRef, text, streaming, cacheKey, ctx, refreshMermaidViewers]); }, [containerRef, text, streaming, cacheKey, deferImages, ctx, refreshMermaidViewers]);
React.useEffect(() => { React.useEffect(() => {
const container = containerRef.current; const container = containerRef.current;
@@ -999,6 +1060,7 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
variant = 'assistant', variant = 'assistant',
onShowPopup, onShowPopup,
enableFileReferences = true, enableFileReferences = true,
enableLocalImages = false,
}) => { }) => {
streamPerfCount('ui.markdown_renderer.render'); streamPerfCount('ui.markdown_renderer.render');
if (isStreaming) streamPerfCount('ui.markdown_renderer.render.streaming'); if (isStreaming) streamPerfCount('ui.markdown_renderer.render.streaming');
@@ -1030,15 +1092,33 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
enabled: enableFileReferences && !isStreaming, enabled: enableFileReferences && !isStreaming,
}); });
useExternalLinkInteractions({ containerRef }); useExternalLinkInteractions({ containerRef });
useMarkdownImageLinkInteractions({
containerRef,
directory: effectiveDirectory,
enabled: enableLocalImages && variant === 'assistant' && !isStreaming,
onShowPopup,
});
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]); const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS); const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS);
const cacheKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`; 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 = ( 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 className={markdownContentClassName(variant)} data-markdown-content />
</div> </div>
); );
@@ -1065,6 +1145,7 @@ export const MarkdownRenderer = React.memo(MarkdownRendererImpl, (prev, next) =>
&& prev.messageId === next.messageId && prev.messageId === next.messageId
&& prev.onShowPopup === next.onShowPopup && prev.onShowPopup === next.onShowPopup
&& prev.enableFileReferences === next.enableFileReferences && prev.enableFileReferences === next.enableFileReferences
&& prev.enableLocalImages === next.enableLocalImages
&& prev.part?.id === next.part?.id; && 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'; import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
const { extractMarkdownImageCandidates, renderMarkdownSync } = await import('./markdownCore');
const { resolveMarkdownImageSource } = await import('./markdownImageAssets');
describe('markdown sanitization', () => { describe('markdown sanitization', () => {
test('turns raw assistant HTML into inert visible text', () => { test('turns raw assistant HTML into inert visible text', () => {
const payload = '<style>@import url("https://example.test/theme.css");</style>'; const payload = '<style>@import url("https://example.test/theme.css");</style>';
@@ -16,3 +30,102 @@ describe('markdown sanitization', () => {
expect(MARKDOWN_FORBIDDEN_TAGS).toContain('style'); 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 remend from 'remend';
import katex from 'katex'; import katex from 'katex';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
@@ -10,6 +10,95 @@ import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecuri
const escapeAttr = (value: string): string => const escapeAttr = (value: string): string =>
value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); 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) // 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, gfm: true,
breaks: false, breaks: false,
extensions: [inlineMathExtension, blockMathExtension], extensions: [inlineMathExtension, blockMathExtension],
@@ -175,6 +264,11 @@ const parser = marked.use({
}, },
link({ href, title, text }) { link({ href, title, text }) {
const target = href ?? ''; 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); const agentName = parseAgentHref(target);
if (agentName) { 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>`; 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)}"` : ''; const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
return `<a href="${escapeAttr(target)}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer">${text}</a>`; 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 // 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); if (oldest) htmlCache.delete(oldest);
}; };
const parseBlock = async (block: MarkdownBlock): Promise<string> => { const parseBlock = async (block: MarkdownBlock, deferImages: boolean): Promise<string> => {
const parsed = await Promise.resolve(parser.parse(block.src)); const parsed = await Promise.resolve((deferImages ? imageParser : parser).parse(block.src));
const withMath = renderMathExpressions(parsed); const withMath = renderMathExpressions(parsed);
const highlighted = block.highlight ? await highlightCodeBlocks(withMath) : withMath; const highlighted = block.highlight ? await highlightCodeBlocks(withMath) : withMath;
return sanitize(highlighted); 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 * is synchronous (marked is not configured `async`), so this never blocks on a
* worker round-trip. * worker round-trip.
*/ */
export const renderMarkdownSync = (text: string): string => { export const renderMarkdownSync = (text: string, deferImages = false): string => {
if (!text) return ''; if (!text) return '';
const parsed = parser.parse(text) as string; const parsed = (deferImages ? imageParser : parser).parse(text) as string;
const withMath = renderMathExpressions(parsed); const withMath = renderMathExpressions(parsed);
return sanitize(withMath); return sanitize(withMath);
}; };
@@ -382,6 +480,7 @@ export const renderMarkdownBlocks = async (
text: string, text: string,
streaming: boolean, streaming: boolean,
cacheKey: string, cacheKey: string,
deferImages = false,
): Promise<RenderedBlock[]> => { ): Promise<RenderedBlock[]> => {
if (!text) return []; if (!text) return [];
@@ -389,14 +488,14 @@ export const renderMarkdownBlocks = async (
return Promise.all( return Promise.all(
blocks.map(async (block, index) => { blocks.map(async (block, index) => {
const contentHash = hash(block.raw); const contentHash = hash(block.raw);
const id = `${contentHash}:${block.mode}:${block.highlight ? 1 : 0}`; const id = `${contentHash}:${block.mode}:${block.highlight ? 1 : 0}:${deferImages ? 1 : 0}`;
const key = `${cacheKey}:${index}:${block.mode}`; const key = `${cacheKey}:${index}:${block.mode}:${deferImages ? 1 : 0}`;
const cached = htmlCache.get(key); const cached = htmlCache.get(key);
if (cached && cached.hash === contentHash) { if (cached && cached.hash === contentHash) {
touch(key, cached); touch(key, cached);
return { id, html: cached.html }; return { id, html: cached.html };
} }
const html = await parseBlock(block); const html = await parseBlock(block, deferImages);
touch(key, { hash: contentHash, html }); touch(key, { hash: contentHash, html });
return { id, 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 { ArrowsMerge } from '@/components/icons/ArrowsMerge';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow'; import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import { SimpleMarkdownRenderer } from '../MarkdownRenderer'; import { MarkdownImageGallery, SimpleMarkdownRenderer } from '../MarkdownRenderer';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { flattenAssistantTextParts, suggestPlanTitleFromText } from '@/lib/messages/messageText'; import { flattenAssistantTextParts, suggestPlanTitleFromText } from '@/lib/messages/messageText';
@@ -1211,6 +1211,11 @@ const AssistantMessageBody = React.memo(({
const assistantTextParts = React.useMemo(() => { const assistantTextParts = React.useMemo(() => {
return visibleParts.filter((part) => part.type === 'text'); return visibleParts.filter((part) => part.type === 'text');
}, [visibleParts]); }, [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 assistantPlanText = React.useMemo(() => flattenAssistantTextParts(assistantTextParts), [assistantTextParts]);
const suggestedPlanTitle = React.useMemo(() => suggestPlanTitleFromText(assistantPlanText), [assistantPlanText]); const suggestedPlanTitle = React.useMemo(() => suggestPlanTitleFromText(assistantPlanText), [assistantPlanText]);
@@ -1863,6 +1868,7 @@ const AssistantMessageBody = React.memo(({
chatRenderMode={chatRenderMode} chatRenderMode={chatRenderMode}
onContentChange={onContentChange} onContentChange={onContentChange}
onShowPopup={onShowPopup} onShowPopup={onShowPopup}
enableMarkdownImages={isMessageCompleted}
/> />
</div> </div>
); );
@@ -2017,6 +2023,7 @@ const AssistantMessageBody = React.memo(({
collapsedPreviewCount, collapsedPreviewCount,
expandedTools, expandedTools,
isMobile, isMobile,
isMessageCompleted,
isActivityOwnerMessage, isActivityOwnerMessage,
isSortedRenderMode, isSortedRenderMode,
lastRenderableTextPartIndex, lastRenderableTextPartIndex,
@@ -2228,6 +2235,10 @@ const AssistantMessageBody = React.memo(({
)} )}
</div> </div>
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} /> <MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
<MarkdownImageGallery
contents={finalizedAssistantMarkdownContents}
onShowPopup={onShowPopup}
/>
{shouldRenderStandaloneActionsAfterContent && ( {shouldRenderStandaloneActionsAfterContent && (
<div className={INLINE_MESSAGE_ACTIONS_CLASS_NAME} data-message-actions="true"> <div className={INLINE_MESSAGE_ACTIONS_CLASS_NAME} data-message-actions="true">
<div className="flex items-center gap-1.5" data-message-action-group="true"> <div className="flex items-center gap-1.5" data-message-action-group="true">
@@ -1,5 +1,6 @@
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', () => {
@@ -29,3 +30,43 @@ 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 } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } 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,6 +29,11 @@ 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;
@@ -158,7 +163,7 @@ const usePierreThemeConfig = (): PierreThemeConfig => {
}; };
}; };
type ViewportSize = { width: number; height: number }; type ViewportSize = ImagePreviewViewport;
const getWindowViewport = (): ViewportSize => ({ const getWindowViewport = (): ViewportSize => ({
width: typeof window !== 'undefined' ? window.innerWidth : 0, width: typeof window !== 'undefined' ? window.innerWidth : 0,
@@ -331,8 +336,11 @@ const ImagePreviewDialog: React.FC<{
}, [popup.image]); }, [popup.image]);
const [currentIndex, setCurrentIndex] = React.useState(0); const [currentIndex, setCurrentIndex] = React.useState(0);
const [imageNaturalSize, setImageNaturalSize] = React.useState<{ width: number; height: number } | null>(null); const [loadedImageSize, setLoadedImageSize] = React.useState<{
const { isRendered, isVisible, isTransitioning } = usePreviewOverlayState(popup.open); url: string;
width: number;
height: number;
} | null>(null);
const viewport = usePreviewViewport(popup.open); const viewport = usePreviewViewport(popup.open);
React.useEffect(() => { React.useEffect(() => {
@@ -352,9 +360,10 @@ 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] ?? popup.image; const currentImage = gallery[currentIndex] ?? gallery[0];
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;
@@ -372,11 +381,6 @@ 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();
@@ -393,126 +397,108 @@ const ImagePreviewDialog: React.FC<{
return () => { return () => {
window.removeEventListener('keydown', onKeyDown); window.removeEventListener('keydown', onKeyDown);
}; };
}, [hasMultipleImages, onOpenChange, popup.open, showNext, showPrevious]); }, [hasMultipleImages, popup.open, showNext, showPrevious]);
React.useEffect(() => { const imageNaturalSize = loadedImageSize?.url === currentImage?.url
setImageNaturalSize(null); ? loadedImageSize
}, [currentImage?.url]); : null;
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 { maxWidth, maxHeight } = getImagePreviewBounds(viewport, isMobile, markdownImage);
let imageDisplaySize = {
width: Math.round(maxWidth),
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]); }
if (!isRendered || !currentImage || typeof document === 'undefined') { const handleImageLoad = React.useCallback((event: React.SyntheticEvent<HTMLImageElement>) => {
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 content = ( const dialogLayout = getImagePreviewDialogLayout(imageDisplaySize, viewport, isMobile);
<div className={cn('fixed inset-0 z-50', popup.open ? 'pointer-events-auto' : 'pointer-events-none')}>
<div
aria-hidden="true"
className={cn(
'absolute inset-0 bg-black/40',
isTransitioning && 'transition-opacity duration-150 ease-out',
isVisible ? 'opacity-100' : 'opacity-0'
)}
onMouseDown={() => onOpenChange(false)}
/>
{hasMultipleImages && ( return (
<> <Dialog open={popup.open} onOpenChange={onOpenChange}>
<button <DialogContent
type="button"
onMouseDown={(event) => event.stopPropagation()}
onClick={showPrevious}
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')}
>
<Icon name="arrow-left-s" className="h-6 w-6" />
</button>
<button
type="button"
onMouseDown={(event) => event.stopPropagation()}
onClick={showNext}
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')}
>
<Icon name="arrow-right-s" className="h-6 w-6" />
</button>
</>
)}
<div
className={cn( className={cn(
'absolute inset-0 flex items-center justify-center pointer-events-none', 'max-w-none gap-3 overflow-hidden p-4',
isMobile ? 'p-2.5' : '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"
> >
<div <DialogHeader className="min-w-0 pr-8">
className={cn( <DialogTitle className="flex min-w-0 items-center gap-2 text-left">
'pointer-events-auto flex flex-col gap-2', <Icon name="file-image" className="h-4 w-4 shrink-0 text-muted-foreground" />
isTransitioning && 'transition-opacity duration-150 ease-out', <span className="min-w-0 truncate" title={imageTitle}>{imageTitle}</span>
isVisible ? 'opacity-100' : 'opacity-0' </DialogTitle>
)} </DialogHeader>
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>
<div
className="relative flex min-h-0 max-w-full self-center items-center justify-center overflow-hidden rounded-lg bg-muted/20"
style={{ width: `${dialogLayout.imageWidth}px`, height: `${dialogLayout.imageHeight}px` }}
>
{hasMultipleImages ? (
<>
<button
type="button"
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"
aria-label={t('chat.toolOutputDialog.image.previousAria')}
>
<Icon name="arrow-left-s" className="h-6 w-6" />
</button>
<button
type="button"
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"
aria-label={t('chat.toolOutputDialog.image.nextAria')}
>
<Icon name="arrow-right-s" className="h-6 w-6" />
</button>
</>
) : null}
<img <img
src={currentImage.url} src={currentImage.url}
alt={imageTitle} alt={imageTitle}
className="block object-contain" className="block h-full w-full object-contain"
style={{ width: `${imageDisplaySize.width}px`, height: `${imageDisplaySize.height}px` }}
loading="lazy" loading="lazy"
onLoad={(event) => { onLoad={handleImageLoad}
const element = event.currentTarget; data-openchamber-markdown-image-preview={markdownImage ? 'true' : undefined}
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> <div aria-hidden="true" className="h-4 shrink-0" />
</div> </DialogContent>
</Dialog>
); );
return createPortal(content, document.body);
}; };
// ── PERF-007: Virtualised sub-components for dialog ────────────────── // ── PERF-007: Virtualised sub-components for dialog ──────────────────
@@ -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)),
};
};
@@ -19,6 +19,7 @@ interface AssistantTextPartProps {
chatRenderMode?: 'sorted' | 'live'; chatRenderMode?: 'sorted' | 'live';
onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void; onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void;
onShowPopup?: (content: ToolPopupContent) => void; onShowPopup?: (content: ToolPopupContent) => void;
enableMarkdownImages?: boolean;
} }
const AssistantTextPart: React.FC<AssistantTextPartProps> = ({ const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
@@ -27,6 +28,7 @@ const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
streamPhase, streamPhase,
chatRenderMode = 'live', chatRenderMode = 'live',
onShowPopup, onShowPopup,
enableMarkdownImages = false,
}) => { }) => {
// Use part directly from props — parent provides the latest version from the store. // 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. // 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'} disableStreamAnimation={chatRenderMode === 'sorted'}
variant={part.type === 'reasoning' ? 'reasoning' : 'assistant'} variant={part.type === 'reasoning' ? 'reasoning' : 'assistant'}
enableFileReferences={isFinalized} enableFileReferences={isFinalized}
enableLocalImages={enableMarkdownImages && !isStreaming && part.type === 'text'}
onShowPopup={onShowPopup} onShowPopup={onShowPopup}
/> />
</div> </div>
@@ -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 HTML is sanitized as defense in depth, with script and style elements
forbidden, so message content cannot inject active DOM or application-wide forbidden, so message content cannot inject active DOM or application-wide
CSS into any runtime surface. 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`. - `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.
+6
View File
@@ -1151,6 +1151,12 @@ html:not(.dark) .chat-scroll {
color: var(--markdown-link-hover, var(--primary)); 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"] { .markdown-content [data-openchamber-file-link="true"] {
color: var(--markdown-link, var(--primary)); color: var(--markdown-link, var(--primary));
cursor: pointer; cursor: pointer;
+1
View File
@@ -37,6 +37,7 @@ Keep `bridge.ts` as a thin orchestration layer that delegates message handling t
- directory listing - directory listing
- file search - file search
- file read path safety checks - file read path safety checks
- active-directory selection across multi-root workspaces
- dropped-file parsing and attachment reading - dropped-file parsing and attachment reading
- models metadata fetch helper - 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 => { export const getFsMimeType = (filePath: string): string => {
const ext = path.extname(filePath).toLowerCase(); const ext = path.extname(filePath).toLowerCase();
@@ -564,13 +570,13 @@ export type FsReadPathResolution =
| { ok: true; resolvedPath: string } | { ok: true; resolvedPath: string }
| { ok: false; status: number; error: 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(); const trimmed = targetPath.trim();
if (!trimmed) { if (!trimmed) {
return { ok: false, status: 400, error: 'Path is required' }; return { ok: false, status: 400, error: 'Path is required' };
} }
const baseRoot = getFsAccessRoot(); const baseRoot = getFsAccessRoot(requestedRoot);
const resolved = resolveUserPath(trimmed, baseRoot); const resolved = resolveUserPath(trimmed, baseRoot);
if (!resolved) { if (!resolved) {
return { ok: false, status: 400, error: 'Path is required' }; return { ok: false, status: 400, error: 'Path is required' };
@@ -1,32 +1,25 @@
import { describe, expect, it, mock } from 'bun:test'; 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', () => ({ mock.module('fs', () => ({
promises: { promises: fsPromises,
realpath: mock(async () => { default: { promises: fsPromises },
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;
}),
},
},
})); }));
mock.module('vscode', () => ({ mock.module('vscode', () => ({
@@ -34,7 +27,10 @@ mock.module('vscode', () => ({
file: (fsPath) => ({ fsPath }), file: (fsPath) => ({ fsPath }),
}, },
workspace: { 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); 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 targetPath = parsed.searchParams.get('path') || '';
const optional = parsed.searchParams.get('optional') === 'true'; 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 (!resolution.ok) {
if (fsProxyPath === '/api/fs/stat' && optional && resolution.status === 404) { if (fsProxyPath === '/api/fs/stat' && optional && resolution.status === 404) {
return { return {