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.

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.