Two selects that never wrote anything, and a queue

The approval card in Auto mode and the missing /effort were one bug. Both
selects hung their hx-patch on an empty sibling form reached by form="…",
and htmx binds a trigger to the annotated element: change fires on the
select and bubbles to its ancestors, which a sibling is not. The live rows
read agent_mode=manual and params_json={} while the browser showed Auto and
Effort: high. policy.py was never involved.

The verb moves onto the control; the empty form stays as value scoping,
which is the half of the CLAUDE.md note that was right. conftest gains
control_named so a test asserts the element carrying the name carries the
verb, rather than asserting the markup that was there throughout.

The composer's highlight was a third instance of the same carelessness in
CSS: .tok-mention is written for the transcript and scoped to nothing, so
the mirror painted its token in accent-coloured monospace over the
textarea's own text. Scoped under .msg; the mirror restates transparency
and font rather than inheriting them, and bleeds by box-shadow.

/effort is now offered before the first prompt and _new_chat reads it.
/index re-walks the project directory on demand, file_write drops the
listing it just invalidated, and the index ladder falls through to SFTP on
a host that refuses exec instead of returning nothing.

A second message during a reply is queued rather than starting a second
concurrent generation: a real Message row with queued set, so it survives a
restart and can be withdrawn. _drain hands one on at the end of a reply,
_inject takes one in at a tool-round boundary so an agent can be steered
mid-task. Stop leaves the queue undelivered. The terminal's Auto toggle
becomes off/copy/send, and send posts straight to the chat without touching
the composer.

@ now offers notes, skills, this chat's attachments and a URL to fetch; a
knowledge base attaches as a reference rather than a copy. copy_document
carries provenance, which was the one attach path that dropped it.

Also fixes an unrelated live bug: the round loop compared against the
global MAX_ROUNDS of 3 while sizing itself from the agent budget of 40, so
agent replies stopped after three rounds and reported forty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-02 19:49:34 +02:00
parent 439f1a5d84
commit 52770d7ab1
30 changed files with 2028 additions and 80 deletions
+37 -16
View File
@@ -215,10 +215,16 @@
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. #}
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="Approval mode"
form="agent-mode-form">
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>
@@ -234,17 +240,27 @@
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. Its own form, for the reason the mode has
one -- a form cannot nest inside another.
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 chat and current_model and current_model.capabilities_json.get("reasoning") %}
{% if current_model and current_model.capabilities_json.get("reasoning") %}
<select class="select select--sm" name="reasoning_effort" data-effort
aria-label="Reasoning effort" title="How hard this model should think"
form="chat-params-form">
{% if chat %}
form="chat-params-form"
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
{% endif %}>
{% set chosen = chat.params_json.get('reasoning_effort') if chat
else (current_model.params_json or {}).get('reasoning_effort') %}
<option value="">Effort: default</option>
{% for value in efforts %}
<option value="{{ value }}"
{{ 'selected' if chat.params_json.get('reasoning_effort') == value }}>
<option value="{{ value }}" {{ 'selected' if chosen == value }}>
Effort: {{ value }}
</option>
{% endfor %}
@@ -286,17 +302,22 @@
</div>
</form>
{# 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. #}
{# 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" %}
<form id="agent-mode-form" hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
hx-trigger="change"></form>
<form id="agent-mode-form"></form>
{% endif %}
{% if chat %}
<form id="chat-params-form" hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
hx-trigger="change"></form>
<form id="chat-params-form"></form>
{% endif %}
<p class="composer__hint">