{% 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. #} {# The chat, the connection and the directory, where composer.js can read them without parsing them back out of a URL. On a new chat the connection is still being chosen, so the select and the hidden field win over these. #}
{% 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 %} {# The text, with a mirror behind it. A textarea cannot style its own contents, so the mirror holds the same text with every character transparent and contributes nothing but a rounded rectangle behind each recognised token. The real text stays in the textarea, where it is native and selectable -- the other way round, showing the mirror's text and hiding the textarea's, means any style drift renders as doubled or blurred glyphs instead of a rectangle a pixel out of place. composer.js fills it. Without JavaScript there is simply no mirror. #}
{% 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. #}
{# The same menu the `@` key opens, for anyone who would rather press than type. It inserts the character and gets out of the way. #} {% 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" %} {# Only the mode. The connection and the directory moved to the topbar: they cannot change -- update_chat refuses both with a 409 -- so they are facts about the chat rather than controls on the message, and they were taking a slot in a row that has work to do. Its own form: nesting one inside the composer's form is invalid HTML and the browser drops the inner one. The verb is on the select, not on that form. htmx binds a trigger to the annotated element itself, and `change` fires here and bubbles through this element's *ancestors* -- which a sibling form is not. `form=` scopes the values, and only the values. #}
{% endif %} {# How hard a reasoning model should think. Outside the agent branch above, because it applies to any chat. Only on a model an administrator has marked as **reasoning**: that flag has existed since the beginning with no reader at all, and offering the control everywhere would be offering a setting that does nothing almost everywhere. On an existing chat it writes on change, and needs the same treatment the mode select gets: the verb on the control, `form=` for the values. Before there is a chat there is nothing to PATCH, so it is an ordinary field of the composer's own form and `_new_chat` reads it -- which is what makes the setting choosable before the first prompt rather than after it. #} {% if current_model and current_model.capabilities_json.get("reasoning") %} {% 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 two selects' `form` attributes above. These carry no htmx of their own: they exist so that `Nt(e)` -- htmx's "which form do the values come from", which reads `e.form` before falling back to `closest("form")` -- resolves to a form holding exactly one control. Without them the PATCH would carry the composer's `content`, `model_id` and `project_dir`, and `update_chat` answers `project_dir` with a 409. 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 these controls spent the first half of their lives doing nothing. #} {% if chat and chat.kind == "agent" %}
{% endif %} {% if chat %}
{% endif %}

Enter to send, Shift+Enter for a new line. , @ for files. {% 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 %}