From e8852fe8e8e3502fee006cd068d78c3858e1856e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 6 Aug 2026 18:45:40 -0700 Subject: [PATCH 1/3] fix(chat): render HEIC attachments and restyle composer file chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer previewed every attachment through URL.createObjectURL of the raw bytes. No browser decodes HEVC-coded HEIF, so a HEIC showed a broken glyph, and the upload-completion handler never replaced that blob URL — so it stayed broken even once a derivative was available. - Skip the blob for HEIC/HEIF and pick up the serve URL (preview=1) once the upload lands, so the server derivative renders. - Fall back to the type icon if the image still fails to decode. - Documents render as labelled cards (icon, name, type) instead of a 9px extension caption; media keeps a thumbnail. - Fix a blob-URL leak: the unmount cleanup closed over the first render's empty array and revoked nothing. --- .../attached-files-list.test.tsx | 83 +++++++ .../attached-files-list.tsx | 204 +++++++++++------- .../user-input/hooks/use-file-attachments.ts | 120 +++++++---- 3 files changed, 293 insertions(+), 114 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.test.tsx 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..7ce5ebaeb44 --- /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 type { AttachedFile } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments' +import { AttachedFilesList } from './attached-files-list' + +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..dd6dff53006 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,155 @@ '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, borrowed from the chip family's filled field so a + * hand-rolled card still reads as part of the system. Both shapes stand 48px tall, so a + * row mixing thumbnails and documents sits on one baseline. + */ +const CHIP_SURFACE = + 'relative h-[48px] cursor-pointer rounded-[10px] border border-[var(--border)] bg-[var(--surface-5)] transition-colors dark:bg-[var(--surface-4)] hover-hover:bg-[var(--surface-active)]' + 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 { From a0164892ab383d93df5ecb254607a86f3a806a9c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 6 Aug 2026 18:50:46 -0700 Subject: [PATCH 2/3] fix(chat): make composer chips read against the composer shell The composer is --white in light and --surface-4 in dark. The chip reused chipFilledFillTokens (--surface-5 / dark:--surface-4), which assumes a page background, so in dark mode the chip fill matched its own container exactly and only the border showed. Same for the remove badge, which sits on the shell and was 5/255 from it. - Chip fills --surface-5 in both themes and hover steps away from the shell in each theme's 'raised' direction. - Remove badge uses --surface-6, readable on white and on --surface-4. - Cap the document card at min(220px,100%) so a long filename truncates on a narrow viewport instead of overflowing the composer. --- .../attached-files-list.tsx | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) 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 dd6dff53006..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 @@ -8,12 +8,17 @@ 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, borrowed from the chip family's filled field so a - * hand-rolled card still reads as part of the system. Both shapes stand 48px tall, so a - * row mixing thumbnails and documents sits on one baseline. + * 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 dark:bg-[var(--surface-4)] hover-hover:bg-[var(--surface-active)]' + '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[] @@ -49,7 +54,7 @@ const AttachedFileChip = React.memo(function AttachedFileChip({ return ( -
+
From 710767ee5d5fd9639ab7ad5bd4501893b621f903 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 6 Aug 2026 18:57:45 -0700 Subject: [PATCH 3/3] chore(chat): use the absolute alias for the chip test import --- .../components/attached-files-list/attached-files-list.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 7ce5ebaeb44..d030eb456da 100644 --- 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 @@ -4,8 +4,8 @@ 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' -import { AttachedFilesList } from './attached-files-list' function file(overrides: Partial): AttachedFile { return {