)
})}
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 && (
+ {
+ e.stopPropagation()
+ onRemoveFile(file.id)
+ }}
+ aria-label={`Remove ${file.name}`}
+ /* Always visible: reveal-on-hover would hide it from touch and from keyboard
+ focus, and `hover-hover` cannot express "while the chip is hovered" from
+ here anyway — it carries its own `&:hover`, so it binds to this element. */
+ className='absolute top-[2px] right-[2px] flex size-[16px] items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-2)] text-[var(--text-body)]'
+ >
+
+
+ )}
+
)
})
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)
}, [])