fix(desktop): open a composer image attachment in the lightbox, not the rail

Clicking an attached image routed it through normalizeOrLocalPreviewTarget and
into the right rail, where it rendered as a small contained <img> inside a
pane built for reading and editing files. The bytes were already in hand as a
data URL, so the rail's whole read/edit/reload apparatus was doing nothing for
a picture the user just wanted to look at.

Route images with a resolved thumbnail to ImageLightbox — the same overlay the
thread uses — so the attachment previews at full size with the download action,
and drop the dataUrl/previewKind graft that only existed to make the rail
render bytes it shouldn't have been handed. Non-image attachments, and images
whose thumbnail never resolved, still fall through to the rail unchanged.
This commit is contained in:
Brooklyn Nicholson 2026-07-27 22:00:31 -05:00
parent 84858d76ba
commit d8cb73b4ab

View file

@ -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}
/>
)}
</>
)
}