Skip to content

feat(chat): support non-image file attachments in the composer#4058

Open
andreidrSB wants to merge 7 commits into
pingdotgg:mainfrom
A1-Events:feat/file-attachments
Open

feat(chat): support non-image file attachments in the composer#4058
andreidrSB wants to merge 7 commits into
pingdotgg:mainfrom
A1-Events:feat/file-attachments

Conversation

@andreidrSB

@andreidrSB andreidrSB commented Jul 16, 2026

Copy link
Copy Markdown

Implements #4057.

What

The composer currently rejects anything that isn't an image. This PR lets users drag/paste any file (PDF, logs, CSV, …) into the composer and have the agent read it — including on remote environments, where there was previously no way to hand the agent a file at all.

Images are untouched: same native image content blocks per provider, same preview tiles, same expand dialog. Limits unchanged (10 MB per attachment, 8 per message).

How

Non-image files reuse the existing upload path (base64 data URL → server writes to <stateDir>/attachments/), and are delivered by reference at the single sendTurn choke point in ProviderCommandReactor:

[Attached file: /abs/.../attachments/<id>.csv (vendor-report.csv, text/csv, 40 B). Read it from disk when needed.]

is appended to the turn input, and the attachment list passed to the provider is filtered to images only. The agent reads the file with its own tools — zero changes to the Claude/Codex/Grok adapters, and remote environments work by construction (the file lands on the machine the agent runs on).

Details

  • New ChatFileAttachment contract variant — additive union member; existing persisted image rows and localStorage drafts decode unchanged (persisted draft type is optional, missing ⇒ image, no storage-version bump).
  • GC fix: collectThreadAttachmentRelativePaths skipped non-image attachments while the pruning pass deletes anything not in the keep-set — without this, file attachments would be deleted from disk on the next projection pass.
  • Reactor filter is required, not optional: Codex/Grok adapters wrap any attachment in an image block today; only the Claude adapter skips non-images.
  • Disk extensions are allowlist-gated (SAFE_FILE_EXTENSIONS, .bin fallback) so resolveAttachmentPathById's fixed-extension scan keeps working and .html-class extensions never land on the same-origin asset route. Non-image asset requests get Content-Disposition: attachment (download, never inline render).
  • Prompt line sanitizes the file name (control chars/brackets stripped); the on-disk name is <server-generated-id>.<allowlisted-ext>, so the original filename never touches a path.
  • Timeline renders file attachments as name+size chips that download via the signed asset URL; historyBootstrap summarizes them for provider switches.

Screenshots

Composer with a dropped CSV staged as a chip (images keep their tiles):

composer chip

Sent message in the timeline with the file chip:

timeline chip

Verified

  • vp check 0 errors (the new reactor test uses it.effect — no new manual-runner debt), vp run typecheck clean repo-wide, all package test suites green: server 1419, web 1313, contracts 183, mobile 505, relay 187.
  • +19 server tests / +12 contracts+web tests: Normalizer ingestion (new file), GC keep/prune for file attachments, reactor images-only filter + exact injected prompt line, AssetAccess download flag, extension inference, draft persistence compat, timeline chip rendering.
  • Live E2E on a dev instance: dropped a CSV in the browser → file persisted server-side with correct id/extension → Codex's own session rollout log shows the turn input containing the exact [Attached file: …] line with the resolved path.

