diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageImage.test.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageImage.test.tsx new file mode 100644 index 0000000000..53cfe21d5f --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageImage.test.tsx @@ -0,0 +1,63 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { UserMessageImage } from './UserMessageImage'; + +const { readFileContent } = vi.hoisted(() => ({ readFileContent: vi.fn() })); +vi.mock('@/infrastructure/api/service-api/WorkspaceAPI', () => ({ workspaceAPI: { readFileContent } })); +vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: (_key: string, values: { message: string }) => `Load failed: ${values.message}` }) })); +vi.mock('@/shared/utils/logger', () => ({ createLogger: () => ({ warn: vi.fn(), debug: vi.fn(), info: vi.fn(), error: vi.fn() }) })); + +const image = { id: 'image', name: 'Photo.png', imagePath: '/Lark images/Photo.png', mimeType: 'image/png' }; + +describe('UserMessageImage', () => { + let container: HTMLDivElement; + let root: Root; + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + readFileContent.mockReset(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + afterEach(() => { act(() => root.unmount()); container.remove(); }); + const render = async (element: React.ReactNode) => { await act(async () => { root.render(element); }); }; + + + it('reads host image bytes through the transport and previews the same data URL', async () => { + readFileContent.mockResolvedValue('aW1hZ2U='); + const onPreview = vi.fn(); + await render(); + const thumbnail = container.querySelector('img')!; + expect(readFileContent).toHaveBeenCalledWith(image.imagePath, 'base64'); + expect(thumbnail.getAttribute('src')).toBe('data:image/png;base64,aW1hZ2U='); + act(() => thumbnail.click()); + expect(onPreview).toHaveBeenCalledWith(thumbnail.getAttribute('src')); + }); + + it('uses embedded clipboard data without reading a host file', async () => { + await render(); + expect(container.querySelector('img')!.getAttribute('src')).toBe('data:image/png;base64,AA=='); + expect(readFileContent).not.toHaveBeenCalled(); + }); + + it('displays read failures without falling back to controller-local URLs', async () => { + readFileContent.mockRejectedValue(new Error('Host offline')); + await render(); + expect(container.querySelector('[role=alert]')!.textContent).toContain('Host offline'); + expect(container.querySelector('img')).toBeNull(); + expect(readFileContent).toHaveBeenCalledTimes(1); + }); + + it('ignores a stale image read after the attachment changes', async () => { + let finishOld!: (content: string) => void; + readFileContent.mockImplementationOnce(() => new Promise(resolve => { finishOld = resolve; })); + readFileContent.mockResolvedValueOnce('NEW'); + await render(); + await render(); + expect(container.querySelector('img')!.getAttribute('src')).toBe('data:image/png;base64,NEW'); + await act(async () => { finishOld('OLD'); }); + expect(container.querySelector('img')!.getAttribute('src')).toBe('data:image/png;base64,NEW'); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageImage.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageImage.tsx new file mode 100644 index 0000000000..687f94d148 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageImage.tsx @@ -0,0 +1,57 @@ +import { useEffect, useState } from 'react'; +import { workspaceAPI } from '@/infrastructure/api/service-api/WorkspaceAPI'; +import { useI18n } from '@/infrastructure/i18n'; +import { createLogger } from '@/shared/utils/logger'; +import type { ImageDisplayData } from '../../utils/imagePayload'; +import { getMimeTypeFromFilename } from '../../utils/imageUtils'; + +const log = createLogger('UserMessageImage'); + +interface UserMessageImageProps { + image: ImageDisplayData; + onPreview: (source: string) => void; +} + +export function UserMessageImage({ image, onPreview }: UserMessageImageProps) { + const { t } = useI18n('tools'); + const { dataUrl, imagePath, mimeType, name } = image; + const [loaded, setLoaded] = useState<{ path: string; source: string } | null>(null); + const [error, setError] = useState(null); + const source = dataUrl || (loaded && loaded.path === imagePath ? loaded.source : undefined); + + useEffect(() => { + let disposed = false; + setLoaded(null); + setError(null); + if (!dataUrl && imagePath) { + // Attachment paths belong to the runtime host. The transport routes this + // read to that host, including peer mode; never construct a local asset URL. + void workspaceAPI.readFileContent(imagePath, 'base64').then(content => { + if (!disposed) { + setLoaded({ path: imagePath, source: `data:${mimeType || getMimeTypeFromFilename(imagePath)};base64,${content}` }); + } + }).catch(cause => { + if (!disposed) { + log.warn('Failed to load message image', { imagePath, error: cause }); + setError(String(cause)); + } + }); + } + return () => { disposed = true; }; + }, [dataUrl, imagePath, mimeType]); + + const failure = error ? t('editor.imageViewer.loadImageFailedWithMessage', { message: error }) : null; + return ( +
{ event.stopPropagation(); if (source && !error) onPreview(source); }} + title={failure || name} + > + {failure ? {failure} : source ? ( + {name} setError(name)} /> + ) : {name}} +
+ ); +} diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx index b173578414..f8099223a0 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx @@ -56,6 +56,7 @@ import { } from '../../utils/composerPresentation'; import { restoreImageContextsFromPayload } from '../../utils/imageContextRestoration'; import { UserMessagePresentationContent } from './UserMessagePresentationContent'; +import { UserMessageImage } from './UserMessageImage'; import './UserMessageItem.scss'; const log = createLogger('UserMessageItem'); @@ -633,14 +634,9 @@ export const UserMessageItem = React.memo( {message.images && message.images.length > 0 && (
- {message.images.map(img => { - const src = img.dataUrl || (img.imagePath ? `https://asset.localhost/${encodeURIComponent(img.imagePath)}` : undefined); - return src ? ( -
{ e.stopPropagation(); setLightboxImage(src); }}> - {img.name} -
- ) : null; - })} + {message.images.map(img => ( + + ))}
)}