- {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 (
+ <>
+
+
+ >
+ )
+}
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', '', '', '', '', '', ``, ''].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:',
+ '',
+ '',
+ '',
+ ``,
+ '',
+ ``,
+ '',
+ '',
+ ].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 = (
+
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 (
<>