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 0bee366488
commit 8a3a225fea
31 changed files with 2131 additions and 81 deletions
+42 -8
View File
@@ -576,6 +576,24 @@
.msg:focus-within .msg__actions { opacity: 1; }
.msg__actions .is-copied { color: var(--success); }
/* --- A turn that is waiting to be sent ------------------------------------
Its actions do not fade in on hover like the others: they are the only way
to withdraw something that has not happened yet, and a control you have to
find by hovering is one somebody will not find. */
.msg--queued { opacity: 0.75; }
.msg--queued .msg__body {
border-inline-start: 2px dashed var(--border-strong);
padding-inline-start: var(--sp-2);
}
.msg__actions--queued { opacity: 1; align-items: center; }
.msg__note {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
color: var(--ink-muted);
font-size: var(--text-xs);
}
/* --- Rendered Markdown ---------------------------------------------------- */
.msg__body > :first-child { margin-top: 0; }
.msg__body > :last-child { margin-bottom: 0; }
@@ -761,16 +779,32 @@
.composer__mirror .tok-mention,
.composer__mirror .tok-command {
border-radius: var(--radius-sm);
/* Bled sideways so the rectangle does not sit hard against the next word,
and the negative margin keeps the text metrics identical. */
padding: 0 2px;
margin: 0 -2px;
/* Restated rather than inherited. `color: transparent` on the mirror is an
inherited value, and a colour the span declares itself beats it -- which is
exactly what the transcript's rule below used to do from across the file,
painting the token in accent-coloured mono at 0.95em on top of the
textarea's own text. Doubled, and shifted from there on, because the
metrics differ. The font must be restated for the same reason. */
color: transparent;
font: inherit;
/* Bled sideways by a shadow, not by padding: a rectangle that spreads cannot
move a glyph, and the negative margin that used to do this was the only
thing in the mirror that could. */
padding: 0;
margin: 0;
}
.composer__mirror .tok-mention {
background: var(--accent-soft);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.composer__mirror .tok-command {
background: var(--leaf-soft);
box-shadow: 0 0 0 2px var(--leaf-soft);
}
.composer__mirror .tok-mention { background: var(--accent-soft); }
.composer__mirror .tok-command { background: var(--leaf-soft); }
/* The same two in a sent message, where they are text rather than a backdrop. */
.tok-mention {
/* The same two in a sent message, where they are text rather than a backdrop --
and scoped to it, because unscoped they also matched the mirror's spans. */
.msg .tok-mention {
border-radius: var(--radius-sm);
padding: 0 2px;
background: var(--accent-soft);
+42 -2
View File
@@ -75,7 +75,11 @@
name: "effort",
summary: "How hard a reasoning model should think",
argument: "low | medium | high",
when: function () { return !!chat() && !!el("[data-effort]"); },
/* Offered wherever there is a model, not only where the control is.
`available()` filters `find()` and `run()` as well as the menu, so a
command hidden here is not merely unlisted -- typing it in full stops
being a command and gets sent as a message. Better to answer. */
when: function () { return !!el('[name="model_id"]'); },
run: function (rest) { setEffort(rest); }
},
{
@@ -115,6 +119,12 @@
});
}
},
{
name: "index",
summary: "Read the project directory again",
when: function () { return !!chat() && isAgent(); },
run: function () { reindex(); }
},
{
name: "terminal",
summary: "Show or hide the terminal",
@@ -163,6 +173,32 @@
return function () { window.location = url; };
}
/* --- Reading the project directory again --------------------------------
The listing is cached for five minutes and only ever built when a reply
starts, so anything done in the terminal panel -- a checkout, a build --
is invisible to it until then. This is the "look again now". */
var indexing = false;
function reindex() {
if (indexing) return note("Already reading the project directory.");
indexing = true;
note("Reading the project directory…");
post("/api/chats/" + chat() + "/index")
.then(function (response) {
return response.json().then(function (body) {
return { ok: response.ok, body: body };
});
})
.then(function (result) {
if (!result.ok) {
return note(result.body.detail || "Could not read the project directory.", "error");
}
note(result.body.message);
})
.catch(function () { note("Could not read the project directory.", "error"); })
.finally(function () { indexing = false; });
}
/* --- Reasoning effort ---------------------------------------------------
The command drives the same select the composer shows, so there is one
piece of state and the control updates itself when the command is used. */
@@ -171,7 +207,11 @@
function setEffort(rest) {
var select = el("[data-effort]");
if (!select) {
return note("This model is not marked as a reasoning model.", "error");
return note(
"This model is not marked as a reasoning model, so effort would do " +
"nothing. An administrator can mark it on the model's page.",
"error"
);
}
var wanted = (rest || "").trim().toLowerCase();
if (!wanted) {
+18
View File
@@ -273,6 +273,24 @@
} else if (option.dataset.mentionKnowledge) {
body.append("document_id", option.dataset.mentionKnowledge);
attach("/api/files/from-knowledge", body);
} else if (option.dataset.mentionNote) {
body.append("note_id", option.dataset.mentionNote);
attach("/api/files/from-note", body);
} else if (option.dataset.mentionSkill) {
body.append("skill_id", option.dataset.mentionSkill);
attach("/api/files/from-skill", body);
} else if (option.dataset.mentionAttachment) {
body.append("attachment_id", option.dataset.mentionAttachment);
attach("/api/files/from-attachment", body);
} else if (option.dataset.mentionUrl) {
body.append("url", option.dataset.mentionUrl);
attach("/api/files/link", body);
} else if (option.dataset.mentionBase) {
/* Not an attachment: nothing is copied, and what changes is what this
chat is allowed to search. It goes on the chat, so the route is the
chat's own. */
body.append("base_id", option.dataset.mentionBase);
attach("/api/chats/" + where.chatId + "/bases", body);
}
}
+44 -19
View File
@@ -35,7 +35,11 @@
/* Whether this shell tells us where commands begin and end -- "live",
"loading" or "none". Everything the three buttons do keys off it. */
var integration = "loading";
var autoSend = false;
/* "off" | "copy" | "send". Three states rather than a boolean, because the
old one did the wrong one of them: it appended into the composer, on top of
whatever was being typed there. A select rather than a cycling button --
a button cannot say which of three states it is in. */
var autoMode = "off";
var lastCommand = null;
function say(text, isError) {
@@ -169,8 +173,12 @@
and the buttons fetch what they need when they are pressed. */
if (integration !== "live") { integration = "live"; applyIntegration(); }
showLast(payload.command);
if (autoSend) {
capture(true).then(function (text) { intoComposer(text, true); });
if (autoMode !== "off") {
capture(true).then(function (text) {
if (!text) return;
if (autoMode === "copy") return intoComposer(text, true);
sendStraightToChat(text);
});
}
return;
}
@@ -287,10 +295,10 @@
var usable = integration === "live";
auto.disabled = !usable;
auto.title = usable
? "Attach every command you run to your next message"
? "What to do with each command you run"
: "This shell did not load LLeMbas's command markers, so there is no way " +
"to tell where one command's output ends.";
if (!usable && autoSend) setAuto(false);
if (!usable && autoMode !== "off") setAuto("off");
}
function showLast(command) {
@@ -301,16 +309,32 @@
slot.textContent = lastCommand ? lastCommand.summary : "";
}
function setAuto(on) {
autoSend = !!on;
var button = panel.querySelector("[data-terminal-auto]");
if (button) {
button.setAttribute("aria-pressed", autoSend ? "true" : "false");
button.classList.toggle("is-active", autoSend);
}
say(autoSend
? "Every command you run will be attached to your next message."
: "Commands are no longer attached automatically.");
var AUTO_SAID = {
off: "Commands are no longer attached automatically.",
copy: "Every command you run will be put into the message box.",
send: "Every command you run will be sent as a message on its own."
};
function setAuto(mode) {
autoMode = AUTO_SAID[mode] ? mode : "off";
var select = panel && panel.querySelector("[data-terminal-auto]");
if (select && select.value !== autoMode) select.value = autoMode;
say(AUTO_SAID[autoMode]);
}
/* Sent, not typed. The composer is left entirely alone -- somebody may be
half-way through a sentence in it, and overwriting that is the complaint
this replaces. The thread receives whatever the server decides the message
is: a streaming pair, or a single queued bubble if a reply is already being
written. Nothing here needs to know which. */
function sendStraightToChat(text) {
var url = panel.dataset.url.replace(/\/terminal\/ws$/, "/messages");
if (!window.htmx) return;
window.htmx.ajax("POST", url, {
target: "#thread",
swap: "beforeend",
values: { content: text }
});
}
/* A selection always wins, in every state. People rely on it, and it is the
@@ -427,10 +451,11 @@
event.preventDefault();
return copyToClipboard();
}
if (event.target.closest("[data-terminal-auto]")) {
event.preventDefault();
return setAuto(!autoSend);
}
});
panel.addEventListener("change", function (event) {
var select = event.target.closest("[data-terminal-auto]");
if (select) setAuto(select.value);
});
/* xterm holds colours as values, not as variables, so a theme change has
@@ -0,0 +1,19 @@
{% from "_macros.html" import icon %}
{#
A knowledge base attached from the `@` menu.
Deliberately not an attachment chip: nothing was copied and there is no
`file_ids` input to submit. The base is already on the chat, and what it does
is narrow what `knowledge_search` may see -- so this says so, and says it in
the present tense, because it is already in force before the message is sent.
No remove button. Removing one is a checkbox in the chat's settings, where
the whole set is visible at once rather than only whichever was added last.
#}
<div class="attach-chip attach-chip--base" id="base-chip-{{ base.id }}">
<span class="attach-chip__icon">{{ icon("archive", "icon--sm") }}</span>
<span class="attach-chip__body">
<span class="attach-chip__name" title="{{ base.name }}">{{ base.name }}</span>
<span class="attach-chip__meta">This chat now searches only the bases it is attached to</span>
</span>
</div>
+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">
@@ -11,7 +11,8 @@
all of it is escaped by autoescaping and none of it is marked safe.
#}
<div id="mention-results">
{% if not files and not documents %}
{% if not files and not documents and not notes and not skills
and not bases and not attachments and not website %}
<p class="muted text-sm" style="padding: var(--sp-3)">
{% if q %}
Nothing matches “{{ q }}”.
@@ -21,6 +22,22 @@
</p>
{% else %}
{% if website %}
<p class="picker__group">A page to read</p>
<ul class="picker__list">
<li>
<button class="picker__option" type="button"
data-mention-url="{{ website }}" data-mention-token="{{ website }}">
{{ icon("link", "icon--sm") }}
<span class="picker__option-body">
<span class="picker__option-name">Fetch this page</span>
<span class="picker__option-note mono">{{ website }}</span>
</span>
</button>
</li>
</ul>
{% endif %}
{% if files %}
<p class="picker__group">In the project</p>
<ul class="picker__list">
@@ -61,5 +78,82 @@
</ul>
{% endif %}
{% if notes %}
<p class="picker__group">Notes</p>
<ul class="picker__list">
{% for note in notes %}
<li>
<button class="picker__option" type="button"
data-mention-note="{{ note.id }}" data-mention-token="{{ note.title }}">
{{ icon("file-text", "icon--sm") }}
<span class="picker__option-body">
<span class="picker__option-name">{{ note.title }}</span>
<span class="picker__option-note">
{{ note.body | truncate(70) }}{% if note.owner_id != user.id %} · shared{% endif %}
</span>
</span>
</button>
</li>
{% endfor %}
</ul>
{% endif %}
{% if skills %}
<p class="picker__group">Skills</p>
<ul class="picker__list">
{% for skill in skills %}
<li>
<button class="picker__option" type="button"
data-mention-skill="{{ skill.id }}" data-mention-token="{{ skill.name }}">
{{ icon("sparkle", "icon--sm") }}
<span class="picker__option-body">
<span class="picker__option-name mono">{{ skill.name }}</span>
<span class="picker__option-note">{{ skill.description }}</span>
</span>
</button>
</li>
{% endfor %}
</ul>
{% endif %}
{% if bases %}
{# A base is a reference and not a copy: choosing one narrows what this chat
may search rather than putting anything into the message. #}
<p class="picker__group">Search only these</p>
<ul class="picker__list">
{% for base in bases %}
<li>
<button class="picker__option" type="button"
data-mention-base="{{ base.id }}" data-mention-token="{{ base.name }}">
{{ icon("archive", "icon--sm") }}
<span class="picker__option-body">
<span class="picker__option-name">{{ base.name }}</span>
<span class="picker__option-note">Scope this chat to this knowledge base</span>
</span>
</button>
</li>
{% endfor %}
</ul>
{% endif %}
{% if attachments %}
<p class="picker__group">Already in this chat</p>
<ul class="picker__list">
{% for attachment in attachments %}
<li>
<button class="picker__option" type="button"
data-mention-attachment="{{ attachment.id }}"
data-mention-token="{{ attachment.filename }}">
{{ icon("attach", "icon--sm") }}
<span class="picker__option-body">
<span class="picker__option-name">{{ attachment.filename }}</span>
<span class="picker__option-note">{{ attachment.human_size }}</span>
</span>
</button>
</li>
{% endfor %}
</ul>
{% endif %}
{% endif %}
</div>
+27 -2
View File
@@ -13,8 +13,16 @@
escaped plain text for everyone else.
#}
{% set streaming = (message.role == "assistant" and not message.complete) %}
{#
Typed while the previous reply was still being written, and not yet handed to
a model. It is in the transcript and it is not in the request. It must never
carry `sse-connect` -- a queued turn with a streaming shell on it would be the
second concurrent reply the queue exists to prevent.
#}
{% set queued = (message.role == "user" and message.queued) %}
<article class="msg msg--{{ message.role }}" id="msg-{{ message.id }}"
<article class="msg msg--{{ message.role }}{{ ' msg--queued' if queued }}"
id="msg-{{ message.id }}"
{% if streaming %}
hx-ext="sse"
sse-connect="/api/chats/{{ chat.id }}/messages/{{ message.id }}/stream"
@@ -210,7 +218,24 @@
</div>
{% endif %}
{% if not streaming %}
{% if queued %}
{# Nothing has been sent to any model. Both buttons sit on the bubble they
act on rather than in a toast somewhere, and Edit is deliberately absent:
editing rewinds and then starts a reply, which on a row you can press
while another reply is streaming is a second concurrent generation behind
a pencil. Discard and retype is the honest affordance. #}
<footer class="msg__actions msg__actions--queued">
<span class="msg__note">{{ icon("clock", "icon--sm") }} Waiting to be sent</span>
<button class="btn btn--sm" type="button"
hx-post="/api/chats/{{ chat.id }}/messages/{{ message.id }}/send-now"
hx-target="#thread" hx-swap="innerHTML">Send now</button>
<button class="btn btn--sm" type="button"
hx-post="/api/chats/{{ chat.id }}/messages/{{ message.id }}/discard"
hx-target="#msg-{{ message.id }}" hx-swap="outerHTML"
data-confirm-button="Discard this message?">Discard</button>
</footer>
<div hidden id="msg-body-{{ message.id }}">{{ message.content }}</div>
{% elif not streaming %}
<footer class="msg__actions">
<button class="btn btn--icon btn--sm" type="button"
data-copy="msg-body-{{ message.id }}" aria-label="Copy message">
+15 -6
View File
@@ -50,12 +50,21 @@
aria-label="Send to chat">
{{ icon("arrow-up", "icon--sm") }}
</button>
<button class="btn btn--icon btn--sm" type="button" data-terminal-auto
aria-pressed="false"
title="Attach every command you run to your next message"
aria-label="Send every command automatically">
{{ icon("sparkle", "icon--sm") }}
</button>
{#
Three states, not two. A cycling icon button cannot say which of three it
is in, and this one decides whether things are sent to a model without
being asked again -- so it says so in words.
Not remembered between page loads on purpose: a switch that forwards every
command you run to a model is not something to inherit from last week.
#}
<select class="select select--sm" data-terminal-auto
aria-label="What to do when a command finishes"
title="What to do with each command you run">
<option value="off" selected>Auto: off</option>
<option value="copy">Auto: copy</option>
<option value="send">Auto: send</option>
</select>
<button class="btn btn--icon btn--sm" type="button" data-toggle="#terminal"
aria-label="Close terminal">
{{ icon("x", "icon--sm") }}