11'use client'
22
33import React , { useState } from 'react'
4- import { cn , Loader , Tooltip } from '@sim/emcn'
4+ import { cn , Loader } from '@sim/emcn'
55import { X } from '@sim/emcn/icons'
66import { getDocumentIcon } from '@/components/icons/document-icons'
77import { getFileExtension } from '@/lib/uploads/utils/file-utils'
88import type { AttachedFile } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
99
1010/**
11- * Chrome shared by both chip shapes. Both stand 48px tall so a row mixing thumbnails
12- * and documents sits on one baseline.
13- *
14- * Deliberately NOT `chipFilledFillTokens` (`--surface-5` / `dark:--surface-4`): that
15- * pair assumes a page background, but this chip sits inside the composer, which is
16- * already `--surface-4` in dark mode — reusing it would make the chip invisible against
17- * its own container. `--surface-5` steps away from the composer in both themes, and
18- * hover steps further away in the direction each theme reads as "raised".
11+ * Chrome shared by both chip shapes. Not `chipFilledFillTokens` — its dark fill is
12+ * `--surface-4`, which is the composer's own background.
1913 */
2014const CHIP_SURFACE =
2115 '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)]'
2216
23- /** Height lives on the wrapper so both shapes are the same size by construction. */
24- const CHIP_HEIGHT = 'h-[48px]'
25-
2617interface AttachedFilesListProps {
2718 attachedFiles : AttachedFile [ ]
2819 onFileClick : ( file : AttachedFile ) => void
@@ -36,11 +27,9 @@ interface AttachedFileChipProps {
3627}
3728
3829/**
39- * One attachment.
40- *
41- * Media renders as a thumbnail; everything else renders as a labelled card — icon
42- * badge, filename, file type. A document has no thumbnail worth showing, and the
43- * filename is the thing worth reading.
30+ * One attachment: media renders as a thumbnail, anything else as a labelled card.
31+ * Shape keys off the media type, not preview presence — a HEIC has no preview until its
32+ * server derivative lands, and flipping shape mid-upload would jump the layout.
4433 */
4534const AttachedFileChip = React . memo ( function AttachedFileChip ( {
4635 file,
@@ -49,118 +38,89 @@ const AttachedFileChip = React.memo(function AttachedFileChip({
4938} : AttachedFileChipProps ) {
5039 const Icon = getDocumentIcon ( file . type , file . name )
5140 const isVideo = file . type . startsWith ( 'video/' )
52- // Keyed off the type, not the presence of a preview: a HEIC has no preview until its
53- // upload finishes, and flipping shape mid-upload would jump the layout.
5441 const isMedia = isVideo || file . type . startsWith ( 'image/' )
5542 const extension = getFileExtension ( file . name )
5643 const [ previewFailed , setPreviewFailed ] = useState ( false )
5744
5845 return (
59- < Tooltip . Root >
60- { /* Both the size and the width cap live here, not on the button: this wrapper
61- anchors the remove badge, so sizing it to the button's uncapped max-content
62- width would strand the badge far to the right of a long filename. */ }
63- < div
46+ /* Owns the width cap: it anchors the remove badge, which a max-content button would strand. */
47+ < div
48+ className = { cn (
49+ 'group relative' ,
50+ isMedia ? 'size-[48px] shrink-0' : 'h-[48px] min-w-0 max-w-[min(220px,100%)]'
51+ ) }
52+ >
53+ < button
54+ type = 'button'
6455 className = { cn (
65- 'group relative' ,
66- CHIP_HEIGHT ,
67- isMedia ? 'w-[48px] shrink-0 ' : 'min-w-0 max-w-[min(220px,100%)] '
56+ CHIP_SURFACE ,
57+ 'size-full' ,
58+ isMedia ? 'overflow-hidden ' : 'flex items-center gap-2 py-[7px] pr-5 pl-2 '
6859 ) }
60+ onClick = { ( ) => onFileClick ( file ) }
6961 >
70- < Tooltip . Trigger asChild >
71- < button
72- type = 'button'
73- className = { cn (
74- CHIP_SURFACE ,
75- 'size-full' ,
76- isMedia
77- ? 'overflow-hidden'
78- : // `pr-5` reserves room for the remove badge so it never sits over the
79- // filename.
80- 'flex items-center gap-2 py-2 pr-5 pl-2'
81- ) }
82- onClick = { ( ) => onFileClick ( file ) }
83- >
84- { isMedia ? (
85- < >
86- < span className = 'absolute inset-0 flex items-center justify-center text-[var(--text-icon)]' >
87- < Icon className = 'size-[18px]' />
88- </ span >
89- { file . previewUrl &&
90- ! previewFailed &&
91- ( isVideo ? (
92- < video
93- src = { file . previewUrl }
94- muted
95- playsInline
96- preload = 'metadata'
97- className = 'relative size-full object-cover'
98- />
99- ) : (
100- < img
101- src = { file . previewUrl }
102- alt = { file . name }
103- // A HEIC whose server-side transcode failed comes back as bytes the
104- // browser still cannot decode. Dropping the image reveals the type
105- // icon beneath instead of a broken glyph.
106- onError = { ( ) => setPreviewFailed ( true ) }
107- className = 'relative size-full object-cover'
108- />
109- ) ) }
110- </ >
111- ) : (
112- < >
113- { /* Steps again on hover: the chip's own hover fill closes to within
114- 7/255 of this badge in light mode, which would erase it during the
115- one interaction where it is being looked at. */ }
116- < span className = 'flex size-[32px] shrink-0 items-center justify-center rounded-md bg-[var(--surface-6)] text-[var(--text-icon)] transition-colors group-hover:bg-[var(--surface-7)] dark:bg-[var(--surface-3)] dark:group-hover:bg-[var(--surface-3)]' >
117- < Icon className = 'size-[16px]' />
118- </ span >
119- < span className = 'flex min-w-0 flex-col items-start' >
120- < span className = 'w-full truncate text-[var(--text-body)] text-small leading-tight' >
121- { file . name }
122- </ span >
123- { /* The name truncates from the tail, so the extension is often not
124- readable from it — this is the format, not a restatement.
125- `--text-icon`, not `--text-muted`: muted lands at 2.4:1 on this
126- fill in dark mode, well under AA. */ }
127- { extension && (
128- < span className = 'text-[var(--text-icon)] text-caption uppercase leading-tight' >
129- { extension }
130- </ span >
131- ) }
132- </ span >
133- </ >
134- ) }
135- { file . uploading && (
136- < span className = 'absolute inset-0 flex items-center justify-center rounded-[inherit] bg-[var(--surface-5)]/70 dark:bg-[var(--surface-4)]/70' >
137- < Loader className = 'size-[14px] text-[var(--text-icon)]' animate />
62+ { isMedia ? (
63+ < >
64+ < span className = 'absolute inset-0 flex items-center justify-center text-[var(--text-icon)]' >
65+ < Icon className = 'size-[18px]' />
66+ </ span >
67+ { file . previewUrl &&
68+ ! previewFailed &&
69+ ( isVideo ? (
70+ < video
71+ src = { file . previewUrl }
72+ muted
73+ playsInline
74+ preload = 'metadata'
75+ className = 'relative size-full object-cover'
76+ />
77+ ) : (
78+ < img
79+ src = { file . previewUrl }
80+ alt = { file . name }
81+ onError = { ( ) => setPreviewFailed ( true ) }
82+ className = 'relative size-full object-cover'
83+ />
84+ ) ) }
85+ </ >
86+ ) : (
87+ < >
88+ { /* Hover steps too: --surface-active is within 7/255 of --surface-6 in light mode. */ }
89+ < span className = 'flex size-[32px] shrink-0 items-center justify-center rounded-md bg-[var(--surface-6)] text-[var(--text-icon)] transition-colors hover-hover:group-hover:bg-[var(--surface-7)] dark:bg-[var(--surface-3)]' >
90+ < Icon className = 'size-[16px]' />
91+ </ span >
92+ < span className = 'flex min-w-0 flex-col items-start' >
93+ < span className = 'w-full truncate text-[var(--text-body)] text-small leading-tight' >
94+ { file . name }
13895 </ span >
139- ) }
140- </ button >
141- </ Tooltip . Trigger >
142- { ! file . uploading && (
143- < button
144- type = 'button'
145- onClick = { ( e ) => {
146- e . stopPropagation ( )
147- onRemoveFile ( file . id )
148- } }
149- aria-label = { `Remove ${ file . name } ` }
150- // Opaque, not a translucent scrim: a semi-transparent fill composites with
151- // whatever sits under it, so the same badge reads differently over a light
152- // card than over a photo. An opaque surface plus a border keeps the glyph
153- // contrast fixed and gives the badge an edge against any thumbnail.
154- className = 'absolute top-[2px] right-[2px] flex size-[16px] items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-1)] text-[var(--text-body)] opacity-0 transition-opacity group-hover:opacity-100'
155- >
156- < X className = 'size-[9px]' />
157- </ button >
96+ { extension && (
97+ < span className = 'text-[var(--text-icon)] text-caption uppercase leading-tight' >
98+ { extension }
99+ </ span >
100+ ) }
101+ </ span >
102+ </ >
103+ ) }
104+ { file . uploading && (
105+ < span className = 'absolute inset-0 flex items-center justify-center rounded-[inherit] bg-[var(--surface-5)]/70 dark:bg-[var(--surface-4)]/70' >
106+ < Loader className = 'size-[14px] text-[var(--text-icon)]' animate />
107+ </ span >
158108 ) }
159- </ div >
160- { /* No width or truncation here — Tooltip.Content already caps and wraps, and this
161- exists precisely to reveal the name the card truncated. */ }
162- < Tooltip . Content > { file . name } </ Tooltip . Content >
163- </ Tooltip . Root >
109+ </ button >
110+ { ! file . uploading && (
111+ < button
112+ type = 'button'
113+ onClick = { ( e ) => {
114+ e . stopPropagation ( )
115+ onRemoveFile ( file . id )
116+ } }
117+ aria-label = { `Remove ${ file . name } ` }
118+ 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)] opacity-0 transition-opacity group-hover:opacity-100'
119+ >
120+ < X className = 'size-[10px]' />
121+ </ button >
122+ ) }
123+ </ div >
164124 )
165125} )
166126
0 commit comments