Two reports against 1.6.0 and 1.7.0, both correct. The crowd worked end to end and was, in practice, not there: the picker was behind the ⋯ menu of a chat that already existed, and the switch was a card on the Agents page, which made it read as an agent-chat feature. The picker is now a button in the composer toolbar on both screens that include it, and on the new-chat screen the choice rides along with the first message, so a chat can start as a crowd instead of having to be converted into one. The instance switch has its own page. The width bug was the suggestion cards, exactly as reported. `.suggestions` rendered 455px inside a 366px column, and the tree's standing rule applied on its own made it worse -- 428px to 455px. A grid item carries `min-width: auto`, which is a min-content floor, and a floor beats `width: 100%`; the floor is measured while the percentage is indefinite, so `min(100%, …)` alone sends the track to a card's max-content. Both halves now go on all four auto-fit grids, and a test refuses either alone. It survived four releases of narrow-width checking because the harness never rendered that screen: `TestClient(app)` runs no lifespan outside a `with` block, so the startup-seeded cards were missing from every shot ever taken of it. And its overflow check skipped anything inside a scroller -- right for a table in its own scroller, blind to the scroller itself, which `overflow-y: auto` makes scroll sideways too. Both fixed; it now names the box and the child to blame. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
631 lines
32 KiB
HTML
631 lines
32 KiB
HTML
{% 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. #}
|
|
<div class="composer" {% if can.get("files.upload") %}data-dropzone{% endif %}
|
|
{% if chat %}data-chat-id="{{ chat.id }}"{% endif %}
|
|
{% if chat and chat.ssh_profile_id %}data-profile-id="{{ chat.ssh_profile_id }}"
|
|
data-project-dir="{{ chat.project_dir }}"{% endif %}>
|
|
{% 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. #}
|
|
<input class="visually-hidden" type="file" id="file-input" multiple
|
|
data-upload-url="/api/files{% if chat %}?chat_id={{ chat.id }}{% endif %}"
|
|
accept=".pdf,.txt,.md,.csv,.json,.py,.js,.ts,.rs,.go,.sh,.sql,.yaml,.yml,.toml,.log"
|
|
onchange="window.lembas.uploadFiles(this.files); this.value = ''">
|
|
<input class="visually-hidden" type="file" id="image-input" multiple accept="image/*"
|
|
onchange="window.lembas.uploadFiles(this.files); this.value = ''">
|
|
{% endif %}
|
|
|
|
<div class="composer__inner">
|
|
<form class="composer__form"
|
|
{% if chat %}
|
|
hx-post="/api/chats/{{ chat.id }}/messages"
|
|
hx-target="#thread" hx-swap="beforeend"
|
|
{# `event.target === this` is load-bearing, and its absence quietly
|
|
threw away typed messages for releases.
|
|
|
|
htmx events BUBBLE. This form contains six things that make
|
|
requests -- the two scope switches, "ask me about these again",
|
|
the agent mode select, the effort select and the jobs chip -- and
|
|
every one of their `htmx:afterRequest` events reached this handler.
|
|
So changing the mode, or the effort, or toggling a tool, called
|
|
`this.reset()` on a composer somebody was typing in and dragged the
|
|
view to the bottom. The jobs chip did not introduce that; it polls,
|
|
so it made it happen every five seconds, which is what finally made
|
|
it visible.
|
|
|
|
A form's own handler answers its own request. Anything else in here
|
|
that fetches is somebody else's business. There is a test. #}
|
|
hx-on::after-request="if (event.target === this && event.detail.successful) {
|
|
this.reset();
|
|
document.getElementById('attachments').replaceChildren();
|
|
window.lembas.autosize(this.querySelector('textarea'));
|
|
window.lembas.scrollThread(true);
|
|
}"
|
|
{% else %}
|
|
hx-post="/api/chats/start" hx-swap="none"
|
|
{% endif %}>
|
|
|
|
<div class="composer__attachments" id="attachments"></div>
|
|
|
|
{% if not chat and current_model %}
|
|
<input type="hidden" name="model_id" value="{{ current_model.model_id }}">
|
|
{% endif %}
|
|
{% if not chat and starting_temporary %}
|
|
<input type="hidden" name="temporary" value="true">
|
|
{% 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. #}
|
|
<input type="hidden" name="folder_id" value="{{ starting_folder.id }}">
|
|
{% 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.
|
|
#}
|
|
<div class="composer__field">
|
|
<div class="composer__mirror" data-composer-mirror aria-hidden="true"></div>
|
|
<textarea class="composer__input" name="content" rows="1"
|
|
data-autosize data-max-height="320" data-composer-input
|
|
placeholder="{% if chat %}Send a message…{% else %}Ask anything…{% endif %}"
|
|
aria-label="{{ t('Message') }}" {{ 'autofocus' if not chat }}></textarea>
|
|
</div>
|
|
|
|
<div class="composer__toolbar">
|
|
<div class="composer__tools">
|
|
{% 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. #}
|
|
<div class="picker picker--up" data-picker>
|
|
<button class="btn btn--icon composer__btn" type="button" data-picker-toggle
|
|
aria-haspopup="menu" aria-expanded="false"
|
|
aria-label="{{ t('Attach') }}" title="{{ t('Attach') }}">
|
|
{{ icon("attach") }}
|
|
</button>
|
|
|
|
<div class="picker__menu picker__menu--compact" data-picker-menu role="menu"
|
|
hidden aria-label="{{ t('Attach') }}">
|
|
<button class="picker__option" type="button" role="menuitem"
|
|
data-attach="file">
|
|
{{ icon("attach", "icon--sm") }}
|
|
<span class="picker__option-body">
|
|
<span class="picker__option-name">{{ t("File") }}</span>
|
|
<span class="picker__option-note">{{ t("PDF, text, code") }}</span>
|
|
</span>
|
|
</button>
|
|
<button class="picker__option" type="button" role="menuitem"
|
|
data-attach="image">
|
|
{{ icon("image", "icon--sm") }}
|
|
<span class="picker__option-body">
|
|
<span class="picker__option-name">{{ t("Image") }}</span>
|
|
<span class="picker__option-note">{{ t("Sent only to vision models") }}</span>
|
|
</span>
|
|
</button>
|
|
<button class="picker__option" type="button" role="menuitem"
|
|
data-attach="link">
|
|
{{ icon("link", "icon--sm") }}
|
|
<span class="picker__option-body">
|
|
<span class="picker__option-name">{{ t("Link") }}</span>
|
|
<span class="picker__option-note">{{ t("Fetch a page and attach its text") }}</span>
|
|
</span>
|
|
</button>
|
|
{% if can.get("library.use") %}
|
|
<button class="picker__option" type="button" role="menuitem"
|
|
data-attach="knowledge">
|
|
{{ icon("archive", "icon--sm") }}
|
|
<span class="picker__option-body">
|
|
<span class="picker__option-name">{{ t("Knowledge") }}</span>
|
|
<span class="picker__option-note">{{ t("From your library") }}</span>
|
|
</span>
|
|
</button>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
{% endif %}
|
|
|
|
{#
|
|
What this chat may use.
|
|
|
|
This slot used to be an `@` button that inserted the character and
|
|
got out of the way -- which the `@` key already does, from the
|
|
keyboard, without a button. It then kept that as a row inside the
|
|
menu, which was the same redundancy one level down: a menu you open
|
|
to press a button that types one character. Both are gone. Typing
|
|
`@` is untouched; composer.js recognises the token on its own and
|
|
knows nothing about this menu.
|
|
|
|
With the row gone there is nothing to show on a chat with no scope,
|
|
so the guard is `has_scope` alone rather than `has_scope or can
|
|
upload`. An empty menu is worse than no button.
|
|
|
|
The rows are `<label>`s wrapping a checkbox and deliberately carry
|
|
no `role="menuitem"`: ui.js closes a picker when a menuitem is
|
|
clicked, which is right for an action menu and wrong for a list of
|
|
switches you want to set several of. That is the whole reason this
|
|
needs no JavaScript at all.
|
|
|
|
The verb is on the CHECKBOX, not on the label and not on a form: the
|
|
element carrying `name` has to be the element carrying the request,
|
|
which is what tests/conftest.py:control_named exists to pin.
|
|
|
|
It is on the new-chat screen too, and there the switches post
|
|
nothing: they are plain checkboxes carried by the first message.
|
|
That matters more than it sounds. The harness puts a tool's guidance
|
|
in front of the model the moment the tool is offered, so a menu that
|
|
only appeared once a chat existed was one you could not reach until
|
|
after the model had been told how to keep notes and given the tools
|
|
to do it. Switching it off then does not un-send that turn.
|
|
|
|
Checked means ON, which is the natural reading -- but a browser
|
|
submits only the *ticked* boxes, and this feature needs to know which
|
|
ones were unticked. So every gate also has a hidden input naming it,
|
|
always submitted, and `start_chat` subtracts one list from the other.
|
|
The alternative, inverting the control so ticking means "off", reads
|
|
backwards in a menu that says "everything is on unless you say
|
|
otherwise". Still no JavaScript.
|
|
#}
|
|
{% set has_scope = scope_families or scope_skills or scope_allow %}
|
|
{% if has_scope %}
|
|
<div class="picker picker--up" data-picker>
|
|
<button class="btn btn--icon composer__btn" type="button" data-picker-toggle
|
|
aria-haspopup="menu" aria-expanded="false"
|
|
aria-label="{{ t('Toggle') }}" title="{{ t('Toggle') }}">
|
|
{{ icon("sliders") }}
|
|
</button>
|
|
|
|
<div class="picker__menu picker__menu--scope" data-picker-menu role="menu"
|
|
hidden aria-label="{{ t('Toggle') }}">
|
|
{% if has_scope %}
|
|
<p class="picker__lede">{{ t("Switched off here only. Everything is on unless you say otherwise.") }}</p>
|
|
{% endif %}
|
|
|
|
{% if scope_families %}
|
|
<p class="picker__group">{{ t("Tools") }}</p>
|
|
{% for family in scope_families %}
|
|
<label class="picker__option picker__option--toggle">
|
|
{% if scope_prospective %}
|
|
<input type="hidden" name="scope_all" value="{{ family.gate }}">
|
|
<input type="checkbox" name="scope_on" value="{{ family.gate }}"
|
|
{{ 'checked' if family.on }}>
|
|
{% else %}
|
|
<input type="checkbox" name="on" value="true"
|
|
{{ 'checked' if family.on }}
|
|
hx-post="/api/chats/{{ chat.id }}/scope" hx-swap="none"
|
|
hx-vals='{"kind": "family", "name": "{{ family.gate }}"}'>
|
|
{% endif %}
|
|
<span class="picker__option-body">
|
|
<span class="picker__option-name">{{ family.label }}</span>
|
|
</span>
|
|
</label>
|
|
{% endfor %}
|
|
{% endif %}
|
|
|
|
{% if scope_skills %}
|
|
<p class="picker__group">{{ t("Skills") }}</p>
|
|
{% for skill in scope_skills %}
|
|
<label class="picker__option picker__option--toggle">
|
|
{% if scope_prospective %}
|
|
<input type="hidden" name="scope_skill_all" value="{{ skill.name }}">
|
|
<input type="checkbox" name="scope_skill_on" value="{{ skill.name }}"
|
|
{{ 'checked' if skill.on }}>
|
|
{% else %}
|
|
<input type="checkbox" name="on" value="true"
|
|
{{ 'checked' if skill.on }}
|
|
hx-post="/api/chats/{{ chat.id }}/scope" hx-swap="none"
|
|
hx-vals='{"kind": "skill", "name": "{{ skill.name }}"}'>
|
|
{% endif %}
|
|
<span class="picker__option-body">
|
|
<span class="picker__option-name">{{ skill.name }}</span>
|
|
{% if skill.description %}
|
|
<span class="picker__option-note">{{ skill.description }}</span>
|
|
{% endif %}
|
|
</span>
|
|
</label>
|
|
{% endfor %}
|
|
{% endif %}
|
|
|
|
{# What this chat has been told to stop asking about, one entry per
|
|
press of "Always allow this" on an approval card. Shown because
|
|
a standing permission nobody can see is one nobody can revoke --
|
|
and because the entry for a command is the exact command line,
|
|
which is worth being able to read back.
|
|
|
|
Clear swaps the whole block for nothing, so the row disappears.
|
|
htmx does not swap on a 204, which is why the route returns an
|
|
empty body. #}
|
|
{% if scope_allow %}
|
|
<div class="picker__allow">
|
|
<p class="picker__group">{{ t("Always allowed here") }}</p>
|
|
{% for entry in scope_allow %}
|
|
<p class="picker__allow-entry">{{ entry }}</p>
|
|
{% endfor %}
|
|
<button class="picker__option" type="button" role="menuitem"
|
|
hx-post="/api/chats/{{ chat.id }}/allow/clear"
|
|
hx-target="closest .picker__allow" hx-swap="outerHTML">
|
|
{{ icon("trash", "icon--sm") }}
|
|
<span class="picker__option-body">
|
|
<span class="picker__option-name">{{ t("Ask me about these again") }}</span>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
{% endif %}
|
|
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{#
|
|
Who else answers.
|
|
|
|
Beside the tool switches rather than buried in Chat settings, and
|
|
*inside this form* rather than in the topbar, for one reason each.
|
|
|
|
The first: somebody deciding who answers is making the same kind of
|
|
choice as somebody picking the model, and the first version of this
|
|
put it only in the Chat settings panel — behind the ⋯ menu, inside a
|
|
chat that already existed. The owner enabled the feature, went
|
|
looking, and could not find it. A control nobody can find is a
|
|
feature nobody has.
|
|
|
|
The second: on the new-chat screen there is no chat row to attach
|
|
anybody to, so the choice has to *ride along with the first message*
|
|
— which means being a field of this form. That is the same mechanism
|
|
the scope switches above use, with the same hidden-input trick,
|
|
because a browser submits only the ticked boxes and `start_chat`
|
|
needs to know which ones were not.
|
|
#}
|
|
{% if crowd_available %}
|
|
<div class="picker picker--up" data-picker>
|
|
<button class="btn btn--icon composer__btn" type="button" data-picker-toggle
|
|
aria-haspopup="menu" aria-expanded="false"
|
|
aria-label="{{ t('Crowd') }}" title="{{ t('Crowd') }}">
|
|
{{ icon("users") }}
|
|
{% if crowd_member_ids %}
|
|
<span class="composer__count">{{ crowd_member_ids|length + 1 }}</span>
|
|
{% endif %}
|
|
</button>
|
|
|
|
<div class="picker__menu picker__menu--scope" data-picker-menu role="menu"
|
|
hidden aria-label="{{ t('Crowd') }}">
|
|
<p class="picker__lede">
|
|
{{ t("Tick a model to have it answer after this one, then be asked whether it disagrees.") }}
|
|
</p>
|
|
<p class="picker__group">{{ t("Also answering") }}</p>
|
|
{% if chat %}
|
|
{# One hidden field for the whole list, always submitted, so
|
|
unticking the last box still says something -- an absent checkbox
|
|
carries no signal of its own. #}
|
|
<input type="hidden" name="crowd_model_ids" value="" form="crowd-form">
|
|
{% else %}
|
|
<input type="hidden" name="crowd_model_ids" value="">
|
|
{% endif %}
|
|
{% for model in crowd_available %}
|
|
<label class="picker__option picker__option--toggle">
|
|
{% if chat %}
|
|
{# An existing chat: written at once. The verb is on the checkbox
|
|
and not on `#crowd-form`, because htmx binds a trigger to the
|
|
annotated element and `change` bubbles through *ancestors* --
|
|
which a sibling form is not. `form=` scopes the values, and
|
|
only the values: without it the PATCH would carry the
|
|
composer's own `content` and `project_dir`, and `update_chat`
|
|
answers that with a 409. The same reasoning the agent mode
|
|
select below carries. #}
|
|
<input type="checkbox" name="crowd_model_ids" value="{{ model.model_id }}"
|
|
{{ 'checked' if model.model_id in crowd_member_ids }}
|
|
form="crowd-form"
|
|
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none">
|
|
{% else %}
|
|
<input type="checkbox" name="crowd_model_ids" value="{{ model.model_id }}"
|
|
{{ 'checked' if model.model_id in crowd_member_ids }}>
|
|
{% endif %}
|
|
<span class="picker__option-body">
|
|
<span class="picker__option-name">{{ model.label }}</span>
|
|
{% if model.description %}
|
|
<span class="picker__option-note">{{ model.description }}</span>
|
|
{% endif %}
|
|
</span>
|
|
</label>
|
|
{% endfor %}
|
|
{% if crowd_member_ids %}
|
|
{# One sentence and not three, with the numbers as placeholders: a
|
|
translation puts the parts in its own order, and two of these
|
|
fragments are not sentences in any language. #}
|
|
<p class="picker__lede">
|
|
{{ t("%(models)s models answer each turn, over up to %(rounds)s rounds.",
|
|
models=crowd_member_ids|length + 1, rounds=crowd_rounds) }}
|
|
</p>
|
|
{% endif %}
|
|
{% if crowd_skipped %}
|
|
<p class="picker__lede">
|
|
{{ t("Skipped, because you cannot reach them any more:") }}
|
|
<s>{{ crowd_skipped|join(", ") }}</s>
|
|
</p>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{#
|
|
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 %}
|
|
<div class="composer__context" data-agent-picker>
|
|
{# Seeded from `?kind=`, which is how the sidebar's Agent side opens
|
|
this screen already on the right fork. `ui.js` reads the checked
|
|
radio when it wires the picker, so the hidden field and the
|
|
revealed connection follow from this and nothing else. #}
|
|
<input type="hidden" name="kind" value="{{ starting_kind | default('chat') }}"
|
|
id="chat-kind">
|
|
|
|
<div class="segmented" role="group" aria-label="{{ t('Kind of chat') }}">
|
|
<label class="segmented__option">
|
|
<input type="radio" name="kind_choice" value="chat"
|
|
{{ '' if starting_kind == 'agent' else 'checked' }}>
|
|
<span>{{ icon("chat", "icon--sm") }} Chat</span>
|
|
</label>
|
|
<label class="segmented__option">
|
|
<input type="radio" name="kind_choice" value="agent"
|
|
{{ 'checked' if starting_kind == 'agent' }}>
|
|
<span>{{ icon("bolt", "icon--sm") }} Agent</span>
|
|
</label>
|
|
</div>
|
|
|
|
<span class="composer__agent" data-agent-extra hidden>
|
|
<select class="select select--sm composer__connection"
|
|
name="ssh_profile_id" aria-label="{{ t('Connection') }}">
|
|
{% for profile in agent_profiles %}
|
|
<option value="{{ profile.id }}" data-dir="{{ profile.default_dir }}"
|
|
{{ 'disabled' if not profile.verified }}>
|
|
{{ profile.name }}{{ ' — not checked' if not profile.verified }}
|
|
</option>
|
|
{% endfor %}
|
|
</select>
|
|
|
|
{# 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. #}
|
|
<input type="hidden" name="project_dir" value="" data-dir-value>
|
|
<button class="btn btn--sm composer__dir" type="button" data-dir-browse
|
|
aria-label="{{ t('Project directory') }}"
|
|
title="{{ t('Choose the directory this chat works in') }}">
|
|
{{ icon("folder", "icon--sm") }}
|
|
<span class="composer__dir-path" data-dir-label>/</span>
|
|
</button>
|
|
|
|
{% if agent_modes %}
|
|
<select class="select select--sm" name="agent_mode" aria-label="{{ t('Approval mode') }}">
|
|
{% for value, label, hint in agent_modes %}
|
|
<option value="{{ value }}" title="{{ hint }}">{{ label }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
{% endif %}
|
|
</span>
|
|
</div>
|
|
|
|
{% 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. #}
|
|
<div class="composer__context">
|
|
<select class="select select--sm" name="agent_mode" aria-label="{{ t('Approval mode') }}"
|
|
form="agent-mode-form"
|
|
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none">
|
|
{% for value, label, hint in agent_modes %}
|
|
<option value="{{ value }}" title="{{ hint }}"
|
|
{{ 'selected' if value == chat.agent_mode }}>{{ label }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
|
|
{# What is still running on the far side. Only where jobs can exist at
|
|
all -- a chat without background commands enabled has none, and a chip
|
|
that could never show anything is a chip that only takes room.
|
|
|
|
`hx-trigger="load"` and not the contents inline: the listing reads the
|
|
`agent_jobs` table, and the composer is rendered on every page load
|
|
and after every reply. One request five seconds later costs nothing;
|
|
a query on the render path costs it every time. #}
|
|
{# `hx-target="this"` is load-bearing and is the whole of a bug that
|
|
blanked every agent chat. htmx INHERITS `hx-target` from ancestors,
|
|
and this sits inside the composer's form, which carries
|
|
`hx-target="#thread"` so that a sent message appends a bubble. Without
|
|
a target of its own, this element's request resolved to `#thread` --
|
|
and with `outerHTML` it replaced the entire transcript with the chip.
|
|
The reply rendered and then vanished, prompt and all.
|
|
|
|
Anything in here that fetches must say where the answer goes. There
|
|
is a test. #}
|
|
{% if jobs_enabled %}
|
|
<div hx-get="/api/chats/{{ chat.id }}/jobs" hx-trigger="load"
|
|
hx-target="this" hx-swap="outerHTML"></div>
|
|
{% endif %}
|
|
{% 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") %}
|
|
<select class="select select--sm" name="reasoning_effort" data-effort
|
|
aria-label="{{ t('Reasoning effort') }}" title="{{ t('How hard this model should think') }}"
|
|
{% if chat %}
|
|
form="chat-params-form"
|
|
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
|
|
{% endif %}>
|
|
{#
|
|
It shows the level actually in force, never the word "default".
|
|
|
|
On an existing chat that is `resolved_effort`, which is the chat's
|
|
own value and nothing else -- `build_request` reads the same field,
|
|
so what is shown is what is sent, by construction. Before there is a
|
|
chat it is the model's configured level, which `_new_chat` seeds
|
|
onto the row, so the same holds.
|
|
|
|
"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.
|
|
#}
|
|
{% set chosen = resolved_effort if chat
|
|
else (current_model.params_json or {}).get('reasoning_effort') %}
|
|
<option value="off" {{ 'selected' if chosen not in efforts }}>{{ t("Effort: off") }}</option>
|
|
{% for value in efforts %}
|
|
<option value="{{ value }}" {{ 'selected' if chosen == value }}>
|
|
Effort: {{ value }}
|
|
</option>
|
|
{% endfor %}
|
|
</select>
|
|
{% endif %}
|
|
|
|
<div class="composer__actions">
|
|
{% if can_dictate %}
|
|
{# Recording is started and stopped by the same button; audio.js swaps
|
|
data-mic-state and the icon with it. #}
|
|
<button class="btn btn--icon composer__btn composer__mic" type="button"
|
|
data-mic data-mic-state="idle"
|
|
aria-label="{{ t('Dictate a message') }}" title="{{ t('Dictate a message') }}">
|
|
<span class="composer__icon composer__icon--mic">{{ icon("mic") }}</span>
|
|
<span class="composer__icon composer__icon--recording" aria-hidden="true">
|
|
{{ icon("stop-circle") }}
|
|
</span>
|
|
</button>
|
|
{% 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.
|
|
#}
|
|
<button class="btn btn--primary btn--icon composer__btn" type="submit"
|
|
data-composer-action="send" aria-label="{{ t('Send') }}">
|
|
<span class="composer__icon composer__icon--send">{{ icon("send") }}</span>
|
|
<span class="composer__icon composer__icon--stop" aria-hidden="true">
|
|
<span class="composer__stop-square"></span>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
|
|
{# 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 crowd_available %}
|
|
{# Empty, and a sibling of the composer's form rather than inside it. See the
|
|
crowd checkboxes above, and `#agent-mode-form` below, for why both halves
|
|
of that sentence matter. #}
|
|
<form id="crowd-form"></form>
|
|
{% endif %}
|
|
{% if chat and chat.kind == "agent" %}
|
|
<form id="agent-mode-form"></form>
|
|
{% endif %}
|
|
{% if chat %}
|
|
<form id="chat-params-form"></form>
|
|
{% endif %}
|
|
|
|
<p class="composer__hint">
|
|
Enter to send, Shift+Enter for a new line.
|
|
<button class="composer__hint-link" type="button"
|
|
onclick="window.lembasCommands && window.lembasCommands.help()">{{ t("/ for commands") }}</button>,
|
|
@ 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") %}
|
|
<strong>{{ current_model.label }} has no vision</strong>, so images
|
|
will not be sent — documents still will.
|
|
{% endif %}
|
|
{% endif %}
|
|
</p>
|
|
</div>
|
|
|
|
{% if can.get("files.upload") %}
|
|
<div class="dropzone-overlay" aria-hidden="true">
|
|
{{ icon("attach", "icon--lg") }}
|
|
<span>{{ t("Drop to attach") }}</span>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|