{% 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 %} {% if not chat and starting_folder %} {# `/api/chats/start` has accepted a folder_id since folders existed and nothing ever sent one, so the only way into a folder was to make the chat elsewhere and move it. This is "New chat here" arriving. #} {% endif %} {# The text, with a mirror behind it. A textarea cannot style its own contents, so the text you see is the mirror's: the same string, every character once and in place, with the Markdown coloured (never rendered -- `**x**` keeps its asterisks) and a backdrop behind a resolved /command or an @mention. The textarea sits in front and does everything else -- the caret, the selection, typing, IME, spell-check -- with its own glyphs transparent. That is safe only because both lay the text out identically: chat.css declares every property that moves a character once, for both, and the mirror's styles may change colour and weight and nothing else. And only once composer.js has painted (`is-painted` on this field): without JavaScript the mirror stays empty and the textarea shows its own text, as an ordinary textarea does. #}
{% 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 %} {# The chat's settings: one button. It used to be two -- a menu of tool and skill switches here, and the effort as a chip of its own -- with a third, Chat settings, up in the header's overflow menu. Now the tools are a default per model in Settings -> Models, the switches for this chat live in the settings panel with every other tweak, and this keeps what is reached for while writing: how hard the model thinks, as a slider, and the way into the panel. Without a reasoning model there is nothing to slide, and a menu holding one button is a menu you open to press a button -- so the button opens the panel itself. #} {% set reasons = current_model and current_model.capabilities_json.get("reasoning") %} {% set has_panel = chat or scope_families or scope_skills or scope_allow %} {% if reasons %} {# "off" is a sentinel and NOT an empty value. `start_chat` declares `reasoning_effort: str = Form("")`, so absent and empty are indistinguishable there -- with `value=""` the reader would pick off and silently get the model's default. It shows the level actually in force, never the word "default": on an existing chat `resolved_effort`, the chat's own value and what `build_request` sends; before there is one, what `_new_chat` will seed -- the reader's own default for the model, else the model's. #} {% set chosen = resolved_effort if chat else new_chat_effort %} {% set chosen = chosen if chosen in efforts else "off" %} {% set levels = ["off"] + efforts|list %} {# An icon button, the attach button's twin -- same classes, same icon size. No short form of the effort beside it (L, M, XH): that would make it the one control in the row whose width depends on a setting, and a letter nobody can read without opening the popover anyway. The level is in the popover, on the slider that sets it. #}
{% elif has_panel %} {% 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 starting_kind == 'agent' and agent_devices %}
{# No Chat/Agent switch here any more: the sidebar's Agent side opens this screen with `?kind=agent`, and that is the whole choice. The switch took the width of two controls in a row that has none to spare, to repeat a decision already made by where you clicked. #} {# `data-upward`: its list opens above it, as the composer's other menus do -- the browser's own dropped off the screen. #} {# A button and a hidden field, not a text box. The text box was real and submitted, but unlabelled and squeezed to a few characters by the select beside it, so it read as broken. A path is also something you would rather find than spell. Empty until something chooses: the device's latest directory (ui.js), or the folder's own when the chat starts in a folder that names one (`data-dir-seed`, which `_new_chat` would apply anyway). With neither, the composer will not send until one is picked -- the device's first root was a guess, not a choice. #} {% if agent_modes %} {% endif %}
{% elif chat and chat.kind == "chat" and not chat.parent_chat_id %} {# Plan or Normal. A chat has one mode worth a control: planning -- reading freely, changing nothing, and finishing with a plan for you to approve. The same column `agent_mode` holds an agent chat's four; for a chat, empty is normal. Its own form, for the reason the agent select below gives. #}
{% 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. #}
{# A temporary chat is held at Plan until it is kept, so the select shows that and does not offer the others. #} {% if chat.temporary and not chat.parent_chat_id %} {% else %} {% endif %}
{# The background jobs are not in here any more: they are the strip above the composer (`chat/_jobs_strip.html`), outside this form -- because htmx INHERITS `hx-target`, this form carries `#thread`, and a chip in here without a target of its own once replaced the whole transcript with itself. Anything that fetches says where its answer goes. There is a test. #} {% 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 %}
{% endif %} {# The button sits inside the sentence, so it is a field of it: a sentence cut around it could not be translated, since the order of the parts is not the same in every language. `{% set %}…{% endset %}` is Markup, so `tm` keeps it as the button it is. #} {% set commands_link %}{% endset %}

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

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