Skip to content

Chat Attachments — Flow 1

The chat persists references to Storage documents in Message.parts instead of signed URLs. The reference shape is stable; the URL is generated fresh at every stream and every read. This is what keeps the chat history intact when signed URLs expire.

The contract: data-attachment part

ts
{
  type: 'data-attachment',
  data: {
    documentId: string, // uuid v7
    mediaType: string,  // e.g. 'image/png'
    filename: string,
  },
}

Defined in packages/schemas/src/ai/messages/data-attachment.schema.ts. The schema is exported from @repo/schemas so the panel and any other consumer can build attachments with type-safety.

The existing createMessageSchema (the request body for sending a message) keeps its looseObject({ type }) validation; the strict validation of data-attachment happens inside the resolver, so an unknown future part type doesn't break the endpoint.

What the resolver does

The primary entry point is AttachmentResolverService.resolveMessages(messages, orgId) — it operates over a whole chat, not one message at a time, so a chat history with attachments costs one findByIds call regardless of how many messages reference documents.

Per call:

  1. Collects every data-attachment across all messages, recording (msgIdx, partIdx) for each. Each is validated with the strict zod schema; malformed attachments are left untouched.
  2. One IDocumentRepository.findByIds([...]) call with globally deduped documentIds.
  3. Filters by tenancy and soft-delete in code: document.orgId === orgId and document.deletedAt === null. The repo method is not org-scoped at the SQL level.
  4. Signs each unique accessible documentId once in parallel via IStorageObjectSigner.createReadUrl(...). The signer is bound to the cached adapter from Signed URL Cache, so the same s3Key requested across multiple messages — or across re-renders within the cache TTL — hits Redis. A documentId referenced in five messages produces one sign call, one signed URL, reused everywhere.
  5. Reconstructs each message preserving message order, part order, and message identity (messages with no attachments keep referential identity, useful when the caller wants to detect "did anything change?").

A resolveParts(parts, orgId) helper exists for single-message use cases; it is implemented as a thin wrapper over resolveMessages.

Placeholders

When a document cannot be served — missing, wrong org, soft-deleted, or the signer fails — the part is replaced with:

ts
{ type: 'text', text: '[Attachment unavailable: <filename>]' }

The placeholder is uniform across stream and read paths, by design:

  • Read path: the user opening an old chat sees a clear marker instead of a broken card or a 403. The chat keeps loading.
  • Stream path: the model receives natural-language context about the missing file ("the user attached report.pdf but it is unavailable") rather than a broken URL.

If you ever need to render the placeholder differently in the UI, detect the [Attachment unavailable: …] prefix on the client side. The marker format is intentionally stable.

Where the resolver runs

Both call sites use resolveMessages over the full message array — one batch call per request.

PathHookWhat it processes
Stream (sending to the LLM)CreateMessageStreamHandler → just before convertToModelMessages(...)The entire chat history + the new user message in a single resolve.
Read (returning history to the client)MessageController.getMessagesByChat → after MessageMapper.toDto(...) over the pageThe full page of messages from GET /chat/:id/messages.

Persisted Message.parts is never mutated. The resolver always returns a new array; the data-attachment rows in Postgres stay as the canonical reference.

What the cache layer does

AttachmentResolverService does NOT cache resolved parts. Caching happens at the signed-URL level only (Signed URL Cache). This keeps the resolver stateless and lets the cache TTL be the single source of truth for URL freshness.

The chat-interaction-cache (Redis cache of message arrays) stores data-attachment parts as they were persisted — never resolved. Resolution is the consumer's responsibility, not the cache's.

Failure modes (deliberately tolerant)

  • The resolver never throws. Errors from findByIds propagate to the caller (who decides whether to fail the request); errors from createReadUrl produce a placeholder part and a warn log.
  • A whole message can have multiple invalid attachments and still render — each one becomes its own placeholder.
  • Tenancy violations (a documentId from another org) are silently replaced with a placeholder. This avoids leaking the existence of the foreign document via differential error responses.

Observability

Every placeholder emission logs:

ai.attachment_resolver.placeholder_emitted
  documentId
  reason: 'not_found_or_unauthorized' | 'sign_failed'
  orgId

Sign failures additionally log ai.attachment_resolver.sign_failed with the storage error code.

Listing attachable documents (composer file picker)

The chat composer does not call a dedicated endpoint. It calls the generic Document Search endpoint with a forced mediaType[in] filter derived from the active agent's model.

ts
import { getSupportedMediaTypesForModalities } from '@repo/schemas';

const supportedMimes = getSupportedMediaTypesForModalities(
  agent.brainConfig.model.architecture.input_modalities,
);

const { items } = await listDocuments(client, {
  page: 1,
  limit: 25,
  sort: [{ field: 'createdAt', direction: 'DESC' }],
  mediaType: { in: supportedMimes },
});

