Repository navigation
feat(native-chat): attach files to a structured chat on a paired server - #25146
Conversation
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.
…h, restore upload result types
…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.
Review status: ready for reviewUpdate (2026-10-06): current main is merged in at head The problemA 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 userIn a structured chat on a paired server:
Local, SSH and terminal-backed chats are unchanged. How it worksThe 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 changedThe first version differed in three main ways, and the review replaced each:
Further fixes:
Deferred (not blocking)
Verified
Not verified
|
…-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
… the attachment grant test
… 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
…e runtime project
…-attachments # Conflicts: # src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts
… the attachment tests
…-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
… apart, as main's notice card does
…ow that records have no import still owed
…-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
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:
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:
Local chats, SSH chats and terminal-backed chats behave exactly as before.
Mechanism.
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.agentSessionAttachment.uploadStart,uploadAppend,uploadCommit,uploadAbortfill 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, anduploadStartrefuses a chat the server doesn't hold.agentSessionAttachment.readreturns 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 capabilityagent-session.attachments.v1.fs:uploadPathsToAgentSessionAttachments, which reuses the file-import slice streamer; pastes go through the existingclipboard:saveImageAsTempFilewith 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.attachmentExpiredreason, which the client turns into the composer restore above. The server's own writes (the/clearcarry, 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.Wire compatibility.
Why
/clearmoves 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..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
.orca/drops— temporary: a follow-up can move them onto a host-owned store.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):
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,
/clearcarry, 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/ipctest and every test that mocksfs: 737 files (one pre-existing timeout inprofile-state-backup-rotation.test.ts, same on main); cross-version wire suites 17/17 files (105 tests);tc:nodeandtc:webpass; 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
docs/reference/agent-skill-sharing-upstream-boundary.mdand copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.Notes
path.join; claim matching accepts either separator and any case on Windows; the commit rename retries on Windows file locks.Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (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:
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.