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>.txtattachment (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_attachmentDB table. NFS path is resolved viaproject.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
UsagePricingCalculatorvia 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 type | Category | Content block |
|---|---|---|
image/png | Image | image (base64) |
image/jpeg | Image | image (base64) |
application/pdf | Document | document (base64) |
text/plain | Text | text (UTF-8 inline) |
text/markdown | Text | text (UTF-8 inline) |
text/javascript | Text | text (UTF-8 inline) |
text/x-kotlin | Text | text (UTF-8 inline) |
text/css | Text | text (UTF-8 inline) |
text/html | Text | text (UTF-8 inline) |
application/json | Text (exception) | text (UTF-8 inline) |
application/x-yaml | Text (exception) | text (UTF-8 inline) |
application/xml | Text (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/).
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | BIGSERIAL | PK | Auto-generated |
conversation_id | BIGINT | NOT NULL, FK → conversations(id) ON DELETE CASCADE | Scopes attachment to conversation |
message_id | BIGINT | NULL, FK → messages(id) ON DELETE SET NULL | Set when attachment is linked to a sent message; NULL = draft (not yet sent) |
filename | VARCHAR(255) | NOT NULL | Original filename (or Pasted-<ISO>.txt for paste) |
mime_type | VARCHAR(100) | NOT NULL | IANA MIME type string |
size_bytes | BIGINT | NOT NULL | File size at upload time |
relative_path | VARCHAR(500) | NOT NULL | Path relative to /projects/{slug}/ for portability, e.g. .talkide/chat-attachments/7/42_architecture-diagram.png |
created_at | TIMESTAMPTZ | NOT 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.yamlhasdrop-first: false. Never edit existing changeset files. Always add a new file with the next sequential number undertalkide-be/src/main/resources/db/changelog/changes/.
Steps for the BE developer:
- 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 is0053; always verify the actual highest existing number before creating the new file —0052-create-environment-secrets-table.xmlis already taken). - Create
0053-create-chat-attachment.xml(or the actual next number) with:CREATE TABLE chat_attachmentas described above.CREATE INDEX idx_chat_attachment_conversation_id.
- Add
<include file="changes/0053-create-chat-attachment.xml"/>(with the actual file name) todb.changelog-master.xml. - The
message_idcolumn lives inchat_attachment(nullable FK tomessages) — noALTER TABLE messagesis 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
| Component | Purpose |
|---|---|
ChatAttachmentUpload.vue | File input button + drag-drop zone in the Mara chat input area. Triggers upload on file selection. |
ChatAttachmentChip.vue | Preview 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.vue | Renders attachment chips inside a message bubble in the conversation history. Click opens inline preview (image/PDF) or triggers download (other). |
useChatAttachments(conversationId) composable | Manages 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:
| Type | Approximate 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 intalkide-fe/src/main.css(light and dark theme blocks under:rootand[data-theme="light"]), exposed to Tailwind v4 via the@themeblock 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: 0 → opacity: 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
dragleaveevent fires every time the cursor crosses any child element inside the drop zone, which causes the highlight state to flicker on/off. Use adragenter/dragleavecounter (increment on enter, decrement on leave, hide the highlight only when the counter reaches0) — or alternatively compareevent.targetagainst the drop-zone root and ignore events whose target is a descendant. The composableuseChatAttachmentsshould expose this state as a single booleanisDragOverso 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 aPasted-<ISO-timestamp>.txtfile and uploaded via the sameupload(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 onvar(--bg-3)track) renders below the thumbnail or icon, spanning the full chip width. - Progress value is driven by the
XMLHttpRequestprogressevent exposed throughuseChatAttachmentscomposable (uploadProgressref, 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.
| Trigger | Toast message | Severity |
|---|---|---|
| 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 file | error |
| 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 type | Action |
|---|---|
| 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 |
Opens the GET endpoint URL in a new browser tab (target="_blank"); the browser’s built-in PDF viewer handles rendering | |
| Text / code / JSON / YAML / XML | Opens 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"andaria-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:
Tabmoves through chips in DOM order.EnterorSpaceon a chip opens its preview.DeleteorBackspaceon a focused chip triggers removal (same as clicking ×). - Image thumbnails have
alt="{filename}". Non-image chips havearia-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;
Escapecloses it.
Responsive Behaviour
| Viewport | Chip row behaviour | Chip size | Remove button |
|---|---|---|---|
| Desktop (> 1024 px) | Flex row, wrap after 3–4 chips | 120×80 px | On hover only |
| Tablet (768–1024 px) | Flex row, max 2 chips per visual row before wrap | 120×80 px | On hover only |
| Mobile (< 768 px) | Full-width chips stacked vertically, width: 100% | 100% × 64 px; thumbnail 60×60 px | Always 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
| Field | Constraints | Size | Pattern | Note |
|---|---|---|---|---|
| file (upload) | not_null | ≤ 10 MB | — | Client-side MIME check against allow-list before upload |
| attachmentIds (send message) | optional, each item positive | 0 – 5 items | — | Count checked before submitting; empty array = message without attachments |
| paste text → auto-attach | auto-triggered | > 1 000 chars | — | FE converts to Pasted-<ISO>.txt file and uploads silently |
Backend
Validations
| Field | Constraints | Size | Pattern | Note |
|---|---|---|---|---|
| projectId | not_null, positive | — | — | Path variable; must reference an existing project belonging to the user’s tenant |
| conversationId | not_null, positive | — | — | Path variable; must reference a conversation in the project |
| file (multipart) | not_null, not_empty | 1 B – 10 485 760 B | — | Reject if Content-Length > 10 MB or MIME not in allow-list |
| mimeType | in allow-list | — | — | Allow-list is the explicit set from the MIME Allow-list section above (no text/* wildcard). |
| attachmentIds (send message) | optional, each references a valid chat_attachment | 0 – 5 | — | Each attachment must belong to this conversation and have message_id IS NULL |
Test Cases
| GIVEN | WHEN | THEN |
|---|---|---|
| authenticated user, ACTIVE conversation, valid PNG ≤ 10 MB | POST …/attachments is called | 201 Created; file written to NFS; DB row inserted with correct relativePath; response contains id, filename, mimeType, sizeBytes |
| authenticated user, ACTIVE conversation, valid PDF ≤ 10 MB | POST …/attachments is called | 201 Created; mimeType = application/pdf; file on NFS |
| authenticated user, ACTIVE conversation, text/plain file | POST …/attachments is called | 201 Created; mimeType = text/plain; file on NFS |
| file size exactly 10 MB (10 485 760 bytes) | POST …/attachments is called | 201 Created (boundary is inclusive) |
| file size 10 485 761 bytes (10 MB + 1 byte) | POST …/attachments is called | 400 ATTACHMENT_TOO_LARGE |
| unsupported MIME type (e.g. video/mp4) | POST …/attachments is called | 400 ATTACHMENT_MIME_NOT_ALLOWED |
| CLOSED conversation | POST …/attachments is called | 409 CONFLICT_CONVERSATION |
| conversation belongs to a different project | POST …/attachments is called | 404 NOT_FOUND_CONVERSATION |
| project belongs to a different tenant | POST …/attachments is called | 403 FORBIDDEN |
| no Authorization header | POST …/attachments is called | 401 AUTHENTICATION_FAILED |
| attachment belongs to a different conversation | GET …/attachments/{attachmentId} is called | 404 NOT_FOUND_ATTACHMENT |
| authenticated user, correct conversation | GET …/attachments/{attachmentId} is called | 200 OK; file bytes streamed; Content-Type matches mimeType |
| request without Authorization header | GET …/attachments/{attachmentId} is called | 401 AUTHENTICATION_FAILED |
| valid token of a different tenant | GET …/attachments/{attachmentId} is called | 403 FORBIDDEN |
| send message with 2 valid draft attachmentIds | POST …/messages is called | 201 Created; USER message returned with attachments list; both chat_attachment rows updated with message_id |
| send message with attachmentId already linked to another message | POST …/messages is called | 400 ATTACHMENT_ALREADY_USED |
| send message with 6 attachmentIds | POST …/messages is called | 400 ATTACHMENT_COUNT_EXCEEDED |
| FE pastes text > 1 000 chars | paste event fires | file created as Pasted-<ISO>.txt, upload request sent, chip shown in input area |
| FE pastes text ≤ 1 000 chars | paste event fires | text inserted normally into input field, no upload triggered |
| worker receives message with PNG attachment; nfsPath is valid | SSE stream open | Anthropic image content block sent; Mara responds to the image |
| worker receives message with PDF attachment | SSE stream open | Anthropic document content block (base64 PDF) sent; Mara reads the PDF |
| worker receives message with text attachment | SSE stream open | Anthropic text content block with file content inline; Mara reads the text |
| worker receives message; nfsPath file missing on NFS | SSE stream open | attachment block skipped; WARN log emitted with attachmentId and path; processing continues |
| two concurrent uploads to the same conversation | both POST …/attachments requests in flight | each attachment gets a unique BIGSERIAL id and a distinct NFS filename; no collision |
| project is deleted | project delete triggered | entire /projects/{slug}/ tree (including attachments) removed from NFS; DB rows cascade-deleted |
| send message with empty attachmentIds array | POST …/messages is called | 201 Created; message sent without attachments (backward-compatible) |
| send message without attachmentIds field | POST …/messages is called | 201 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:
| Feature | Notes |
|---|---|
| Inline image rendering in markdown | Attachment chips only in MVP; no {/* Image missing: img (url) */} injection into message body |
| Image cropping / editing before upload | FE sends file as-is |
| Bulk download (ZIP) | Single-file download/preview only |
| Anthropic Files API integration | Not used — inline base64 content blocks only |
| Per-user storage quota tracking | No total quota in MVP; to be added in Stopa D if abuse is observed |
| Virus / malware scanning | Recommend adding ClamAV scan as a Stopa D follow-up before public launch |
| Attachment deletion endpoint | Attachments are immutable once uploaded; cascade-deleted with conversation/project |
| Video / audio MIME types | Out of scope; Anthropic SDK does not support them natively |
| Attachment reuse across messages | Each attachment is linked to exactly one message (single-use) |
Thanks for the feedback.