From 0c24a5da3eba9f7f45d825163dc4dbb3c5dcab4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 30 May 2026 18:11:21 +0800 Subject: [PATCH] feat(desktop): render relative workspace images inline; stop images doubling as cards Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AssistantMessage.linkrouting.test.tsx | 35 ++++++++ .../src/components/chat/AssistantMessage.tsx | 8 +- .../chat/InlineImageGallery.test.tsx | 84 +++++++++++++++++++ .../components/chat/InlineImageGallery.tsx | 64 ++++++++++++-- 4 files changed, 182 insertions(+), 9 deletions(-) create mode 100644 desktop/src/components/chat/InlineImageGallery.test.tsx 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