Skip to content

Commit e8852fe

Browse files
committed
fix(chat): render HEIC attachments and restyle composer file chips
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.
1 parent e1f2bf8 commit e8852fe

3 files changed

Lines changed: 293 additions & 114 deletions

File tree

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
7+
import type { AttachedFile } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
8+
import { AttachedFilesList } from './attached-files-list'
9+
10+
function file(overrides: Partial<AttachedFile>): AttachedFile {
11+
return {
12+
id: 'f1',
13+
name: 'report.pdf',
14+
size: 1024,
15+
type: 'application/pdf',
16+
path: '',
17+
uploading: false,
18+
...overrides,
19+
}
20+
}
21+
22+
let container: HTMLDivElement
23+
let root: Root
24+
25+
beforeEach(() => {
26+
globalThis.IS_REACT_ACT_ENVIRONMENT = true
27+
container = document.createElement('div')
28+
document.body.appendChild(container)
29+
root = createRoot(container)
30+
})
31+
32+
afterEach(() => {
33+
act(() => root.unmount())
34+
container.remove()
35+
})
36+
37+
function render(files: AttachedFile[]) {
38+
act(() => {
39+
root.render(
40+
<AttachedFilesList attachedFiles={files} onFileClick={() => {}} onRemoveFile={() => {}} />
41+
)
42+
})
43+
}
44+
45+
describe('AttachedFilesList', () => {
46+
it('renders a document as a labelled card showing the filename', () => {
47+
render([file({})])
48+
49+
expect(container.textContent).toContain('report.pdf')
50+
expect(container.querySelector('img')).toBeNull()
51+
})
52+
53+
it('renders an image with a preview as a thumbnail, not a filename card', () => {
54+
render([file({ name: 'photo.png', type: 'image/png', previewUrl: 'blob:xyz' })])
55+
56+
expect(container.querySelector('img')?.getAttribute('src')).toBe('blob:xyz')
57+
expect(container.textContent).not.toContain('photo.png')
58+
})
59+
60+
it('keeps a HEIC on the thumbnail shape while it has no preview yet', () => {
61+
// The shape is keyed off the type, not the preview: a HEIC gets its preview only
62+
// once the server derivative exists, and switching shape mid-upload would jump the
63+
// layout. It must not fall back to the document card.
64+
render([file({ name: 'photo.heic', type: 'image/heic' })])
65+
66+
expect(container.textContent).not.toContain('photo.heic')
67+
expect(container.querySelector('img')).toBeNull()
68+
})
69+
70+
it('drops the image and reveals the type icon when the preview fails to decode', () => {
71+
render([file({ name: 'photo.heic', type: 'image/heic', previewUrl: '/api/files/serve/x' })])
72+
73+
const img = container.querySelector('img')
74+
expect(img).not.toBeNull()
75+
76+
act(() => {
77+
img?.dispatchEvent(new Event('error'))
78+
})
79+
80+
expect(container.querySelector('img')).toBeNull()
81+
expect(container.querySelector('svg')).not.toBeNull()
82+
})
83+
})
Lines changed: 131 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,97 +1,155 @@
11
'use client'
22

3-
import React from 'react'
4-
import { Loader, Tooltip } from '@sim/emcn'
3+
import React, { useState } from 'react'
4+
import { cn, Loader, Tooltip } from '@sim/emcn'
55
import { X } from '@sim/emcn/icons'
66
import { getDocumentIcon } from '@/components/icons/document-icons'
7+
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
78
import type { AttachedFile } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
89

10+
/**
11+
* Chrome shared by both chip shapes, borrowed from the chip family's filled field so a
12+
* hand-rolled card still reads as part of the system. Both shapes stand 48px tall, so a
13+
* row mixing thumbnails and documents sits on one baseline.
14+
*/
15+
const CHIP_SURFACE =
16+
'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)]'
17+
918
interface AttachedFilesListProps {
1019
attachedFiles: AttachedFile[]
1120
onFileClick: (file: AttachedFile) => void
1221
onRemoveFile: (id: string) => void
1322
}
1423

