Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion web/src/components/AssistantChat/AttachmentItem.tsx
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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<string, unknown>).previewUrl as string | undefined
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] isImage now depends on previewUrl, but previewUrl is only added after the adapter switches the attachment to requires-action (web/src/lib/attachmentAdapter.ts:93-107). During both running yields there is still no preview URL, so this new thumbnail + spinner state never appears while the upload is in progress.

Suggested fix:

const previewUrl =
    isImageMimeType(contentType) && file.size <= MAX_PREVIEW_BYTES
        ? await fileToDataUrl(file)
        : undefined

yield {
    id,
    type: 'file',
    name: file.name,
    contentType,
    file,
    status: { type: 'running', reason: 'uploading', progress: 0 },
    previewUrl
}

const isImage = typeof previewUrl === 'string' && previewUrl.length > 0

if (isImage && !isError) {
return (
<>
<AttachmentPrimitive.Root className="group relative overflow-hidden rounded-lg">
<img
src={previewUrl}
alt={name}
className="h-16 max-w-[120px] cursor-pointer rounded-lg object-cover transition-opacity hover:opacity-80"
onClick={() => setLightboxOpen(true)}
/>
{isUploading && (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-black/40">
<Spinner size="sm" label={null} className="text-white" />
</div>
)}
<AttachmentPrimitive.Remove
className="absolute -right-1 -top-1 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-black/60 text-white/80 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-black/80 hover:text-white"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] group-hover:opacity-100 makes the new remove affordance hover-only, so on touch devices the image chip has no visible undo action before send. That is a regression for the PWA/phone flow this composer is used in.

Suggested fix:

<AttachmentPrimitive.Remove
    className="absolute right-1 top-1 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-black/60 text-white/80 opacity-100 transition-opacity group-focus-within:opacity-100 md:opacity-0 md:group-hover:opacity-100 hover:bg-black/80 hover:text-white"
    aria-label="Remove attachment"
    title="Remove attachment"
>

aria-label="Remove attachment"
title="Remove attachment"
>
<RemoveIcon />
</AttachmentPrimitive.Remove>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent px-1.5 py-1">
<span className="text-[10px] leading-tight text-white/90 line-clamp-1">{name}</span>
</div>
</AttachmentPrimitive.Root>
<ImageLightbox src={previewUrl} alt={name} open={lightboxOpen} onClose={() => setLightboxOpen(false)} />
</>
)
}

