Skip to content

feat(web): upload files into the project from the files view - #8151

Open
msegec wants to merge 25 commits into
pingdotgg:mainfrom
msegec:feat/files-view-upload
Open

feat(web): upload files into the project from the files view#8151
msegec wants to merge 25 commits into
pingdotgg:mainfrom
msegec:feat/files-view-upload

Conversation

@msegec

@msegec msegec commented Aug 25, 2026

Copy link
Copy Markdown

What changed

The files view can now put files into the project, not just read them.

  • Drop files anywhere on the files panel, or use the new upload button next to refresh. Each upload gets a progress row and the tree refreshes as files land.
  • The client asks the server to mint a signed upload URL over the existing WebSocket (projects.createUploadUrl), then POSTs the raw bytes to /api/workspace/upload/<token>. The token carries the target path, size, and a 10 minute expiry, so file bytes never travel over the WebSocket.
  • The server writes to a .part temp file and renames it into place, so a dropped connection never leaves a half-written file at the target path.
  • Uploading over an existing file asks for confirmation first. The existence check happens when the URL is minted, so the prompt appears before any bytes are sent.
  • Uploads cap at 100 MiB and target the project root in this first pass. Folder drops are filtered out.

This applies to the web app and the desktop wrapper, over local, relay, and tunnel connections alike. The mobile files view is unchanged.

Why

Getting a file into the workspace currently means the shell or composer attachments, and attachments only feed the agent's turn, they never land in the project. For anyone driving a remote environment from app.t3.codes or the tunnel there is no path at all. Reusing the signed URL pattern the asset routes already use keeps auth in one place and keeps the WebSocket free of file payloads.

UI changes

The panel gains one upload button beside refresh; everything else appears only while an upload is in flight.

Before Drop overlay
Files panel before, read-only tree Drop files to upload overlay while dragging
Upload in progress After upload
Progress row for a 24 MiB upload Tree showing both uploaded files
Overwrite confirm
Replace meeting-notes.txt confirmation dialog

Video of the full flow (drop, large upload with progress, overwrite confirm): upload-flow.mp4

Verification

  • 151 focused server, web queue, and attachment tests pass (vp test run apps/server/src/workspace/WorkspaceUpload.test.ts apps/server/src/server.test.ts apps/web/src/lib/workspaceUploadQueue.test.ts apps/web/src/lib/attachmentUploadQueue.test.ts).
  • Server and web typechecks pass.
  • Targeted lint and git diff --check pass.
  • Checked drop upload, button upload, progress, overwrite confirm, and tree refresh in an isolated real-app preview using copied project data.

Checklist

  • This PR is small and focused on one concern
  • I explained what changed and why
  • I included screenshots of the UI changes
  • I included a short video of the upload flow

Note

Medium Risk
New authenticated HTTP endpoint writes arbitrary paths inside the workspace with complex concurrency and symlink handling; mistakes could cause path escape or data loss, though the PR adds extensive guards and tests.

Overview
Adds workspace file upload from the files panel: drag-and-drop, a picker button, per-file progress, cancel/retry/dismiss, and a shared drop overlay with chat.

The client mints a signed URL over WebSocket (projects.createUploadUrl), then POSTs bytes to /api/workspace/upload/<token> (up to 100 MiB, 10-minute TTL). File payloads stay off the socket. Overwrites prompt when minting fails with “exists” or the server returns 409.

On the server, WorkspaceUpload issues and validates workspace-upload tokens (same signing key as assets/attachments), writes via a .part staging file with path/symlink checks and atomic commit (hard link or rename fallback), refreshes the workspace index, and enforces Content-Length / body limits (including 0-byte uploads). projects.createUploadUrl requires orchestration operate scope.

Contracts and client-runtime expose the new RPC; attachment uploads now share uploadXhr. User docs describe the files-view upload flow (root-only in v1; folder drops ignored).

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

Note

Add file upload to Files view with signed URLs, queue, and progress UI

  • Adds a full upload pipeline: a projects.createUploadUrl WS-RPC mints time-limited signed URLs, a new POST route at WORKSPACE_UPLOAD_ROUTE_PREFIX validates tokens and stores bytes with path-escape, symlink, size, and overwrite safety, and a linkless-volume fallback handles filesystems without hard links.
  • Adds a zustand-backed workspace upload queue in workspaceUploadQueue.ts with per-environment concurrency capped at 3 (MAX_UPLOADS_PER_ENVIRONMENT), plus cancel, retry, dismiss, and single-overwrite-retry on mint-time or commit-time 409 conflicts.
  • Integrates drag-and-drop and an Upload button into FileBrowserPanel.tsx; dropped directories are filtered out and a WorkspaceFileDropOverlay shows during drag. Upload rows show coarse progress, failure reasons, and retry/dismiss actions.
  • Extracts a shared uploadXhr.ts XHR helper with UploadRejectedError from the old inline uploadBytes.
  • Authorizes the new RPC under AuthOrchestrationOperateScope in RpcAuthorization.ts and documents the feature in files.md.
  • Behavioral Change: the HTTP handler floors MaxBodySize to 1 byte for zero-byte claims so empty uploads succeed; chunked uploads that exceed the claimed size return 400 without creating the file.

