mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Merge pull request #73073 from NousResearch/bb/composer-image-lightbox
fix(desktop): open a composer image attachment in the lightbox, not the rail
This commit is contained in:
commit
ea0775eb8b
2 changed files with 135 additions and 72 deletions
|
|
@ -1,11 +1,14 @@
|
|||
import { act, cleanup, render, screen } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { I18nProvider } from '@/i18n/context'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import { $previewTabs } from '@/store/preview'
|
||||
|
||||
import { AttachmentList } from './attachments'
|
||||
|
||||
const DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANS'
|
||||
|
||||
function makeAttachment(id: string, label = 'test.pdf'): ComposerAttachment {
|
||||
return { id, kind: 'file', label }
|
||||
}
|
||||
|
|
@ -66,4 +69,43 @@ describe('AttachmentList', () => {
|
|||
|
||||
expect(screen.getByText('valid.txt')).toBeDefined()
|
||||
})
|
||||
|
||||
it('opens an attached image in the lightbox, not the preview rail', async () => {
|
||||
$previewTabs.set([])
|
||||
|
||||
const image: ComposerAttachment = {
|
||||
id: 'img',
|
||||
kind: 'image',
|
||||
label: 'shot.png',
|
||||
path: '/tmp/shot.png',
|
||||
previewUrl: DATA_URL
|
||||
}
|
||||
|
||||
await renderWithI18n(<AttachmentList attachments={[image]} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /shot\.png/ }))
|
||||
})
|
||||
|
||||
// The lightbox renders the full-size image in a dialog; the rail stays empty.
|
||||
const lightbox = await screen.findByRole('dialog')
|
||||
|
||||
expect(lightbox.querySelector<HTMLImageElement>('img')?.src).toBe(DATA_URL)
|
||||
expect($previewTabs.get()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('still routes a non-image attachment to the preview rail', async () => {
|
||||
$previewTabs.set([])
|
||||
|
||||
const file: ComposerAttachment = { id: 'doc', kind: 'file', label: 'notes.md', path: '/tmp/notes.md' }
|
||||
|
||||
await renderWithI18n(<AttachmentList attachments={[file]} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /notes\.md/ }))
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect($previewTabs.get().map(tab => tab.target.path)).toEqual(['/tmp/notes.md'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useSessionView } from '@/app/chat/session-view'
|
||||
import { ImageLightbox } from '@/components/chat/zoomable-image'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useImageDownload } from '@/hooks/use-image-download'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { AlertCircle, FileText, FolderOpen, ImageIcon, Link, Loader2, Terminal } from '@/lib/icons'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
|
|
@ -38,12 +41,25 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
|||
const hasUploadError = attachment.uploadState === 'error'
|
||||
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal' && !isUploading
|
||||
const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined
|
||||
// An attached image already holds its full bytes as a data URL, so it belongs
|
||||
// in the same lightbox the thread uses. The rail is for files you read or
|
||||
// edit — not a picture you just want to look at. Images that never resolved a
|
||||
// thumbnail still fall through to the rail rather than dead-clicking.
|
||||
const lightboxSrc = attachment.kind === 'image' && !isUploading ? attachment.previewUrl : undefined
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false)
|
||||
const { download, saving } = useImageDownload(lightboxSrc)
|
||||
|
||||
async function openAttachmentPreview() {
|
||||
async function openAttachment() {
|
||||
if (!canPreview) {
|
||||
return
|
||||
}
|
||||
|
||||
if (lightboxSrc) {
|
||||
setLightboxOpen(true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const rawTarget =
|
||||
attachment.path ||
|
||||
attachment.detail ||
|
||||
|
|
@ -64,85 +80,90 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
|||
throw new Error(c.couldNotPreview(attachment.label))
|
||||
}
|
||||
|
||||
// We already hold the image bytes (the card thumbnail) — render those
|
||||
// directly so a screenshot/clipboard image previews even when its only
|
||||
// on-disk copy is a transient path the renderer can't re-read.
|
||||
const withBytes =
|
||||
attachment.kind === 'image' && attachment.previewUrl
|
||||
? { ...preview, dataUrl: attachment.previewUrl, previewKind: 'image' as const }
|
||||
: preview
|
||||
|
||||
openPreview(withBytes, 'manual')
|
||||
openPreview(preview, 'manual')
|
||||
} catch (error) {
|
||||
notifyError(error, c.previewUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tip label={attachment.path || attachment.detail || attachment.label}>
|
||||
<div className="group/attachment relative min-w-0 shrink-0">
|
||||
<button
|
||||
aria-busy={isUploading || undefined}
|
||||
aria-label={canPreview ? c.previewLabel(attachment.label) : attachment.label}
|
||||
className={cn(
|
||||
'flex max-w-56 items-center gap-2 rounded-2xl border bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.18)] transition-colors disabled:cursor-default',
|
||||
hasUploadError
|
||||
? 'border-destructive/45 hover:border-destructive/60'
|
||||
: 'border-border/60 hover:border-primary/35 hover:bg-accent/45'
|
||||
)}
|
||||
disabled={!canPreview}
|
||||
onClick={() => void openAttachmentPreview()}
|
||||
type="button"
|
||||
>
|
||||
<span className="relative grid size-8 shrink-0 place-items-center overflow-hidden rounded-lg border border-border/55 bg-muted/35 text-muted-foreground">
|
||||
{attachment.previewUrl && attachment.kind === 'image' ? (
|
||||
<img
|
||||
alt={attachment.label}
|
||||
className="size-full object-cover"
|
||||
draggable={false}
|
||||
src={attachment.previewUrl}
|
||||
/>
|
||||
) : (
|
||||
<Icon className="size-3.5" />
|
||||
)}
|
||||
{isUploading && (
|
||||
<span className="absolute inset-0 grid place-items-center bg-background/60 backdrop-blur-[1px]">
|
||||
<Loader2 className="size-3.5 animate-spin text-foreground/75" />
|
||||
</span>
|
||||
)}
|
||||
{hasUploadError && (
|
||||
<span className="absolute inset-0 grid place-items-center bg-destructive/15">
|
||||
<AlertCircle className="size-3.5 text-destructive" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">
|
||||
{attachment.label}
|
||||
</span>
|
||||
{detail && (
|
||||
<span
|
||||
className={cn(
|
||||
'block truncate text-[0.62rem] leading-3.5',
|
||||
hasUploadError ? 'text-destructive/80' : 'text-muted-foreground/65'
|
||||
)}
|
||||
>
|
||||
{detail}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
{onRemove && (
|
||||
<>
|
||||
<Tip label={attachment.path || attachment.detail || attachment.label}>
|
||||
<div className="group/attachment relative min-w-0 shrink-0">
|
||||
<button
|
||||
aria-label={c.removeAttachment(attachment.label)}
|
||||
className="absolute -right-1 -top-1 grid size-3.5 place-items-center rounded-full border border-border/70 bg-background text-muted-foreground opacity-0 shadow-xs transition hover:bg-accent hover:text-foreground group-hover/attachment:opacity-100 focus-visible:opacity-100"
|
||||
onClick={() => onRemove(attachment.id)}
|
||||
aria-busy={isUploading || undefined}
|
||||
aria-label={canPreview ? c.previewLabel(attachment.label) : attachment.label}
|
||||
className={cn(
|
||||
'flex max-w-56 items-center gap-2 rounded-2xl border bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.18)] transition-colors disabled:cursor-default',
|
||||
hasUploadError
|
||||
? 'border-destructive/45 hover:border-destructive/60'
|
||||
: 'border-border/60 hover:border-primary/35 hover:bg-accent/45'
|
||||
)}
|
||||
disabled={!canPreview}
|
||||
onClick={() => void openAttachment()}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="close" size="0.625rem" />
|
||||
<span className="relative grid size-8 shrink-0 place-items-center overflow-hidden rounded-lg border border-border/55 bg-muted/35 text-muted-foreground">
|
||||
{attachment.previewUrl && attachment.kind === 'image' ? (
|
||||
<img
|
||||
alt={attachment.label}
|
||||
className="size-full object-cover"
|
||||
draggable={false}
|
||||
src={attachment.previewUrl}
|
||||
/>
|
||||
) : (
|
||||
<Icon className="size-3.5" />
|
||||
)}
|
||||
{isUploading && (
|
||||
<span className="absolute inset-0 grid place-items-center bg-background/60 backdrop-blur-[1px]">
|
||||
<Loader2 className="size-3.5 animate-spin text-foreground/75" />
|
||||
</span>
|
||||
)}
|
||||
{hasUploadError && (
|
||||
<span className="absolute inset-0 grid place-items-center bg-destructive/15">
|
||||
<AlertCircle className="size-3.5 text-destructive" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">
|
||||
{attachment.label}
|
||||
</span>
|
||||
{detail && (
|
||||
<span
|
||||
className={cn(
|
||||
'block truncate text-[0.62rem] leading-3.5',
|
||||
hasUploadError ? 'text-destructive/80' : 'text-muted-foreground/65'
|
||||
)}
|
||||
>
|
||||
{detail}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Tip>
|
||||
{onRemove && (
|
||||
<button
|
||||
aria-label={c.removeAttachment(attachment.label)}
|
||||
className="absolute -right-1 -top-1 grid size-3.5 place-items-center rounded-full border border-border/70 bg-background text-muted-foreground opacity-0 shadow-xs transition hover:bg-accent hover:text-foreground group-hover/attachment:opacity-100 focus-visible:opacity-100"
|
||||
onClick={() => onRemove(attachment.id)}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="close" size="0.625rem" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Tip>
|
||||
{lightboxSrc && (
|
||||
<ImageLightbox
|
||||
alt={attachment.label}
|
||||
copy={t.desktop}
|
||||
onClick={download}
|
||||
onOpenChange={setLightboxOpen}
|
||||
open={lightboxOpen}
|
||||
saving={saving}
|
||||
src={lightboxSrc}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue