Skip to content

feat(web): attach files of any type from the composer, beside images - #949

Merged
ericleepi314 merged 3 commits into
mainfrom
feat/web-file-attachments
Sep 23, 2026
Merged

ericleepi314 merged 3 commits into
mainfrom
feat/web-file-attachments

Conversation

@ericleepi314

@ericleepi314 ericleepi314 commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

Why

The web composer's upload menu had an image option only. deepseek-harness offers generic file attachments beside images; this adds the same to ClawCodex.

What changed

Composer (ui-web)

  • The Add menu gains File (paperclip) for every model, after Image (which stays gated on vision). Picking it opens a file picker with no type filter; a drop or a paste sorts its files — images the image way (refused with the reason on a model without vision), everything else as a file.
  • An attached file becomes a [File #N] chip in the draft and a card under the text (type glyph, name, extension · size, remove ×, #N tag). Same contract as images: the draft is the truth, deleting the chip un-attaches, the strip shows only what the draft claims.
  • The user row renders file cards before the caption; a reopened conversation rebuilds them from the agent's header lines and keeps the inlined contents out of the caption.
  • Files are capped at 10 MB with the limit named before any upload. The card shows the name the backend kept. A dropped folder is skipped with a notice; a paste with files on the clipboard attaches them the same way a drop does.

Backend

  • file.attach gateway RPC (twin of image.attach): base64 in, size refused before decode, temp file, agent control, temp removed.
  • Agent attach_file control: copies the upload under its own (sanitized) name into the session's readable artifact directory (…/tool-results/attachments/file-<rand>/<name>, the place accepted image originals go, readable by the Read tool), queues it (cap 8), answers {id, name, path, size}.
  • _drain_pending_files at submit: for each file whose chip survives, appends a block after the prompt — [File #N: name] saved at <path> (<size>), then the text contents (≤ 256 KB; larger files are pointed at without being read) or a Read-tool hint for binaries/PDFs/images, via a new read_file_attachment classifier that shares the @path mention rules. A literal </system-reminder> inside inlined contents is neutralised so a file cannot end the envelope early. /clear, resume and a deleted chip drop pending files and remove their persisted copies.
  • The prompt's edge readers (the end-anchored +500k turn-budget shorthand, UserPromptSubmit hooks, the blocked-prompt echo) now read the first text block — the user's own words — instead of every text block joined; a trailing file block (or a resized image's metadata block) used to silently defeat the budget and hand hooks the file contents.
  • Stored names: Windows reserved device names and Unicode format characters are neutralised; a relative path in the control resolves against the session directory.

Verification

  • pytest: tests/server/test_file_attach_control.py (25 new: turn budget and hooks survive a file drain, ephemeral turns leave files queued, image → prompt → file block order, resume/clear/dropped-chip cleanup, failed copy and cap race leave nothing, persistence under the artifact dir, drain inlining, chip deletion drops, binary hint without mojibake, large-text pointer, size/pending caps, missing path, /clear, classifier, leaf sanitizing, gateway round trip / data URL / empty file / oversize / bad base64 / refused control).

  • vitest: 718 green (new: chip kinds and labels, menu row order and vision gating, composer picker + card + remove, drop sorting, transcript hydration, message cards, captions, actions upload/refusal/limit).

  • Browser e2e against clawcodex serve with the live model: attached a text file, asked for its content, got the code word back; after a reload the user row shows the file card and none of the inlined contents.

  • Two critic review rounds: the first (REQUEST CHANGES) is answered by the second commit; the second (APPROVE with follow-ups) by the third — shutdown discards unsent copies, the file drain runs in a thread, stricter name filtering, any spelling of the closing reminder tag neutralised, uploads landing after more typing insert into the current draft, entry-based folder detection, and a user's own first block is never read as a file block.

  • vitest: 719 green; pytest: 10,347 green locally; Windows CI fails only the four pre-existing tests plus one wall-clock-ordering memory test unrelated to this change.

🤖 Generated with Claude Code

The composer's Add menu offered an image picker only. It now has a File
row too, for every model: a text file is inlined into the prompt, and a
PDF, an archive or a spreadsheet is saved where the agent's Read tool can
open it. A drop or a paste sorts its files the same way — images the image
way, everything else as a file.

The mechanism is the image one, generalised. The bytes go over the socket
(`file.attach`), the agent's new `attach_file` control copies them under
their own name into the session's readable artifact directory (the same
place accepted image originals go) and answers with a number, and a
`[File #N]` chip lands in the draft beside a file card (name, extension,
size). The draft is the truth: a chip deleted at submit un-attaches, as it
does for images. At submit `_drain_pending_files` appends one block per
file after the prompt — a header the clients recognise, then the contents
of a text file or a Read-tool hint for a binary one, classified the way an
`@path` mention is (`read_file_attachment`, shared with that pipeline).
Text over 256 KB is pointed at rather than inlined; files are capped at
10 MB, refused before the upload on the client and before the decode on
the gateway; eight pending files at most, as for images. A reopened
conversation turns the blocks back into file cards and keeps the inlined
contents out of the caption.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ericleepi314

Copy link
Copy Markdown
Collaborator Author

Follow-up noted during the e2e run (pre-existing, not changed here): _build_runtime constructs the session's ToolContext without a session_id, so resolve_tool_results_dir falls back to <tmp>/clawcodex_tool_results/<pid>/tool-results/ for every agent-server session. Accepted image originals already land there; attached files now do too. The inlined contents ride in the conversation regardless, so a reopened text attachment is intact, but a binary attachment's saved path (the Read-tool hint) is only as durable as the temp directory and is keyed by process id. Passing session_id=sess.session_id would move both to ~/.clawcodex/<workspace>/<session>/tool-results/attachments/; that also relocates spilled tool results, so it deserves its own change.

@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Test Results

     5 files   1 021 suites   30m 51s ⏱️
15 996 tests 15 969 ✅ 22 💤 5 ❌
31 963 runs  31 887 ✅ 71 💤 5 ❌

For more details on these failures, see this check.

Results for commit 0fd555b.

♻️ This comment has been updated with latest results.

ericleepi314 and others added 2 commits September 22, 2026 21:30
…ed file's

Review round. The end-anchored "+500k" turn-budget shorthand and the
UserPromptSubmit hooks read the prompt through _extract_prompt_text, which
joins every text block with no separator — so the file block the drain
appends welded "+500k" to "[File #1: …" and silently no-op'd the budget,
and hooks were handed up to 256 KB of file contents as "the prompt" (the
resized-image metadata block had the same latent effect). They now read
the first text block, which is what the user typed.

Also: Windows reserved device names and Unicode format characters are
neutralised in stored names; a large text file is pointed at without being
read whole; a literal closing tag inside inlined contents can no longer end
the envelope early; a file dropped at submit, on /clear or on resume takes
its persisted copy with it, and losing the pending-cap race removes the
whole copy folder; a relative path resolves against the session directory;
the composer shows the name the backend kept; a dropped folder is skipped
with a notice instead of being uploaded as an empty file; a paste with
files on the clipboard attaches them uniformly; the stored header regex
tolerates any name and path shape short of a newline. The text fixture is
written as bytes so its size holds on Windows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… off the loop

Follow-ups from the approving review: a session's shutdown discards the
copies of files attached for a draft that will never be sent, as /clear and
resume already did; the file drain's reads and decodes run in a thread so a
slow disk cannot stall the other sessions on a multi-session transport;
stored names also lose C1 controls and line separators; any spelling of the
closing reminder tag is neutralised; an upload that lands after more typing
inserts its chip into the current draft, not the one it started from; a
dropped folder is told apart by its entry (Linux gives a folder the inode's
size) and the notice says what is skipped; a user's own first block can
never be read as a file block on reopen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ericleepi314
ericleepi314 merged commit b8b6d5d into main Sep 23, 2026
6 of 7 checks passed
@ericleepi314
ericleepi314 deleted the feat/web-file-attachments branch September 23, 2026 05:31
@ericleepi314

Copy link
Copy Markdown
Collaborator Author

Deferred follow-ups from the two review rounds (none loses an attachment, misroutes a prompt, or leaves files behind):

  • Aggregate inline cap. Eight files × 256 KiB can put ~2 MiB of inlined text into one prompt; the existing prompt-too-long path then asks for /compact. Tighter than @path mentions (no cap at all), but a per-prompt cap (e.g. 512 KiB total, the rest pointed at the Read tool) would be cheap.
  • Raw control callers with an empty prompt. attach_file with placeholder: False (not sent by the web client) and an empty user text makes the file block the first text block, so the turn budget and hooks would read its header. Reject an empty prompt that carries attachments, or have the drains mark their blocks.
  • Client/server cap duplication. MAX_FILE_BYTES in attachments.ts mirrors MAX_ATTACHED_FILE_BYTES; a server bump desyncs the pre-upload notice (the server refusal still names the real cap). Carrying it on system/init would remove the copy.
  • Wording. A file that vanished between attach and submit is described as "a binary file and was not inlined"; the model's Read then surfaces the real error. A pasted screenshot now carries the clipboard's name (image.png in Chrome) instead of pasted-image.png.
  • Inherited from images. A file attached while an earlier prompt is queued client-side is dropped by that prompt's drain (its chip is absent), and the later prompt carries an orphan chip. Same behavior as image chips today.
  • Artifact directory (noted above): agent-server sessions build their tool context without a session id, so copies land in the temp fallback keyed by process id.

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