# Attachment Drag-and-Drop Upload Spec Date: 2026-06-29 Status: Draft ## Problem OpenSquilla should let users attach files by dragging them into the chat surface in both the browser Web UI and the Electron desktop client. The behavior must be consistent across surfaces and must use the existing gateway attachment ingestion path rather than adding a parallel desktop-only file pipeline. The current code already contains most of the upload path: - `opensquilla-webui/src/views/ChatView.vue` listens for drag/drop on the chat thread and passes dropped files to `addAttachment`. - `opensquilla-webui/src/composables/chat/useChatAttachments.ts` validates file type and size, inlines small files, and stages large files through `/api/v1/files/upload`. - `opensquilla-webui/src/composables/chat/useChatSend.ts` sends staged files as `{ type, file_uuid, mime, name }`. - `src/opensquilla/gateway/uploads.py` exposes the multipart upload route and stores staged bytes behind an opaque `file_uuid`. - `src/opensquilla/gateway/attachment_ingest.py` resolves `file_uuid` entries into transcript material references before a turn runs. - `desktop/electron/src/main.ts` creates a sandboxed, context-isolated browser window; `desktop/electron/src/preload.cts` exposes only explicit desktop APIs. The missing product-quality layer is a complete, reviewed drag-upload UX and test contract that covers both browser and desktop surfaces, including auth, error states, retry behavior, and cross-platform desktop validation. ## Goals - Support dragging files from the OS/browser file picker into the Web UI chat surface. - Support the same drag behavior in the Electron desktop client without a separate upload implementation. - Preserve the existing attachment policy: max count, per-category size caps, total size cap, MIME sniffing, and staged upload TTL. Any file type is admitted; the MIME set only routes representation (rendered families are extracted or inlined, everything else stages as an opaque workspace file). - Make upload progress and failure states visible in the composer. - Prevent sending while any attachment is still reading or uploading. - Ensure token-authenticated gateways can upload files by using the same bearer token source as the WebSocket/RPC client. - Keep raw local file paths out of renderer code and out of chat payloads. - Persist only stable transcript material references after `chat.send` accepts the turn; do not persist `file_uuid` in transcripts. ## Non-Goals - Do not implement folder drag-and-drop. - Do not upload directory trees or recursively enumerate local paths. - Do not expose arbitrary local filesystem reads through Electron preload. - Do not make Electron bypass the gateway upload endpoint. - Do not add resumable/chunked uploads in this iteration. - Do not add per-type client-side gating: admission is any-type (opaque staging), and rejections happen only on size/count policy, mirroring the gateway's category router in `contracts/attachments.py`. - Do not support remote URL drag/drop as file upload unless the browser supplies a real `File` object. ## Existing Surfaces ### Web UI - `ChatView.vue` owns high-level chat surface events, including drag/drop and paste. - `ChatComposer.vue` renders pending attachment chips and the file input. - `useChatAttachments.ts` owns attachment state, MIME resolution, inline reads, staged uploads, and local validation. - `useChatSend.ts` serializes pending attachments into the `chat.send` RPC payload. ### Gateway - `contracts/attachments.py` defines allowed media types and size limits. - `uploads.py` handles multipart staging and returns `file_uuid`. - `attachment_ingest.py` validates inline and staged attachments, writes transcript material, and returns consumed staged UUIDs. - `rpc_sessions.py` evicts consumed `file_uuid` values only after the turn has been accepted into the runtime. ### Desktop - The Electron renderer runs the same Vue Web UI as the browser. - The Electron window is sandboxed and context-isolated. - The preload API currently exposes gateway status, settings, onboarding, and artifact open helpers; it does not expose raw file reads. ## Recommended Design Use one shared Web UI upload path for browser and Electron: ```text User drops files -> ChatView drop handler -> useChatAttachments.addAttachments(files) -> inline small files OR POST large files to /api/v1/files/upload -> ChatComposer renders pending chips -> useChatSend serializes attachments -> chat.send RPC -> gateway attachment_ingest resolves file_uuid -> turn runtime receives stable attachment refs ``` Electron should not get a special upload bridge in the first iteration. When a file is dragged from the OS into the Electron browser window, Chromium exposes it to renderer code as a `File`. That is the same object shape the browser path uses, so the renderer can call the same composable. Add a desktop-specific bridge only if manual validation proves a platform cannot produce usable `DataTransfer.files`, or if a later feature explicitly needs native behavior such as folder selection, revealing local file paths, or watching local files for changes. ## User Experience ### Drop Zone The primary drop zone is the chat thread/composer area. Expected behavior: - `dragenter` / `dragover` with at least one file item shows a visible drop affordance. - `dragleave` uses a drag-depth counter or equivalent containment check so the affordance does not flicker when moving across child elements. - `drop` extracts only `File` objects and ignores non-file drag data. - Dropping files focuses the composer and appends attachments to the pending list. - Unsupported items produce a toast and do not block valid files in the same drop. ### Attachment Chips Each pending attachment chip should show: - name; - MIME or concise type label; - size; - state: reading, uploading, ready, failed; - thumbnail for image inline attachments when available; - remove button; - retry button for staged upload failures. Sending is disabled while any attachment is `reading` or `uploading`. ### Empty Message Behavior If the user sends with attachments and no text, the existing fallback message `Describe these attachments` remains acceptable. The visible bubble should still show the user's display text when present and attachment chips/previews when attachments exist. ## Attachment State Model Use an explicit state machine in `useChatAttachments.ts`. Recommended internal states: | State | Meaning | Sendable | | --- | --- | --- | | `inline_pending` | FileReader is reading a small file. | No | | `inline` | Base64 data is ready in the pending payload. | Yes | | `uploading` | Multipart upload is in progress. | No | | `staged` | Gateway returned a `file_uuid`. | Yes | | `failed` | Read or upload failed and can be removed or retried. | No | The public type may keep the existing names, but code paths should treat them as states rather than ad-hoc variants. `addAttachment(file)` should be implemented in terms of `addAttachments(files: File[])` so file picker, paste, and drag/drop share batch validation behavior. ## Data Contracts ### Inline Attachment Payload Small files are sent through `chat.send` as: ```json { "type": "image/png", "mime": "image/png", "name": "screenshot.png", "data": "" } ``` ### Staged Upload Request Large files are uploaded first: ```http POST /api/v1/files/upload Content-Type: multipart/form-data Authorization: Bearer ``` Multipart fields: - `file`: the file bytes and filename; - `mime`: the client-resolved MIME, used only as a claim; gateway revalidates. Expected success response: ```json { "file_uuid": "u-...", "filename": "report.pdf", "mime": "application/pdf", "size": 12345 } ``` ### Staged `chat.send` Payload After staging, `chat.send` sends: ```json { "type": "application/pdf", "mime": "application/pdf", "name": "report.pdf", "file_uuid": "u-..." } ``` The gateway resolves this into a stable attachment ref and must not persist `file_uuid` into transcript envelopes. ## Auth Requirements The multipart upload route intentionally requires header-based auth in token mode. Query-string token auth must remain rejected for uploads. Vue upload requests must therefore include the same token source used by the WebSocket/RPC connection: - read `sessionStorage.getItem("opensquilla.wsToken")`; - when present, set `Authorization: Bearer `; - keep `credentials: "same-origin"` for same-origin cookies and browser policy; - never place the token in the upload URL. This is important because the current Vue staged upload path uses `credentials: "same-origin"` but does not include an authorization header. The legacy static chat upload path already includes the bearer token and should be used as the behavior reference. ## Security and Safety Rules - Frontend validation is advisory UX only; gateway validation remains authoritative. - The renderer must never send local filesystem paths in chat payloads. - Electron preload must not expose an arbitrary `readFile(path)` API for this feature. - `file_uuid` is a short-lived upload-store identifier, not a durable reference. - Staged upload bytes are evicted only after the turn is accepted. - Failed `chat.send` after successful staging must keep the staged file retryable until TTL expiry. - MIME sniffing mismatches are handled by gateway policy, not by trusting the browser-provided `File.type`. - Directory entries and zero-byte files should be rejected with clear UX. ## Implementation Plan ### Frontend 1. Add `addAttachments(files: File[])` in `useChatAttachments.ts`. 2. Route file picker, paste, and drop through `addAttachments`. 3. Add drag-depth or containment-safe drop-zone state in `ChatView.vue`. 4. Show a drop affordance only when the drag payload contains files. 5. Add upload auth headers to staged upload requests. 6. Add retry handling for staged upload failures. 7. Keep `ChatComposer.vue` presentational: props down, events up. 8. Ensure `useChatSend.ts` does not clear failed attachments as if they were successfully queued. ### Gateway 1. Keep `/api/v1/files/upload` as the only staged upload endpoint. 2. Keep the upload route header-token requirement unchanged. 3. Confirm `UploadStore` and `attachment_ingest` reject unsupported MIME, over size, unknown UUID, restart-lost UUID, and total size overflow. 4. Add targeted tests only where the new frontend contract exposes gaps. ### Desktop 1. Validate drag/drop in packaged or dev Electron on macOS, Windows, and Linux where available. 2. Keep Electron preload unchanged unless validation proves native bridging is required. 3. If a bridge becomes required, expose only a narrow capability such as `stageDroppedFile(handle)` and keep all byte validation in the gateway. ## Test Plan ### Frontend Unit Tests - `resolveAttachmentMime()` prefers allowed browser MIME and falls back to extension. - Unknown UTF-8 text degrades to `text/plain`. - Unknown binary files are rejected. - Oversize files are rejected by per-MIME cap. - `addAttachments()` handles mixed valid and invalid files without dropping the valid ones. - Inline files transition from `inline_pending` to `inline`. - Large stageable files transition from `uploading` to `staged`. - Failed staged upload leaves a failed/removable state. - Upload requests include `Authorization: Bearer ` when the token exists. - Upload requests do not put the token in the URL. ### Vue Component Tests - Dragging files over the chat surface shows the drop affordance. - Dragging non-file data does not show the drop affordance. - Dropping files calls the attachment composable once with all dropped files. - Sending is disabled while any attachment is reading or uploading. - Removing an attachment updates the pending list without mutating child props. ### Gateway Tests - Multipart upload rejects missing auth header in token mode. - Multipart upload accepts bearer token in token mode. - Multipart upload rejects query-token-only auth. - Staged `file_uuid` resolution returns an attachment ref with no `file_uuid` or inline `data`. - Restart-lost UUID produces the re-upload error path. - Successful turn acceptance evicts consumed UUIDs. - Failed turn acceptance does not evict consumed UUIDs. ### Browser E2E - Drop one small image into Web UI, send, and assert `chat.send` carries inline base64 attachment data. - Drop one large PDF into Web UI, wait for staged chip, send, and assert `chat.send` carries `file_uuid`. - Drop a valid file and an invalid file together; valid file remains pending and invalid file produces a toast. - Verify mobile and desktop layouts do not overflow with long filenames or wide image thumbnails. ### Desktop Manual Smoke Run on Electron dev and packaged builds: 1. Start the desktop client. 2. Drag a small PNG from the OS file manager into the chat thread. 3. Confirm the composer shows a thumbnail chip. 4. Send and confirm the user bubble renders the attachment. 5. Drag a PDF larger than the inline threshold. 6. Confirm upload progress changes to staged/ready. 7. Send and confirm the gateway accepts the turn. 8. Repeat with token auth enabled. 9. Confirm no local file path appears in the chat payload, transcript, or logs. ## Acceptance Criteria - Browser Web UI supports drag-and-drop upload for every MIME currently allowed by `contracts/attachments.py`. - Electron desktop supports the same drag-and-drop behavior without a separate upload code path. - Token-authenticated uploads succeed because the frontend sends bearer auth headers. - Query-token-only multipart upload remains rejected. - Users cannot send while attachments are still reading or uploading. - Failed uploads are visible and removable or retryable. - Staged attachments are sent as `file_uuid` and are materialized into stable transcript attachment refs before runtime execution. - `file_uuid` never appears in persisted transcript envelopes. - Successful turn acceptance evicts consumed staged uploads. - Frontend and backend tests cover valid, invalid, oversize, auth, retry, and layout cases. ## Rollout - Ship behind the normal chat composer behavior with no user-facing setting. - Keep existing file input and paste upload behavior working during rollout. - Validate Web UI first, then Electron dev, then packaged desktop. - Add troubleshooting guidance only if desktop platform differences require user-visible explanation. ## Open Questions - Should failed staged upload chips offer retry, or should failure remove the chip and rely on toast only? - Should duplicate files in the same drop be deduplicated by `name + size + lastModified`, or should duplicates be allowed? - Should the drop affordance cover only the chat thread or the full chat view? - Should future folder drag support be handled through a separate desktop-only import flow rather than this attachment pipeline?