Why this works:

  • getSupportedMediaTypesForModalities lives in @repo/schemas/ai/attachment-modality so backend (validation) and frontend (picker) read from the same MIME→modality table.
  • A text-only agent gets supportedMimes = [] — an empty [in] array is rejected by the schema, so the panel must hide the attach button entirely when the model doesn't support attachments. (This is the right UX: don't let the user pick something the API will reject anyway.)
  • Tenancy and per-document permission stay server-side. The panel cannot widen the scope by sending a different filter.

The same endpoint serves the agent knowledge (RAG) upload picker with a different mediaType[in] set, and the free storage browser without any forced filter. One endpoint, multiple consumers, one security boundary.

Composer flow (panel) — uploading and picking from storage

The chat composer in the panel ships with two ways to add attachments, both producing the same data-attachment part shape on submit.

The two entry points

The attach button (apps/panel/src/features/ai-chat/components/chat-input-secondary-actions.tsx) renders a dropdown when the active agent's model accepts files. It is disabled with a tooltip when supportedMediaTypes is empty (text-only model).

EntryWhat it doesResulting chip status
Subir archivoNative OS file picker (multiple, accept filtered by model media types)uploadingready (or error)
Buscar en storageOpens the storage library dialog with a forced mediaType[in] filter (see Document Search)ready immediately

Single source of state: useChatAttachments

apps/panel/src/features/ai-chat/hooks/use-chat-attachments.ts owns the chip list. It exposes:

  • add(files) — uploads new files concurrently (limit 3) via uploadFile from @repo/api-client. Each file produces a chip with status: 'uploading', then transitions to ready (with the backend documentId) or error.
  • addFromStorage(documents) — appends chips with status: 'ready' immediately; documents already exist in storage so no upload is required.
  • remove(id) / clear() — chip removal individually or all at once.

A hard cap of MAX_ATTACHMENTS = 5 is enforced on every add. Files past the cap are silently dropped (no toast, no error) — the user sees the chip count and can intuit the limit. The same cap applies to picked storage documents.

Upload happens through the existing storage endpoint

The composer calls uploadFile(client, file, FileRouteType.STORAGE). Two consequences worth noting:

  • Files uploaded from the chat are real Storage documents. They appear in /files, count against the org's storage quota, and obey the same retention/trash rules as any other document. There is no isolated "chat attachments" bucket — the user can re-pick them from storage on the next message via the same dropdown.
  • The backend creates the Document row before returning the presigned URL. GenerateUploadUrlCommandHandler dispatches RegisterDocumentCommand synchronously inside the upload command (apps/api/src/modules/storage/application/commands/documents/generate-upload-url.command.ts), so the response includes both the S3 presigned POST instructions AND the new documentId. This is why uploadFile returns { s3Key, documentId } rather than just the key — the panel needs the documentId to build the data-attachment part.

The <Attachments> component family

Both the composer chips and the in-message rendering share a single AI-Elements–style component family at apps/panel/src/shared/ai-elements/attachments.tsx. Components: Attachments (container with three layout variants — grid / inline / list), Attachment (the item, accepts an IAttachmentData shape and an optional onRemove), AttachmentPreview (image thumbnail or category icon — FileText / FileVideo / FileAudio / generic), AttachmentInfo (filename + optional media-type label), AttachmentRemove (the X button). Helpers getMediaCategory and getAttachmentLabel are exported for callers that need to branch by category.

The component takes the same IAttachmentData shape regardless of source — { id?, type?, mediaType?, filename?, url? } — so it's agnostic to whether the data came from a file UI part (after server resolution), a data-attachment UI part (fresh send), or a composer chip (IChatAttachment). The two consumers below feed it different shapes.

Composer chips (variant="inline")

apps/panel/src/features/ai-chat/components/attachment-chips.tsx renders one chip per IChatAttachment using the inline variant. Status is communicated by both icon and color:

StatusIconColor tone
uploadingLoader2 (animate-spin)amber border + bg
readyCheckCircle2emerald border + bg
errorAlertCircledestructive border + bg, error message in tooltip

Each chip has a trailing X button that calls remove(id). There is no retry button for failed uploads — the user removes the broken chip and re-adds the file. This is intentional: a per-chip retry path adds state-machine complexity for an edge case where re-picking is faster anyway.

For uploaded images, the chip shows a real thumbnail (16×16 in the inline variant) sourced from a local blob URL — see Local previews via blob URLs below.

Inline rendering in the conversation (variant="grid")

User messages with attachments render the attachments outside the text bubble, as a grid of cards above the optional text bubble. The split is intentional: a single rounded muted bubble holding both attachments and text felt cluttered, especially with multi-attachment messages.

