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}
-
+ <>
+
setOpen(true)}>
+

+
+
+ {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..4fa1e8a8b
--- /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 */}
+

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 (
+ <>
+
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' })
}