diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments/chat-message-attachments.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments/chat-message-attachments.tsx index c4626ad23d0..ff13612456f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments/chat-message-attachments.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments/chat-message-attachments.tsx @@ -5,9 +5,9 @@ import type { ChatMessageAttachment } from '@/app/workspace/[workspaceId]/home/t function FileAttachmentPill(props: { mediaType: string; filename: string }) { const Icon = getDocumentIcon(props.mediaType, props.filename) return ( -
- - {props.filename} +
+ + {props.filename}
) } @@ -41,7 +41,7 @@ export function ChatMessageAttachments(props: { return (
@@ -51,14 +51,14 @@ export function ChatMessageAttachments(props: { muted playsInline preload='metadata' - className='relative h-full w-full object-cover' + className='relative size-full object-cover' />
) } return ( -
- {att.filename} +
+ {att.filename}
) })} 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 d030eb456da..2fe1d9c1e7b 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 @@ -58,15 +58,31 @@ describe('AttachedFilesList', () => { }) 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('caps the card wrapper so a long filename cannot strand the remove badge', () => { + render([file({ name: '9bacf973-cd64-437b-be12-58be9f2c1a4d-very-long-name.pdf' })]) + + // jsdom does no layout, so the cap can only be asserted structurally: it has to sit + // on the wrapper the badge is positioned against, not on the button. + const wrapper = container.querySelector('button')?.parentElement + expect(wrapper?.className).toMatch(/max-w-/) + }) + + it('keeps the remove control visible rather than gating it on hover', () => { + render([file({})]) + + // A reveal-on-hover badge is unreachable on touch and invisible while it holds + // keyboard focus, so it must not be opacity-gated at all. + const remove = container.querySelector('button[aria-label^="Remove"]') + expect(remove).not.toBeNull() + expect(remove?.className).not.toMatch(/opacity-0/) + }) + 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' })]) 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 c07cf15ee28..db3f8c36ecd 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,24 +1,18 @@ 'use client' import React, { useState } from 'react' -import { cn, Loader, Tooltip } from '@sim/emcn' +import { cn, Loader } 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". + * Chrome shared by both chip shapes. Not `chipFilledFillTokens` — its dark fill is + * `--surface-4`, which is the composer's own background. */ 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)]' + 'relative cursor-pointer rounded-lg 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[] @@ -33,11 +27,9 @@ interface AttachedFileChipProps { } /** - * 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. + * One attachment: media renders as a thumbnail, anything else as a labelled card. + * Shape keys off the media type, not preview presence — a HEIC has no preview until its + * server derivative lands, and flipping shape mid-upload would jump the layout. */ const AttachedFileChip = React.memo(function AttachedFileChip({ file, @@ -46,100 +38,94 @@ const AttachedFileChip = React.memo(function AttachedFileChip({ }: 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 ( - -
- - - - {!file.uploading && ( - + {extension && ( + + {extension} + + )} + + + )} + {file.uploading && ( + + + )} -
- -

{file.name}

-
-
+ + {!file.uploading && ( + + )} +
) }) 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 52e7064cdc8..d8b68c21e4a 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 @@ -159,8 +159,8 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { if (files.length === 0) return 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. + /** Resolved once: browsers report `application/octet-stream` (or nothing) for + * plenty of files, and both the chip and the preview decision key off it. */ const type = resolveFileType(file) return { id: generateId(), @@ -211,10 +211,9 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { 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. + /** A format the browser cannot decode has no local preview; the + * stored bytes now have a renderable derivative. Anything already + * previewing keeps its blob URL. */ previewUrl: f.previewUrl ?? getMothershipAttachmentPreviewUrl({ @@ -352,12 +351,11 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { }, []) /** - * Replaces the current attached files with a given set. - * Cleans up preview URLs from the prior set before replacing. + * Replaces the current attached files with a given set, revoking the prior set's + * preview URLs first. Revoked outside the updater, which must stay pure — React + * double-invokes updaters in StrictMode and may replay them. */ const restoreAttachedFiles = useCallback((files: AttachedFile[]) => { - // 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) }, [])