apps/panel/src/features/ai-chat/lib/extract-message-attachments.ts is a pure helper that walks message.parts once and returns:

  • attachments: IAttachmentData[] — collected from both file parts (post-resolution, with url) and data-attachment parts (fresh send, optional url from getPreviewUrl).
  • nonAttachmentIndexes: number[] — original indexes of every part that should still go through the existing text / reasoning / tool-* rendering.

ChatView then renders:

tsx
<Message from={msg.role}>
  {attachments.length > 0 && (
    <Attachments variant="grid" className={msg.role === 'user' ? 'ml-auto justify-end' : ''}>
      {attachments.map((data) => (
        <Attachment key={data.id} data={data}>
          <AttachmentPreview />
          <AttachmentInfo />
        </Attachment>
      ))}
    </Attachments>
  )}
  {hasNonAttachmentContent && (
    <MessageContent>{/* text/reasoning/tool-* parts here */}</MessageContent>
  )}
</Message>

Two UX details worth pinning down:

  • Attachments-only messages don't render an empty bubble. MessageContent is conditional on nonAttachmentIndexes.length > 0. If a user sends an attachment with no text caption, only the cards show.
  • Image previews use a HoverCard in the grid variant — hovering over the thumbnail expands a larger preview anchored to the card. The thumbnail itself is also an <a target="_blank"> so click-through opens the signed URL.

Local previews via blob URLs

The data-attachment part has no URL (the resolver runs server-side only on read), so a freshly sent message would render with a plain icon until the page reloads. To smooth this over, useChatAttachments keeps a Map<documentId, blobUrl>:

  • On add(files), every image File gets a URL.createObjectURL(file) blob URL stored on the chip itself.
  • When the upload completes, ownership of the blob URL transfers from a per-chip-id map to the per-documentId map. The transfer matters because chip ids are local UUIDs that go away on clear() (called after submit), while the documentId survives in the persisted user message.
  • clear() is intentionally non-destructive for blob URLs in the documentId map — they need to outlive the chips so the just-sent message can render its thumbnail.
  • All blob URLs are revoked on ChatView unmount. On a real chat reload, the resolver's file parts (with fresh signed URLs) replace the local blob preview.
  • useChatAttachments.getPreviewUrl(documentId) is the accessor extractMessageAttachments uses to attach the local URL to a data-attachment's IAttachmentData. Storage-picked documents have no previewUrl (we don't fetch a signed URL just for the chip — we let the post-send file part bring the real URL in).

Submit gating

The send button is disabled while attachments.some(a => a.status === 'uploading' || a.status === 'error'). Rationale: silently sending only the ready chips would surprise the user (they expect what they see in the composer to be what gets sent). The user must remove blocking chips to unblock send.

From chips to data-attachment parts

apps/panel/src/features/ai-chat/lib/build-attachment-parts.ts exposes buildAttachmentParts(attachments) — a pure function that:

  1. Filters out chips that aren't ready or are missing documentId (defensive — should never happen, but cheap to enforce).
  2. Maps each remaining chip to the canonical data-attachment shape.
  3. Preserves order so chips appear in the order they were added.

The result is plugged into the user message as parts: [...attachmentParts, { type: 'text', text }] and sent through useMessageQueue.enqueue(text, attachmentParts?). The queue dispatches differently depending on whether parts are present:

  • Text-only: sendMessage({ text }) — preserves the existing fast path.
  • With attachments: sendMessage({ role: 'user', parts: [...attachmentParts, { type: 'text', text }] }) — uses the SDK's full CreateUIMessage shape.

chat-transport.ts already forwards lastMessage.parts verbatim to the server, so no transport change was needed. The server-side AttachmentResolverService (described in What the resolver does above) handles them on receipt.

Picking modality-compatible files only

The OS file dialog passes accept={supportedMediaTypes.join(',')}, and the storage picker forces mediaTypes filter via the dialog's pick session API (useFilesLibraryDialogStore.startPickSession). Both paths share the same source of truth: getSupportedMediaTypesForModalities in @repo/schemas/ai/attachment-modality. A model that doesn't support attachments has supportedMediaTypes = [] and the attach button is hidden via the disabled state — the user never reaches the picker for an unsupported file.

Out of scope (handled elsewhere)

  • Modality validation — whether the model accepts image/file inputs at all. See Attachment Validation.
  • Listing attachable documents — handled by Document Search with the panel forcing mediaType[in] based on the active agent.
  • Drag-and-drop onto the chat surface — the OS file dialog is the only entry point today. Drop-target behavior is a follow-up.
  • Per-chip retry — a failed chip is removed and re-added; there is no retry button.
  • Styled rendering of [Attachment unavailable: …] placeholders — when the resolver can't serve a document (deleted, wrong org, sign failed) it emits a plain text part with that prefix. The panel currently renders it as plain message text. A future pass could detect the prefix and render a destructive-styled error card; today the prefix format is stable and that's enough.