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:
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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") }}
|
||||
|
||||
Reference in New Issue
Block a user