439f1a5d84
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>
326 lines
15 KiB
HTML
326 lines
15 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"
|
|
hx-on::after-request="if (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 %}
|
|
|
|
{#
|
|
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">
|
|
{% 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="Attach" title="Attach">
|
|
{{ icon("attach") }}
|
|
</button>
|
|
|
|
<div class="picker__menu picker__menu--compact" data-picker-menu role="menu"
|
|
hidden aria-label="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">File</span>
|
|
<span class="picker__option-note">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">Image</span>
|
|
<span class="picker__option-note">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">Link</span>
|
|
<span class="picker__option-note">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">Knowledge</span>
|
|
<span class="picker__option-note">From your library</span>
|
|
</span>
|
|
</button>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
{# The same menu the `@` key opens, for anyone who would rather press
|
|
than type. It inserts the character and gets out of the way. #}
|
|
<button class="btn btn--icon composer__btn" type="button" data-mention-open
|
|
aria-label="Mention a file or a document"
|
|
title="Mention a file or a document">
|
|
{{ icon("at") }}
|
|
</button>
|
|
{% 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>
|
|
<input type="hidden" name="kind" value="chat" id="chat-kind">
|
|
|
|
<div class="segmented" role="group" aria-label="Kind of chat">
|
|
<label class="segmented__option">
|
|
<input type="radio" name="kind_choice" value="chat" checked>
|
|
<span>{{ icon("chat", "icon--sm") }} Chat</span>
|
|
</label>
|
|
<label class="segmented__option">
|
|
<input type="radio" name="kind_choice" value="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="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="Project directory"
|
|
title="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="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. #}
|
|
<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 %}
|
|
<option value="{{ value }}" title="{{ hint }}"
|
|
{{ 'selected' if value == chat.agent_mode }}>{{ label }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</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
|
|
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="Dictate a message" title="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="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 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. #}
|
|
{% if chat and chat.kind == "agent" %}
|
|
<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.
|
|
<button class="composer__hint-link" type="button"
|
|
onclick="window.lembasCommands && window.lembasCommands.help()">
|
|
/ 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>Drop to attach</span>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|