Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -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>): 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(
<AttachedFilesList attachedFiles={files} onFileClick={() => {}} 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()
})
})
Original file line numberDiff line numberDiff line change
@@ -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 (
<div className='mb-1.5 flex flex-wrap gap-1.5'>
{attachedFiles.map((file) => {
const isVideo = file.type.startsWith('video/')
const hasPreview = Boolean(file.previewUrl)
return (
<Tooltip.Root key={file.id}>
<div className='group relative size-[56px] flex-shrink-0'>
<Tooltip.Trigger asChild>
<button
type='button'
className='relative h-full w-full cursor-pointer overflow-hidden rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-5)] p-0 hover:bg-[var(--surface-4)]'
onClick={() => onFileClick(file)}
>
{hasPreview && isVideo ? (
<>
<div className='absolute inset-0 flex items-center justify-center text-[var(--text-icon)]'>
{(() => {
const Icon = getDocumentIcon(file.type, file.name)
return <Icon className='size-[18px]' />
})()}
</div>
<video
src={file.previewUrl}
muted
playsInline
preload='metadata'
className='relative h-full w-full object-cover'
/>
</>
) : hasPreview ? (
<Tooltip.Root>
<div className={cn('group relative', isMedia ? 'flex-shrink-0' : 'min-w-0')}>
<Tooltip.Trigger asChild>
<button
type='button'
className={cn(
CHIP_SURFACE,
isMedia
? 'w-[48px] overflow-hidden'
: // Capped at 220px but never wider than the composer, so a long filename
// truncates on a narrow viewport instead of overflowing the shell.
'flex max-w-[min(220px,100%)] items-center gap-2 py-2 pr-3 pl-2'
)}
onClick={() => onFileClick(file)}
>
{isMedia ? (
<>
<span className='absolute inset-0 flex items-center justify-center text-[var(--text-icon)]'>
<Icon className='size-[18px]' />
</span>
{file.previewUrl &&
!previewFailed &&
(isVideo ? (
<video
src={file.previewUrl}
muted
playsInline
preload='metadata'
className='relative size-full object-cover'
/>
) : (
<img
src={file.previewUrl}
alt={file.name}
className='h-full w-full object-cover'
// A HEIC whose server-side transcode failed comes back as bytes the
// browser still cannot decode. Dropping the image reveals the type
// icon beneath instead of a broken glyph.
onError={() => setPreviewFailed(true)}
className='relative size-full object-cover'
/>
) : (
<div className='flex h-full w-full flex-col items-center justify-center gap-0.5 text-[var(--text-icon)]'>
{(() => {
const Icon = getDocumentIcon(file.type, file.name)
return <Icon className='size-[18px]' />
})()}
<span className='max-w-[48px] truncate px-[2px] text-[9px] text-[var(--text-muted)]'>
{file.name.split('.').pop()}
</span>
</div>
))}
</>
) : (
<>
<span className='flex size-[32px] shrink-0 items-center justify-center rounded-[8px] bg-[var(--surface-6)] text-[var(--text-icon)] dark:bg-[var(--surface-3)]'>
<Icon className='size-[16px]' />
</span>
<span className='flex min-w-0 flex-col items-start'>
<span className='w-full truncate text-[var(--text-body)] text-small'>
{file.name}
</span>
{/* The name truncates, so the extension is genuinely not readable from
it — this is the format, not a restatement of the label. */}
{extension && (
<span className='text-[var(--text-muted)] text-xs uppercase'>{extension}</span>
)}
{file.uploading && (
<div className='absolute inset-0 flex items-center justify-center bg-black/50'>
<Loader className='size-[14px] text-white' animate />
</div>
)}
</button>
</Tooltip.Trigger>
{!file.uploading && (
<button
type='button'
onClick={(e) => {
e.stopPropagation()
onRemoveFile(file.id)
}}
className='absolute top-[2px] right-[2px] flex size-[16px] items-center justify-center rounded-full bg-black/60 opacity-0 group-hover:opacity-100'
>
<X className='size-[10px] text-white' />
</button>
)}
</div>
<Tooltip.Content side='top'>
<p className='max-w-[200px] truncate'>{file.name}</p>
</Tooltip.Content>
</Tooltip.Root>
)
})}
</span>
</>
)}
{file.uploading && (
<span className='absolute inset-0 flex items-center justify-center rounded-[inherit] bg-[var(--surface-5)]/70 dark:bg-[var(--surface-4)]/70'>
<Loader className='size-[14px] text-[var(--text-icon)]' animate />
</span>
)}
</button>
</Tooltip.Trigger>
{!file.uploading && (
<button
type='button'
onClick={(e) => {
e.stopPropagation()
onRemoveFile(file.id)
}}
aria-label={`Remove ${file.name}`}
// Overhangs the chip by 5px, which the composer's `py-2` absorbs. `--surface-6`
// (not the chip's own fill) because this badge sits on the composer shell —
// white in light mode, `--surface-4` in dark — and must read against both.
className='-top-[5px] -right-[5px] absolute flex size-[16px] items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-6)] text-[var(--text-icon)] opacity-0 transition-opacity group-hover:opacity-100'
>
<X className='size-[9px]' />
</button>
)}
</div>
<Tooltip.Content side='top'>
<p className='max-w-[200px] truncate'>{file.name}</p>
</Tooltip.Content>
</Tooltip.Root>
)
})

export const AttachedFilesList = React.memo(function AttachedFilesList({
attachedFiles,
onFileClick,
onRemoveFile,
}: AttachedFilesListProps) {
if (attachedFiles.length === 0) return null

return (
<div className='mb-1.5 flex flex-wrap items-center gap-1.5'>
{attachedFiles.map((file) => (
<AttachedFileChip
key={file.id}
file={file}
onFileClick={onFileClick}
onRemoveFile={onRemoveFile}
/>
))}
</div>
)
})
Loading
Loading