diff --git a/apps/desktop/src/app/chat/composer/attachments.test.tsx b/apps/desktop/src/app/chat/composer/attachments.test.tsx
index 52e8f6bf98e..b405b011d62 100644
--- a/apps/desktop/src/app/chat/composer/attachments.test.tsx
+++ b/apps/desktop/src/app/chat/composer/attachments.test.tsx
@@ -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()
+
+ 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('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()
+
+ 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'])
+ })
})
diff --git a/apps/desktop/src/app/chat/composer/attachments.tsx b/apps/desktop/src/app/chat/composer/attachments.tsx
index f50f74be668..cb42dc9f1c9 100644
--- a/apps/desktop/src/app/chat/composer/attachments.tsx
+++ b/apps/desktop/src/app/chat/composer/attachments.tsx
@@ -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 (
-
-
-
- {onRemove && (
+ <>
+
+
- )}
-
-
+ {onRemove && (
+
+ )}
+
+
+ {lightboxSrc && (
+
+ )}
+ >
)
}