The menu that never appeared, and the reason it never did
composer.js built its menu lazily inside show(), and refresh() wrote list.innerHTML before calling it. `list` is null until build() has run, so the first `/` or `@` ever typed threw a TypeError and took the handler with it. The menu has never appeared in any browser. That is why /compact "isn't there": nothing was. I shipped it having only run `node --check`, which parses the file happily. So this also brings the thing that catches it: a DOM stub driven under node -- not committed, hard rule 1 stands, it is an instrument like curl. It reproduced the crash in one run and immediately found two more: choosing a command from the menu left `/help` sitting in the box so the next Enter ran it again, and Tab completed nothing. Tab now completes and Enter runs, which is the split that matters for a command taking an argument. `.select--sm` was used three times and defined nowhere. I deleted the copy in chat.css and left a comment saying it "is defined once, in app.css", where it did not exist -- so those selects fell back to plain `.select`: width 100% in a flex row where four siblings wanted the same, all of them shrinking together until each was a few characters wide, and half a rem taller than everything beside them. That was the whole of "the connection switch needs to be wider". The connection and directory move to the topbar. They cannot change -- update_chat refuses both with a 409 -- so they are facts about the chat, of a kind with the Temporary badge, not controls on the message. The mode stays by the box. Compaction says it is working. It makes a model call that takes seconds and had no indicator anywhere: `hx-indicator` appears nowhere in this codebase, and the Generation.status channel that says "Summarising earlier messages…" for the automatic path cannot be borrowed, because it lives in the streaming bubble and this endpoint refuses to run while any message is unfinished. The overflow menu now runs the same code as /compact rather than posting for itself, so there is one implementation, one spinner, and one place the endpoint's four carefully written 409s finally reach somebody. /effort, low medium high, per chat with a per-model default. It goes out twice because there is no field that works everywhere: OpenAI and vLLM read reasoning_effort, llama.cpp's own docs say other values "have no effect" and its maintainer says the field "simply gets dropped without error or logging" -- what reaches gpt-oss behind it is chat_template_kwargs. Both are sent, and only once an effort has been chosen, so a provider strict about unknown parameters sees exactly the request it always did until somebody opts in. The control appears only on a model marked `reasoning`, a flag that has existed since the beginning with no reader at all. Mentions and recognised commands are marked as you type -- a mirror behind the textarea holding the same text with every character transparent, contributing nothing but a rounded rectangle, so a pixel of drift is a misplaced rectangle rather than a doubled glyph. A command is marked only when it resolves, so `/thoughts on this` visibly is not one before you send it. And again in the transcript, where user turns had no render step at all and now escape before they inject. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -104,6 +104,32 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="default-effort">Default reasoning effort</label>
|
||||
<select class="select" id="default-effort" name="default_effort">
|
||||
<option value="">Whatever the model does</option>
|
||||
{% for value in efforts %}
|
||||
<option value="{{ value }}"
|
||||
{{ 'selected' if model.params_json.get('reasoning_effort') == value }}>
|
||||
{{ value }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="field__hint">
|
||||
Where new chats on this model start. Anyone can change it per chat with
|
||||
<span class="mono">/effort</span>, and the control only appears on a
|
||||
model marked <strong>Reasoning</strong> above.
|
||||
<br>
|
||||
Sent two ways at once, because there is no one field that works: OpenAI
|
||||
and vLLM read <span class="mono">reasoning_effort</span>, while
|
||||
llama.cpp drops it silently and reads only
|
||||
<span class="mono">chat_template_kwargs</span> — which is the route by
|
||||
which it reaches gpt-oss. Both go out, and only on a chat that has an
|
||||
effort set, so an endpoint strict about unknown parameters is untouched
|
||||
until somebody chooses one.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="description">Description</label>
|
||||
<textarea class="textarea" id="description" name="description" rows="2"
|
||||
|
||||
@@ -67,10 +67,26 @@
|
||||
<input type="hidden" name="temporary" value="true">
|
||||
{% endif %}
|
||||
|
||||
<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="Message" {{ 'autofocus' if not chat }}></textarea>
|
||||
{#
|
||||
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="Message" {{ 'autofocus' if not chat }}></textarea>
|
||||
</div>
|
||||
|
||||
<div class="composer__toolbar">
|
||||
<div class="composer__tools">
|
||||
@@ -160,7 +176,8 @@
|
||||
</div>
|
||||
|
||||
<span class="composer__agent" data-agent-extra hidden>
|
||||
<select class="select select--sm" name="ssh_profile_id" aria-label="Connection">
|
||||
<select class="select select--sm composer__connection"
|
||||
name="ssh_profile_id" aria-label="Connection">
|
||||
{% for profile in agent_profiles %}
|
||||
<option value="{{ profile.id }}" data-dir="{{ profile.default_dir }}"
|
||||
{{ 'disabled' if not profile.verified }}>
|
||||
@@ -192,15 +209,14 @@
|
||||
</div>
|
||||
|
||||
{% elif chat and chat.kind == "agent" %}
|
||||
<div class="composer__context">
|
||||
<span class="composer__where" title="{{ chat.project_dir }}">
|
||||
{{ icon("bolt", "icon--sm") }}
|
||||
<span>{{ agent_profile.name if agent_profile else "connection missing" }}</span>
|
||||
<span class="composer__where-dir">{{ chat.project_dir }}</span>
|
||||
</span>
|
||||
{# 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. #}
|
||||
Its own form: nesting one inside the composer's form is invalid HTML
|
||||
and the browser drops the inner one. #}
|
||||
<div class="composer__context">
|
||||
<select class="select select--sm" name="agent_mode" aria-label="Approval mode"
|
||||
form="agent-mode-form">
|
||||
{% for value, label, hint in agent_modes %}
|
||||
@@ -211,6 +227,30 @@
|
||||
</div>
|
||||
{% 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. Its own form, for the reason the mode has
|
||||
one -- a form cannot nest inside another.
|
||||
#}
|
||||
{% if chat and 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">
|
||||
<option value="">Effort: default</option>
|
||||
{% for value in efforts %}
|
||||
<option value="{{ value }}"
|
||||
{{ 'selected' if chat.params_json.get('reasoning_effort') == 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
|
||||
@@ -254,6 +294,10 @@
|
||||
<form id="agent-mode-form" hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
|
||||
hx-trigger="change"></form>
|
||||
{% endif %}
|
||||
{% if chat %}
|
||||
<form id="chat-params-form" hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
|
||||
hx-trigger="change"></form>
|
||||
{% endif %}
|
||||
|
||||
<p class="composer__hint">
|
||||
Enter to send, Shift+Enter for a new line.
|
||||
|
||||
@@ -187,7 +187,10 @@
|
||||
<p class="msg__note">{{ icon("x", "icon--sm") }} Stopped. This reply is cut short.</p>
|
||||
{% endif %}
|
||||
{% elif message.content %}
|
||||
<div class="msg__body msg__body--plain">{{ message.content }}</div>
|
||||
{# `tokens` escapes and then marks up: @mentions read as references
|
||||
rather than as punctuation. It must stay `pre-wrap` -- the newlines
|
||||
are still carried by CSS, not by markup. #}
|
||||
<div class="msg__body msg__body--plain">{{ message.content|tokens|safe }}</div>
|
||||
{% endif %}
|
||||
{# An attachment-only turn has no text; rendering the bubble anyway would
|
||||
leave an empty box under the file. #}
|
||||
|
||||
@@ -35,6 +35,21 @@
|
||||
{% endif %}
|
||||
</h1>
|
||||
|
||||
{# Where this chat runs. Beside the title because it describes the chat
|
||||
and cannot be changed -- update_chat refuses the connection and the
|
||||
directory with a 409 -- so it is of a kind with the Temporary badge
|
||||
rather than with the controls on the right. The mode is the one thing
|
||||
here that moves, and it stays down by the message box. #}
|
||||
{% if chat and chat.kind == "agent" %}
|
||||
<span class="topbar__where" title="{{ chat.project_dir }}">
|
||||
{{ icon("bolt", "icon--sm") }}
|
||||
<span class="topbar__where-name">
|
||||
{{ agent_profile.name if agent_profile else "connection missing" }}
|
||||
</span>
|
||||
<span class="topbar__where-dir">{{ chat.project_dir }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<div class="topbar__actions">
|
||||
{#
|
||||
A link, not a script: the flag lives in the URL, so it survives a
|
||||
@@ -118,12 +133,11 @@
|
||||
{% endif %}
|
||||
|
||||
{% if messages %}
|
||||
{# Runs the same code as /compact rather than posting itself. Two
|
||||
implementations meant a spinner on neither and the endpoint's
|
||||
error messages reaching nobody. #}
|
||||
<button class="picker__option" type="button" role="menuitem"
|
||||
hx-post="/api/chats/{{ chat.id }}/compact"
|
||||
hx-target="#thread" hx-swap="innerHTML"
|
||||
hx-confirm="Summarise everything before the last reply? The messages stay in the transcript; they just stop being sent to the model."
|
||||
data-confirm-title="Compact this chat"
|
||||
data-confirm-label="Compact">
|
||||
onclick="window.lembasCommands && window.lembasCommands.run('compact')">
|
||||
{{ icon("archive", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">Compact</span>
|
||||
|
||||
Reference in New Issue
Block a user