From be93975d907a9e26c993ec5f597f7d9b84c6786c Mon Sep 17 00:00:00 2001 From: Chris Scott <> Date: Tue, 13 Oct 2026 00:29:00 +0000 Subject: [PATCH 1/3] feat(markdown): render inline images in messages and markdown files Route markdown image nodes through a shared MarkdownImage component that loads local paths through the raw file API, and allow data:image/* sources through the sanitizer and URL transform so inline images survive rendering. Render image attachments as thumbnails in the message thread, and accept image/webp uploads. --- backend/src/services/files.ts | 1 + .../file-browser/MarkdownRenderer.test.tsx | 10 ++++ .../file-browser/MarkdownRenderer.tsx | 12 ++++- .../components/message/MessagePart.test.tsx | 48 +++++++++++++++++++ .../src/components/message/MessagePart.tsx | 2 +- .../components/message/MessageThread.test.tsx | 24 ++++++++++ .../src/components/message/MessageThread.tsx | 28 +++++++---- frontend/src/components/message/TextPart.tsx | 18 +++++-- .../src/components/ui/inline-image.test.tsx | 25 ++++++++++ frontend/src/components/ui/inline-image.tsx | 44 +++++++++++++++++ frontend/src/components/ui/markdown-image.tsx | 34 +++++++++++++ frontend/src/lib/markdownRehypePlugins.ts | 34 +++++++++++-- shared/src/config/defaults.ts | 1 + 13 files changed, 263 insertions(+), 18 deletions(-) create mode 100644 frontend/src/components/ui/inline-image.test.tsx create mode 100644 frontend/src/components/ui/inline-image.tsx create mode 100644 frontend/src/components/ui/markdown-image.tsx diff --git a/backend/src/services/files.ts b/backend/src/services/files.ts index bfa13007d..8c4670d02 100644 --- a/backend/src/services/files.ts +++ b/backend/src/services/files.ts @@ -274,6 +274,7 @@ function getMimeType(filePath: string): AllowedMimeType { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', + '.webp': 'image/webp', '.svg': 'image/svg+xml', '.pdf': 'application/pdf', '.zip': 'application/zip', diff --git a/frontend/src/components/file-browser/MarkdownRenderer.test.tsx b/frontend/src/components/file-browser/MarkdownRenderer.test.tsx index 6f2443692..63182c0ed 100644 --- a/frontend/src/components/file-browser/MarkdownRenderer.test.tsx +++ b/frontend/src/components/file-browser/MarkdownRenderer.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' import { MarkdownRenderer } from './MarkdownRenderer' +import { getFileApiUrl } from '@/api/files' describe('MarkdownRenderer', () => { it('toggles a task list item in the source content', () => { @@ -27,4 +28,13 @@ describe('MarkdownRenderer', () => { expect(document.documentElement.dataset.fileProbe).toBeUndefined() expect(screen.getByAltText('Logo')).toHaveAttribute('src', 'docs/logo.png') }) + + it('loads a relative image through the raw file API resolved against the file path', () => { + render() + + expect(screen.getByAltText('l')).toHaveAttribute( + 'src', + getFileApiUrl('repo/docs/img/logo.png', { params: { raw: true } }), + ) + }) }) diff --git a/frontend/src/components/file-browser/MarkdownRenderer.tsx b/frontend/src/components/file-browser/MarkdownRenderer.tsx index ccfc798b3..99a339ba1 100644 --- a/frontend/src/components/file-browser/MarkdownRenderer.tsx +++ b/frontend/src/components/file-browser/MarkdownRenderer.tsx @@ -2,8 +2,9 @@ import { memo, useMemo, useCallback } from 'react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { markdownComponents } from './MarkdownComponents' -import { markdownRehypePlugins } from '@/lib/markdownRehypePlugins' +import { markdownRehypePlugins, markdownUrlTransform } from '@/lib/markdownRehypePlugins' import { MarkdownLink } from '@/components/ui/markdown-link' +import { MarkdownImage } from '@/components/ui/markdown-image' import { resolvePathFromFile } from '@/lib/markdownLinks' import type { Components } from 'react-markdown' @@ -65,6 +66,11 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, classN } }, [filePath, onOpenFile]) + const resolveLocalPath = useMemo( + () => (filePath ? (path: string) => resolvePathFromFile(filePath, path) : undefined), + [filePath], + ) + const components: Components = { ...markdownComponents, input(props) { @@ -88,6 +94,9 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, classN a(props) { return }, + img(props) { + return + }, } return ( @@ -95,6 +104,7 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, classN {content} diff --git a/frontend/src/components/message/MessagePart.test.tsx b/frontend/src/components/message/MessagePart.test.tsx index f704cbc49..8ff528b9b 100644 --- a/frontend/src/components/message/MessagePart.test.tsx +++ b/frontend/src/components/message/MessagePart.test.tsx @@ -3,6 +3,7 @@ import { render, screen, fireEvent } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { MemoryRouter } from 'react-router-dom' import { MessagePart } from './MessagePart' +import { getFileApiUrl } from '@/api/files' import { applySessionEvent, emptySessionTranscript } from '@/lib/session-projection' import { promptSequence } from '@/test/fixtures/session-projection' import type { @@ -129,6 +130,53 @@ describe('MessagePart', () => { expect(container.firstChild).toBeNull() }) + describe('markdown images', () => { + it('loads a relative image through the raw file API resolved against the directory', () => { + renderWithProviders( + , + ) + + expect(screen.getByAltText('shot')).toHaveAttribute( + 'src', + getFileApiUrl('/workspace/repos/app/docs/shot.png', { params: { raw: true } }), + ) + }) + + it('loads an absolute image through the raw file API without a directory', () => { + renderWithProviders() + + expect(screen.getByAltText('a')).toHaveAttribute( + 'src', + getFileApiUrl('/workspace/x/a.png', { params: { raw: true } }), + ) + }) + + it('keeps data image sources from markdown and raw HTML', () => { + renderWithProviders( + ')} + />, + ) + + expect(screen.getByAltText('d')).toHaveAttribute('src', 'data:image/png;base64,aGVsbG8=') + expect(screen.getByAltText('raw')).toHaveAttribute('src', 'data:image/png;base64,aGVsbG8=') + }) + + it('keeps an external image source', () => { + renderWithProviders() + + expect(screen.getByAltText('h')).toHaveAttribute('src', 'https://example.com/a.png') + }) + + it('does not render a non-image data source', () => { + const { container } = renderWithProviders( + , + ) + + expect(container.querySelector('img')).toBeNull() + }) + }) + describe('reasoning', () => { it('renders null when showReasoning is false', () => { setupSettings({ diff --git a/frontend/src/components/message/MessagePart.tsx b/frontend/src/components/message/MessagePart.tsx index 79444f9e3..ced32ced0 100644 --- a/frontend/src/components/message/MessagePart.tsx +++ b/frontend/src/components/message/MessagePart.tsx @@ -25,7 +25,7 @@ export const MessagePart = memo(function MessagePart({ part, messageID, director switch (part.type) { case 'text': - return + return case 'reasoning': if (simpleChatMode || !showReasoning) return null if (!part.text.trim()) return null diff --git a/frontend/src/components/message/MessageThread.test.tsx b/frontend/src/components/message/MessageThread.test.tsx index 971c6baba..4347cf24e 100644 --- a/frontend/src/components/message/MessageThread.test.tsx +++ b/frontend/src/components/message/MessageThread.test.tsx @@ -491,6 +491,30 @@ describe('MessageThread', () => { expect(container.querySelector('[data-message-id="user-42"]')).not.toBeNull() }) + it('renders image attachments as thumbnails and other files as chips', () => { + setupSettings({ simpleChatMode: false, showReasoning: false }) + + const messages: SessionMessageInfo[] = [ + { + id: 'user-attachments', + type: 'user', + text: 'Look at these', + files: [ + { data: 'aGVsbG8=', mime: 'image/png', source: { type: 'inline' }, name: 'shot.png' }, + { data: 'dGV4dA==', mime: 'text/plain', source: { type: 'inline' }, name: 'notes.txt' }, + ], + time: { created: Date.now() }, + }, + ] + + render() + + const image = screen.getByAltText('shot.png') + expect(image).toHaveAttribute('src', 'data:image/png;base64,aGVsbG8=') + expect(screen.getByText('@')).toBeInTheDocument() + expect(screen.getByText('notes.txt')).toBeInTheDocument() + }) + it('renders a projected shell message from the fixtures', () => { setupSettings({ simpleChatMode: false, showReasoning: false }) diff --git a/frontend/src/components/message/MessageThread.tsx b/frontend/src/components/message/MessageThread.tsx index 2e13bac3d..a2f360d7d 100644 --- a/frontend/src/components/message/MessageThread.tsx +++ b/frontend/src/components/message/MessageThread.tsx @@ -21,6 +21,7 @@ import { UserMessageActionButtons } from './UserMessageActionButtons' import { EditableUserMessage, ClickableUserMessage } from './EditableUserMessage' import { useSettings } from '@/hooks/useSettings' import { CopyButton } from '@/components/ui/copy-button' +import { InlineImage } from '@/components/ui/inline-image' import { TTSButton } from '@/components/ui/tts-button' import { backgroundShellID, collectBackgroundParts, type ShellNoticeOutcome } from '@/lib/backgroundWork' import { groupExplorationParts, type AssistantContentItem } from '@/lib/explorationGroups' @@ -121,15 +122,24 @@ function UserAttachments({ files: attachedFiles, agents: attachedAgents, skills: return (
- {files.map((file, index) => ( - - @ - {attachmentLabel(file)} - - ))} + {files.map((file, index) => + file.mime.startsWith('image/') ? ( + + ) : ( + + @ + {attachmentLabel(file)} + + ), + )} {agents.map((agent, index) => ( void + directory?: string } interface MermaidBlockProps { @@ -181,11 +184,16 @@ function isMermaidBlockComplete(text: string): boolean { return false } -export function TextPart({ text, onFileClick }: TextPartProps) { +export function TextPart({ text, onFileClick, directory }: TextPartProps) { const mermaidComplete = React.useMemo(() => { return text ? isMermaidBlockComplete(text) : false }, [text]) + const resolveLocalPath = useMemo( + () => (directory ? (path: string) => getWorkspaceFilePath(path, { directory }) : undefined), + [directory], + ) + if (!text || text.trim() === '') { return null } @@ -195,6 +203,7 @@ export function TextPart({ text, onFileClick }: TextPartProps) { }, + img(props) { + return + }, }} > {text} diff --git a/frontend/src/components/ui/inline-image.test.tsx b/frontend/src/components/ui/inline-image.test.tsx new file mode 100644 index 000000000..fab0ef5fa --- /dev/null +++ b/frontend/src/components/ui/inline-image.test.tsx @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { InlineImage } from './inline-image' + +describe('InlineImage', () => { + it('opens a full-size dialog when the thumbnail is clicked', () => { + render() + + const thumbnail = screen.getByAltText('shot.png') + expect(thumbnail).toHaveAttribute('src', 'data:image/png;base64,aGVsbG8=') + + fireEvent.click(screen.getByRole('button', { name: 'Expand image: shot.png' })) + + expect(screen.getAllByAltText('shot.png')).toHaveLength(2) + }) + + it('falls back to the alt text when the image fails to load', () => { + render() + + fireEvent.error(screen.getByAltText('shot.png')) + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + expect(screen.getByText('shot.png')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/ui/inline-image.tsx b/frontend/src/components/ui/inline-image.tsx new file mode 100644 index 000000000..7fd5f1850 --- /dev/null +++ b/frontend/src/components/ui/inline-image.tsx @@ -0,0 +1,44 @@ +import { useState } from 'react' +import { cn } from '@/lib/utils' +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' + +/** + * Renders an image as a bounded thumbnail button that opens a full-size preview dialog. + * Falls back to a muted alt-text span (or nothing) when the image fails to load. + */ +export function InlineImage({ src, alt, className }: { src: string; alt?: string; className?: string }) { + const [open, setOpen] = useState(false) + const [failed, setFailed] = useState(false) + + if (failed) { + return alt ? {alt} : null + } + + return ( + <> + + + + {alt ?? 'Image'} + {alt + + + + ) +} diff --git a/frontend/src/components/ui/markdown-image.tsx b/frontend/src/components/ui/markdown-image.tsx new file mode 100644 index 000000000..614cfcb76 --- /dev/null +++ b/frontend/src/components/ui/markdown-image.tsx @@ -0,0 +1,34 @@ +import type { ComponentPropsWithoutRef } from 'react' +import type { ExtraProps } from 'react-markdown' +import { InlineImage } from '@/components/ui/inline-image' +import { getFileApiUrl } from '@/api/files' +import { getLocalLinkPath } from '@/lib/markdownLinks' + +type MarkdownImageProps = ComponentPropsWithoutRef<'img'> & ExtraProps & { + resolveLocalPath?: (path: string) => string | null +} + +/** + * Renders a markdown image through the inline image viewer. Local file sources, resolved by + * `resolveLocalPath` or absolute, load through the raw file API; everything else, including + * `data:image/*` URIs, is rendered unchanged. + */ +export function MarkdownImage({ src, alt, resolveLocalPath }: MarkdownImageProps) { + if (typeof src !== 'string' || src === '') { + return null + } + + const localPath = getLocalLinkPath(src) + let imageSrc = src + + if (localPath) { + const resolvedPath = resolveLocalPath?.(localPath) ?? null + if (resolvedPath) { + imageSrc = getFileApiUrl(resolvedPath, { params: { raw: true } }) + } else if (localPath.startsWith('/')) { + imageSrc = getFileApiUrl(localPath, { params: { raw: true } }) + } + } + + return +} diff --git a/frontend/src/lib/markdownRehypePlugins.ts b/frontend/src/lib/markdownRehypePlugins.ts index 2e81a113a..764e0ee0a 100644 --- a/frontend/src/lib/markdownRehypePlugins.ts +++ b/frontend/src/lib/markdownRehypePlugins.ts @@ -1,11 +1,37 @@ import rehypeHighlight from 'rehype-highlight' import rehypeRaw from 'rehype-raw' -import rehypeSanitize from 'rehype-sanitize' -import type { Options } from 'react-markdown' +import rehypeSanitize, { defaultSchema } from 'rehype-sanitize' +import { defaultUrlTransform } from 'react-markdown' +import type { Options, UrlTransform } from 'react-markdown' + +const markdownSanitizeSchema = { + ...defaultSchema, + protocols: { + ...defaultSchema.protocols, + src: [...(defaultSchema.protocols?.src ?? []), 'data'], + }, +} /** * Rehype pipeline shared by every Markdown renderer. Raw HTML is parsed, then sanitized to * GitHub's allow-list so scripts, frames and event handlers are stripped from model output and - * repository files, and highlighting runs last so its classes survive sanitization. + * repository files, and highlighting runs last so its classes survive sanitization. The schema + * additionally allows the `data` protocol on `src` so inline image data URIs survive sanitization. */ -export const markdownRehypePlugins: NonNullable = [rehypeRaw, rehypeSanitize, rehypeHighlight] +export const markdownRehypePlugins: NonNullable = [ + rehypeRaw, + [rehypeSanitize, markdownSanitizeSchema], + rehypeHighlight, +] + +/** + * URL transform for Markdown renderers. Keeps `data:image/*` sources intact, which react-markdown's + * default transform strips, and defers every other URL to that default. + */ +export const markdownUrlTransform: UrlTransform = (url, key) => { + if (key === 'src' && /^data:image\//i.test(url)) { + return url + } + + return defaultUrlTransform(url) +} diff --git a/shared/src/config/defaults.ts b/shared/src/config/defaults.ts index 641765f4d..c90637229 100644 --- a/shared/src/config/defaults.ts +++ b/shared/src/config/defaults.ts @@ -90,6 +90,7 @@ export const ALLOWED_MIME_TYPES = [ 'image/png', 'image/jpeg', 'image/gif', + 'image/webp', 'image/svg+xml', 'application/pdf', 'application/zip', From 458f26134b880664c528138b08100729917ccab7 Mon Sep 17 00:00:00 2001 From: Chris Scott <> Date: Tue, 13 Oct 2026 01:38:55 +0000 Subject: [PATCH 2/3] test(e2e): cover inline markdown images and image attachments Exercise the file browser preview, assistant markdown images served by a local mock OpenAI-compatible provider, and composer image attachments in a real browser against the isolated e2e instance. --- e2e/inline-images.spec.ts | 290 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 e2e/inline-images.spec.ts diff --git a/e2e/inline-images.spec.ts b/e2e/inline-images.spec.ts new file mode 100644 index 000000000..88d29402a --- /dev/null +++ b/e2e/inline-images.spec.ts @@ -0,0 +1,290 @@ +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import http from 'node:http' +import path from 'node:path' +import { request as playwrightRequest, type APIRequestContext, type Locator } from '@playwright/test' +import { test, expect } from './fixtures' +import { BASE_URL, E2E_TMP, STORAGE_STATE } from './env' + +const PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAFElEQVQI12P8z8Dwn4GBgYGJAQoAHxcCAmep4oIAAAAASUVORK5CYII=' +const WEBP_BASE64 = 'UklGRjgAAABXRUJQVlA4ICwAAACQAQCdASoCAAIAAgA0JaACdLoAA5gA/vmTb/+QH/+QH/+QH/8gP+IXeyAwAA==' + +const PNG_BUFFER = Buffer.from(PNG_BASE64, 'base64') +const WEBP_BUFFER = Buffer.from(WEBP_BASE64, 'base64') + +const MOCK_PROVIDER_PORT = 5565 +const MOCK_MODEL_ID = 'mock-model' + +let mockReply = '' +let mockServer: http.Server | undefined + +interface MockRepo { + id: number + fullPath: string + sessionId: string +} + +let mockRepo: MockRepo + +function rawFileUrl(filePath: string): string { + return `/api/files?${new URLSearchParams({ raw: 'true', path: filePath })}` +} + +async function expectImageLoaded(image: Locator): Promise { + await image.scrollIntoViewIfNeeded() + await expect(image).toBeVisible() + await expect + .poll(() => image.evaluate((element) => (element as HTMLImageElement).naturalWidth), { timeout: 30_000 }) + .toBeGreaterThan(0) +} + +function handleMockRequest(request: http.IncomingMessage, response: http.ServerResponse): void { + const chunks: Buffer[] = [] + request.on('data', (chunk: Buffer) => chunks.push(chunk)) + request.on('end', () => { + const url = request.url ?? '' + if (request.method === 'GET' && url.startsWith('/v1/models')) { + response.writeHead(200, { 'Content-Type': 'application/json' }) + response.end( + JSON.stringify({ + object: 'list', + data: [{ id: MOCK_MODEL_ID, object: 'model', created: 0, owned_by: 'mock' }], + }), + ) + return + } + + if (request.method === 'POST' && url.startsWith('/v1/chat/completions')) { + let body: { stream?: boolean } = {} + try { + body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}') + } catch { + body = {} + } + + const id = 'chatcmpl-mock' + const created = Math.floor(Date.now() / 1000) + const usage = { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } + + if (body.stream) { + response.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }) + const base = { id, object: 'chat.completion.chunk', created, model: MOCK_MODEL_ID } + response.write( + `data: ${JSON.stringify({ ...base, choices: [{ index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null }] })}\n\n`, + ) + response.write( + `data: ${JSON.stringify({ ...base, choices: [{ index: 0, delta: { content: mockReply }, finish_reason: null }] })}\n\n`, + ) + response.write( + `data: ${JSON.stringify({ ...base, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], usage })}\n\n`, + ) + response.write('data: [DONE]\n\n') + response.end() + return + } + + response.writeHead(200, { 'Content-Type': 'application/json' }) + response.end( + JSON.stringify({ + id, + object: 'chat.completion', + created, + model: MOCK_MODEL_ID, + choices: [{ index: 0, message: { role: 'assistant', content: mockReply }, finish_reason: 'stop' }], + usage, + }), + ) + return + } + + response.writeHead(404, { 'Content-Type': 'application/json' }) + response.end(JSON.stringify({ error: { message: `unhandled ${request.method} ${url}` } })) + }) +} + +function git(directory: string, ...args: string[]): void { + execFileSync('git', args, { cwd: directory, stdio: 'pipe' }) +} + +async function createSession(api: APIRequestContext, directory: string): Promise { + const response = await api.post('/api/opencode/api/session', { data: { location: { directory } } }) + if (!response.ok()) { + throw new Error(`create session failed: ${response.status()} ${await response.text()}`) + } + const body = (await response.json()) as { data: { id: string } } + return body.data.id +} + +test.beforeAll(async () => { + const server = http.createServer(handleMockRequest) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(MOCK_PROVIDER_PORT, '127.0.0.1', () => resolve()) + }) + mockServer = server + + const directory = path.join(E2E_TMP, 'workspace', 'repos', 'e2e-inline-mock-repo') + fs.rmSync(directory, { recursive: true, force: true }) + fs.mkdirSync(path.join(directory, 'docs', 'img'), { recursive: true }) + fs.writeFileSync(path.join(directory, 'docs', 'img', 'logo.png'), PNG_BUFFER) + fs.writeFileSync(path.join(directory, 'docs', 'notes.txt'), 'attachment notes\n') + fs.writeFileSync( + path.join(directory, 'opencode.json'), + JSON.stringify( + { + $schema: 'https://opencode.ai/config.json', + provider: { + mock: { + npm: '@ai-sdk/openai-compatible', + name: 'Mock', + options: { baseURL: `http://127.0.0.1:${MOCK_PROVIDER_PORT}/v1`, apiKey: 'mock' }, + models: { [MOCK_MODEL_ID]: { name: 'Mock Model' } }, + }, + }, + model: `mock/${MOCK_MODEL_ID}`, + small_model: `mock/${MOCK_MODEL_ID}`, + }, + null, + 2, + ), + ) + fs.writeFileSync(path.join(directory, 'README.md'), '# mock repository\n') + git(directory, '-c', 'init.defaultBranch=main', 'init', '-q') + git(directory, '-c', 'user.name=e2e', '-c', 'user.email=e2e@example.com', 'add', '-A') + git(directory, '-c', 'user.name=e2e', '-c', 'user.email=e2e@example.com', 'commit', '-q', '-m', 'initial commit') + + const api = await playwrightRequest.newContext({ baseURL: BASE_URL, storageState: STORAGE_STATE }) + try { + const repoResponse = await api.post('/api/repos', { data: { localPath: directory } }) + if (!repoResponse.ok()) { + throw new Error(`register mock repo failed: ${repoResponse.status()} ${await repoResponse.text()}`) + } + const repo = (await repoResponse.json()) as { id: number; fullPath: string } + const sessionId = await createSession(api, repo.fullPath) + mockRepo = { id: repo.id, fullPath: repo.fullPath, sessionId } + } finally { + await api.dispose() + } +}) + +test.afterAll(async () => { + const server = mockServer + if (!server) return + await new Promise((resolve) => server.close(() => resolve())) + mockServer = undefined +}) + +test.describe('inline markdown images', () => { + test('file browser markdown preview resolves local images through the raw file API', async ({ + page, + repo, + request, + }) => { + const docsDir = path.join(repo.fullPath, 'docs') + fs.mkdirSync(path.join(docsDir, 'img'), { recursive: true }) + fs.writeFileSync(path.join(docsDir, 'img', 'logo.png'), PNG_BUFFER) + fs.writeFileSync(path.join(docsDir, 'img', 'pic.webp'), WEBP_BUFFER) + fs.writeFileSync( + path.join(docsDir, 'README.md'), + ['# docs', '', '![logo](img/logo.png)', '', '![pic](img/pic.webp)', '', `![inline](data:image/png;base64,${PNG_BASE64})`, ''].join('\n'), + ) + + const repoDetailResponse = await request.get(`/api/repos/${repo.id}`) + expect(repoDetailResponse.ok()).toBe(true) + const { localPath } = (await repoDetailResponse.json()) as { localPath: string } + + const webpResponse = await request.get(rawFileUrl(`${localPath}/docs/img/pic.webp`)) + expect(webpResponse.ok()).toBe(true) + expect(webpResponse.headers()['content-type']).toContain('image/webp') + + await page.goto(`/repos/${repo.id}?panel=files`) + const filesPanel = page.locator('aside[aria-label="Files"]') + await filesPanel.getByText('docs', { exact: true }).click() + await filesPanel.getByText('README.md', { exact: true }).click() + + const logo = page.getByAltText('logo') + await expect(logo).toHaveAttribute('src', rawFileUrl(`${localPath}/docs/img/logo.png`)) + await expectImageLoaded(logo) + + const pic = page.getByAltText('pic') + await expect(pic).toHaveAttribute('src', rawFileUrl(`${localPath}/docs/img/pic.webp`)) + await expectImageLoaded(pic) + + const inline = page.getByAltText('inline') + await expect(inline).toHaveAttribute('src', `data:image/png;base64,${PNG_BASE64}`) + await expectImageLoaded(inline) + }) + + test('assistant markdown images resolve relative, absolute and inline data sources', async ({ page }) => { + mockReply = [ + 'Here are the images:', + '', + '![rel](docs/img/logo.png)', + '', + `![abs](${mockRepo.fullPath}/docs/img/logo.png)`, + '', + `![dataimg](data:image/png;base64,${PNG_BASE64})`, + '', + '![bad](data:text/html,hello)', + ].join('\n') + + await page.goto(`/repos/${mockRepo.id}/sessions/${mockRepo.sessionId}`) + const composer = page.getByPlaceholder('Send a message...') + await expect(composer).toBeVisible() + await composer.fill('render the images') + await page.locator('[data-submit-prompt]').click() + + const expectedLocal = rawFileUrl(`${mockRepo.fullPath}/docs/img/logo.png`) + + const rel = page.getByAltText('rel') + await expect(rel).toHaveAttribute('src', expectedLocal, { timeout: 60_000 }) + await expectImageLoaded(rel) + + const abs = page.getByAltText('abs') + await expect(abs).toHaveAttribute('src', expectedLocal) + await expectImageLoaded(abs) + + const dataimg = page.getByAltText('dataimg') + await expect(dataimg).toHaveAttribute('src', `data:image/png;base64,${PNG_BASE64}`) + await expectImageLoaded(dataimg) + + await expect(page.locator('img[alt="bad"]')).toHaveCount(0) + }) + + test('user attachments render images as thumbnails and other files as chips', async ({ page, request }) => { + mockReply = 'Received your files.' + const sessionId = await createSession(request, mockRepo.fullPath) + + await page.goto(`/repos/${mockRepo.id}/sessions/${sessionId}`) + const composer = page.getByPlaceholder('Send a message...') + await expect(composer).toBeVisible() + + await page + .locator('input[type="file"]') + .first() + .setInputFiles({ name: 'attachment.png', mimeType: 'image/png', buffer: PNG_BUFFER }) + await expect(page.getByText('attachment.png')).toBeVisible() + await composer.fill('image attachment') + await page.locator('[data-submit-prompt]').click() + + const thumbnail = page.getByAltText('attachment.png').first() + await expect(thumbnail).toHaveAttribute('src', `data:image/png;base64,${PNG_BASE64}`, { timeout: 60_000 }) + await expectImageLoaded(thumbnail) + + const textFileResponse = await request.post(`/api/opencode/api/session/${sessionId}/prompt`, { + data: { + text: 'text attachment', + files: [{ uri: `file://${mockRepo.fullPath}/docs/notes.txt`, name: 'notes.txt' }], + }, + }) + expect(textFileResponse.ok()).toBe(true) + + await expect(page.getByText('notes.txt').first()).toBeVisible({ timeout: 60_000 }) + await expect(page.locator('img[alt="notes.txt"]')).toHaveCount(0) + }) +}) From f488b8762d833e67fae100a637de8882f2185ba7 Mon Sep 17 00:00:00 2001 From: Chris Scott <> Date: Tue, 13 Oct 2026 01:46:51 +0000 Subject: [PATCH 3/3] fix(markdown): address inline image review feedback Keep the leading slash when resolving relative paths against an absolute markdown file, retry an inline image when its source changes after a load failure, and render linked markdown images without a nested expand button. --- .../components/message/MessagePart.test.tsx | 18 ++++++- .../src/components/ui/inline-image.test.tsx | 29 +++++++++++ frontend/src/components/ui/inline-image.tsx | 48 +++++++++++++------ frontend/src/components/ui/markdown-image.tsx | 5 +- frontend/src/components/ui/markdown-link.tsx | 15 ++++-- frontend/src/lib/markdownLinks.test.ts | 12 +++++ frontend/src/lib/markdownLinks.ts | 13 +++-- 7 files changed, 118 insertions(+), 22 deletions(-) diff --git a/frontend/src/components/message/MessagePart.test.tsx b/frontend/src/components/message/MessagePart.test.tsx index 8ff528b9b..57272eebe 100644 --- a/frontend/src/components/message/MessagePart.test.tsx +++ b/frontend/src/components/message/MessagePart.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent } from '@testing-library/react' +import { render, screen, fireEvent, within } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { MemoryRouter } from 'react-router-dom' import { MessagePart } from './MessagePart' @@ -175,6 +175,22 @@ describe('MessagePart', () => { expect(container.querySelector('img')).toBeNull() }) + + it('renders a linked image without a nested expand button', () => { + renderWithProviders( + , + ) + + const link = screen.getByRole('link') + expect(within(link).getByAltText('logo')).toHaveAttribute('src', 'https://example.com/a.png') + expect(within(link).queryByRole('button')).toBeNull() + }) + + it('renders an expand button for an unlinked image', () => { + renderWithProviders() + + expect(screen.getByRole('button', { name: 'Expand image: logo' })).toBeInTheDocument() + }) }) describe('reasoning', () => { diff --git a/frontend/src/components/ui/inline-image.test.tsx b/frontend/src/components/ui/inline-image.test.tsx index fab0ef5fa..d3ecde34c 100644 --- a/frontend/src/components/ui/inline-image.test.tsx +++ b/frontend/src/components/ui/inline-image.test.tsx @@ -22,4 +22,33 @@ describe('InlineImage', () => { expect(screen.queryByRole('button')).not.toBeInTheDocument() expect(screen.getByText('shot.png')).toBeInTheDocument() }) + + it('retries a new source after an image failure', () => { + const { rerender } = render() + + fireEvent.error(screen.getByAltText('shot.png')) + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + expect(screen.getByText('shot.png')).toBeInTheDocument() + + rerender() + + expect(screen.getByAltText('shot.png')).toHaveAttribute('src', 'data:image/png;base64,aGVsbG8=') + expect(screen.getByRole('button', { name: 'Expand image: shot.png' })).toBeInTheDocument() + }) + + it('renders only the image without an expand button when not expandable', () => { + render() + + expect(screen.getByAltText('shot.png')).toHaveAttribute('src', 'data:image/png;base64,aGVsbG8=') + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('falls back to the alt text for a non-expandable image that fails to load', () => { + render() + + fireEvent.error(screen.getByAltText('shot.png')) + + expect(screen.getByText('shot.png')).toBeInTheDocument() + }) }) diff --git a/frontend/src/components/ui/inline-image.tsx b/frontend/src/components/ui/inline-image.tsx index 7fd5f1850..6870f9a9d 100644 --- a/frontend/src/components/ui/inline-image.tsx +++ b/frontend/src/components/ui/inline-image.tsx @@ -3,17 +3,46 @@ import { cn } from '@/lib/utils' import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' /** - * Renders an image as a bounded thumbnail button that opens a full-size preview dialog. - * Falls back to a muted alt-text span (or nothing) when the image fails to load. + * Renders an image as a bounded thumbnail that opens a full-size preview dialog when expandable. + * Falls back to a muted alt-text span (or nothing) when the image fails to load, retrying when the source changes. */ -export function InlineImage({ src, alt, className }: { src: string; alt?: string; className?: string }) { +export function InlineImage({ + src, + alt, + className, + expandable = true, +}: { + src: string + alt?: string + className?: string + expandable?: boolean +}) { const [open, setOpen] = useState(false) - const [failed, setFailed] = useState(false) + const [failedSrc, setFailedSrc] = useState(null) + const failed = failedSrc === src if (failed) { return alt ? {alt} : null } + const image = ( + {alt setFailedSrc(src)} + className={cn( + 'max-w-full max-h-80 rounded-lg border border-border object-contain', + expandable && 'cursor-zoom-in', + className, + )} + /> + ) + + if (!expandable) { + return image + } + return ( <> diff --git a/frontend/src/components/ui/markdown-image.tsx b/frontend/src/components/ui/markdown-image.tsx index 614cfcb76..ee972b30e 100644 --- a/frontend/src/components/ui/markdown-image.tsx +++ b/frontend/src/components/ui/markdown-image.tsx @@ -1,6 +1,7 @@ import type { ComponentPropsWithoutRef } from 'react' import type { ExtraProps } from 'react-markdown' import { InlineImage } from '@/components/ui/inline-image' +import { useIsInsideMarkdownLink } from '@/components/ui/markdown-link' import { getFileApiUrl } from '@/api/files' import { getLocalLinkPath } from '@/lib/markdownLinks' @@ -14,6 +15,8 @@ type MarkdownImageProps = ComponentPropsWithoutRef<'img'> & ExtraProps & { * `data:image/*` URIs, is rendered unchanged. */ export function MarkdownImage({ src, alt, resolveLocalPath }: MarkdownImageProps) { + const insideLink = useIsInsideMarkdownLink() + if (typeof src !== 'string' || src === '') { return null } @@ -30,5 +33,5 @@ export function MarkdownImage({ src, alt, resolveLocalPath }: MarkdownImageProps } } - return + return } diff --git a/frontend/src/components/ui/markdown-link.tsx b/frontend/src/components/ui/markdown-link.tsx index 7a5b4bf62..b3b3b9b85 100644 --- a/frontend/src/components/ui/markdown-link.tsx +++ b/frontend/src/components/ui/markdown-link.tsx @@ -1,7 +1,15 @@ +import { createContext, useContext } from 'react' import type { ComponentPropsWithoutRef } from 'react' import type { ExtraProps } from 'react-markdown' import { getLocalLinkPath, isExternalLink } from '@/lib/markdownLinks' +const MarkdownLinkContext = createContext(false) + +/** Returns whether the current subtree is rendered inside a markdown link. */ +export function useIsInsideMarkdownLink(): boolean { + return useContext(MarkdownLinkContext) +} + type MarkdownLinkProps = ComponentPropsWithoutRef<'a'> & ExtraProps & { onOpenLocalPath?: (linkPath: string) => void } @@ -13,6 +21,7 @@ type MarkdownLinkProps = ComponentPropsWithoutRef<'a'> & ExtraProps & { export function MarkdownLink({ href, children, onOpenLocalPath, ...rest }: MarkdownLinkProps) { delete (rest as Record).node const localPath = onOpenLocalPath ? getLocalLinkPath(href) : null + const wrappedChildren = {children} if (localPath && onOpenLocalPath) { return ( @@ -24,14 +33,14 @@ export function MarkdownLink({ href, children, onOpenLocalPath, ...rest }: Markd onOpenLocalPath(localPath) }} > - {children} + {wrappedChildren} ) } if (isExternalLink(href)) { - return {children} + return {wrappedChildren} } - return {children} + return {wrappedChildren} } diff --git a/frontend/src/lib/markdownLinks.test.ts b/frontend/src/lib/markdownLinks.test.ts index 997641048..517049209 100644 --- a/frontend/src/lib/markdownLinks.test.ts +++ b/frontend/src/lib/markdownLinks.test.ts @@ -42,6 +42,18 @@ describe('resolvePathFromFile', () => { it('returns absolute paths unchanged', () => { expect(resolvePathFromFile(FROM, '/workspace/repos/app/report.html')).toBe('/workspace/repos/app/report.html') }) + + it('resolves relative links against an absolute base file keeping the leading slash', () => { + expect(resolvePathFromFile('/workspace/docs/README.md', 'img.png')).toBe('/workspace/docs/img.png') + expect(resolvePathFromFile('/workspace/docs/README.md', './img.png')).toBe('/workspace/docs/img.png') + expect(resolvePathFromFile('/workspace/docs/README.md', 'sub/img.png')).toBe('/workspace/docs/sub/img.png') + }) + + it('applies parent segments against an absolute base without climbing above root', () => { + expect(resolvePathFromFile('/workspace/docs/README.md', '../img.png')).toBe('/workspace/img.png') + expect(resolvePathFromFile('/a/index.md', '../../x.md')).toBe('/x.md') + expect(resolvePathFromFile('/a/index.md', '..')).toBe('/') + }) }) describe('getWorkspaceFilePath', () => { diff --git a/frontend/src/lib/markdownLinks.ts b/frontend/src/lib/markdownLinks.ts index fd3474be7..189005ba5 100644 --- a/frontend/src/lib/markdownLinks.ts +++ b/frontend/src/lib/markdownLinks.ts @@ -19,24 +19,31 @@ export function getLocalLinkPath(href: string | undefined): string | null { /** * Resolves a local link path against the workspace path of the file that contains it. - * Absolute paths are returned unchanged for the files API to validate; leading parent segments are kept. + * Absolute link paths are returned unchanged for the files API to validate. Leading parent segments + * are kept for relative bases, while an absolute base keeps its leading slash and never climbs above root. */ export function resolvePathFromFile(fromFilePath: string, linkPath: string): string | null { if (linkPath.startsWith('/')) { return linkPath } + const isAbsolute = fromFilePath.startsWith('/') const segments = fromFilePath.split('/').filter(Boolean).slice(0, -1) for (const segment of linkPath.split('/')) { if (segment === '' || segment === '.') continue - if (segment === '..' && segments.length > 0 && segments[segments.length - 1] !== '..') { + if (segment !== '..') { + segments.push(segment) + } else if (segments.length === 0) { + if (!isAbsolute) segments.push(segment) + } else if (isAbsolute || segments[segments.length - 1] !== '..') { segments.pop() } else { segments.push(segment) } } - return segments.join('/') || null + const resolved = segments.join('/') + return isAbsolute ? `/${resolved}` : resolved || null } /**