15-
export const AttachedFilesList = React.memo(function AttachedFilesList({
16-
attachedFiles,
24+
interface AttachedFileChipProps {
25+
file: AttachedFile
26+
onFileClick: (file: AttachedFile) => void
27+
onRemoveFile: (id: string) => void
28+
}
29+
30+
/**
31+
* One attachment.
32+
*
33+
* Media renders as a thumbnail; everything else renders as a labelled card — icon
34+
* badge, filename, file type. A document has no thumbnail worth showing, and the
35+
* filename is the thing worth reading.
36+
*/
37+
const AttachedFileChip = React.memo(function AttachedFileChip({
38+
file,
1739
onFileClick,
1840
onRemoveFile,
19-
}: AttachedFilesListProps) {
20-
if (attachedFiles.length === 0) return null
41+
}: AttachedFileChipProps) {
42+
const Icon = getDocumentIcon(file.type, file.name)
43+
const isVideo = file.type.startsWith('video/')
44+
// Keyed off the type, not the presence of a preview: a HEIC has no preview until its
45+
// upload finishes, and flipping shape mid-upload would jump the layout.
46+
const isMedia = isVideo || file.type.startsWith('image/')
47+
const extension = getFileExtension(file.name)
48+
const [previewFailed, setPreviewFailed] = useState(false)
2149

2250
return (
23-
<div className='mb-1.5 flex flex-wrap gap-1.5'>
24-
{attachedFiles.map((file) => {
25-
const isVideo = file.type.startsWith('video/')
26-
const hasPreview = Boolean(file.previewUrl)
27-
return (
28-
<Tooltip.Root key={file.id}>
29-
<div className='group relative size-[56px] flex-shrink-0'>
30-
<Tooltip.Trigger asChild>
31-
<button
32-
type='button'
33-
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)]'
34-
onClick={() => onFileClick(file)}
35-
>
36-
{hasPreview && isVideo ? (
37-
<>
38-
<div className='absolute inset-0 flex items-center justify-center text-[var(--text-icon)]'>
39-
{(() => {
40-
const Icon = getDocumentIcon(file.type, file.name)
41-
return <Icon className='size-[18px]' />
42-
})()}
43-
</div>
44-
<video
45-
src={file.previewUrl}
46-
muted
47-
playsInline
48-
preload='metadata'
49-
className='relative h-full w-full object-cover'
50-
/>
51-
</>
52-
) : hasPreview ? (
51+
<Tooltip.Root>
52+
<div className='group relative flex-shrink-0'>
53+
<Tooltip.Trigger asChild>
54+
<button
55+
type='button'
56+
className={cn(
57+
CHIP_SURFACE,
58+
isMedia
59+
? 'w-[48px] overflow-hidden'
60+
: 'flex max-w-[220px] items-center gap-2 py-2 pr-3 pl-2'
61+
)}
62+
onClick={() => onFileClick(file)}
63+
>
64+
{isMedia ? (
65+
<>
66+
<span className='absolute inset-0 flex items-center justify-center text-[var(--text-icon)]'>
67+
<Icon className='size-[18px]' />
68+
</span>
69+
{file.previewUrl &&
70+
!previewFailed &&
71+
(isVideo ? (
72+
<video
73+
src={file.previewUrl}
74+
muted
75+
playsInline
76+
preload='metadata'
77+
className='relative size-full object-cover'
78+
/>
79+
) : (
5380
<img
5481
src={file.previewUrl}
5582
alt={file.name}
56-
className='h-full w-full object-cover'
83+
// A HEIC whose server-side transcode failed comes back as bytes the
84+
// browser still cannot decode. Dropping the image reveals the type
85+
// icon beneath instead of a broken glyph.
86+
onError={() => setPreviewFailed(true)}
87+
className='relative size-full object-cover'
5788
/>
58-
) : (
59-
<div className='flex h-full w-full flex-col items-center justify-center gap-0.5 text-[var(--text-icon)]'>
60-
{(() => {
61-
const Icon = getDocumentIcon(file.type, file.name)
62-
return <Icon className='size-[18px]' />
63-
})()}
64-
<span className='max-w-[48px] truncate px-[2px] text-[9px] text-[var(--text-muted)]'>
65-
{file.name.split('.').pop()}
66-
</span>
67-
</div>
89+
))}
90+
</>
91+
) : (
92+
<>
93+
<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)]'>
94+
<Icon className='size-[16px]' />
95+
</span>
96+
<span className='flex min-w-0 flex-col items-start'>
97+
<span className='w-full truncate text-[var(--text-body)] text-small'>
98+
{file.name}
99+
</span>
100+
{/* The name truncates, so the extension is genuinely not readable from
101+
it — this is the format, not a restatement of the label. */}
102+
{extension && (
103+
<span className='text-[var(--text-muted)] text-xs uppercase'>{extension}</span>
68104
)}
69-
{file.uploading && (
70-
<div className='absolute inset-0 flex items-center justify-center bg-black/50'>
71-
<Loader className='size-[14px] text-white' animate />
72-
</div>
73-
)}
74-
</button>
75-
</Tooltip.Trigger>
76-
{!file.uploading && (
77-
<button
78-
type='button'
79-
onClick={(e) => {
80-
e.stopPropagation()
81-
onRemoveFile(file.id)
82-
}}
83-
className='absolute top-[2px] right-[2px] flex size-[16px] items-center justify-center rounded-full bg-black/60 opacity-0 group-hover:opacity-100'
84-
>
85-
<X className='size-[10px] text-white' />
86-
</button>
87-
)}
88-
</div>
89-
<Tooltip.Content side='top'>
90-
<p className='max-w-[200px] truncate'>{file.name}</p>
91-
</Tooltip.Content>
92-
</Tooltip.Root>
93-
)
94-
})}
105+
</span>
106+
</>
107+
)}
108+
{file.uploading && (
109+
<span className='absolute inset-0 flex items-center justify-center rounded-[inherit] bg-[var(--surface-5)]/70 dark:bg-[var(--surface-4)]/70'>
110+
<Loader className='size-[14px] text-[var(--text-icon)]' animate />
111+
</span>
112+
)}
113+
</button>
114+
</Tooltip.Trigger>
115+
{!file.uploading && (
116+
<button
117+
type='button'
118+
onClick={(e) => {
119+
e.stopPropagation()
120+
onRemoveFile(file.id)
121+
}}
122+
aria-label={`Remove ${file.name}`}
123+
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 dark:bg-[var(--surface-3)]'
124+
>
125+
<X className='size-[9px]' />
126+
</button>
127+
)}
128+
</div>
129+
<Tooltip.Content side='top'>
130+
<p className='max-w-[200px] truncate'>{file.name}</p>
131+
</Tooltip.Content>
132+
</Tooltip.Root>
133+
)
134+
})
135+
136+
export const AttachedFilesList = React.memo(function AttachedFilesList({
137+
attachedFiles,
138+
onFileClick,
139+
onRemoveFile,
140+
}: AttachedFilesListProps) {
141+
if (attachedFiles.length === 0) return null
142+
143+
return (
144+
<div className='mb-1.5 flex flex-wrap items-center gap-1.5'>
145+
{attachedFiles.map((file) => (
146+
<AttachedFileChip
147+
key={file.id}
148+
file={file}
149+
onFileClick={onFileClick}
150+
onRemoveFile={onRemoveFile}
151+
/>
152+
))}
95153
</div>
96154
)
97155
})

0 commit comments

Comments
 (0)