Elements

Attachments

Files stage inside the composer with per-file progress before the message sends.

screenshot.pnguploading
trace.log38 KB
fig. 01 · plays once, replay from the corner

Installation

npx shadcn@latest add "@assistant-ui/elements-composer"
First time? Set up a runtime

Runtime components read their state from an assistant-ui runtime. Add one to an existing project:

npx assistant-ui@latest init

Then wrap your app in a runtime provider:

import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";

export default function App() {
  const runtime = useChatRuntime({
    transport: new AssistantChatTransport({ api: "/api/chat" }),
  });

  return (
    <AssistantRuntimeProvider runtime={runtime}>
      {/* your components */}
    </AssistantRuntimeProvider>
  );
}

The installation guide covers new projects, templates, and API routes.

A file added to the composer becomes a chip: an icon for its kind, its name and size, and a trailing slot that carries a spinner while it uploads and a remove button once it is done. With a runtime the chip tracks a real upload through an attachment adapter; standalone you hold the list of files and their state yourself.

Getting started

A composer stages files the same way it stages text: aui.composer.addAttachment(file) adds one, an AttachmentAdapter turns it into a PendingAttachment and then a CompleteAttachment, and s.composer.attachments reflects the list at every step.

Configure an attachment adapter

Attachments are opt-in: without an adapter, s.thread.capabilities.attachments is false and AddAttachment, paste, and drop all no-op. assistant-ui ships adapters for the common cases:

app/chat-provider.tsx
import { useLocalRuntime, CompositeAttachmentAdapter, SimpleImageAttachmentAdapter, SimpleTextAttachmentAdapter } from "@assistant-ui/react";

const runtime = useLocalRuntime(chatModel, {
  adapters: {
    attachments: new CompositeAttachmentAdapter([
      new SimpleImageAttachmentAdapter(),
      new SimpleTextAttachmentAdapter(),
    ]),
  },
});

Add and list attachments

components/assistant-ui/elements/composer-attachments.tsx
"use client";

import { ComposerPrimitive, AttachmentPrimitive } from "@assistant-ui/react";
import { PlusIcon, XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { field, ghostButton } from "@/components/assistant-ui/elements/surfaces";

export function ComposerAttachmentsRow() {
  return (
    <ComposerPrimitive.Attachments>
      {({ attachment }) => (
        <AttachmentPrimitive.Root
          className={cn(field, "flex items-center gap-2.5 rounded-[14px] py-1.5 ps-1.5 pe-2.5")}
        >
          <span className="max-w-36 truncate text-xs font-medium">
            <AttachmentPrimitive.Name />
          </span>
          {attachment.status.type === "complete" && (
            <AttachmentPrimitive.Remove aria-label={`Remove ${attachment.name}`} className={cn(ghostButton, "size-5")}>
              <XIcon className="size-3" />
            </AttachmentPrimitive.Remove>
          )}
        </AttachmentPrimitive.Root>
      )}
    </ComposerPrimitive.Attachments>
  );
}

export function AddAttachmentButton() {
  return (
    <ComposerPrimitive.AddAttachment aria-label="Add attachment" className={cn(ghostButton, "size-8")}>
      <PlusIcon className="size-4" />
    </ComposerPrimitive.AddAttachment>
  );
}

ComposerPrimitive.Attachments is a render-prop over every staged file; AttachmentPrimitive.Root scopes .Name, .Remove, and .unstable_Thumb to the attachment at that position, so they need no index or id passed in by hand. AddAttachment opens a native file picker filtered to s.composer.attachmentAccept (the adapter's accept, or every file type when none is configured).

Accept drag-and-drop

<ComposerPrimitive.AttachmentDropzone className="data-[dragging=true]:border-dashed data-[dragging=true]:bg-blue-500/[0.04]">
  {/* the rest of the bar */}
</ComposerPrimitive.AttachmentDropzone>

AttachmentDropzone sets data-dragging="true" while a file is dragged over it and stages every dropped file the same way addAttachment does; it claims the drop even without an adapter configured, so an unprevented drop never navigates the tab away to the file.

Anatomy

<div data-slot="composer-attachment" data-state={/* "uploading" | "done" | "error" */}>
  <span>{/* icon: image, text, or archive, by kind */}</span>
  <span>
    <span>{/* name */}</span>
    <span>{/* meta; turns red when state is "error" */}</span>
  </span>
  <span>{/* trailing slot */}</span>
  {/* progress bar along the bottom edge, only while uploading */}
</div>

The trailing slot has three outcomes, not two: uploading shows a spinner; done with an onRemove handler shows a remove button; done with no onRemove shows a plain check mark instead, since there is nothing to remove once nothing is watching for it. An "error" state shows none of the three (only the red meta text), matching the runtime's own "incomplete" status, which carries a message but no built-in retry affordance of its own.

Examples

Upload progress

aui.composer.getState().attachments[i].status is { type: "running", reason: "uploading", progress } while an adapter streams a PendingAttachment; read it to drive your own progress bar, or use AttachmentPrimitive.unstable_Thumb, which falls back to the file's extension or MIME type when no thumbnail is supplied.

const status = useAuiState((s) => s.attachment.status);
// { type: "running", reason: "uploading", progress: 42 } while uploading
// { type: "complete" } once sent

Restyle the chip

Both lanes take className on the outer element; the icon square, the two-line label, and the trailing slot are laid out with flex and don't need to move together.

<AttachmentPrimitive.Root className="rounded-2xl py-2" />

Once the message is sent

Attachments in the composer are still local and removable; once a message sends, they live inside that message instead, read-only. Attachment covers the runtime message-side rendering, and its received-files design covers the equivalent standalone piece.

API reference

ComposerPrimitive

PartRendersNotes
Attachmentsrender prop{({ attachment }) => ReactNode}, once per staged file.
AttachmentByIndexwrapperRenders one attachment at a fixed index; used internally by Attachments.
AddAttachmentbuttonOpens a native file picker filtered to attachmentAccept; disabled only while the composer is not editable, so a pick made with no adapter configured opens the picker but silently fails to add the file.
AttachmentDropzonediv (or asChild)data-dragging="true" while a file drag is over it; drops call addAttachment per file.

AttachmentPrimitive

PartRendersNotes
RootdivScopes .Name, .Remove, and .unstable_Thumb to the attachment at this position. Must be inside ComposerPrimitive.Attachments (or MessagePrimitive.Attachments, for an attachment already on a sent message).
NametextThe attachment's name.
RemovebuttonCalls aui.attachment.remove().
unstable_ThumbdivRenders its children, or falls back to the file extension (or MIME type) as text.

Composer and attachment state

SelectorTypeDescription
s.composer.attachmentsreadonly Attachment[]Every file staged on this composer.
s.composer.attachmentAcceptstringThe configured adapter's accept, or "*".
s.thread.capabilities.attachmentsbooleanWhether an attachment adapter is configured at all.
s.attachment.name / .type / .contentType?stringIdentity of the attachment in scope.
s.attachment.statusPendingAttachmentStatus | CompleteAttachmentStatusSee below.
aui.composer.addAttachment(file)(file: File | CreateAttachment) => Promise<void>Stages a file.
aui.composer.clearAttachments()() => Promise<void>Removes every staged attachment.

PendingAttachmentStatus is { type: "running", reason: "uploading", progress: number }, { type: "requires-action", reason: "composer-send" } (uploaded, waiting to be sent), or { type: "incomplete", reason: "error" | "upload-paused", message?: string }. CompleteAttachmentStatus is { type: "complete" }.