diff --git a/desktop/src/components/chat/AttachmentGallery.test.tsx b/desktop/src/components/chat/AttachmentGallery.test.tsx index d435074c..1a1d22cf 100644 --- a/desktop/src/components/chat/AttachmentGallery.test.tsx +++ b/desktop/src/components/chat/AttachmentGallery.test.tsx @@ -1,14 +1,43 @@ // @vitest-environment jsdom import '@testing-library/jest-dom' -import { fireEvent, render } from '@testing-library/react' +import { fireEvent, render, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { browserHost } from '../../lib/desktopHost/browserHost' +import { useOpenTargetStore } from '../../stores/openTargetStore' import { useSettingsStore } from '../../stores/settingsStore' +import { useUIStore } from '../../stores/uiStore' import { AttachmentGallery } from './AttachmentGallery' describe('AttachmentGallery', () => { + const openPath = vi.fn().mockResolvedValue(undefined) + beforeEach(() => { + vi.clearAllMocks() useSettingsStore.setState({ locale: 'en' }) + useUIStore.setState({ toasts: [] }) + useOpenTargetStore.setState({ + targets: [ + { id: 'code', kind: 'ide', label: 'VS Code', icon: 'vscode', platform: 'darwin' }, + { id: 'finder', kind: 'file_manager', label: 'Finder', icon: 'finder', platform: 'darwin' }, + ], + fetchedAt: Date.now(), + loading: false, + error: null, + }) + window.desktopHost = { + ...browserHost, + kind: 'electron', + isDesktop: true, + capabilities: { + ...browserHost.capabilities, + shell: true, + }, + shell: { + ...browserHost.shell, + openPath, + }, + } }) it('renders diff comments as note-first composer cards with side-aware locations', () => { @@ -76,6 +105,114 @@ describe('AttachmentGallery', () => { expect(document.body.textContent).not.toContain(':L') }) + it.each([ + ['report.pdf', 'PDF', 'picture_as_pdf'], + ['brief.docx', 'DOCX', 'docs'], + ['budget.xlsx', 'XLSX', 'table_chart'], + ['launch.pptx', 'PPTX', 'slideshow'], + ['sources.zip', 'ZIP', 'folder_zip'], + ['notes.md', 'MD', 'markdown'], + ])('renders a type-specific visual for %s', (name, extension, icon) => { + const view = render( + , + ) + + expect(view.container.querySelector(`[data-file-extension="${extension}"]`)).toBeInTheDocument() + expect(view.getByText(icon)).toBeInTheDocument() + }) + + it('opens an absolute desktop attachment with the system default app', async () => { + const path = '/Users/example/Desktop/report.pdf' + const view = render( + , + ) + + fireEvent.click(view.getByRole('button', { name: 'Open report.pdf' })) + + await waitFor(() => expect(openPath).toHaveBeenCalledWith(path)) + }) + + it('keeps relative and pathless attachments non-interactive', () => { + const view = render( + , + ) + + expect(view.queryByRole('button', { name: 'Open relative.md' })).not.toBeInTheDocument() + expect(view.queryByRole('button', { name: 'Open detached.pdf' })).not.toBeInTheDocument() + expect(openPath).not.toHaveBeenCalled() + }) + + it('keeps absolute attachments non-interactive outside the desktop runtime', () => { + window.desktopHost = browserHost + const view = render( + , + ) + + expect(view.queryByRole('button', { name: 'Open report.pdf' })).not.toBeInTheDocument() + expect(view.queryByRole('button', { name: 'Open with' })).not.toBeInTheDocument() + }) + + it('shows a localized toast when the local file cannot be opened', async () => { + openPath.mockRejectedValueOnce(new Error('missing')) + const view = render( + , + ) + + fireEvent.click(view.getByRole('button', { name: 'Open missing.pdf' })) + + await waitFor(() => { + expect(useUIStore.getState().toasts.at(-1)?.message).toBe( + 'Could not open missing.pdf. The file may have been moved or deleted.', + ) + }) + }) + + it('reuses the Open With menu for IDE and file-manager destinations', async () => { + const view = render( + , + ) + + fireEvent.click(view.getByRole('button', { name: 'Open with' })) + + expect(await view.findByText('Open in VS Code')).toBeInTheDocument() + expect(view.getByText('Reveal in Finder')).toBeInTheDocument() + expect(openPath).not.toHaveBeenCalled() + }) + it('removes a quoted workspace attachment by id', () => { const onRemove = vi.fn() diff --git a/desktop/src/components/chat/AttachmentGallery.tsx b/desktop/src/components/chat/AttachmentGallery.tsx index 721ca2bf..756820c0 100644 --- a/desktop/src/components/chat/AttachmentGallery.tsx +++ b/desktop/src/components/chat/AttachmentGallery.tsx @@ -1,6 +1,13 @@ import { useMemo, useState } from 'react' -import { MessageSquare, X } from 'lucide-react' -import { useTranslation } from '../../i18n' +import type { MouseEvent as ReactMouseEvent } from 'react' +import { ChevronDown, MessageSquare, X } from 'lucide-react' +import { useTranslation, type TranslationKey } from '../../i18n' +import { getDesktopHost } from '../../lib/desktopHost' +import { isAbsoluteLocalPath } from '../../lib/handlePreviewLink' +import { buildOpenWithItems, describeFileType, type OpenWithItem } from '../../lib/openWithItems' +import { useOpenTargetStore } from '../../stores/openTargetStore' +import { useUIStore } from '../../stores/uiStore' +import { OpenWithMenu } from '../common/OpenWithMenu' import { ImageGalleryModal } from './ImageGalleryModal' export type AttachmentPreview = { @@ -9,6 +16,7 @@ export type AttachmentPreview = { name: string path?: string data?: string + mimeType?: string previewUrl?: string isDirectory?: boolean lineStart?: number @@ -19,6 +27,27 @@ export type AttachmentPreview = { quote?: string } +const FILE_ICON_ACCENTS: Record = { + picture_as_pdf: '#d14343', + docs: '#3f6ecf', + markdown: '#4d6b8a', + text_snippet: '#667085', + table_chart: '#24845b', + slideshow: '#c85b2b', + folder_zip: '#a46a17', + code: '#7656b5', + audio_file: '#ad477c', + video_file: '#6655b8', + html: '#c05d2c', + image: '#24899a', + folder: '#a46a17', + insert_drive_file: '#667085', +} + +function fileIconAccent(icon: string): string { + return FILE_ICON_ACCENTS[icon] ?? FILE_ICON_ACCENTS.insert_drive_file! +} + type Props = { attachments: AttachmentPreview[] variant?: 'composer' | 'message' @@ -28,6 +57,8 @@ type Props = { export function AttachmentGallery({ attachments, variant = 'message', onRemove }: Props) { const t = useTranslation() const [activeImageIndex, setActiveImageIndex] = useState(null) + const [openWith, setOpenWith] = useState<{ items: OpenWithItem[]; anchor: DOMRect; triggerEl: HTMLElement } | null>(null) + const desktopHost = getDesktopHost() const images = useMemo( () => @@ -44,6 +75,53 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove } const isComposer = variant === 'composer' + const showOpenFailure = (name: string) => { + useUIStore.getState().addToast({ + type: 'error', + message: t('attachments.openFailed', { name }), + }) + } + + const openLocalAttachment = (attachment: AttachmentPreview) => { + if (!attachment.path) return + void desktopHost.shell.openPath(attachment.path).catch(() => showOpenFailure(attachment.name)) + } + + const openAttachmentWith = ( + event: ReactMouseEvent, + attachment: AttachmentPreview, + ) => { + event.stopPropagation() + if (!attachment.path) return + if (openWith) { + setOpenWith(null) + return + } + + const triggerEl = event.currentTarget + const anchor = triggerEl.getBoundingClientRect() + void (async () => { + await useOpenTargetStore.getState().ensureTargets() + const items = buildOpenWithItems( + { kind: 'file', absolutePath: attachment.path! }, + useOpenTargetStore.getState().targets, + { + openInAppBrowser: () => {}, + openSystem: (path) => { + void desktopHost.shell.openPath(path).catch(() => showOpenFailure(attachment.name)) + }, + openWorkspacePreview: () => {}, + openTarget: (targetId, path) => { + void useOpenTargetStore.getState().openTarget(targetId, path) + .catch(() => showOpenFailure(attachment.name)) + }, + t: (key, vars) => t(key as TranslationKey, vars), + }, + ) + if (items.length > 0) setOpenWith({ items, anchor, triggerEl }) + })() + } + return ( <>
@@ -190,31 +268,85 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove } : '' const quotePreview = attachment.quote?.trim().replace(/\s+/g, ' ') const hasQuotePreview = !!quotePreview + const typeInfo = describeFileType(attachment.path || attachment.name) + const fileIcon = attachment.isDirectory ? 'folder' : typeInfo.icon + const typeLabel = attachment.isDirectory + ? t('openWith.fileType.file') + : typeInfo.ext || t(typeInfo.categoryKey as TranslationKey) + const canOpenLocally = + !isComposer && + !!attachment.path && + isAbsoluteLocalPath(attachment.path) && + desktopHost.isDesktop && + desktopHost.capabilities.shell - return ( -
- - {hasQuotePreview ? 'chat_bubble' : attachment.isDirectory ? 'folder' : 'description'} + const fileVisual = ( + <> + - - + + {attachment.name}{lineLabel} + + {typeLabel} + {hasQuotePreview && ( {quotePreview} )} + + ) + + if (canOpenLocally) return ( +
+ + +
+ ) + + return ( +
+ {fileVisual} {onRemove && attachment.id && (