diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.test.tsx new file mode 100644 index 00000000000..d030eb456da --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.test.tsx @@ -0,0 +1,83 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { AttachedFilesList } from '@/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list' +import type { AttachedFile } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments' + +function file(overrides: Partial): AttachedFile { + return { + id: 'f1', + name: 'report.pdf', + size: 1024, + type: 'application/pdf', + path: '', + uploading: false, + ...overrides, + } +} + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +function render(files: AttachedFile[]) { + act(() => { + root.render( + {}} onRemoveFile={() => {}} /> + ) + }) +} + +describe('AttachedFilesList', () => { + it('renders a document as a labelled card showing the filename', () => { + render([file({})]) + + expect(container.textContent).toContain('report.pdf') + expect(container.querySelector('img')).toBeNull() + }) + + it('renders an image with a preview as a thumbnail, not a filename card', () => { + render([file({ name: 'photo.png', type: 'image/png', previewUrl: 'blob:xyz' })]) + + expect(container.querySelector('img')?.getAttribute('src')).toBe('blob:xyz') + expect(container.textContent).not.toContain('photo.png') + }) + + it('keeps a HEIC on the thumbnail shape while it has no preview yet', () => { + // The shape is keyed off the type, not the preview: a HEIC gets its preview only + // once the server derivative exists, and switching shape mid-upload would jump the + // layout. It must not fall back to the document card. + render([file({ name: 'photo.heic', type: 'image/heic' })]) + + expect(container.textContent).not.toContain('photo.heic') + expect(container.querySelector('img')).toBeNull() + }) + + it('drops the image and reveals the type icon when the preview fails to decode', () => { + render([file({ name: 'photo.heic', type: 'image/heic', previewUrl: '/api/files/serve/x' })]) + + const img = container.querySelector('img') + expect(img).not.toBeNull() + + act(() => { + img?.dispatchEvent(new Event('error')) + }) + + expect(container.querySelector('img')).toBeNull() + expect(container.querySelector('svg')).not.toBeNull() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx index f4e01792827..c07cf15ee28 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx @@ -1,97 +1,165 @@ 'use client' -import React from 'react' -import { Loader, Tooltip } from '@sim/emcn' +import React, { useState } from 'react' +import { cn, Loader, Tooltip } from '@sim/emcn' import { X } from '@sim/emcn/icons' import { getDocumentIcon } from '@/components/icons/document-icons' +import { getFileExtension } from '@/lib/uploads/utils/file-utils' import type { AttachedFile } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments' +/** + * Chrome shared by both chip shapes. Both stand 48px tall so a row mixing thumbnails + * and documents sits on one baseline. + * + * Deliberately NOT `chipFilledFillTokens` (`--surface-5` / `dark:--surface-4`): that + * pair assumes a page background, but this chip sits inside the composer, which is + * already `--surface-4` in dark mode — reusing it would make the chip invisible against + * its own container. `--surface-5` steps away from the composer in both themes, and + * hover steps further away in the direction each theme reads as "raised". + */ +const CHIP_SURFACE = + 'relative h-[48px] cursor-pointer rounded-[10px] border border-[var(--border)] bg-[var(--surface-5)] transition-colors hover-hover:bg-[var(--surface-active)] dark:hover-hover:bg-[var(--surface-6)]' + interface AttachedFilesListProps { attachedFiles: AttachedFile[] onFileClick: (file: AttachedFile) => void onRemoveFile: (id: string) => void } -export const AttachedFilesList = React.memo(function AttachedFilesList({ - attachedFiles, +interface AttachedFileChipProps { + file: AttachedFile + onFileClick: (file: AttachedFile) => void + onRemoveFile: (id: string) => void +} + +/** + * One attachment. + * + * Media renders as a thumbnail; everything else renders as a labelled card — icon + * badge, filename, file type. A document has no thumbnail worth showing, and the + * filename is the thing worth reading. + */ +const AttachedFileChip = React.memo(function AttachedFileChip({ + file, onFileClick, onRemoveFile, -}: AttachedFilesListProps) { - if (attachedFiles.length === 0) return null +}: AttachedFileChipProps) { + const Icon = getDocumentIcon(file.type, file.name) + const isVideo = file.type.startsWith('video/') + // Keyed off the type, not the presence of a preview: a HEIC has no preview until its + // upload finishes, and flipping shape mid-upload would jump the layout. + const isMedia = isVideo || file.type.startsWith('image/') + const extension = getFileExtension(file.name) + const [previewFailed, setPreviewFailed] = useState(false) return ( -
- {attachedFiles.map((file) => { - const isVideo = file.type.startsWith('video/') - const hasPreview = Boolean(file.previewUrl) - return ( - -
- - - - {!file.uploading && ( - - )} -
- -

{file.name}

-
-
- ) - })} + + + )} + {file.uploading && ( + + + + )} + + + {!file.uploading && ( + + )} +
+ +

{file.name}

+
+ + ) +}) + +export const AttachedFilesList = React.memo(function AttachedFilesList({ + attachedFiles, + onFileClick, + onRemoveFile, +}: AttachedFilesListProps) { + if (attachedFiles.length === 0) return null + + return ( +
+ {attachedFiles.map((file) => ( + + ))}
) }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts index 4c40839d27b..52e7064cdc8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts @@ -5,12 +5,39 @@ import { toast } from '@sim/emcn' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { getMothershipAttachmentPreviewUrl } from '@/lib/copilot/chat/attachment-preview' import { uploadViaApiFallback } from '@/lib/uploads/client/api-fallback' import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload' import { resolveFileType } from '@/lib/uploads/utils/file-utils' const logger = createLogger('useFileAttachments') +/** + * Image formats no browser decodes in an ``. A blob URL of these bytes renders + * broken, so their preview has to come from the serve route, which substitutes a + * JPEG derivative once the file is uploaded. + * + * Matched as prefixes so the `-sequence` variants Safari reports for Live Photos and + * burst captures are covered too. The server decides for real by sniffing the + * container — see `isHevcHeifContainer` in `@/lib/uploads/server/heic`; keep the two + * in step when adding a format. + */ +const SERVER_RENDERED_IMAGE_PREFIXES = ['image/heic', 'image/heif'] as const + +/** Whether the browser can decode these bytes itself, making a blob URL worth creating. */ +function rendersFromBlobUrl(mediaType: string): boolean { + if (SERVER_RENDERED_IMAGE_PREFIXES.some((prefix) => mediaType.startsWith(prefix))) return false + return mediaType.startsWith('image/') || mediaType.startsWith('video/') +} + +/** + * Revokes a preview URL only when it is one we minted. A preview can also be a serve + * URL, which owns no object-URL handle. + */ +function revokePreviewUrl(previewUrl?: string): void { + if (previewUrl?.startsWith('blob:')) URL.revokeObjectURL(previewUrl) +} + /** * File size units for formatting */ @@ -68,16 +95,22 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { const [dragCounter, setDragCounter] = useState(0) const fileInputRef = useRef(null) + /** + * Mirrors the current attachments so the unmount cleanup below can reach them. The + * cleanup runs once, so reading state directly would close over the empty first + * render and revoke nothing. + */ + const attachedFilesRef = useRef(attachedFiles) + useEffect(() => { + attachedFilesRef.current = attachedFiles + }, [attachedFiles]) + /** * Cleanup preview URLs on unmount */ useEffect(() => { return () => { - attachedFiles.forEach((f) => { - if (f.previewUrl) { - URL.revokeObjectURL(f.previewUrl) - } - }) + attachedFilesRef.current.forEach((f) => revokePreviewUrl(f.previewUrl)) } }, []) @@ -125,18 +158,20 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { const files = Array.from(fileList) if (files.length === 0) return - const placeholders: AttachedFile[] = files.map((file) => ({ - id: generateId(), - name: file.name, - size: file.size, - type: resolveFileType(file), - path: '', - uploading: true, - previewUrl: - file.type.startsWith('image/') || file.type.startsWith('video/') - ? URL.createObjectURL(file) - : undefined, - })) + const placeholders: AttachedFile[] = files.map((file) => { + // Resolve once: the browser reports `application/octet-stream` (or nothing) for + // plenty of files, and both the chip and the preview decision key off the type. + const type = resolveFileType(file) + return { + id: generateId(), + name: file.name, + size: file.size, + type, + path: '', + uploading: true, + previewUrl: rendersFromBlobUrl(type) ? URL.createObjectURL(file) : undefined, + } + }) setAttachedFiles((prev) => [...prev, ...placeholders]) @@ -171,7 +206,22 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { setAttachedFiles((prev) => prev.map((f) => f.id === placeholder.id - ? { ...f, path: result.path, key: result.key, uploading: false } + ? { + ...f, + path: result.path, + key: result.key, + uploading: false, + // A format the browser cannot decode has no local preview; now that + // the bytes are stored, the serve route can hand back a renderable + // derivative. Anything already previewing keeps its blob URL rather + // than paying a round trip for a thumbnail it can draw locally. + previewUrl: + f.previewUrl ?? + getMothershipAttachmentPreviewUrl({ + key: result.key, + media_type: f.type, + }), + } : f ) ) @@ -180,7 +230,7 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { toast.error(`Couldn't upload "${file.name}"`, { description: toError(error).message, }) - if (placeholder.previewUrl) URL.revokeObjectURL(placeholder.previewUrl) + revokePreviewUrl(placeholder.previewUrl) setAttachedFiles((prev) => prev.filter((f) => f.id !== placeholder.id)) } }) @@ -220,16 +270,10 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { * Removes a file from attachments * @param fileId - ID of the file to remove */ - const removeFile = useCallback( - (fileId: string) => { - const file = attachedFiles.find((f) => f.id === fileId) - if (file?.previewUrl) { - URL.revokeObjectURL(file.previewUrl) - } - setAttachedFiles((prev) => prev.filter((f) => f.id !== fileId)) - }, - [attachedFiles] - ) + const removeFile = useCallback((fileId: string) => { + revokePreviewUrl(attachedFilesRef.current.find((f) => f.id === fileId)?.previewUrl) + setAttachedFiles((prev) => prev.filter((f) => f.id !== fileId)) + }, []) /** * Opens file in new tab (for preview) @@ -303,25 +347,19 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { * Clears all attached files and cleanup preview URLs */ const clearAttachedFiles = useCallback(() => { - attachedFiles.forEach((f) => { - if (f.previewUrl) { - URL.revokeObjectURL(f.previewUrl) - } - }) + attachedFilesRef.current.forEach((f) => revokePreviewUrl(f.previewUrl)) setAttachedFiles([]) - }, [attachedFiles]) + }, []) /** * Replaces the current attached files with a given set. * Cleans up preview URLs from the prior set before replacing. */ const restoreAttachedFiles = useCallback((files: AttachedFile[]) => { - setAttachedFiles((prev) => { - prev.forEach((f) => { - if (f.previewUrl) URL.revokeObjectURL(f.previewUrl) - }) - return files - }) + // Revoked outside the updater: React double-invokes updaters in StrictMode and may + // replay them, so they have to stay pure. + attachedFilesRef.current.forEach((f) => revokePreviewUrl(f.previewUrl)) + setAttachedFiles(files) }, []) return {