Skip to content
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 (
<div className='flex max-w-[140px] items-center gap-[5px] rounded-[10px] bg-[var(--surface-5)] px-[6px] py-[3px]'>
<Icon className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate text-[11px] text-[var(--text-body)]'>{props.filename}</span>
<div className='flex max-w-[140px] items-center gap-[5px] rounded-lg bg-[var(--surface-5)] px-[6px] py-[3px]'>
<Icon className='size-[14px] shrink-0 text-[var(--text-icon)]' />
<span className='truncate text-[var(--text-body)] text-xs'>{props.filename}</span>
</div>
)
}
Expand DownExpand Up@@ -41,7 +41,7 @@ export function ChatMessageAttachments(props: {
return (
<div
key={att.id}
className='relative size-[56px] overflow-hidden rounded-[8px] bg-[var(--surface-5)]'
className='relative size-[56px] overflow-hidden rounded-lg bg-[var(--surface-5)]'
>
<div className='absolute inset-0 flex items-center justify-center text-[var(--text-icon)]'>
<Icon className='size-[18px]' />
Expand All@@ -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'
/>
</div>
)
}
return (
<div key={att.id} className='size-[56px] overflow-hidden rounded-[8px]'>
<img src={att.previewUrl} alt={att.filename} className='h-full w-full object-cover' />
<div key={att.id} className='size-[56px] overflow-hidden rounded-lg'>
<img src={att.previewUrl} alt={att.filename} className='size-full object-cover' />
</div>
)
})}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' })])

Expand Down
Original file line numberDiff line numberDiff line change
@@ -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[]
Expand All@@ -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,
Expand All@@ -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 (
<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}
// 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'
/>
))}
</>
) : (
<>
<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>
)}
</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 />
/* Owns the width cap: it anchors the remove badge, which a max-content button would strand. */
<div
className={cn(
'group relative',
isMedia ? 'size-[48px] shrink-0' : 'h-[48px] min-w-0 max-w-[min(220px,100%)]'
)}
>
<button
type='button'
className={cn(
CHIP_SURFACE,
'size-full',
isMedia ? 'overflow-hidden' : 'flex items-center gap-2 py-[7px] pr-5 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}
onError={() => setPreviewFailed(true)}
className='relative size-full object-cover'
/>
))}
</>
Comment thread
waleedlatif1 marked this conversation as resolved.
) : (
<>
{/* Fill is constant — the chip's own hover is the only hover affordance. It
sits a step below `--surface-6` in light mode so the chip's hover fill
(`--surface-active`) cannot close on it. */}
<span className='flex size-[32px] shrink-0 items-center justify-center rounded-md bg-[var(--surface-7)] 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 leading-tight'>
{file.name}
</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>
{extension && (
<span className='text-[var(--text-icon)] text-caption uppercase leading-tight'>
{extension}
</span>
)}
</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>
)}
</div>
<Tooltip.Content side='top'>
<p className='max-w-[200px] truncate'>{file.name}</p>
</Tooltip.Content>
</Tooltip.Root>
</button>
{!file.uploading && (
<button
type='button'
onClick={(e) => {
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)]'
>
<X className='size-[10px]' />
</button>
)}
</div>
)
})

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(),
Expand DownExpand Up@@ -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({
Expand DownExpand Up@@ -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)
}, [])
Expand Down
Loading