Skip to content

feat(native-chat): attach files to a structured chat on a paired server - #25146

Merged
brennanb2025 merged 66 commits into
mainfrom
brennanb2025/rsc-chat-attachments
Oct 7, 2026
Merged

brennanb2025 merged 66 commits into
mainfrom
brennanb2025/rsc-chat-attachments

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 30 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​3207 $\color{#cf222e}{\Huge{\mathbf{−}}}$​51 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​3156
Prod 75 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​2880 $\color{#cf222e}{\Huge{\mathbf{−}}}$​273 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​2607

ELI5

When your chat runs on another computer (a paired Orca server), you couldn't give it a file. Pasting a screenshot, dropping a file from Finder/Explorer or using the file picker only showed "Local attachments are not available for remote sessions." Now Orca copies the file to that computer first, and the agent reads it from there.

What Changed

The problem. A structured agent chat (the native chat that talks to Claude or Codex directly, not through a terminal) can run on a paired Orca server. The agent runs on the server, so a path on this machine means nothing to it. That is why the composer refused every client file:

  • an image-only paste and the paste button showed the refusal;
  • a paste with text and an image silently dropped the image;
  • a Finder/Explorer drop and the file picker showed the refusal.

Dragging a file from the workspace's own file tree already worked, because that file is already on the server.

For the user, after this PR. In a structured chat on a paired server:

  • Pasted images, text+image pastes, the paste button, dropped files and picked files all attach.
  • Each dropped or picked file shows a chip with its name while it uploads. Send waits for those chips, so a message never goes out without a file you attached. Removing a chip while it uploads keeps that file out of the message.
  • If a file can't be attached, the composer's notice line says so once, naming the file and the reason (for example the 50 MiB limit). Dropped folders are skipped.
  • Image chips and sent images are read back from the server for previews. Large images (over the remote reply budget) show "Preview unavailable" instead of breaking the connection.
  • If the server runs an older Orca, the notice line shows the existing "This needs a newer Orca on the computer running this chat. Update Orca there, then try again." Nothing is uploaded and no path from this machine is sent.
  • An upload that finishes while a question or approval card has replaced the composer is still attached when the composer comes back.
  • A message that names a file the server no longer has (unsent for more than 24 hours, or removed) is not sent. The message goes back into the composer, and the notice says "This attachment expired. Remove it and attach it again."
  • Claude can read attached non-image files without asking for permission.

Local chats, SSH chats and terminal-backed chats behave exactly as before.

Mechanism.

  • Server-owned store. The server keeps uploads under its own userData: agent-session-attachments/<upload id>/<original file name> (the name is kept because agents read meaning into it; it is sanitized and capped in bytes, Windows device names are avoided). The directory is created when the structured host starts.
  • Upload RPCs. agentSessionAttachment.uploadStart, uploadAppend, uploadCommit, uploadAbort fill the store in chunks; upload ids are bound to the client that started them, the number in flight is capped, idle uploads expire after 5 minutes, and uploadStart refuses a chat the server doesn't hold. agentSessionAttachment.read returns a stored image for previews through the existing protected, bounded preview read and reply budget, and refuses anything outside the store. All five sit behind the existing structured-session gate, and the server advertises them as the capability agent-session.attachments.v1.
  • Client upload. Main does the byte transfer so the renderer never holds a whole file. Drops and picks go through fs:uploadPathsToAgentSessionAttachments, which reuses the file-import slice streamer; pastes go through the existing clipboard:saveImageAsTempFile with a new target. Every call is pinned to the pairing revision and every chunk to the server process id, so a re-pair or server restart aborts the upload. The browser client's paste is pinned the same way.
  • Lifetime: a claim at admission. When the server admits a message from a client (send, queue, steer, retry), it finds the store paths the message names and records a claim (upload × chat) in the same database transaction that saves the message. If a named upload is missing or being deleted, the whole message is refused with a new attachmentExpired reason, which the client turns into the composer restore above. The server's own writes (the /clear carry, restart continuation, queue drain) claim best-effort and never refuse. A cleanup pass (about a minute after the host starts, then every 6 hours) deletes unfinished uploads after 1 hour and unclaimed uploads after 24 hours. It marks an upload for deletion only if no claim exists, in the same single database connection the claims use, so a send and a cleanup can never both win; a cleanup interrupted mid-delete is finished by the next pass. Only exact paths under the server's own store directory count, so text that merely mentions such a path is left alone.
  • Agent access. Structured Claude gets the store directory as an added readable directory. Codex's sandbox modes already allow reading it.

Wire compatibility.

  • Every method is new; no existing params changed. A new client checks the capability first, so an older server gets the notice instead of a call. An older client never calls the new methods.
  • The new refusal reason is an extra value under an existing refusal code; an older client shows its generic "wasn't sent" text.
  • The two new journal tables are created if missing with no schema version bump, so an older build keeps opening the database.
  • Mobile clients don't call the new methods (not on the mobile allowlist).

Why

  • The common pattern. The client uploads the bytes to the server that runs the session; the server keeps them in a store it owns, records which messages use each upload when it admits them, refuses a message whose upload is gone, and sweeps only uploads nothing claims. This PR adopts that mechanism.
  • Rejected: deriving "still needed" by scanning chat history for paths. An earlier revision of this PR did that. History rows move or disappear (/clear moves queued messages to a new chat, history rollover deletes rows), the scan was per file on the main thread, and nothing coordinated a deletion with a send in progress. Recording the claim when the message is admitted removes that class of bug.
  • Rejected: checking on the client which server a file belongs to. That bookkeeping was lost whenever a message was rebuilt (Retry, Stop-restore, reload) and wrongly stripped valid files after a same-server re-pair. The server is the only party that knows whether a path is one of its uploads, so the claim is the single check.
  • Rejected: the server's .orca/drops (what SSH chats use) and the clipboard RPC's temp files. The first writes into the user's worktree; neither has an owner or a lifetime.

Differences from the common pattern

  • Uploads travel as chunks over the existing paired-server connection instead of an HTTP upload — intended: that connection is the only channel to a paired server.
  • Whether an upload exists is read from disk; there is no separate upload row — intended: the file is the record, and there is nothing to keep in sync.
  • A message refused for an expired attachment is appended after anything already in the composer, rather than restored only into an empty composer — intended: it reuses Orca's existing Stop-restore behavior, and never overwrites what the user has typed.
  • Claimed uploads live as long as the chat's record, which today is forever because the server never deletes a structured chat — temporary: claims are keyed by chat, so a future chat delete frees its uploads with no new bookkeeping.
  • Claude can read every upload in the store, not only this chat's — intended: same breadth as the common pattern; paths are random ids.
  • Local chats still pass the real file path — intended: same machine, and the agent should act on the real file.
  • SSH chats keep uploading into .orca/drops — temporary: a follow-up can move them onto a host-owned store.
  • The terminal-backed native chat on a paired server still refuses client files — temporary: its agent may run on the server's SSH/WSL host, which the store can't reach.
  • Removing a chip while it uploads keeps the file out of the message but lets the transfer finish (the server deletes it after 24 hours unclaimed) — temporary: wiring the abort is a follow-up.

Linked Issue

N/A (maintainer follow-up to the paired-server structured chat work).

Visual Proof

Before: the composer showed "Local attachments are not available for remote sessions." for every pasted, dropped or picked file (no screenshot of an older build; the box was shared).

After (live, desktop paired to a server Orca, real Claude):

Pasted image chip Claude describes the pasted image
02-s1-image-chip.png 03-s1-reply.png
Dropped text file → server path Claude reads the stored file (no permission prompt)
04-s2-chips.png 05-s2-canary.png
5.5 MB image: preview unavailable, connection stays up Folder skipped with one notice; over-5 MB image refused by the existing Claude limit
09-s4-preview.png 11-s5-folder.png

Testing

  • Unit tests cover the store (upload, name sanitizing incl. byte cap and Windows device names, chunk order, caller isolation, abort, idle expiry, preview reads refusing paths outside the store), claims (same-transaction claim, expired refusal, /clear carry, cleanup vs claim race, interrupted cleanup), the RPC gate and capability, the main upload pump and clipboard route against a real store, the Claude store-directory hand-off, and the renderer paths (pending chips, chip removal, uploads that finish across a prompt card, input-method composition, rich-text paste, older server, expired-refusal restore, cache eviction never dropping an open composer's attachments).

  • Related suites: 491 files / 4,867 tests; every src/main/ipc test and every test that mocks fs: 737 files (one pre-existing timeout in profile-state-backup-rotation.test.ts, same on main); cross-version wire suites 17/17 files (105 tests); tc:node and tc:web pass; changed-code quality gate and localization checks pass.

  • Live QA on a paired server (two Orca builds on one Mac, isolated homes and profiles, real Claude; the Mac was shared with other test runs): paste, drop, file picker, send and agent reads all pass; Claude read the stored text file with no permission card in both the default mode and with launch arguments cleared; a 5.5 MB image left the connection up and showed "Preview unavailable"; a dropped folder was skipped with one notice; removing a pending chip left nothing in the draft; a genuinely local chat on the same paired desktop kept real local paths and uploaded nothing.

  • Not verified live: an older server (needs a second build; unit tests cover the capability check), Manual permission mode (not offered in that setup), Windows/Linux.

  • Not run locally: the tests under src/main/claude (CI runs them) and the real-Claude CLI suite.

  • I manually tested these changes locally

  • Automated tests added/updated, or explained why not below

Review

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • Cross-platform: server paths are built with path.join; claim matching accepts either separator and any case on Windows; the commit rename retries on Windows file locks.
  • SSH chats and folder workspaces are unchanged.
  • The browser client's paste takes the same store route; its drop stays unavailable, as before.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

Merged with main: how this fits #24905 (saved drafts)

Main now keeps a chat's unsent draft across a reload or quit (#24905). This PR now builds on that instead of keeping its own copy:

  • Finished attachments are owned by main's saved draft only.
  • Attachments still uploading stay in a small per-chat cache (native-chat-pending-attachment-cache.ts). They survive the composer being replaced by a prompt card, keep Send disabled, and drop into the draft when the upload finishes. They are never saved, so a restored draft never brings back an unfinished upload as if it were attached.
  • Each uploading attachment carries the same owner its draft would have, so removing a workspace (or closing a tab) also drops its uploads in flight; a late upload can't recreate a deleted draft.
  • Two small changes to fix(native-chat): an unsent draft survives a reload or quit #24905's files, so an image stored on a paired server stays valid in a saved draft: the draft keeps such a pasted image whole (instead of a "Not kept" placeholder), and restoring skips the local file-exists check for it (the file exists only on the server; the server's check at Send refuses an upload that has expired, and the message returns to the composer).
  • The earlier eviction change to the composer scope cache is reverted to main's version: every uploading attachment ends with its upload, so no size cap is needed.

A structured chat on a paired Orca server had nowhere to put a file the client
attached. The server now keeps a per-chat attachment store under its userData,
filled through agentSessionAttachment.uploadStart/Append/Commit/Abort and read
back for previews through agentSessionAttachment.read, behind the structured
session gate and advertised as agent-session.attachments.v1.

Nothing records cleanup as owed: a sweep re-derives what may go from the host's
chat records and journal (unfinished uploads after an hour, uploads for a chat
that never existed or that its journal never mentions after a day). The clipboard
RPC's in-flight bookkeeping moves into a shared ChunkedUploadRegistry both use.
…store

Main streams dropped or picked files (fs:uploadPathsToAgentSessionAttachments)
and pasted images (clipboard:saveImageAsTempFile with agentSessionAttachment)
into the chat's store on its paired server, reusing the file-import slice
streamer and pinning every call to the pairing revision and server process.
The browser client's paste takes the same route.
Pasted images, files dropped from Finder/Explorer and the file picker no longer
refuse with "Local attachments are not available for remote sessions." in a
structured chat on a paired server: they upload into that server's chat store and
the chat gets only the stored path. Dropped files show as pending chips at once
so Send waits for them; every attached item carries the server, pairing and chat
it was stored for, and a send to anywhere else drops it with the existing
"changed hosts" notice. Chips and transcript images read stored files back
through the server, never from this machine's disk. An older server gets an
update notice instead of an upload. The terminal-backed chat keeps refusing.
…eeping

The chunked upload registry stays for the chat attachment store only, beside it.
A message's references into the host's attachment store are claimed in the same
journal transaction that makes it durable (a direct send, a queued draft, a /clear
carry). The sweep deletes an upload only after marking it in that database while
no claim exists, so a send and a sweep can never both win: a client's send that
names an expired or foreign upload is refused whole with the new attachmentExpired
reason, before anything is recorded. This replaces scanning chat transcripts for
paths.

The store is now <root>/<upload id>/<name>, refuses uploads for chats the host
does not hold, caps stored names in UTF-8 bytes and avoids Windows device names.
The preview read goes through the protected bounded read and the request's reply
budget, so an image too large for one reply is refused instead of closing the
connection.
…ent store

Attached non-image files live outside the workspace; the store root is passed as an
added directory so the agent reads them without asking, the common pattern.
Every upload call goes to the environment the paste was meant for and checks its
pairing and server process, as the desktop upload does; a re-pair ends the upload
instead of storing the image on another server.
The composer no longer records which server and pairing each attachment came from,
and Send no longer strips attachments it judges foreign: the host refuses an
expired or unknown stored path when it admits the message, which also covers
retries, Stop-restores and queued edits the client check missed. Previews of
stored files route through the chat's own server by path.

Removing a file's chip while it uploads now keeps its @path out of the draft. A
dropped or picked file shows its name and kind on its chip while it uploads, with
no separate progress toast, and files that did not attach are named in one notice.
A rich-text paste into a chat on an older server no longer shows the update notice
beside the pasted text.
…fit the line budgets

The submission's claim runs in the same append hook as a draft's consume, owned by
the queued-message collaborator, so the journal store stays within its size limit.
Formats the new files and regenerates the runtime-required English catalog.
…ly exact store paths

Claude drops an added directory that does not exist when it starts, so the store
root is created when the host installs it, best effort. A commit keeps its upload
in flight until the rename lands, so a sweep never takes a slow upload's part file.
Only a path under this host's exact store root is claimed and required; any other
mention of a store path is plain text and never refuses the message.
…attach options

An older server's refusal to store attachments uses the notice every other write
it refuses already shows, so one string fewer in every catalog; the attach
callbacks lose an options parameter no caller passes since the provenance check
went.
…ck to the composer

Sending the same message again can never bring the attachment back, so instead of
a Retry that always fails the message's text and images return to the composer,
as a Stop's withdrawn message does, and the notice says what to remove.
…t attach

An upload that finishes while a prompt card has replaced the composer lands in the
scope's attachment and draft caches, which the composer reads back when it
returns, instead of the unmounted composer. The single failure notice names the
cause the files share, such as the size limit. Rich text pasted into a chat on an
older server no longer flashes an image chip: the chip waits for the server to
take the image. A picked command waits, as Send does, while an attachment is still
uploading.
…de grant hand-off

A paste uploading into a paired server's store keeps its chip live when a prompt
card unmounts the composer, and its result is filed for the composer's return,
as a drop's is. A failure cause ending in full-width punctuation gets no extra
full stop. A runtime test pins that the store root reaches Claude's launch
resolver through the adapter.
…ndows-safe

A commit renames the part file with the Windows retry the app uses elsewhere, so
an antivirus or indexer holding it briefly no longer loses the upload. A name cut
to the byte limit is stripped of trailing dots and spaces again. The upload
pump's result cast states why it holds.
…ttachment cache

A prompt card unmounts the composer, and an attachment still saving or uploading
lived only in that composer: one that came back showed no pending chip, so Send
went out without the file and the file then landed in the next draft. Pending
chips now live in the pane's attachment cache beside settled ones and settle
there whichever composer is showing, so Send waits for them after a remount too.
A stored file's @path goes into the pane's draft cache, which keeps it through an
input-method composition and a remount. A rich-text paste's image is registered
as a hidden pending chip before the server is asked, so Send waits for it from
the start, and it shows only once the server takes it.
…ck; keep caret inserts

A rich-text paste's held image is revealed through the pane's attachment cache,
so a composer a prompt card remounted during the server's answer shows it rather
than holding Send on an invisible chip. A stored file's @path goes in at the
caret while the composer is showing and not composing, as every other attach
does; only mid-composition or after a remount does it go to the pane's draft.
…hows them or one is on its way

The pane attachment cache is now where chips live, so its 128-scope bound passes
over a scope a mounted composer subscribes to or that still holds a pending
attachment, and evicts the oldest scope nobody uses instead. Protected scopes are
bounded by mounted composers and attachments in flight.
…le's imports

The method table is imported far and wide (the SSH relay's CLI included), and the
attachment RPCs pulled in the store, whose preview read loads modules that read
fs constants at load: any test that mocks fs/promises without them failed to
import. The installed store now lives in a small registry module; only the host
wiring loads the store itself.
@brennanb2025

brennanb2025 commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Review status: ready for review

Update (2026-10-06): current main is merged in at head 30461ade5ff. Main added saved drafts that survive a reload or quit (#24905), so this PR now builds on them: finished attachments belong to the saved draft, and attachments still uploading stay in a small per-chat cache that is never saved, so a restored draft never brings back an unfinished upload. A merge review found that an upload still in flight when its workspace is removed could recreate the deleted draft; that's fixed (each uploading attachment carries its draft's owner and is dropped with the workspace or tab), with tests. Two small changes to #24905's draft files keep an image stored on a paired server valid in a saved draft. Details in the PR body under "Merged with main".

The problem

A structured agent chat (Claude or Codex in Orca's native chat) can run on a paired Orca server: another computer that owns the workspace. The agent runs on that server, so a file path on this machine means nothing to it. Every way of giving the chat a file was refused with "Local attachments are not available for remote sessions.": pasting a screenshot, dropping files from Finder/Explorer, and the file picker. A paste of text plus an image silently lost the image.

What changes for the user

In a structured chat on a paired server:

  • Attaching works. Pasted images, dropped files and picked files attach, and the agent reads them.
  • Pending chips. A file still uploading shows a chip with its name, and Send waits for it. That holds even if a question or approval card replaces the composer, or the user is mid-way through Japanese or Chinese input.
  • Failures. A file that can't be attached is reported once, naming the file and the reason.
  • Older server. If the server runs an older Orca, the chat shows the existing "needs a newer Orca on the computer running this chat" notice and nothing is sent.
  • Expired attachment. A message naming a file the server no longer has (unsent for more than 24 hours) is not sent. It goes back into the composer with "This attachment expired. Remove it and attach it again."

Local, SSH and terminal-backed chats are unchanged.

How it works

The desktop uploads the bytes to a store the server owns. The server records which messages use each upload in the same database transaction that saves the message. A cleanup pass only deletes uploads nothing has claimed, and it can never race a send.

What the review changed

The first version differed in three main ways, and the review replaced each:

  • Deciding what to keep. It scanned chat history for file paths to decide what to keep. History rows move or vanish (/clear, history rollover), so the scan could delete files still in use, and it scanned once per file on the main thread. It is now an explicit claim recorded when the server accepts a message.
  • Checking which server owns a file. The desktop kept its own record of which server each file belonged to and checked it before Send. That record was lost on Retry, Stop and reload, and it wrongly stripped valid files after a same-server re-pair. That check is deleted; the server's claim is the only check.
  • Large image previews. A preview of an image of about 3 MiB or more was sent as one reply over the server's 4 MiB limit, which dropped the whole connection to that server. Previews now use the existing bounded preview read.

Further fixes:

  • A finished upload could be lost when a prompt card replaced the composer. A file could also be left out of the message being sent and land in the next draft.
  • A removed chip's file could still be inserted.
  • A dropped folder was walked in full before being skipped.
  • Browser-client pastes weren't tied to the server they started on.
  • Claude now gets read access to the upload store, so it doesn't ask before reading attached files.
  • On Windows, an antivirus or indexer briefly locking a fresh upload no longer loses it: the final rename retries.
  • File names cut at the byte limit no longer end in a dot or space.
  • A cache-eviction case could drop an open composer's attachments.
  • Several src/main/ipc test files failed in CI because a new import reached a module that reads a file-system constant on load.

Deferred (not blocking)

  • Removing a chip mid-upload keeps the file out of the message but lets the transfer finish. The unclaimed file is deleted after 24 hours.
  • An upload cancel or idle expiry can race an in-progress chunk write. It's bounded: the commit fails and leftovers are swept after an hour.
  • There is no per-chat or per-server disk quota. The 8 concurrent uploads are shared by all clients.
  • Uploads a message has claimed live as long as the chat. The server never deletes structured chats today, so a future chat delete is what frees them.
  • On macOS, a store path the user retypes in a different case isn't claimed.
  • A reply refused while the chat view is closed loses its error text.
  • Minor cleanups: some test duplication, module naming, and the upload registry has a single user.

Verified

Not verified

  • S7, the older-server refusal, was not exercised live; unit tests only.
  • The Claude read-permission grant was only partly exercised: the server profile runs Claude with bypassed permissions, and Manual mode was not offered, so a prompting mode was never tested live.
  • Windows and Linux hosts.
  • The real-Claude CLI suite.

…-attachments

# Conflicts:
#	src/renderer/src/components/native-chat/native-chat-attachment-upload.ts
…er's attachments

Main's multi-file picker (#23956) and this branch together took NativeChatComposer over the 400-line limit; reading the two pending-image handlers the way the neighbouring ones already are keeps it at the limit.
…-attachments

# Conflicts:
#	src/main/claude/claude-structured-launch-resolution.test.ts
#	src/main/runtime/structured-agent-session-runtime.ts
#	src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts
… Claude Arguments add

Main (#25721) now builds additionalDirectories from the user's --add-dir arguments; this branch's attachment-store grant replaced that list instead of adding to it. Both are granted now. Moves the Claude session-id derivation to its own module to keep the resolver under the line limit.
…-attachments

# Conflicts:
#	src/main/native-chat/agent-session-journal/journal-queued-messages.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts
…-attachments

# Conflicts:
#	src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts
…-attachments

# Conflicts:
#	src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx
#	src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx
#	src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx
…-attachments

# Conflicts:
#	src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts
#	src/renderer/src/store/terminals/terminal-tab-close.ts
…-attachments

# Conflicts:
#	src/preload/api/filesystem-api.ts
…-attachments

# Conflicts:
#	src/main/claude/claude-structured-launch-resolution.ts
#	src/main/runtime/structured-agent-runtime-registrations.ts
#	src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts
…-attachments

# Conflicts:
#	src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts
…-attachments

# Conflicts:
#	src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts
…-attachments

# Conflicts:
#	config/scripts/vitest-sqlite-runtime-files.mjs
#	src/main/native-chat/agent-session-journal/journal-store-collaborators.ts
# Conflicts:
#	src/main/runtime/rpc/methods/index.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant