diff --git a/web/src/components/AssistantChat/AttachmentItem.tsx b/web/src/components/AssistantChat/AttachmentItem.tsx index af08ff4f9..5793a18c6 100644 --- a/web/src/components/AssistantChat/AttachmentItem.tsx +++ b/web/src/components/AssistantChat/AttachmentItem.tsx @@ -1,5 +1,7 @@ +import { useState } from 'react' import { AttachmentPrimitive, useThreadComposerAttachment } from '@assistant-ui/react' import { Spinner } from '@/components/Spinner' +import { ImageLightbox } from '@/components/ImageLightbox' function ErrorIcon() { return ( @@ -31,9 +33,45 @@ function RemoveIcon() { } export function AttachmentItem() { - const { name, status } = useThreadComposerAttachment() + const attachment = useThreadComposerAttachment() + const { name, status } = attachment const isUploading = status.type === 'running' const isError = status.type === 'incomplete' + const [lightboxOpen, setLightboxOpen] = useState(false) + + const previewUrl = (attachment as Record).previewUrl as string | undefined + const isImage = typeof previewUrl === 'string' && previewUrl.length > 0 + + if (isImage && !isError) { + return ( + <> + + {name} setLightboxOpen(true)} + /> + {isUploading && ( +
+ +
+ )} + + + +
+ {name} +
+
+ setLightboxOpen(false)} /> + + ) + } return ( diff --git a/web/src/components/AssistantChat/messages/MessageAttachments.tsx b/web/src/components/AssistantChat/messages/MessageAttachments.tsx index 11eb6b363..f6b9e961a 100644 --- a/web/src/components/AssistantChat/messages/MessageAttachments.tsx +++ b/web/src/components/AssistantChat/messages/MessageAttachments.tsx @@ -1,6 +1,8 @@ +import { useState } from 'react' import type { AttachmentMetadata } from '@/types/api' import { FileIcon } from '@/components/FileIcon' import { isImageMimeType } from '@/lib/fileAttachments' +import { ImageLightbox } from '@/components/ImageLightbox' function formatFileSize(bytes: number): string { if (bytes < 1024) return `${bytes} B` @@ -10,19 +12,25 @@ function formatFileSize(bytes: number): string { function ImageAttachment(props: { attachment: AttachmentMetadata }) { const { attachment } = props + const [open, setOpen] = useState(false) return ( -
- {attachment.filename} -
- - {attachment.filename} - + <> +
setOpen(true)}> + {attachment.filename} +
+ + {attachment.filename} + +
-
+ {attachment.previewUrl && ( + setOpen(false)} /> + )} + ) } diff --git a/web/src/components/ImageLightbox.tsx b/web/src/components/ImageLightbox.tsx new file mode 100644 index 000000000..1d92cfcd8 --- /dev/null +++ b/web/src/components/ImageLightbox.tsx @@ -0,0 +1,100 @@ +import { useEffect, useCallback, useState } from 'react' +import { createPortal } from 'react-dom' + +interface ImageLightboxProps { + src: string + alt?: string + open: boolean + onClose: () => void +} + +export function ImageLightbox({ src, alt, open, onClose }: ImageLightboxProps) { + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + }, + [onClose] + ) + + useEffect(() => { + if (!open) return + document.addEventListener('keydown', handleKeyDown) + document.body.style.overflow = 'hidden' + return () => { + document.removeEventListener('keydown', handleKeyDown) + document.body.style.overflow = '' + } + }, [open, handleKeyDown]) + + if (!open) return null + + return createPortal( +
+ {/* Top-right buttons */} +
+ + +
+ + {/* Image */} + {alt e.stopPropagation()} + /> +
, + document.body + ) +} + +export function useImageLightbox() { + const [lightbox, setLightbox] = useState<{ src: string; alt?: string } | null>(null) + + const openLightbox = useCallback((src: string, alt?: string) => { + setLightbox({ src, alt }) + }, []) + + const closeLightbox = useCallback(() => { + setLightbox(null) + }, []) + + const LightboxPortal = lightbox ? ( + + ) : null + + return { openLightbox, LightboxPortal } +} diff --git a/web/src/components/ToolCard/views/_results.tsx b/web/src/components/ToolCard/views/_results.tsx index 26d7b15a8..efea2b2c1 100644 --- a/web/src/components/ToolCard/views/_results.tsx +++ b/web/src/components/ToolCard/views/_results.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import type { ToolViewComponent, ToolViewProps } from '@/components/ToolCard/views/_all' import { isObject, safeStringify } from '@hapi/protocol' import { CodeBlock } from '@/components/CodeBlock' @@ -5,6 +6,7 @@ import { MarkdownRenderer } from '@/components/MarkdownRenderer' import { ChecklistList, extractTodoChecklist } from '@/components/ToolCard/checklist' import { basename, resolveDisplayPath } from '@/utils/path' import { getInputStringAny } from '@/lib/toolInputUtils' +import { ImageLightbox } from '@/components/ImageLightbox' function parseToolUseError(message: string): { isToolUseError: boolean; errorMessage: string | null } { const regex = /(.*?)<\/tool_use_error>/s @@ -28,6 +30,66 @@ function extractTextFromContentBlock(block: unknown): string | null { return null } +interface ImageBlock { + mediaType: string + dataUrl: string +} + +function extractImageFromContentBlock(block: unknown): ImageBlock | null { + if (!isObject(block)) return null + if (block.type !== 'image') return null + const source = isObject(block.source) ? block.source : null + if (!source) return null + if (source.type === 'base64' && typeof source.media_type === 'string' && typeof source.data === 'string') { + return { mediaType: source.media_type, dataUrl: `data:${source.media_type};base64,${source.data}` } + } + return null +} + +function extractImagesFromResult(result: unknown): ImageBlock[] { + if (!result) return [] + + if (Array.isArray(result)) { + return result.map(extractImageFromContentBlock).filter((img): img is ImageBlock => img !== null) + } + + if (isObject(result)) { + const contentArray = Array.isArray(result.content) ? result.content : null + if (contentArray) { + return contentArray.map(extractImageFromContentBlock).filter((img): img is ImageBlock => img !== null) + } + } + + return [] +} + +function InlineImage({ image }: { image: ImageBlock }) { + const [open, setOpen] = useState(false) + return ( + <> + Tool result image setOpen(true)} + /> + setOpen(false)} /> + + ) +} + +function ResultImages({ result }: { result: unknown }) { + const images = extractImagesFromResult(result) + if (images.length === 0) return null + return ( +
+ {images.map((img, i) => ( + + ))} +
+ ) +} + export function extractTextFromResult(result: unknown, depth: number = 0): string | null { if (depth > 2) return null if (result === null || result === undefined) return null @@ -249,6 +311,7 @@ const BashResultView: ToolViewComponent = (props: ToolViewProps) => { {stdio.stdout ? : null} {stdio.stderr ? : null}
+ ) @@ -259,6 +322,17 @@ const BashResultView: ToolViewComponent = (props: ToolViewProps) => { return ( <> {renderText(text, { mode: 'code', language: 'text' })} + + + + ) + } + + const images = extractImagesFromResult(result) + if (images.length > 0) { + return ( + <> + ) @@ -284,6 +358,17 @@ const MarkdownResultView: ToolViewComponent = (props: ToolViewProps) => { return ( <> {renderText(text, { mode: 'auto' })} + + + + ) + } + + const images = extractImagesFromResult(result) + if (images.length > 0) { + return ( + <> + ) @@ -354,6 +439,8 @@ const ReadResultView: ToolViewComponent = (props: ToolViewProps) => { return
{placeholderForState(props.block.tool.state)}
} + const images = extractImagesFromResult(result) + const file = extractReadFileContent(result) if (file) { const path = file.filePath ? resolveDisplayPath(file.filePath, props.metadata) : null @@ -365,6 +452,16 @@ const ReadResultView: ToolViewComponent = (props: ToolViewProps) => { ) : null} + {images.length > 0 ? : null} + + + ) + } + + if (images.length > 0) { + return ( + <> + ) @@ -595,6 +692,7 @@ const GenericResultView: ToolViewComponent = (props: ToolViewProps) => { {parsed.wallTime && `Wall time: ${parsed.wallTime}`} {renderText(parsed.output.trim(), { mode: 'code' })} + ) @@ -606,11 +704,22 @@ const GenericResultView: ToolViewComponent = (props: ToolViewProps) => { return ( <> {renderText(text, { mode: 'auto' })} + {typeof result === 'object' ? : null} ) } + const images = extractImagesFromResult(result) + if (images.length > 0) { + return ( + <> + + + + ) + } + if (typeof result === 'string') { return renderText(result, { mode: 'auto' }) } diff --git a/web/src/lib/attachmentAdapter.ts b/web/src/lib/attachmentAdapter.ts index 5e3fd325b..8844c9c2f 100644 --- a/web/src/lib/attachmentAdapter.ts +++ b/web/src/lib/attachmentAdapter.ts @@ -30,14 +30,20 @@ export function createAttachmentAdapter(api: ApiClient, sessionId: string): Atta const id = crypto.randomUUID() const contentType = file.type || 'application/octet-stream' + let previewUrl: string | undefined + if (isImageMimeType(contentType) && file.size <= MAX_PREVIEW_BYTES) { + previewUrl = await fileToDataUrl(file) + } + yield { id, type: 'file', name: file.name, contentType, file, - status: { type: 'running', reason: 'uploading', progress: 0 } - } + status: { type: 'running', reason: 'uploading', progress: 0 }, + previewUrl + } as PendingUploadAttachment try { if (cancelledAttachmentIds.has(id)) { @@ -67,8 +73,9 @@ export function createAttachmentAdapter(api: ApiClient, sessionId: string): Atta name: file.name, contentType, file, - status: { type: 'running', reason: 'uploading', progress: 50 } - } + status: { type: 'running', reason: 'uploading', progress: 50 }, + previewUrl + } as PendingUploadAttachment const result = await api.uploadFile(sessionId, file.name, content, contentType) if (cancelledAttachmentIds.has(id)) { @@ -90,12 +97,6 @@ export function createAttachmentAdapter(api: ApiClient, sessionId: string): Atta return } - // Generate preview URL for images under 5MB - let previewUrl: string | undefined - if (isImageMimeType(contentType) && file.size <= MAX_PREVIEW_BYTES) { - previewUrl = await fileToDataUrl(file) - } - yield { id, type: 'file',