Skip to content

feat: add chat attachments (opaque upload + prompt references) - #23

Open
alexanderkreidich wants to merge 7 commits into
appx-org:mainfrom
alexanderkreidich:feat/chat-attachments
Open

feat: add chat attachments (opaque upload + prompt references)#23
alexanderkreidich wants to merge 7 commits into
appx-org:mainfrom
alexanderkreidich:feat/chat-attachments

Conversation

@alexanderkreidich

Copy link
Copy Markdown

What

Adds chat attachments: users can attach files in the chat UI; the client ships them as opaque base64 bytes, agent-server stores them in the project workspace, and the agent reads them with its own file tools when a prompt references them.

  • agent-server
    • POST /v1/projects/{id}/attachments — JSON base64 upload (~25 MB decoded cap), stored at attachments/<uuid>/<sanitized-filename> inside the project dir
    • POST …/sessions/{id}/prompt accepts optional attachments: string[]; the server resolves ids to workspace-relative paths and appends a note to the prompt text; unknown ids → 400
    • New runtime/attachments.ts (sanitization, id validation, prompt composition) + http/attachmentsRoutes.ts
  • agent-protocol — regenerated contract (UploadAttachmentRequest, AttachmentInfo, PromptRequest.attachments, uploadAttachment operation)
  • agent-clientAgentClient.uploadAttachment(projectId, filename, bytes), sendPrompt(…, attachments?), ChatPanel attach button (📎) + pending-attachment chips + upload error surface

Design notes

  • Base64-in-JSON instead of multipart keeps the whole contract pipeline (zod-openapi → openapi.json → generated types → openapi-fetch) uniformly typed.
  • The filesystem is the registry: one dir per attachment id, original (sanitized) filename preserved — no metadata sidecars.
  • The client never interprets attachment bytes; the agent decides whether to read them.

QA / smoke test

Tested revision: 5c380ae (this PR's head; the container image was built from exactly this code — the only host-side working-tree deltas were files not part of the image build context).

Environment: local outer builder container (container/Dockerfile + run-outer.sh, Docker/OrbStack on macOS), agent model openai/gpt-5.6-luna served by an OpenOrange (LiteLLM) instance via the existing LITELLM_* provider envs.

Scenarios & results (all pass):

  1. POST /v1/projects/{id}/attachments with base64 launch-codes.txt → 200, file verified on disk at attachments/<id>/launch-codes.txt inside the project workspace volume.
  2. POST …/prompt with the attachment id → user message carries the appended attachment note; the agent autonomously called its read tool on the workspace path and answered the file's passphrase verbatim.
  3. Invalid base64 upload → 400; prompt with unknown attachment id → 400 (also covered by tests).
  4. Full suites green at this revision: npm run check (biome), npm run typecheck, npm test — 170 agent-server tests (7 new), 93 agent-client tests (2 new), 2 protocol tests.

Out of scope

Container runner env pass-throughs (run-outer.sh) for the OpenOrange/LiteLLM wiring are deliberately not in this PR and will be proposed separately.

Users can attach files in the chat UI; the client ships them as opaque base64 bytes, agent-server stores them in the project workspace at attachments/<id>/<filename>, and prompts referencing attachment ids get the workspace paths appended so the agent can read the files with its own tools when needed.

- agent-server: POST /v1/projects/{id}/attachments, attachment storage module, prompt composition, tests
- agent-protocol: regenerated contract (UploadAttachmentRequest, AttachmentInfo, PromptRequest.attachments)
- agent-client: AgentClient.uploadAttachment, sendPrompt attachments param, ChatPanel attach button + chips
@alexanderkreidich
alexanderkreidich marked this pull request as ready for review August 26, 2026 15:45
The attach button and pending-attachment chips had no CSS rules, so they rendered with browser-default styling (white button on the dark theme). Style them with the existing --ac-* tokens to match the composer.
@alexanderkreidich

Copy link
Copy Markdown
Author

human reviewed

neuromaxer and others added 4 commits September 1, 2026 23:41
Server:
- Enforce the ~25 MB upload cap as an HTTP body limit, so an oversized
  request is refused while streaming instead of after being fully read
  and JSON-parsed (previously one request could OOM the process serving
  every project).
- Validate base64 strictly. `Buffer.from(s, "base64")` never throws and
  silently drops a trailing partial group, so a truncated payload was
  stored as a corrupt file with a `size` that didn't match the upload.
- Accept zero-byte files (base64 of an empty file is the empty string).
- Truncate filenames by UTF-8 byte length, keeping the extension. POSIX
  caps a path segment at 255 bytes, so a long CJK/emoji name passed the
  255-character check and then failed with an uncaught ENAMETOOLONG 500.
- Add a 200 MB per-project attachment quota, reported as 413.
- Shape validation failures on the upload route as the documented
  ErrorResponse instead of a raw zod issue tree.

Client:
- Restore the prompt text and attachment ids when a send fails. They were
  cleared before the request, so a rejected prompt lost the typed text and
  the only handle on the already-uploaded files.
- Cap a file selection at the server's 20-attachment-per-prompt limit.
- Reset pending attachments when the active session changes; ChatPanel is
  not remounted, so chips followed the user into the next session.
- Hide the internal attachment note from the rendered user message and show
  the attached filenames as chips instead. The note is now wrapped in
  `<attached-files>` delimiters that the reducer strips.

Deploy:
- Exclude `.pi` and `attachments` from app image builds. The build context
  is the project root, so every upload was copied into the app image on
  each rebuild.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`sessionLabel` reads `firstMessage`, which is the stored prompt — so it
carried the `<attached-files>` note that the transcript already strips.
The sidebar showed "What is in these files? <att…". Found by driving the
live UI; the transcript and history paths were already covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The textarea, the attach button, and Send were three flex siblings each
carrying the same border and background at three different heights (40.2 /
36 / 35.5px), bottom-aligned. So the paperclip read as a second, shorter
input field beside the real one, and the tops stepped.

Wrap them in `.agent-chat-composer`, which now owns the border, background
and radius; the textarea is transparent and borderless, the attach button
is a ghost icon button, and every control is sized off one `--ac-control-h`
so the row cannot drift again. Attach stays left (an input affordance) and
Send right (the commit action), matching the composer convention.

The textarea also grows with its content up to `max-height: 40vh` instead
of scrolling a one-line window, which is what makes bottom-aligned buttons
meaningful — they stay beside the last line typed. Height is synced from
`scrollHeight` on the `input` value, so programmatic clears (send, session
switch) shrink it too.

Also adds focus styling: `:focus-within` on the shell (the textarea sets
`outline: none` and previously had no focus indication at all) and
`:focus-visible` rings on both buttons.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The hidden file input behind the paperclip used `display: none`. WebKit
refuses to open a file chooser for a programmatic `.click()` on an input
with no rendered box — it does nothing, silently — so the attach button was
dead in Safari and any WebKit shell. Chromium happens to allow it, which is
why this went unnoticed.

Clip the input instead of removing it from layout (a 1x1 box with
`clip-path: inset(50%)` and `pointer-events: none`), which every engine
accepts. It also gets `tabIndex={-1}` and `aria-hidden`, since the paperclip
button is already the accessible control and a rendered input would
otherwise add a duplicate tab stop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

2 participants