{% from "_macros.html" import icon %} {# The composer, used both inside an existing chat and on /chat where no chat row exists yet. The only difference is where it posts. With a chat, the turn is appended to the thread in place; without one, /api/chats/start creates the chat and redirects, and the reply streams on arrival because the page renders the unfinished assistant message with its sse-connect. That is what stops an opened-and-abandoned chat ever being written to the database. The attachment chips live INSIDE this form on purpose. Each carries a hidden file_ids input, and being inside the form is what gets them serialised with the message. Keeping them outside and reaching for hx-include does not work: that attribute only has an effect on the element issuing the request. Layout: chips, then the text, then one toolbar row underneath carrying everything that acts on the message. The kind selector and the connection used to sit in a strip *above* the text, inside the same bordered card, where they read as debris floating in the input rather than as controls. Below the text they are in the same place as attach and send, which is where the hand already is. #}
{% if can.get("files.upload") %} {# Outside the form: it is only ever read by JavaScript, and inside it would be submitted as an empty file part on every message. Two inputs rather than one whose accept attribute is rewritten: changing accept and then calling click() in the same tick is unreliable in Safari, and two hidden inputs cost nothing. #} {% endif %}
{% if not chat and current_model %} {% endif %} {% if not chat and starting_temporary %} {% endif %}
{% if can.get("files.upload") %} {# A menu rather than the file picker straight away: there are four ways to attach something now, and only one of them is a file on disk. Uses the same picker machinery as the model chooser -- see ui.js. #}
{% endif %}
{# What this conversation is and where it runs. On a new chat all of it is editable. On an existing one the kind, the connection and the directory are fixed -- update_chat refuses them with a 409, because a transcript whose earlier turns ran somewhere else is not one conversation -- so they render as a read-only chip and only the mode stays live. The mode is the exception on purpose: it decides what gets asked about, not what the conversation is. #} {% if not chat and agent_profiles %}
{% elif chat and chat.kind == "agent" %}
{{ icon("bolt", "icon--sm") }} {{ agent_profile.name if agent_profile else "connection missing" }} {{ chat.project_dir }} {# Its own form: nesting one inside the composer's form is invalid HTML, and the browser drops the inner one. #}
{% endif %}
{% if can_dictate %} {# Recording is started and stopped by the same button; audio.js swaps data-mic-state and the icon with it. #} {% endif %} {# One button, two jobs. While a reply is being written it becomes Stop, because that is where the hand already is and a second button sitting permanently beside Send is clutter that is wrong most of the time. ui.js flips data-composer-action, and the type with it: as `submit` the form's own handler sends, as `button` the click handler stops. Both icons are rendered here and chosen in CSS, so the swap costs no layout and cannot flash an empty button. #}
{# Outside the composer's form, and referenced by the mode select's `form` attribute above. hx-patch and not hx-post: there is no POST for a chat, only PATCH, and htmx shows nothing when a request 405s -- which is how this control spent its whole life doing nothing. #} {% if chat and chat.kind == "agent" %}
{% endif %}

Enter to send, Shift+Enter for a new line. {% if can.get("files.upload") %} Drag files in, or paste an image. {% if current_model and not current_model.capabilities_json.get("vision") %} {{ current_model.label }} has no vision, so images will not be sent — documents still will. {% endif %} {% endif %}

{% if can.get("files.upload") %} {% endif %}