Internal Documentation internal
TalkIDE internal documentation

Upload files or paste long text as attachments in the Mara chat, send them with a message, and have Mara read the content directly from NFS. Only the project owner can upload attachments. The conversation must be ACTIVE.

  • A user may attach up to 5 files per message — files are uploaded before the message is sent.
  • Accepted MIME types (MVP): images (PNG, JPEG), PDF, and a curated allow-list of text/source-code types — see the MIME Allow-list section below for the full list.
  • Maximum file size per attachment: 10 MB. No total-per-user quota in MVP (tracked as a Stopa D follow-up).
  • Pasting text longer than 1 000 characters into the chat input automatically converts it to a Pasted-<timestamp>.txt attachment (ISO 8601 with colons replaced by dashes — see Frontend Components for the exact pattern) instead of filling the input field (behaviour identical to Claude.ai / ChatGPT).
  • Attachments are stored on NFS under the project slug directory and linked to the conversation in the chat_attachment DB table. NFS path is resolved via project.slug (FK chain: conversation.project_id → project.id → project.slug).
  • Attachments are not editable after upload; deletion happens implicitly via project/conversation cascade.
  • Worker (ADR-024) reads attachment content directly from the NFS mount and builds the appropriate Anthropic content block per MIME type (image → image, PDF → document, text → text).
  • Usage tokens consumed by attachments are tracked automatically by UsagePricingCalculator via the existing gateway pipeline (be#143).
  • Anthropic Files API is not used — all content is sent as inline base64 or text blocks.

MIME Allow-list

MIME typeCategoryContent block
image/pngImageimage (base64)
image/jpegImageimage (base64)
application/pdfDocumentdocument (base64)
text/plainTexttext (UTF-8 inline)
text/markdownTexttext (UTF-8 inline)
text/javascriptTexttext (UTF-8 inline)
text/x-kotlinTexttext (UTF-8 inline)
text/cssTexttext (UTF-8 inline)
text/htmlTexttext (UTF-8 inline)
application/jsonText (exception)text (UTF-8 inline)
application/x-yamlText (exception)text (UTF-8 inline)
application/xmlText (exception)text (UTF-8 inline)

Note: application/json, application/x-yaml, and application/xml are in the application/ namespace but their content is plain text — the worker processes them the same way as text/* (UTF-8 read → text content block). BE validation must use an explicit allow-list (not a text/* wildcard) to prevent uploading arbitrary binary application/ files.


Flow 1 — Upload Attachment

sequenceDiagram
    actor User

    User->>+FE: selects file via button or drag-drop <br> (or pastes text > 1000 chars)

    FE->>FE: validate file size (≤ 10 MB) and MIME type
    alt file too large or unsupported MIME
        FE-->>User: show toast error, abort upload
    end

    FE->>FE: check current draft attachment count < 5
    alt already 5 attachments
        FE-->>User: show toast error, abort upload
    end

    FE->>+BE: POST /api/v1/projects/{projectId}/conversations/{conversationId}/attachments <br> Authorization: Bearer {accessToken} <br> multipart/form-data: file

    BE->>BE: validate access token
    alt token missing or invalid
        BE-->>FE: 401 Unauthorized <br> ErrorResponse
    end

    BE->>DB: load project by projectId
    alt project not found
        BE-->>FE: 404 Not Found <br> ErrorResponse
    end

    BE->>BE: check project belongs to user's tenant
    alt project does not belong to tenant
        BE-->>FE: 403 Forbidden <br> ErrorResponse
    end

    BE->>DB: load conversation by conversationId (scoped to projectId)
    alt conversation not found
        BE-->>FE: 404 Not Found <br> ErrorResponse
    end

    BE->>BE: check conversation status is ACTIVE
    alt conversation is CLOSED
        BE-->>FE: 409 Conflict <br> ErrorResponse
    end

    BE->>BE: validate file size ≤ 10 MB
    alt file too large
        BE-->>FE: 400 Bad Request <br> ErrorResponse
    end

    BE->>BE: validate MIME type is in allow-list
    alt MIME type not allowed
        BE-->>FE: 400 Bad Request <br> ErrorResponse
    end

    BE->>BE: validate filename not blank
    alt filename blank
        BE-->>FE: 400 Bad Request <br> ErrorResponse
    end

    BE->>BE: generate attachmentId (BIGSERIAL, from DB insert)
    BE->>BE: build NFS relative path: <br> .talkide/chat-attachments/{conversationId}/{attachmentId}_{filename}

    BE->>NFS: write file bytes to <br> /projects/{slug}/.talkide/chat-attachments/{conversationId}/{attachmentId}_{filename}

    BE->>DB: insert chat_attachment row <br> (conversationId, filename, mimeType, sizeBytes, relativePath)

    BE->>-FE: 201 Created <br> ChatAttachmentResponse

    FE->>-User: show attachment chip above input <br> (thumbnail for image, file icon for PDF/text)

POST /api/v1/projects/{projectId}/conversations/{conversationId}/attachments

Request: multipart/form-data with a single part named file.

201 Created ChatAttachmentResponse:

{
  "data": {
    "id": 42,
    "filename": "architecture-diagram.png",
    "mimeType": "image/png",
    "sizeBytes": 204800
  }
}

400 Bad Request (file too large) ErrorResponse:

{
  "status": 400,
  "code": "ATTACHMENT_TOO_LARGE",
  "message": "File exceeds the 10 MB limit"
}

400 Bad Request (unsupported MIME type) ErrorResponse:

{
  "status": 400,
  "code": "ATTACHMENT_MIME_NOT_ALLOWED",
  "message": "File type is not supported"
}

400 Bad Request (validation) ErrorResponse:

{
  "status": 400,
  "code": "VALIDATION_ERROR",
  "message": "Validation failed",
  "errors": [
    { "field": "file", "message": "must not be empty" }
  ]
}

401 Unauthorized (missing or invalid access token) ErrorResponse:

{
  "status": 401,
  "code": "AUTHENTICATION_FAILED",
  "message": "Access token is missing or invalid"
}

403 Forbidden (project does not belong to user’s tenant) ErrorResponse:

{
  "status": 403,
  "code": "FORBIDDEN",
  "message": "You do not have access to this project"
}

404 Not Found (project not found) ErrorResponse:

{
  "status": 404,
  "code": "NOT_FOUND_PROJECT",
  "message": "Project not found"
}

404 Not Found (conversation not found or does not belong to project) ErrorResponse:

{
  "status": 404,
  "code": "NOT_FOUND_CONVERSATION",
  "message": "Conversation not found"
}

409 Conflict (conversation is CLOSED) ErrorResponse:

{
  "status": 409,
  "code": "CONFLICT_CONVERSATION",
  "message": "Cannot upload attachments to a CLOSED conversation"
}

Flow 2 — Download / Preview Attachment

sequenceDiagram
    actor User

    User->>+FE: clicks attachment chip or thumbnail in message history

    FE->>+BE: GET /api/v1/projects/{projectId}/conversations/{conversationId}/attachments/{attachmentId} <br> Authorization: Bearer {accessToken}

    BE->>BE: validate access token
    alt token missing or invalid
        BE-->>FE: 401 Unauthorized <br> ErrorResponse
    end

    BE->>DB: load project by projectId
    alt project not found
        BE-->>FE: 404 Not Found <br> ErrorResponse
    end

    BE->>BE: check project belongs to user's tenant
    alt project does not belong to tenant
        BE-->>FE: 403 Forbidden <br> ErrorResponse
    end

    BE->>DB: load chat_attachment by attachmentId (scoped to conversationId)
    alt attachment not found
        BE-->>FE: 404 Not Found <br> ErrorResponse
    end

    BE->>NFS: stream file bytes from relativePath

    BE->>-FE: 200 OK <br> Content-Type: {mimeType} <br> Content-Disposition: inline; filename="{filename}" <br> file byte stream

    FE->>-User: browser renders inline preview or triggers download

GET /api/v1/projects/{projectId}/conversations/{conversationId}/attachments/{attachmentId}

Response: raw file bytes with the original Content-Type and Content-Disposition: inline; filename="{filename}". No JSON wrapper.

404 Not Found (attachment not found) ErrorResponse:

{
  "status": 404,
  "code": "NOT_FOUND_ATTACHMENT",
  "message": "Attachment not found"
}

Flow 3 — Send Message with Attachments

This flow extends UC-04003 Send Message. The SendMessageRequest gains an optional attachmentIds field.

sequenceDiagram
    actor User

    User->>+FE: submits message with attachment chips

    FE->>FE: validate message content and attachment list
    alt content blank and no attachments, or > 5 attachments
        FE-->>User: show error
    end

    FE->>+BE: POST /api/v1/projects/{projectId}/conversations/{conversationId}/messages <br> Authorization: Bearer {accessToken} <br> SendMessageRequest (content + attachmentIds)

    BE->>BE: validate access token, project, conversation (same as UC-04003)

    BE->>DB: verify each attachmentId belongs to this conversation and is unlinked (message_id IS NULL)
    alt any attachmentId invalid or already used
        BE-->>FE: 400 Bad Request <br> ErrorResponse
    end

    BE->>BE: validate total attachment count ≤ 5
    alt more than 5 attachments
        BE-->>FE: 400 Bad Request <br> ErrorResponse
    end

    BE->>DB: insert USER message

    BE->>DB: UPDATE chat_attachment SET message_id = {newMessageId} WHERE id IN (attachmentIds)

    BE->>DB: update conversation updatedAt

    BE->>-FE: 201 Created <br> NewMessagesResponse (USER message + attachments list)

    FE->>FE: append USER message with attachment chips to chat view
    FE->>FE: show typing indicator

    Note over FE,BE: SSE stream proceeds as in UC-04003. <br> Worker receives MessageDto with attachments and builds Anthropic content blocks.

POST /api/v1/projects/{projectId}/conversations/{conversationId}/messages SendMessageRequest (extended):

{
  "content": "Here is the design I was describing — can you implement it?",
  "attachmentIds": [42, 43]
}

201 Created NewMessagesResponse (extended):

{
  "data": {
    "messages": [
      {
        "id": 25,
        "role": "USER",
        "content": "Here is the design I was describing — can you implement it?",
        "createdAt": "2026-05-23T10:00:00Z",
        "attachments": [
          {
            "id": 42,
            "filename": "architecture-diagram.png",
            "mimeType": "image/png",
            "sizeBytes": 204800
          },
          {
            "id": 43,
            "filename": "Pasted-2026-05-23T10-00-00Z.txt",
            "mimeType": "text/plain",
            "sizeBytes": 3200
          }
        ]
      }
    ]
  }
}

400 Bad Request (attachment already linked to another message) ErrorResponse:

{
  "status": 400,
  "code": "ATTACHMENT_ALREADY_USED",
  "message": "One or more attachments are already linked to a message"
}

400 Bad Request (too many attachments) ErrorResponse:

{
  "status": 400,
  "code": "ATTACHMENT_COUNT_EXCEEDED",
  "message": "A message may not have more than 5 attachments"
}

Entity Model Changes

New table: chat_attachment

Added by a new forward-only Liquibase changeset (next sequential number after the current last changeset in talkide-be/src/main/resources/db/changelog/changes/).

ColumnTypeConstraintsNotes
idBIGSERIALPKAuto-generated
conversation_idBIGINTNOT NULL, FK → conversations(id) ON DELETE CASCADEScopes attachment to conversation
message_idBIGINTNULL, FK → messages(id) ON DELETE SET NULLSet when attachment is linked to a sent message; NULL = draft (not yet sent)
filenameVARCHAR(255)NOT NULLOriginal filename (or Pasted-<ISO>.txt for paste)
mime_typeVARCHAR(100)NOT NULLIANA MIME type string
size_bytesBIGINTNOT NULLFile size at upload time
relative_pathVARCHAR(500)NOT NULLPath relative to /projects/{slug}/ for portability, e.g. .talkide/chat-attachments/7/42_architecture-diagram.png
created_atTIMESTAMPTZNOT NULL DEFAULT NOW()Upload timestamp

Index: idx_chat_attachment_conversation_id ON chat_attachment(conversation_id).

ON DELETE SET NULL on message_id is intentional: talkide-be has no endpoint for deleting individual messages — message lifecycle is bound to the conversation cascade. If single-message deletion is added in the future, consider switching this constraint to ON DELETE CASCADE to prevent orphaned attachment rows.

Design decision — message_id in chat_attachment vs. a join table message_attachment: A single nullable message_id FK on chat_attachment is preferred over a separate join table because: (a) there is no many-to-many relationship — each attachment belongs to exactly one message once sent, (b) it keeps the schema simpler and reduces the join depth on the worker side.

Updated entity: MESSAGE

The MessageDto sent to the worker is extended with an attachments list (see Worker Integration section). No new DB column is needed — attachments are fetched via the message_id FK.

Updated entity model diagram

Add to model/README.md:

CHAT_ATTACHMENT {
    bigint id
    bigint conversation_id
    bigint message_id
    string filename
    string mime_type
    bigint size_bytes
    string relative_path
    timestamp created_at
}
CONVERSATION ||--o{ CHAT_ATTACHMENT : has
MESSAGE ||--o{ CHAT_ATTACHMENT : linked_to

NFS Layout

Attachments are stored under the per-project NFS directory that workers already have mounted (ADR-024):

/projects/{slug}/.talkide/chat-attachments/{conversationId}/{attachmentId}_{filename}

Example:

/projects/wildwood-bakery/.talkide/chat-attachments/7/42_architecture-diagram.png

NFS path resolution at upload time: BE loads ProjectEntity by projectId (already required for ownership check) and reads project.slug. The slug column is NOT NULL and unique — no denormalization needed in chat_attachment.

Cleanup: deleting a project removes /projects/{slug}/ entirely, including all attachments — no separate cleanup job needed. The DB rows cascade-delete via conversation.project_id → conversations → chat_attachment ON DELETE CASCADE.


Migration Plan

Production invariant (from project CLAUDE.md): Liquibase changesets are immutable and forward-only. The application-local.yaml has drop-first: false. Never edit existing changeset files. Always add a new file with the next sequential number under talkide-be/src/main/resources/db/changelog/changes/.

Steps for the BE developer:

  1. Identify the current highest changeset number in talkide-be/src/main/resources/db/changelog/changes/ and use the next available number (at the time of writing, the next free slot is 0053; always verify the actual highest existing number before creating the new file — 0052-create-environment-secrets-table.xml is already taken).
  2. Create 0053-create-chat-attachment.xml (or the actual next number) with:
    • CREATE TABLE chat_attachment as described above.
    • CREATE INDEX idx_chat_attachment_conversation_id.
  3. Add <include file="changes/0053-create-chat-attachment.xml"/> (with the actual file name) to db.changelog-master.xml.
  4. The message_id column lives in chat_attachment (nullable FK to messages) — no ALTER TABLE messages is needed.

Worker Integration

The worker receives an extended MessageDto when attachments are present:

interface AttachmentDto {
  id: number
  filename: string
  mimeType: string
  nfsPath: string   // absolute path on worker filesystem, e.g. /projects/wildwood-bakery/.talkide/chat-attachments/7/42_architecture-diagram.png
}

interface MessageDto {
  id: number
  role: 'USER' | 'PM' | 'SYSTEM'
  content: string
  createdAt: string
  attachments?: AttachmentDto[]
}

Content block construction in AnthropicAgentSdkHarness.ts:

function buildAttachmentBlock(attachment: AttachmentDto): ContentBlock {
  const bytes = fs.readFileSync(attachment.nfsPath)
  const base64 = bytes.toString('base64')

  if (attachment.mimeType === 'image/png' || attachment.mimeType === 'image/jpeg') {
    return {
      type: 'image',
      source: { type: 'base64', media_type: attachment.mimeType, data: base64 }
    }
  }

  if (attachment.mimeType === 'application/pdf') {
    return {
      type: 'document',
      source: { type: 'base64', media_type: 'application/pdf', data: base64 }
    }
  }

  // text/* and other text-based MIMEs
  return {
    type: 'text',
    text: `[Attachment: ${attachment.filename}]\n${bytes.toString('utf-8')}`
  }
}

Error handling: if a file is missing on NFS (race condition, partial upload, NFS mount issue), the worker logs a warning and skips the attachment block rather than failing the entire turn. A WARN-level log entry is emitted with attachment.id and attachment.nfsPath for ops visibility.


Frontend Components

ComponentPurpose
ChatAttachmentUpload.vueFile input button + drag-drop zone in the Mara chat input area. Triggers upload on file selection.
ChatAttachmentChip.vuePreview chip displayed above the input field: 80×80 thumbnail for images, 24×24 file icon + filename for PDF/text. Includes a remove (×) button to cancel a draft attachment before sending.
ChatAttachmentDisplay.vueRenders attachment chips inside a message bubble in the conversation history. Click opens inline preview (image/PDF) or triggers download (other).
useChatAttachments(conversationId) composableManages upload API calls, tracks local attachment draft state for the current message, exposes upload(file), remove(id), attachmentIds, isUploading, uploadProgress.

Icons: the attachment chips, drop-zone overlay, lightbox modal, and progress states use icons from lucide-vue-next (already a dependency in talkide-fe/package.json, version ^1.0.0). Specifically: Paperclip (attach button), X (remove chip / close modal), FileText (text/code chip), FileType (PDF chip), TriangleAlert (upload error overlay), Loader2 (preview loading spinner). No new icon library or dependency is required.

Paste handler (in chat input component):

input.addEventListener('paste', async (event) => {
  const text = event.clipboardData?.getData('text') ?? ''
  if (text.length > 1000) {
    event.preventDefault()
    const iso = new Date().toISOString().replace(/[:.]/g, '-')
    const file = new File([text], `Pasted-${iso}.txt`, { type: 'text/plain' })
    await useChatAttachments(conversationId).upload(file)
  }
})

Cost Considerations

Attaching files increases the token count per Anthropic API call:

TypeApproximate token cost
Image 1 MB (PNG/JPEG)~1 600 tokens
PDF 10 pages~3 000 tokens
Text 10 KB~2 500 tokens

Token usage is tracked automatically through the existing UsagePricingCalculator in the gateway pipeline (be#143 recordToolUse). No additional instrumentation is required. Users approaching their spending limit will be warned by the existing 80 % / 100 % alert thresholds in UserBudgetService.


UX Guidelines

Design tokens: All CSS custom properties (var(--*)) referenced below are existing talkide-fe design tokens defined in talkide-fe/src/main.css (light and dark theme blocks under :root and [data-theme="light"]), exposed to Tailwind v4 via the @theme block in the same file. No new tokens are introduced by this UC.

Attachment Chip Zone — Layout Above Chat Input

Chips are rendered in a horizontal flex row directly above the chat input bar, inside ChatAttachmentChip.vue. The row wraps onto additional lines when chips overflow the available width.

┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ [thumbnail]  │ │   [PDF icon] │ │  [TXT icon]  │
│           ×  │ │  report.pdf  │ │  Pasted-202… │
└──────────────┘ └──────────────┘ └──────────────┘
[+ Attach]  [  Type a message…                  ] [Send]

Chip dimensions: 120×80 px, border-radius: 8px, border: 1px solid var(--line-1), background: var(--bg-2).

Image chips: 80×80 px thumbnail with object-fit: cover, border-radius: 6px. The remaining 40 px wide strip on the right (or the top-right corner overlay) contains the remove button.

PDF / text chips: 24×24 px file icon (FileText for text/code, FileType for PDF from lucide-vue-next) centered inside a 48×48 px var(--bg-3) square, border-radius: 6px. Filename below the icon: mono 11px, var(--fg-3), max 16 characters with text-overflow: ellipsis.

Remove button (×): 16×16 px, positioned top: 4px right: 4px. On desktop: visible only on chip hover (opacity: 0opacity: 1, transition 0.12s). On mobile: always visible. Background var(--bg-3), border-radius: 50%, icon X 10px from lucide-vue-next.

”+ Attach” button: ghost, 13px, Paperclip icon 14px. Disabled (opacity 0.4, pointer-events none) when the draft already has 5 attachments or the conversation is CLOSED.


Drag-and-Drop Zone

The entire chat input area acts as a drop zone (ChatAttachmentUpload.vue). When the user drags a file over the area:

  • Container switches to border: 2px dashed var(--amber), background: var(--amber-soft), transition 0.1s.
  • A centered overlay label appears: "Drop file to attach" — 14px, var(--fg-2), font-weight: 500.
  • Cursor changes to copy.

Dropping outside the chat input area is a no-op (dragover not prevented on document body). When the drag leaves the zone, the styling reverts immediately.

Implementation note: the native dragleave event fires every time the cursor crosses any child element inside the drop zone, which causes the highlight state to flicker on/off. Use a dragenter/dragleave counter (increment on enter, decrement on leave, hide the highlight only when the counter reaches 0) — or alternatively compare event.target against the drop-zone root and ignore events whose target is a descendant. The composable useChatAttachments should expose this state as a single boolean isDragOver so the template stays trivial.


Paste Handler UX

Long-text paste is intercepted in the chat input’s paste event handler:

  • Text ≤ 1 000 characters: paste proceeds normally into the textarea — no intervention.
  • Text > 1 000 characters: event.preventDefault() is called; the text is silently converted to a Pasted-<ISO-timestamp>.txt file and uploaded via the same upload(file) path as a manual attachment. The chip appears in the draft zone.

A toast notification appears for 3 seconds (severity: info, var(--indigo-soft) background — info severity reuses the indigo accent track defined in main.css):

“Pasted text saved as attachment. [Insert as text instead]”

The inline link “Insert as text instead” removes the attachment chip, cancels the upload if still in progress, and inserts the original text into the textarea.


Upload Progress

After a file is selected or dropped, the chip appears immediately in the draft zone in an “uploading” state:

  • A linear progress bar (4px height, var(--amber) fill on var(--bg-3) track) renders below the thumbnail or icon, spanning the full chip width.
  • Progress value is driven by the XMLHttpRequest progress event exposed through useChatAttachments composable (uploadProgress ref, 0–100).
  • On completion: the progress bar fades out (opacity: 0, transition 0.2s), the chip settles into its normal state.
  • The [Send] button is disabled while any chip in the draft is still in the uploading state (isUploading === true).

Upload error state on chip: border: 1px solid var(--rose), a TriangleAlert icon (14px, var(--rose)) overlays the top-left corner. A tooltip (shown on chip hover, role="tooltip") displays the human-readable error message. The user can click × to remove the errored chip.


Error States — Toast Notifications

All upload errors trigger a toast notification. Toasts appear at the bottom-right of the viewport, stacked, auto-dismiss after 5 seconds unless the user hovers.

TriggerToast messageSeverity
File > 10 MB (client-side pre-check)“File too large — max 10 MB per attachment”error
5 attachments already in draft”Maximum 5 attachments per message”error
MIME type not in allow-list (client-side)“File type not supported. Allowed: PNG, JPG, PDF, and text/code files”error
Network error or BE 5xx during upload”Upload failed. Retry?” + inline Retry button that re-submits the same fileerror
BE returns 409 CONFLICT_CONVERSATION”Cannot attach files to a closed conversation”error
BE returns 400 ATTACHMENT_TOO_LARGE (server-side)“File too large — max 10 MB per attachment”error
BE returns 400 ATTACHMENT_MIME_NOT_ALLOWED”File type not supported”error

Attachment Display in Message History

Rendered by ChatAttachmentDisplay.vue, inside the USER message bubble, below the message text.

  • Chips share the same visual language as the draft zone chips, but are smaller: 96×64 px. No remove button.
  • Layout: horizontal flex row, flex-wrap: wrap, gap: 6px.
  • Filename is truncated at 18 characters with text-overflow: ellipsis.

Click behaviour:

Attachment typeAction
Image (PNG, JPEG)Opens a lightbox modal: full-resolution image centered, dark overlay (rgba(0,0,0,0.75)), × close button top-right, Download ghost button bottom-right
PDFOpens the GET endpoint URL in a new browser tab (target="_blank"); the browser’s built-in PDF viewer handles rendering
Text / code / JSON / YAML / XMLOpens a modal with a <pre> block, monospace 13px, horizontal scroll, background: var(--bg-2), border-radius: 8px. The modal has a Copy button (top-right) and a Download button (bottom-right). Syntax highlighting via highlight.js is optional and deferred as a follow-up

Loading state (after click): a spinner (Loader2 icon, 20px, spin animation) replaces the preview content area while the GET endpoint streams the file. Timeout after 30 seconds shows an inline error: "Failed to load attachment." + a Retry link.


Accessibility

  • Each chip has aria-label="{filename}, {sizeBytes formatted as KB/MB}".
  • The remove button has aria-label="Remove attachment {filename}".
  • The upload button has aria-label="Attach file".
  • The drop zone wrapper has role="region" and aria-label="Attachment drop zone".
  • Screen reader live region (aria-live="polite") announces upload completion: "Attachment uploaded: {filename}" and upload errors: "Upload failed: {filename} — {reason}".
  • Keyboard navigation: Tab moves through chips in DOM order. Enter or Space on a chip opens its preview. Delete or Backspace on a focused chip triggers removal (same as clicking ×).
  • Image thumbnails have alt="{filename}". Non-image chips have aria-hidden="true" on the decorative icon.
  • All interactive controls meet WCAG AA contrast minimum (4.5:1) against var(--bg-2) chip background.
  • The lightbox modal traps focus while open; Escape closes it.

Responsive Behaviour

ViewportChip row behaviourChip sizeRemove button
Desktop (> 1024 px)Flex row, wrap after 3–4 chips120×80 pxOn hover only
Tablet (768–1024 px)Flex row, max 2 chips per visual row before wrap120×80 pxOn hover only
Mobile (< 768 px)Full-width chips stacked vertically, width: 100%100% × 64 px; thumbnail 60×60 pxAlways visible

On mobile the chip filename truncation extends to 22 characters (more horizontal space available in full-width layout).


Empty / Initial State

No special empty state in the input bar. The [+ Attach] button is always visible. The chip row is hidden (zero height, no space reserved) when the draft has zero attachments. The button is disabled with opacity: 0.4 and cursor: not-allowed only when the draft already contains 5 attachments or the conversation is CLOSED.


Frontend

Validations

FieldConstraintsSizePatternNote
file (upload)not_null≤ 10 MBClient-side MIME check against allow-list before upload
attachmentIds (send message)optional, each item positive0 – 5 itemsCount checked before submitting; empty array = message without attachments
paste text → auto-attachauto-triggered> 1 000 charsFE converts to Pasted-<ISO>.txt file and uploads silently

Backend

Validations

FieldConstraintsSizePatternNote
projectIdnot_null, positivePath variable; must reference an existing project belonging to the user’s tenant
conversationIdnot_null, positivePath variable; must reference a conversation in the project
file (multipart)not_null, not_empty1 B – 10 485 760 BReject if Content-Length > 10 MB or MIME not in allow-list
mimeTypein allow-listAllow-list is the explicit set from the MIME Allow-list section above (no text/* wildcard).
attachmentIds (send message)optional, each references a valid chat_attachment0 – 5Each attachment must belong to this conversation and have message_id IS NULL

Test Cases

GIVENWHENTHEN
authenticated user, ACTIVE conversation, valid PNG ≤ 10 MBPOST …/attachments is called201 Created; file written to NFS; DB row inserted with correct relativePath; response contains id, filename, mimeType, sizeBytes
authenticated user, ACTIVE conversation, valid PDF ≤ 10 MBPOST …/attachments is called201 Created; mimeType = application/pdf; file on NFS
authenticated user, ACTIVE conversation, text/plain filePOST …/attachments is called201 Created; mimeType = text/plain; file on NFS
file size exactly 10 MB (10 485 760 bytes)POST …/attachments is called201 Created (boundary is inclusive)
file size 10 485 761 bytes (10 MB + 1 byte)POST …/attachments is called400 ATTACHMENT_TOO_LARGE
unsupported MIME type (e.g. video/mp4)POST …/attachments is called400 ATTACHMENT_MIME_NOT_ALLOWED
CLOSED conversationPOST …/attachments is called409 CONFLICT_CONVERSATION
conversation belongs to a different projectPOST …/attachments is called404 NOT_FOUND_CONVERSATION
project belongs to a different tenantPOST …/attachments is called403 FORBIDDEN
no Authorization headerPOST …/attachments is called401 AUTHENTICATION_FAILED
attachment belongs to a different conversationGET …/attachments/{attachmentId} is called404 NOT_FOUND_ATTACHMENT
authenticated user, correct conversationGET …/attachments/{attachmentId} is called200 OK; file bytes streamed; Content-Type matches mimeType
request without Authorization headerGET …/attachments/{attachmentId} is called401 AUTHENTICATION_FAILED
valid token of a different tenantGET …/attachments/{attachmentId} is called403 FORBIDDEN
send message with 2 valid draft attachmentIdsPOST …/messages is called201 Created; USER message returned with attachments list; both chat_attachment rows updated with message_id
send message with attachmentId already linked to another messagePOST …/messages is called400 ATTACHMENT_ALREADY_USED
send message with 6 attachmentIdsPOST …/messages is called400 ATTACHMENT_COUNT_EXCEEDED
FE pastes text > 1 000 charspaste event firesfile created as Pasted-<ISO>.txt, upload request sent, chip shown in input area
FE pastes text ≤ 1 000 charspaste event firestext inserted normally into input field, no upload triggered
worker receives message with PNG attachment; nfsPath is validSSE stream openAnthropic image content block sent; Mara responds to the image
worker receives message with PDF attachmentSSE stream openAnthropic document content block (base64 PDF) sent; Mara reads the PDF
worker receives message with text attachmentSSE stream openAnthropic text content block with file content inline; Mara reads the text
worker receives message; nfsPath file missing on NFSSSE stream openattachment block skipped; WARN log emitted with attachmentId and path; processing continues
two concurrent uploads to the same conversationboth POST …/attachments requests in flighteach attachment gets a unique BIGSERIAL id and a distinct NFS filename; no collision
project is deletedproject delete triggeredentire /projects/{slug}/ tree (including attachments) removed from NFS; DB rows cascade-deleted
send message with empty attachmentIds arrayPOST …/messages is called201 Created; message sent without attachments (backward-compatible)
send message without attachmentIds fieldPOST …/messages is called201 Created; treated as zero attachments (backward-compatible)

Out of Scope (MVP)

The following features are explicitly not included in this UC and should be tracked as separate follow-up issues:

FeatureNotes
Inline image rendering in markdownAttachment chips only in MVP; no {/* Image missing: img (url) */} injection into message body
Image cropping / editing before uploadFE sends file as-is
Bulk download (ZIP)Single-file download/preview only
Anthropic Files API integrationNot used — inline base64 content blocks only
Per-user storage quota trackingNo total quota in MVP; to be added in Stopa D if abuse is observed
Virus / malware scanningRecommend adding ClamAV scan as a Stopa D follow-up before public launch
Attachment deletion endpointAttachments are immutable once uploaded; cascade-deleted with conversation/project
Video / audio MIME typesOut of scope; Anthropic SDK does not support them natively
Attachment reuse across messagesEach attachment is linked to exactly one message (single-use)

Was this page helpful?

Thanks for the feedback.