Known version-skew / follow-ups (called out per #4035 precedent)

  • Old clients fail to decode a thread snapshot that contains a type:"file" attachment (usual wire-evolution surface, handled by the version-mismatch banner).
  • Shipped mobile renders file attachments as broken image tiles (it renders every attachment by id as an image). Small mobile render fix is a natural follow-up; kept out to keep this PR single-purpose.
  • Possible v2: native document blocks per provider SDK (e.g. Claude PDF blocks) instead of path injection for specific types.

🤖 Generated with Claude Code


Note

Medium Risk
Touches attachment ingestion, same-origin asset serving, and the provider turn pipeline (security-sensitive), though mitigated by extension allowlists, download headers, and image-only provider payloads.

Overview
Adds ChatFileAttachment alongside images so the composer can stage PDFs, logs, CSVs, and other files—not only pictures—while keeping the same size/count limits.

Server: uploads persist with allowlisted extensions (inferFileExtension, .bin fallback); ProviderCommandReactor appends [Attached file: …] paths to turn input and sends only images to providers; projection GC now keeps file attachments referenced in messages. Non-image assets get Content-Disposition: attachment on /api/assets to avoid inline rendering of risky types.

Web: drag/paste accepts any file type; timeline shows file chips (name + size) vs image tiles; drafts persist type: "file" with legacy image default when type is missing.

Contracts: additive union on ChatAttachment / upload payloads; tests cover decode and limits.

Reviewed by Cursor Bugbot for commit 0f54a4d. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Support non-image file attachments in the chat composer and message timeline

  • Extends the composer to accept any file type via paste or drag-and-drop; file attachments render as chips with a file icon and formatted size, while images retain the existing thumbnail grid.
  • Adds ComposerFileAttachment and ComposerAttachment union types to the draft store so file and image attachments coexist in the same array, with backward-compatible hydration for legacy drafts.
  • Introduces ChatFileAttachment and UploadChatFileAttachment contract schemas, extending the ChatAttachment and UploadChatAttachment unions.
  • On the server, file attachments are persisted to disk via the normalizer, appended to the provider prompt as text references (path, name, MIME, size) rather than sent as content blocks, and included in attachment GC during thread revert.
  • Non-image attachments resolved via AssetAccess are marked with download: true, causing the HTTP asset route to set a Content-Disposition: attachment header.

Macroscope summarized 0f54a4d.

andreidrSB and others added 7 commits July 16, 2026 20:59
Additive union member: existing image rows/drafts decode unchanged.
Limits shared with images (10 MB, 8 per message).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- inferFileExtension with a conservative SAFE_FILE_EXTENSIONS allowlist
  (.bin fallback) so resolve-by-id keeps working and risky extensions
  (.html/.svg-for-files) never land on the same-origin asset route
- Normalizer accepts any MIME for type:"file" uploads; image gate and
  size limits unchanged
- attachment GC keep-set includes file attachments (previously any
  non-image attachment would be pruned from disk)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
File attachments already live on the server's disk; append an
'[Attached file: ...]' line to the turn input at the single sendTurn
choke point and pass only image attachments to the provider. The agent
reads the file with its own tools, so Claude/Codex/Grok need no adapter
changes and remote environments work by construction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Content-Disposition: attachment for any attachment whose extension is
not an image extension, so uploaded content is never rendered inline on
the same-origin asset route.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- drag/drop and paste accept non-image files (staged as name+size
  chips); images keep the existing preview tiles and expand dialog
- timeline renders file attachments as download chips via the signed
  asset URL
- drafts persist an optional attachment type (missing = image, so
  existing persisted drafts decode unchanged; no storage version bump)
- limits and copy: 10 MB per attachment, 8 attachments per message

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ring

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…and downloads

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 93f73046-ba8f-4fcb-8b0c-297dffc176b4

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Jul 16, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want fixes drafted automatically? Bugbot Autofix can create code changes for findings. A team admin can enable Autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 0f54a4d. Configure here.

export function cloneComposerImageForRetry(
image: ComposerImageAttachment,
): ComposerImageAttachment {
export function cloneComposerImageForRetry(image: ComposerAttachment): ComposerAttachment {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

File blob URLs never revoked

Medium Severity

blob: URLs are now created for all file attachments, but revokeUserMessagePreviewUrls and collectUserMessageBlobPreviewUrls only process images. This mismatch causes unrevoked blob: URLs for non-image attachments to accumulate, leading to memory leaks after messages are sent, cleaned up, or threads are switched.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0f54a4d. Configure here.

name: file.name || "image",
mimeType: file.type,
name: file.name || (isImage ? "image" : "file"),
mimeType: file.type || "application/octet-stream",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typeless images staged as files

High Severity

The composer misclassifies actual image files as generic files when the browser provides an empty or generic file.type. This prevents them from rendering as images and being sent to vision models, instead only sending a disk path. Previously, such files were rejected.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0f54a4d. Configure here.

Comment thread apps/server/src/http.ts
? {
"Content-Disposition": `attachment; filename="${asset.path.replace(/^.*[/\\]/, "")}"`,
}
: {}),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Download uses storage filename

Low Severity

Non-image asset responses set Content-Disposition with the on-disk name (&lt;attachmentId&gt;.&lt;ext&gt;). Timeline chips set download to the original attachment.name, but same-origin Content-Disposition typically wins, so downloads save under the server id instead of the user-facing filename.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0f54a4d. Configure here.

@macroscopeapp

macroscopeapp Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

This PR introduces a significant new capability (non-image file attachments) spanning contracts, server orchestration, and client UI. Additionally, unresolved review comments identify potential bugs including image misclassification (high severity) and memory leaks from unrevoked blob URLs (medium severity).

You can customize Macroscope's approvability policy. Learn more.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant