15 KiB
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.vuelistens for drag/drop on the chat thread and passes dropped files toaddAttachment.opensquilla-webui/src/composables/chat/useChatAttachments.tsvalidates file type and size, inlines small files, and stages large files through/api/v1/files/upload.opensquilla-webui/src/composables/chat/useChatSend.tssends staged files as{ type, file_uuid, mime, name }.src/opensquilla/gateway/uploads.pyexposes the multipart upload route and stores staged bytes behind an opaquefile_uuid.src/opensquilla/gateway/attachment_ingest.pyresolvesfile_uuidentries into transcript material references before a turn runs.desktop/electron/src/main.tscreates a sandboxed, context-isolated browser window;desktop/electron/src/preload.ctsexposes 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.sendaccepts the turn; do not persistfile_uuidin 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
Fileobject.
Existing Surfaces
Web UI
ChatView.vueowns high-level chat surface events, including drag/drop and paste.ChatComposer.vuerenders pending attachment chips and the file input.useChatAttachments.tsowns attachment state, MIME resolution, inline reads, staged uploads, and local validation.useChatSend.tsserializes pending attachments into thechat.sendRPC payload.
Gateway
contracts/attachments.pydefines allowed media types and size limits.uploads.pyhandles multipart staging and returnsfile_uuid.attachment_ingest.pyvalidates inline and staged attachments, writes transcript material, and returns consumed staged UUIDs.rpc_sessions.pyevicts consumedfile_uuidvalues 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:
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/dragoverwith at least one file item shows a visible drop affordance.dragleaveuses a drag-depth counter or equivalent containment check so the affordance does not flicker when moving across child elements.dropextracts onlyFileobjects 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:
{
"type": "image/png",
"mime": "image/png",
"name": "screenshot.png",
"data": "<base64>"
}
Staged Upload Request
Large files are uploaded first:
POST /api/v1/files/upload
Content-Type: multipart/form-data
Authorization: Bearer <token>
Multipart fields:
file: the file bytes and filename;mime: the client-resolved MIME, used only as a claim; gateway revalidates.
Expected success response:
{
"file_uuid": "u-...",
"filename": "report.pdf",
"mime": "application/pdf",
"size": 12345
}
Staged chat.send Payload
After staging, chat.send sends:
{
"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 <token>; - 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_uuidis a short-lived upload-store identifier, not a durable reference.- Staged upload bytes are evicted only after the turn is accepted.
- Failed
chat.sendafter 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
- Add
addAttachments(files: File[])inuseChatAttachments.ts. - Route file picker, paste, and drop through
addAttachments. - Add drag-depth or containment-safe drop-zone state in
ChatView.vue. - Show a drop affordance only when the drag payload contains files.
- Add upload auth headers to staged upload requests.
- Add retry handling for staged upload failures.
- Keep
ChatComposer.vuepresentational: props down, events up. - Ensure
useChatSend.tsdoes not clear failed attachments as if they were successfully queued.
Gateway
- Keep
/api/v1/files/uploadas the only staged upload endpoint. - Keep the upload route header-token requirement unchanged.
- Confirm
UploadStoreandattachment_ingestreject unsupported MIME, over size, unknown UUID, restart-lost UUID, and total size overflow. - Add targeted tests only where the new frontend contract exposes gaps.
Desktop
- Validate drag/drop in packaged or dev Electron on macOS, Windows, and Linux where available.
- Keep Electron preload unchanged unless validation proves native bridging is required.
- 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_pendingtoinline. - Large stageable files transition from
uploadingtostaged. - Failed staged upload leaves a failed/removable state.
- Upload requests include
Authorization: Bearer <token>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_uuidresolution returns an attachment ref with nofile_uuidor inlinedata. - 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.sendcarries inline base64 attachment data. - Drop one large PDF into Web UI, wait for staged chip, send, and assert
chat.sendcarriesfile_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:
- Start the desktop client.
- Drag a small PNG from the OS file manager into the chat thread.
- Confirm the composer shows a thumbnail chip.
- Send and confirm the user bubble renders the attachment.
- Drag a PDF larger than the inline threshold.
- Confirm upload progress changes to staged/ready.
- Send and confirm the gateway accepts the turn.
- Repeat with token auth enabled.
- 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_uuidand are materialized into stable transcript attachment refs before runtime execution. file_uuidnever 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?