Skip to content

Attachments

The composer handles file attachments with one flag — and nothing shows the user a paperclip until you set it.

Two elements make the UI:

The default composer shell mounts neither. Add attachments and it mounts both, in their canonical positions:

<aparte-chat attachments></aparte-chat>

Composing your own composer? The flag doesn’t apply — drop the two elements in wherever you want them, as with any other primitive.

<aparte-composer-add-attachment> installs drag & drop on the composer root, not on itself — so a drop anywhere over the composer attaches, not only onto the paperclip. While a drag is over it the root carries aparte-is-dragover, and the dashed outline is drawn on .aparte-composer-shell when your markup has one and on the composer element when it does not. It reads --aparte-focus-outline-width, --aparte-primary and --aparte-radius-input, so it follows your theme with nothing to set.

Two behaviours worth knowing, because they are asymmetric on purpose:

  • disabled on the composer removes the drop target and greys the button out. streaming does neither: attaching a file while a reply arrives is part of preparing the next message, so the button and the drop target stay live mid-turn — only the send is gated.
  • The drop handler calls preventDefault() even while disabled, so a missed drop can never navigate the page away to the file.

The button is what installs it, so a composer you compose yourself gets drag & drop by including <aparte-composer-add-attachment> — and gets none without it.

Clicking a picture — the preview is yours

Section titled “Clicking a picture — the preview is yours”

An image attachment renders as a thumbnail, in the composer and in the sent message. Clicking one asks for a full-size view by emitting aparte-attachment-preview — core has no lightbox, no modal, no opinion about how a picture should open.

So the tile is inert until you say you can open something:

aparteGlobalConfig.setHostHandlers({ attachmentPreview: true });
document.addEventListener('aparte-attachment-preview', (e) => {
const { url, name } = e.detail; // open your own dialog / router / gallery
});

Declared, the image is a real button — role="button", a tab stop, Enter and Space. Which element carries that differs between the two strips, and both are right. In a sent message the tile has no ✕, so the tile itself is the whole control and wears the role. In the composer’s pending strip the tile wraps the remove button, and no role permits a button inside a button — so the role, the tab stop and the aria-label sit on the <img>, with the ✕ beside it. Style .aparte-thumbnail__image[role='button'] for the composer’s, .aparte-thumb--image[role='button'] for a sent message’s.

Undeclared, it is a plain picture: no role, no tab stop, not even a pointer cursor, because looking clickable is the same promise in a quieter voice. The event itself is always public; the declaration only decides whether the trigger is rendered.

The vanilla and React examples do exactly this in ~15 lines with a <dialog> — see apps/examples.

The <aparte-composer> element exposes attachments directly:

composer.addAttachments(files); // FileList | File[]
composer.removeAttachment(file);
composer.clearAttachments();
composer.attachments; // File[] (current selection)

When the user submits, the pending files ride along on the aparte-send event detail. Each wrapper surfaces that same detail under its own convention — one event, five spellings.

composer.addEventListener('aparte-send', (e) => {
const { content, files } = e.detail; // files?: File[]
});

One difference worth keeping straight: React, Vue and Angular hand you the detail directly, while Svelte re-wraps it in a CustomEvent the way the DOM event itself is — so it is e.detail.files in the Vanilla and Svelte tabs and e.files in the other three.

To observe the pending selection live (e.g. to enable a send button), listen for aparte-composer-change — its detail.state.attachments is the current File[].

Driving your own loop? filesToAttachments(files) converts that File[] into the attachments an AparteChatMessage renders — the same conversion the built-in send path does, so your user bubble shows the chips instead of a bare line of text:

import { filesToAttachments } from '@aparte/core';
// The VIEWPORT owns `appendMessage`, not `<aparte-chat>`. The shell matches the
// host selectors but delegates rendering to the viewport inside it, so calling
// `appendMessage` on the shell is a runtime `TypeError` — this snippet used to.
const viewport = document.querySelector('aparte-chat-viewport')!;
viewport.appendMessage({
id, role: 'user', content, timestamp: Date.now(),
...(files?.length ? { attachments: filesToAttachments(files) } : {}),
});

Each attachment’s url comes from URL.createObjectURL, which keeps the underlying File alive for as long as the document. That is what you want while the attachment is on screen — and a leak once it is not: a long session that sends many files holds on to every one of them.

revokeAttachmentUrls(attachments) releases them. Only you know when an attachment stops being rendered (a persisted conversation may re-render one much later), so it is a call you make rather than something the conversion can schedule:

import { revokeAttachmentUrls } from '@aparte/core';
import type { AparteMessage } from '@aparte/core';
function dropConversation(messages: AparteMessage[]): void {
for (const message of messages) revokeAttachmentUrls(message.attachments);
}

Calling it twice is harmless, and the blob is left in place so a storage adapter can still rebuild the url. <aparte-chat-viewport>’s clearAll() already does this for the messages it drops.

Before the provider sees anything, AparteClient decides which pending files are inlined into the request, via its rawFileInject option:

  • 'all' (default) — images and recognized text files (.md, .json, .csv, source code, .env, .log, …): images become image parts, text files are read client-side and injected in full as text.
  • 'images-only' — only images are inlined. Pair it with a requestInterceptor that retrieves relevant chunks (RAG) instead of flooding the context with whole files.
  • 'none' — nothing is inlined; your requestInterceptor owns all file handling.

A file that is neither an image nor a recognized text file — a PDF, a spreadsheet, an archive — is inlined in no mode: aparté sends nothing for it, while the chip stays on screen for your own upload or RAG layer to act on. In the default 'all' mode it says so once with a console.warn naming the file; 'images-only' and 'none' stay silent, since dropping the file is what you asked for.

For per-file control on top of the mode, fileInjectFilter is called for each file the mode would inject — return false to keep it out of the request (the file still rides on the aparte-send event for your upload/RAG layer):

new AparteClient({
// keep the inline UX, but never forward env files or keys
fileInjectFilter: (f) => !/(^|\.)env$|\.(pem|key)$/i.test(f.name),
});

Whether files are actually sent to the model is the provider’s job (multimodal support varies): the OpenAI-compatible adapter maps image parts to the vendor’s image_url format, for example. A provider that doesn’t support a given file type simply ignores it. See Providers.

Attachment chips inherit the surface and border tokens like everything else — see Theming.