Composer Attachments
The Composer Panel carrying attachments: a strip of 56px tiles above the prompt, the thumbnail for images and the plugin document icon with the file name for everything else, each with its dismiss in the corner. Queued files land one after another, the accent ring drawing clockwise around the tile the way the File Upload block traces its drop zone while a 9px percentage counts up in the corner, then at 100 the percentage blurs out and the dismiss blurs in on the same spot.

Installation
Dark mode ready - this component follows BoardUI semantic tokens automatically. Set up theme switching.
Upload queue
ComposerWithAttachments owns the queue. Any file in attachments with progress: 0 waits its turn, then scales in, draws its ring and counts up in the corner; files without a progress value show as landed straight away. The upload is simulated so the block stays backend-agnostic: tune it with uploadDuration and uploadGap, and listen with onUploadComplete and onAllUploaded. Add files whenever you like; they join the back of the queue.
const [files, setFiles] = useState<ComposerAttachment[]>([]);
function attach(picked: File[]) { setFiles((prev) => [ ...prev, ...picked.map((file) => ({ id: crypto.randomUUID(), name: file.name, kind: kindOf(file), progress: 0, // queued: lands after the ones before it })), ]);}
<ComposerWithAttachments attachments={files} onAttachmentsChange={setFiles} // dismissals come back through here uploadDuration={1100} // one simulated upload, ms uploadGap={240} // pause before the next ring starts, ms onUploadComplete={(file) => console.log("landed", file.name)} onAllUploaded={() => console.log("all landed")}/>Tile kinds
Six kinds. image fills the tile with its src and gets a frosted white dismiss; the document, spreadsheet, presentation, code and video kinds carry the 24px plugin icons the add menu already ships, with the file name in 9px underneath, truncated to the tile. Both ComposerAttachmentStrip and the single ComposerAttachmentTile are exported for surfaces outside the composer.
{/* Six tile kinds: images show the thumbnail, the rest carry the plugin document icons */}<ComposerAttachmentStrip attachments={[ { id: "1", name: "cat.jpg", kind: "image", src: "/uploads/cat.jpg" }, { id: "2", name: "payroll.docx", kind: "document" }, { id: "3", name: "timetable.xlsx", kind: "spreadsheet" }, { id: "4", name: "launch.key", kind: "presentation" }, { id: "5", name: "types.tsx", kind: "code" }, { id: "6", name: "birthday.mp4", kind: "video" }, ]} onRemove={(id) => remove(id)}/>
{/* Or one tile at a time */}<ComposerAttachmentTile attachment={file} onRemove={() => remove(file.id)} />Bring your own uploader
The ring is just a number. Skip the queue and hand ComposerPanelthe attachments yourself, mapping each file's real transfer progress onto progress: the ring follows it, and clearing the value marks the file landed.
{/* Real uploads: feed the ring from your own progress events on the core panel */}<ComposerPanel attachments={files.map((file) => ({ ...file, progress: uploads[file.id], // 0–100 draws the ring, undefined means landed }))} onRemoveAttachment={(id) => cancel(id)}/>