return (
<AttachmentPrimitive.Root className="flex items-center gap-2 rounded-lg bg-[var(--app-subtle-bg)] px-3 py-2 text-base text-[var(--app-fg)]">
Expand Down
30 changes: 19 additions & 11 deletions web/src/components/AssistantChat/messages/MessageAttachments.tsx
Original file line number Diff line number Diff line change
@@ -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`
Expand All @@ -10,19 +12,25 @@ function formatFileSize(bytes: number): string {

function ImageAttachment(props: { attachment: AttachmentMetadata }) {
const { attachment } = props
const [open, setOpen] = useState(false)
return (
<div className="relative overflow-hidden rounded-lg">
<img
src={attachment.previewUrl}
alt={attachment.filename}
className="max-h-48 max-w-full object-contain"
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent px-2 py-1.5">
<span className="text-xs text-white/90 line-clamp-1">
{attachment.filename}
</span>
<>
<div className="relative cursor-pointer overflow-hidden rounded-lg" onClick={() => setOpen(true)}>
<img
src={attachment.previewUrl}
alt={attachment.filename}
className="max-h-48 max-w-full object-contain transition-opacity hover:opacity-80"
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent px-2 py-1.5">
<span className="text-xs text-white/90 line-clamp-1">
{attachment.filename}
</span>
</div>
</div>
</div>
{attachment.previewUrl && (
<ImageLightbox src={attachment.previewUrl} alt={attachment.filename} open={open} onClose={() => setOpen(false)} />
)}
</>
)
}

Expand Down
100 changes: 100 additions & 0 deletions web/src/components/ImageLightbox.tsx
Original file line number Diff line number Diff line change
@@ -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(
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/90"
onClick={onClose}
>
{/* Top-right buttons */}
<div className="fixed right-4 top-4 z-[101] flex items-center gap-2">
<button
className="rounded-lg bg-white/10 p-2 text-white/80 backdrop-blur-sm transition-colors hover:bg-white/20 hover:text-white"
title="在新标签页打开"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] These new tooltip strings bypass the existing locale tables, so English sessions now show Chinese-only UI here.

Suggested fix:

import { useTranslation } from '@/lib/use-translation'

const { t } = useTranslation()

<button title={t('imageLightbox.openInNewTab')} aria-label={t('imageLightbox.openInNewTab')} />
<button title={t('button.close')} aria-label={t('button.close')} />

onClick={(e) => {
e.stopPropagation()
window.open(src, '_blank', 'noopener,noreferrer')
}}
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
</button>
<button
className="rounded-lg bg-white/10 p-2 text-white/80 backdrop-blur-sm transition-colors hover:bg-white/20 hover:text-white"
title="关闭"
onClick={(e) => {
e.stopPropagation()
onClose()
}}
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>

{/* Image */}
<img
src={src}
alt={alt ?? 'Preview'}
className="max-h-[90vh] max-w-[90vw] rounded object-contain"
onClick={(e) => e.stopPropagation()}
/>
</div>,
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 ? (
<ImageLightbox
src={lightbox.src}
alt={lightbox.alt}
open={true}
onClose={closeLightbox}
/>
) : null

return { openLightbox, LightboxPortal }
}
109 changes: 109 additions & 0 deletions web/src/components/ToolCard/views/_results.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { useState } from 'react'
import type { ToolViewComponent, ToolViewProps } from '@/components/ToolCard/views/_all'
import { isObject, safeStringify } from '@hapi/protocol'
import { CodeBlock } from '@/components/CodeBlock'
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>(.*?)<\/tool_use_error>/s
Expand All @@ -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 (
<>
<img
src={image.dataUrl}
alt="Tool result image"
className="max-h-48 max-w-full cursor-pointer rounded-lg object-contain transition-opacity hover:opacity-80"
onClick={() => setOpen(true)}
/>
<ImageLightbox src={image.dataUrl} alt="Tool result image" open={open} onClose={() => setOpen(false)} />
</>
)
}

function ResultImages({ result }: { result: unknown }) {
const images = extractImagesFromResult(result)
if (images.length === 0) return null
return (
<div className="mt-2 flex flex-wrap gap-2">
{images.map((img, i) => (
<InlineImage key={i} image={img} />
))}
</div>
)
}

export function extractTextFromResult(result: unknown, depth: number = 0): string | null {
if (depth > 2) return null
if (result === null || result === undefined) return null
Expand Down Expand Up @@ -249,6 +311,7 @@ const BashResultView: ToolViewComponent = (props: ToolViewProps) => {
{stdio.stdout ? <CodeBlock code={stdio.stdout} language="text" /> : null}
{stdio.stderr ? <CodeBlock code={stdio.stderr} language="text" /> : null}
</div>
<ResultImages result={result} />
<RawJsonDevOnly value={result} />
</>
)
Expand All @@ -259,6 +322,17 @@ const BashResultView: ToolViewComponent = (props: ToolViewProps) => {
return (
<>
{renderText(text, { mode: 'code', language: 'text' })}
<ResultImages result={result} />
<RawJsonDevOnly value={result} />
</>
)
}

const images = extractImagesFromResult(result)
if (images.length > 0) {
return (
<>
<ResultImages result={result} />
<RawJsonDevOnly value={result} />
</>
)
Expand All @@ -284,6 +358,17 @@ const MarkdownResultView: ToolViewComponent = (props: ToolViewProps) => {
return (
<>
{renderText(text, { mode: 'auto' })}
<ResultImages result={result} />
<RawJsonDevOnly value={result} />
</>
)
}

const images = extractImagesFromResult(result)
if (images.length > 0) {
return (
<>
<ResultImages result={result} />
<RawJsonDevOnly value={result} />
</>
)
Expand Down Expand Up @@ -354,6 +439,8 @@ const ReadResultView: ToolViewComponent = (props: ToolViewProps) => {
return <div className="text-sm text-[var(--app-hint)]">{placeholderForState(props.block.tool.state)}</div>
}

const images = extractImagesFromResult(result)

const file = extractReadFileContent(result)
if (file) {
const path = file.filePath ? resolveDisplayPath(file.filePath, props.metadata) : null
Expand All @@ -365,6 +452,16 @@ const ReadResultView: ToolViewComponent = (props: ToolViewProps) => {
</div>
) : null}
<CodeBlock code={file.content} language="text" />
{images.length > 0 ? <ResultImages result={result} /> : null}
<RawJsonDevOnly value={result} />
</>
)
}

if (images.length > 0) {
return (
<>
<ResultImages result={result} />
<RawJsonDevOnly value={result} />
</>
)
Expand Down Expand Up @@ -595,6 +692,7 @@ const GenericResultView: ToolViewComponent = (props: ToolViewProps) => {
{parsed.wallTime && `Wall time: ${parsed.wallTime}`}
</div>
{renderText(parsed.output.trim(), { mode: 'code' })}
<ResultImages result={result} />
<RawJsonDevOnly value={result} />
</>
)
Expand All @@ -606,11 +704,22 @@ const GenericResultView: ToolViewComponent = (props: ToolViewProps) => {
return (
<>
{renderText(text, { mode: 'auto' })}
<ResultImages result={result} />
{typeof result === 'object' ? <RawJsonDevOnly value={result} /> : null}
</>
)
}

const images = extractImagesFromResult(result)
if (images.length > 0) {
return (
<>
<ResultImages result={result} />
<RawJsonDevOnly value={result} />
</>
)
}

if (typeof result === 'string') {
return renderText(result, { mode: 'auto' })
}
Expand Down
Loading
Loading