diff --git a/desktop/src/components/chat/AssistantMessage.linkrouting.test.tsx b/desktop/src/components/chat/AssistantMessage.linkrouting.test.tsx
index a680015b..5032424f 100644
--- a/desktop/src/components/chat/AssistantMessage.linkrouting.test.tsx
+++ b/desktop/src/components/chat/AssistantMessage.linkrouting.test.tsx
@@ -93,6 +93,41 @@ describe('AssistantMessage output-target cards', () => {
expect(screen.getByText('assistantOutputs.kind.markdown')).toBeInTheDocument()
})
+ it('renders a relative image inline (an
) but NOT as an image card', () => {
+ render(
+ ,
+ )
+ // Image renders inline through InlineImageGallery (workDir is undefined in this
+ // test's mock, so the relative path resolves as-is and is served via /preview-fs).
+ const img = screen.getByRole('img') as HTMLImageElement
+ expect(img.getAttribute('src')).toBe(
+ 'http://127.0.0.1:4321/preview-fs/s1/outputs/foo/preview_frame.png',
+ )
+ // ...and is NOT duplicated as an output-target card.
+ expect(screen.queryByText('assistantOutputs.kind.image')).toBeNull()
+ })
+
+ it('still renders md/html/localhost cards when those references are present', () => {
+ render(
+ ,
+ )
+ expect(screen.getByText('assistantOutputs.kind.localhost')).toBeInTheDocument()
+ expect(screen.getByText('assistantOutputs.kind.markdown')).toBeInTheDocument()
+ expect(screen.getByText('assistantOutputs.kind.html')).toBeInTheDocument()
+ })
+
it('does NOT render cards while streaming', () => {
render(
(isStreaming || !sessionId ? [] : extractAssistantOutputTargets(content, { workDir })),
+ () =>
+ isStreaming || !sessionId
+ ? []
+ : // Image targets render inline via ; never also as a card.
+ extractAssistantOutputTargets(content, { workDir }).filter((target) => target.kind !== 'image'),
[content, isStreaming, sessionId, workDir],
)
@@ -75,7 +79,7 @@ export const AssistantMessage = memo(function AssistantMessage({ content, isStre
streaming={isStreaming}
onLinkClick={sessionId ? handleLinkClick : undefined}
/>
- {!isStreaming && }
+ {!isStreaming && }
{isStreaming && (
)}
diff --git a/desktop/src/components/chat/InlineImageGallery.test.tsx b/desktop/src/components/chat/InlineImageGallery.test.tsx
new file mode 100644
index 00000000..7862c0d7
--- /dev/null
+++ b/desktop/src/components/chat/InlineImageGallery.test.tsx
@@ -0,0 +1,84 @@
+import '@testing-library/jest-dom'
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+
+// getBaseUrl backs the absolute-path src (/api/filesystem/file).
+vi.mock('../../api/client', () => ({
+ getBaseUrl: () => 'http://127.0.0.1:3456',
+}))
+
+// getServerBaseUrl backs the relative-path src (/preview-fs//...).
+vi.mock('../../lib/desktopRuntime', () => ({
+ getServerBaseUrl: () => 'http://127.0.0.1:4321',
+}))
+
+import { InlineImageGallery } from './InlineImageGallery'
+
+function imgSrcs(): string[] {
+ return screen.getAllByRole('img').map((img) => (img as HTMLImageElement).getAttribute('src') ?? '')
+}
+
+describe('InlineImageGallery', () => {
+ it('renders an absolute image path via /api/filesystem/file (legacy behavior)', () => {
+ render()
+
+ const srcs = imgSrcs()
+ expect(srcs).toHaveLength(1)
+ expect(srcs[0]).toBe(
+ 'http://127.0.0.1:3456/api/filesystem/file?path=' + encodeURIComponent('/Users/me/out/result.png'),
+ )
+ })
+
+ it('ignores relative workspace images when sessionId is absent', () => {
+ render()
+ expect(screen.queryAllByRole('img')).toHaveLength(0)
+ })
+
+ it('renders a relative workspace image via previewFsUrl when sessionId is provided', () => {
+ render(
+ ,
+ )
+
+ const srcs = imgSrcs()
+ expect(srcs).toHaveLength(1)
+ expect(srcs[0]).toBe('http://127.0.0.1:4321/preview-fs/s1/outputs/a/frame.png')
+ })
+
+ it('renders both absolute and relative images together', () => {
+ render(
+ ,
+ )
+
+ const srcs = imgSrcs()
+ expect(srcs).toEqual([
+ 'http://127.0.0.1:3456/api/filesystem/file?path=' + encodeURIComponent('/Users/me/pics/photo.png'),
+ 'http://127.0.0.1:4321/preview-fs/s1/outputs/b/chart.png',
+ ])
+ })
+
+ it('does not render an in-workspace absolute path twice (dedup by basename)', () => {
+ // The absolute path is INSIDE workDir, so extractAssistantOutputTargets also
+ // surfaces it as a relative target (frame.png). It must only render once.
+ render(
+ ,
+ )
+
+ const srcs = imgSrcs()
+ expect(srcs).toHaveLength(1)
+ expect(srcs[0]).toBe(
+ 'http://127.0.0.1:3456/api/filesystem/file?path=' + encodeURIComponent('/w/outputs/a/frame.png'),
+ )
+ })
+})
diff --git a/desktop/src/components/chat/InlineImageGallery.tsx b/desktop/src/components/chat/InlineImageGallery.tsx
index 2f45d8f4..104d5c6f 100644
--- a/desktop/src/components/chat/InlineImageGallery.tsx
+++ b/desktop/src/components/chat/InlineImageGallery.tsx
@@ -1,6 +1,9 @@
import { useMemo, useState } from 'react'
import { ImageGalleryModal } from './ImageGalleryModal'
import { getBaseUrl } from '../../api/client'
+import { extractAssistantOutputTargets } from '../../lib/assistantOutputTargets'
+import { previewFsUrl } from '../../lib/handlePreviewLink'
+import { getServerBaseUrl } from '../../lib/desktopRuntime'
const IMAGE_EXTENSIONS = /\.(png|jpe?g|gif|webp|svg|bmp|avif|ico)$/i
@@ -35,19 +38,66 @@ function fileName(filePath: string): string {
return filePath.split('/').pop() || filePath
}
-type Props = {
- text: string
+type GalleryImage = {
+ src: string
+ name: string
}
-export function InlineImageGallery({ text }: Props) {
+type Props = {
+ text: string
+ /**
+ * When provided, relative workspace image paths (e.g. `outputs/foo/frame.png`)
+ * are also rendered inline, served via `/preview-fs//...`. Absent
+ * (ToolResult/ToolCall usage) keeps the legacy absolute-path-only behavior.
+ */
+ sessionId?: string
+ workDir?: string | null
+}
+
+export function InlineImageGallery({ text, sessionId, workDir }: Props) {
const [activeIndex, setActiveIndex] = useState(null)
const imagePaths = useMemo(() => extractImagePaths(text), [text])
- const images = useMemo(
- () => imagePaths.map((p) => ({ src: fileUrl(p), name: fileName(p) })),
- [imagePaths],
- )
+ const images = useMemo(() => {
+ // 1. Absolute paths (legacy behavior) — served via /api/filesystem/file.
+ const absolute: GalleryImage[] = imagePaths.map((p) => ({ src: fileUrl(p), name: fileName(p) }))
+
+ if (!sessionId) {
+ return absolute
+ }
+
+ // 2. Relative workspace images — only when a sessionId is available so we can
+ // build a /preview-fs URL. Reuses the sandboxed target extractor instead of
+ // a bespoke relative-path regex.
+ const base = getServerBaseUrl()
+ const relativeTargets = extractAssistantOutputTargets(text, { workDir }).filter(
+ (target) => target.kind === 'image',
+ )
+
+ // Dedup: an absolute path inside the workspace can be caught by BOTH sources.
+ // Skip a relative target whose basename already appears among the absolute
+ // images, and also collapse duplicate relative targets by resolved src.
+ const absoluteNames = new Set(absolute.map((img) => img.name))
+ const seenSrc = new Set(absolute.map((img) => img.src))
+ const relative: GalleryImage[] = []
+
+ for (const target of relativeTargets) {
+ const relPath = target.normalizedPath ?? target.href
+ const name = fileName(relPath)
+ if (absoluteNames.has(name)) {
+ continue
+ }
+ const src = previewFsUrl(base, sessionId, relPath)
+ if (seenSrc.has(src)) {
+ continue
+ }
+ seenSrc.add(src)
+ relative.push({ src, name })
+ }
+
+ return [...absolute, ...relative]
+ }, [imagePaths, sessionId, text, workDir])
if (images.length === 0) return null