The mode select in the topbar posted with hx-post against a route that only
answers PATCH, so every change returned 405 and the mode never moved. htmx
shows nothing when a request fails, so the control looked like it worked: the
select stayed where you put it and the server ignored you. It has never worked.
Two more of the same kind. A mode could not be chosen at all until the chat
existed, so reaching Plan meant sending something in Manual first and letting
the model answer under the wrong rules. And the project directory box was real
and submitted, but unlabelled and squeezed to a few characters by the select
beside it, so it read as broken -- which is how it was reported.
So the kind, the connection, the directory and the mode move out of the strip
above the text and into one toolbar row beneath it, where attach and send
already are. The directory becomes a button that opens a browser over SFTP,
because a path is something you would rather find than spell. `scan_dir` is new
beside `list_dir`: a picker has to tell a directory from a file before it can
draw the row, and `list_dir` backs a tool whose contract is a list of names and
must not change under a model mid-conversation.
Browsing is a person clicking, not a model calling, so it does not pass through
policy.py -- the same argument the terminal panel rests on. It does mean Manual
mode has a second exception now.
Also: .chip was two components with one name, and the attachment card won, so
the Chat/Agent pills silently wore its padding. --radius-md was used twice and
declared nowhere, so both fell back to 0. .btn.is-active has been set by
syncToggles since the terminal landed and styled by nothing. Enter-to-send
ignored isComposing, so committing an IME candidate sent the message. The
terminal had five colours of a sixteen-colour palette, with fallbacks from a
palette that no longer exists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Drag, paste or pick a file in the composer. Images go to vision models
as multimodal content parts; PDFs and text files have their content
extracted and placed in the prompt. Verified end to end against
gemma4-e4b-q8 on llama-swap: given a drawing and a text file, it named
the red square and blue circle and read the number out of the document.
Type is decided by inspecting the bytes, never the filename or the
browser's Content-Type -- a .png full of text is stored as text. Images
are downscaled to 1400px and re-encoded: a phone photo is several
megabytes of base64, which is slow and a large slice of the context
window. PDF text is extracted once, at upload, and stored; re-extracting
per request would let a reply change because a parser was upgraded.
Design points worth keeping:
- Images are only sent to models an administrator has marked `vision`.
This is not graceful degradation -- most endpoints reject the entire
request rather than ignoring an image part. A plain text turn stays a
plain string for the same reason: the list form 400s on endpoints that
do not implement it.
- Images reach the model as base64 data URIs, not links. A local
endpoint has no route back to LLeMbas, and a hosted one has no
credentials for it.
- Non-images are served Content-Disposition: attachment with nosniff, so
an uploaded .html can never execute in this origin. Stored names are
random; the uploader's name is a label and never a path.
- Uploads are unbound until the message is sent, which is what lets a
file be removed beforehand. claim() only takes unclaimed rows owned by
the sender, so a forged id cannot pull in someone else's file.
Abandoned uploads are swept at startup.
- A scanned PDF says so rather than silently contributing nothing, and
truncation is declared to the model in the document tag so it can
admit it did not see page 400.
- "Here, look at this" with no words is a legitimate turn, so a message
is only empty when it carries neither text nor files.
Also fixes auto-titling, which read message["content"] as a string and
would have broken on the first multimodal turn.
186 tests, ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>