Files
opensquilla--opensquilla/docs/features/attachment-drag-upload-spec.md
2026-07-13 13:12:33 +08:00

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.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.

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 / 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:

{
  "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_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 <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_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?