Macroscope summarized 90e9527.

msegec added 8 commits August 25, 2026 07:12
Adds the createUploadUrl command atom and a client-side upload queue for
workspace files: FIFO pump capped at 3 concurrent uploads per environment,
XHR-based byte upload with progress, an overwrite confirm flow for
ProjectUploadTargetExistsError, and retry/cancel/dismiss for failed rows.
Floor the workspace upload body limit at 1 byte so a 0-byte upload token
can't disable NodeStream's max-body check for a chunked request with no
Content-Length. Route the overwrite confirm dialog through readLocalApi()
like every other caller instead of calling requestConfirmDialog directly.
Extract the duplicated XHR upload helper (attachments, workspace) into
apps/web/src/lib/uploadXhr.ts. Raise the workspace upload timeout to 10
minutes to match the 100 MiB max and the upload token TTL. Scope the files
view upload docs to web and desktop.
@coderabbitai

coderabbitai Bot commented Aug 25, 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 Plus

Run ID: be6b1ddf-b86b-488f-bd77-23e00d13168d

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

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:XL 500-999 changed lines (additions + deletions). labels Aug 25, 2026

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

Two Effect convention issues in apps/server/src/workspace/WorkspaceUpload.ts. Everything else (namespace imports for effect/* and local service modules, dependency acquisition via yield* Foo.Foo, Effect.catch over the fully handled channel, no runtime boundaries in service code) looks consistent with the conventions.

Posted via Macroscope — Effect Service Conventions

Comment thread apps/server/src/workspace/WorkspaceUpload.ts Outdated
Comment thread apps/server/src/workspace/WorkspaceUpload.ts Outdated

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

Reviewed the new files-view upload UI for consistency with the shared component system and existing upload surfaces. Four findings, all in apps/web/src/components/files/FileBrowserPanel.tsx: an unbounded upload list that can collapse the file tree, row action buttons that don't follow the established compact-row control contract, a divergent progress format, and a drop overlay duplicated verbatim from ChatView.

Posted via Macroscope — UI Consistency

Comment thread apps/web/src/components/files/FileBrowserPanel.tsx Outdated
Comment thread apps/web/src/components/files/FileBrowserPanel.tsx Outdated
Comment thread apps/web/src/components/files/FileBrowserPanel.tsx Outdated
Comment thread apps/web/src/components/files/FileBrowserPanel.tsx Outdated
Comment thread apps/server/src/workspace/WorkspaceUpload.ts Outdated
Comment thread apps/web/src/lib/workspaceUploadQueue.ts
Comment thread apps/server/src/workspace/WorkspaceUpload.ts Outdated
Comment thread apps/server/src/workspace/WorkspaceUpload.ts
Comment thread packages/contracts/src/project.ts Outdated
@macroscopeapp

macroscopeapp Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This adds a substantial end-to-end file-upload capability spanning authorization, RPC contracts, server-side filesystem writes, and client queue/UI behavior. The new authenticated endpoint writes up to 100 MiB into workspaces and changes an auth-package file, so the runtime and security surface warrant human review.

You can add or adjust custom eligibility rules. Learn more.

Store the non-overwrite upload with an atomic hard link so a concurrent
upload gets a 409 instead of silently replacing the file, and ignore a
second retry click while the retried job is already uploading. Share one
drop-overlay component between the chat and files views, reuse the
attachment progress formatter, cap the uploads strip height, size the
row buttons to the compact-row contract, and name the mint target in the
resolve error message.
@github-actions github-actions Bot added size:XXL 1,000+ changed lines (additions + deletions). and removed size:XL 500-999 changed lines (additions + deletions). labels Aug 25, 2026

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

One convention finding on the new ProjectCreateUploadUrlError declaration. The previously flagged Effect.catchTag("WorkspacePathOutsideRootError", ...) in apps/server/src/workspace/WorkspaceUpload.ts (line 181) is still present — Effect.catchTags({ ... }) is the convention even for a single tag.

Posted via Macroscope — Effect Service Conventions

Comment thread packages/contracts/src/project.ts Outdated

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

One finding on the new shared drop-overlay component; the four items from the previous run (upload strip max-height/scroll, micro row-action contract, shared progress formatter, extracted overlay) all look addressed.

Posted via Macroscope — UI Consistency

Comment thread apps/web/src/components/chat/WorkspaceFileDropOverlay.tsx Outdated
The lexical resolve cannot see symlinked directory components, so a
signed claim for a path under an in-workspace symlink could write
outside the project. Canonicalize the workspace root and the target
directory before any bytes land and reject with 400, the same guard
AssetAccess applies to signed reads.
Comment thread apps/server/src/workspace/WorkspaceUpload.ts
Comment thread apps/server/src/workspace/WorkspaceUpload.ts Outdated
Comment thread apps/server/src/workspace/WorkspaceUpload.ts Outdated
Comment thread apps/server/src/workspace/WorkspaceUpload.ts
Comment thread apps/server/src/workspace/WorkspaceUpload.ts Outdated
msegec added 3 commits August 25, 2026 09:22
…ages

Check the deepest existing ancestor against canonical paths before
recursive mkdir so a symlinked component cannot create directories
outside the workspace, derive ProjectCreateUploadUrlError messages from
a stage discriminator like the sibling file errors, and merge consumer
classNames into the shared drop overlay instead of letting them replace
the treatment.
The repo's Effect conventions check requires catchTags for statically
known tagged failures even with a single tag.
…rupt cleanup

The part file now uses a fixed-length UUID name beside the target, so a
long target basename cannot exceed the 255-byte filename component limit.
The canonical containment check now rejects only a real parent traversal,
so in-root directories like '..config' upload fine. A part file left by
fiber interruption is reclaimed with an ensuring finalizer, since
Effect.catch does not run on interrupts.
…resh callback throws

The success path cleared the job map and upload state before invoking
onUploaded, so a throwing callback fell into the failure handler and
recreated the entry as failed with no job left to retry. The callback
now runs in its own guard and only logs.

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

Reviewed the new Effect-side code (apps/server/src/workspace/WorkspaceUpload.ts, the HTTP route, ws handler, and contracts) against the service conventions. The upload module mirrors AttachmentUpload.ts (namespace subpath imports, dependencies acquired via yield* Foo.Foo, catchTags for known tags, catchIf only for a platform reason._tag), and ProjectCreateUploadUrlError now derives its message from a structural stage with a required cause. One error-modeling issue remains in the newly added rename/delete contracts.

Posted via Macroscope — Effect Service Conventions

Comment thread packages/contracts/src/project.ts Outdated
The signed token base64url-encodes the workspace cwd, so a long but
valid cwd could push the relative url past the 4096 bound and fail
result encoding. 8192 clears a PATH_MAX cwd plus the longest relative
path after encoding overhead.
@msegec
msegec force-pushed the feat/files-view-upload branch from d06e4a6 to 3ec000c Compare August 25, 2026 03:10
Comment thread apps/server/src/workspace/WorkspaceUpload.ts
Comment thread apps/web/src/lib/workspaceUploadQueue.ts Outdated
Comment thread apps/server/src/workspace/WorkspaceUpload.ts
Directory targets are rejected at mint with a target-not-file stage and
at store with a 409, so an overwrite can no longer end in a generic 500
while renaming the part file over a folder. The ancestor walk stops at
the filesystem root. The replace confirm renders as destructive, the
retry button uses the retry icon, and the target-exists check derives
from the contracts schema. cause is optional on
ProjectCreateUploadUrlError so validation stages construct without one.
Comment thread apps/server/src/workspace/WorkspaceUpload.ts Outdated
msegec added 2 commits August 25, 2026 12:00
Non-overwrite uploads landed via link plus rename, and link fails with
EPERM on FAT and exFAT volumes, turning every such upload into a 500.
Write the target directly with O_EXCL instead; the exclusive create is
just as atomic against a concurrent file at the target and works on
filesystems without hard links.
Comment thread apps/server/src/workspace/WorkspaceUpload.ts
Comment thread apps/server/src/workspace/WorkspaceUpload.ts Outdated
A failed fallback write removed the target before surfacing, which could
delete a rival's file created after the exists check. The fallback now
claims the name with an empty O_EXCL create and renames the part onto its
own claim, so the failed create removes nothing and only a failed rename
reclaims the name, by which point the name holds this upload's claim. The
staged part is also reclaimed before the entries refresh, so the rebuilt
index never lists a phantom part entry.

@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 2 potential issues.

Fix All in Cursor

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

Reviewed by Cursor Bugbot for commit b630758. Configure here.

Comment thread apps/server/src/workspace/WorkspaceUpload.ts Outdated
Comment thread apps/server/src/workspace/WorkspaceUpload.ts
An interrupt between the fallback's claim and rename stranded a permanent
empty file at the target, and a failed rename removed the target
unconditionally, which in a double race could delete a rival's confirmed
overwrite. The claim and rename now run uninterruptibly, and the reclaim
only removes the target while it still holds the empty claim.

Upload jobs now trim the file name at creation. The RPC schema trims the
path on encode, so an untrimmed name stored the file under a different
path than the one the progress row and overwrite dialog reported. Empty
names fail up front.
Comment thread apps/server/src/workspace/WorkspaceUpload.ts
msegec added 3 commits August 25, 2026 13:28
A rival's confirmed overwrite can legitimately be zero bytes, so the
failed-rename reclaim could not tell it from the empty claim and deleted
it. The reclaim now also compares the inode captured at claim time and
falls back to the size check only where the platform reports no inode.
The rival simulation in the tests now replaces the inode like a real
rename, and a zero-byte rival test pins the survival.
A stat failure between the wx claim and the inode capture surfaced the error while leaving the empty claim at the target, so every later upload to that path read the name as taken. The claim is now force-removed before the error surfaces.
Comment thread apps/server/src/workspace/WorkspaceUpload.ts Outdated
Comment thread apps/server/src/workspace/WorkspaceUpload.ts Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ 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