Files
LLeMbas/src/lembas/web/static/js/composer.js
T
Jaroslav Beneš 439f1a5d84 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>
2026-08-02 18:17:04 +02:00

452 lines
16 KiB
JavaScript

/*
Typing affordances in the composer: `@` to attach something by name, and
(from the commands section below) `/` to run something instead of sending.
Both are the same shape -- a token at the caret opens a menu, the menu
filters as you type, and choosing replaces the token -- so they share one
menu and one keyboard handler rather than fighting over the composer.
Three things here are not obvious.
The keydown listener is registered with `capture: true`. app.js already has a
document-level Enter handler that submits the form, and listeners on the same
element in the same phase fire in registration order -- app.js loads first,
so a bubble-phase listener here would never get to say "that Enter chose a
menu item, it did not send the message".
Nothing is inserted into the composer as HTML. Every name in the menu came
off somebody's filesystem or out of their library.
A chip is a chip. Choosing a file posts to a route that returns the same
attachment chip an upload returns, so the composer learns nothing new and the
remove button, the hidden file_ids input and `claim()` on send all work
already.
*/
(function () {
"use strict";
var menu = null;
var list = null;
var footer = null;
var open = false;
var kind = "";
var pending = null;
var active = -1;
/* commands.js loads first and defines these. Guarded anyway so that a page
which does not carry it -- or one where it failed to parse -- still gets
`@`, and a message beginning with a slash is simply sent. */
function commands() {
return window.lembasCommands || {
list: function () { return document.createElement("div"); },
find: function () { return null; },
run: function () {}
};
}
function commandList(query) { return commands().list(query); }
function commandIn(value) { return commands().find(value); }
function runCommand(name, rest) { commands().run(name, rest); }
/* --- Where we are ------------------------------------------------------- */
function composer() {
return document.querySelector("[data-composer-input]");
}
function context() {
var box = document.querySelector(".composer");
var picker = document.querySelector('select[name="ssh_profile_id"]');
var dir = document.querySelector("[data-dir-value]");
return {
chatId: (box && box.dataset.chatId) || "",
/* A chat under way carries its connection on the composer; a new one is
still choosing it, so the select and the hidden field are the truth. */
profileId: picker ? picker.value : (box && box.dataset.profileId) || "",
projectDir: dir ? dir.value : (box && box.dataset.projectDir) || ""
};
}
/*
The token being typed, or null.
`@` is claimed anywhere it follows whitespace, so `see @src/main.py` works
mid-sentence, but not inside an email address -- `a@b` is not a mention and
treating it as one would open a menu every time somebody typed one.
*/
function tokenAt(input) {
var value = input.value;
var caret = input.selectionStart;
if (caret !== input.selectionEnd) return null;
var before = value.slice(0, caret);
var at = before.lastIndexOf("@");
if (at !== -1 && (at === 0 || /\s/.test(before[at - 1]))) {
var query = before.slice(at + 1);
if (!/\s/.test(query)) return { kind: "@", query: query, start: at, end: caret };
}
/* A slash command is only ever the first thing in the box. Anywhere else a
slash is a path, a date or a fraction. */
if (before[0] === "/" && before[1] !== "/") {
var word = before.slice(1);
if (!/\s/.test(word) && caret === value.length) {
return { kind: "/", query: word, start: 0, end: caret };
}
}
return null;
}
/* --- The menu ----------------------------------------------------------- */
function build() {
if (menu) return;
var host = document.querySelector(".composer__inner");
if (!host) return;
menu = document.createElement("div");
menu.className = "composer-menu";
menu.setAttribute("role", "listbox");
menu.hidden = true;
list = document.createElement("div");
menu.appendChild(list);
/* What the keys do, in the one place somebody is looking when they need to
know. Cheaper than a help sheet nobody opens. */
footer = document.createElement("div");
footer.className = "composer-menu__keys";
menu.appendChild(footer);
host.insertBefore(menu, host.firstChild);
menu.addEventListener("mousedown", function (event) {
/* Before the composer loses focus, or the caret position the choice is
about to be written at is already gone. */
event.preventDefault();
});
menu.addEventListener("click", function (event) {
var option = event.target.closest("[data-mention-token], [data-command]");
if (option) choose(option);
});
}
function show() {
build();
if (!menu) return;
if (footer) {
// textContent: nothing here is markup, and one day somebody will want to
// put a filename in it.
footer.textContent = options().length
? (kind === "/"
? "↑↓ choose · Tab complete · Enter run · Esc dismiss"
: "↑↓ choose · Enter attach · Esc dismiss")
: "Esc dismiss";
}
menu.hidden = false;
open = true;
}
function hide() {
if (!menu) return;
menu.hidden = true;
open = false;
kind = "";
active = -1;
}
function options() {
return menu ? Array.prototype.slice.call(
menu.querySelectorAll("[data-mention-token], [data-command]")
) : [];
}
function highlight(index) {
var all = options();
if (!all.length) return;
active = (index + all.length) % all.length;
all.forEach(function (option, position) {
option.classList.toggle("is-selected", position === active);
});
if (all[active].scrollIntoView) all[active].scrollIntoView({ block: "nearest" });
}
/* --- Filling it --------------------------------------------------------- */
function loadMentions(query) {
var where = context();
var url =
"/api/files/mention-picker?q=" + encodeURIComponent(query) +
"&chat_id=" + encodeURIComponent(where.chatId) +
"&profile_id=" + encodeURIComponent(where.profileId) +
"&project_dir=" + encodeURIComponent(where.projectDir);
fetch(url, { credentials: "same-origin" })
.then(function (response) { return response.text(); })
.then(function (html) {
/* The reply is a round trip late: the caret may have moved off the
mention, or onto a slash, by the time it lands. */
if (kind !== "@" || !list) return;
list.innerHTML = html;
show();
highlight(0);
})
.catch(function () { hide(); });
}
function refresh() {
var input = composer();
if (!input) return;
/* Built here, not lazily inside `show()`. It used to be, and everything
below writes to `list` *before* calling `show()` -- so the first slash or
at-sign ever typed threw on a null `list` and took the whole handler with
it. The menu never appeared, in any browser, for the entire life of the
feature. Anything that touches `list` builds first. */
build();
if (!list) return;
var token = tokenAt(input);
if (!token) return hide();
kind = token.kind;
if (token.kind === "/") {
list.innerHTML = "";
list.appendChild(commandList(token.query));
show();
highlight(0);
return;
}
clearTimeout(pending);
pending = setTimeout(function () { loadMentions(token.query); }, 150);
}
/* --- Choosing ----------------------------------------------------------- */
function replaceToken(text) {
var input = composer();
var token = tokenAt(input);
if (!input || !token) return;
var head = input.value.slice(0, token.start);
var tail = input.value.slice(token.end);
var written = (token.kind === "@" ? "@" : "/") + text + " ";
input.value = head + written + tail;
var caret = head.length + written.length;
input.setSelectionRange(caret, caret);
if (window.lembas && window.lembas.autosize) window.lembas.autosize(input);
input.focus();
}
/*
`complete` is Tab, `!complete` is Enter, and the difference matters for a
command that takes an argument: Tab writes `/mode ` and leaves the caret
after it so the value can be typed, Enter runs what is there. Picking with
the mouse runs, because there is nowhere else for a click to mean.
*/
function choose(option, complete) {
if (option.dataset.command) {
if (complete) {
replaceToken(option.dataset.command);
hide();
/* Straight back, because `/mode ` is now a token with a space in it and
the menu would otherwise stay open over the argument being typed. */
return;
}
var input = composer();
hide();
/* Cleared before running. A command is the whole message, never part of
one, so leaving `/help` sitting in the box after running it means the
next Enter runs it again. */
if (input) {
input.value = "";
if (window.lembas && window.lembas.autosize) window.lembas.autosize(input);
}
runCommand(option.dataset.command, "");
return;
}
var where = context();
/* The reference stays in the sentence being written *and* the contents
come along as a chip. The first is what makes "change the thing in
@main.py" read as a sentence; the second is what stops a small model
having to spend a round fetching it. */
replaceToken(option.dataset.mentionToken);
hide();
var body = new FormData();
body.append("chat_id", where.chatId);
if (option.dataset.mentionFile) {
body.append("profile_id", where.profileId);
body.append("path", option.dataset.mentionFile);
attach("/api/files/from-project", body);
} else if (option.dataset.mentionKnowledge) {
body.append("document_id", option.dataset.mentionKnowledge);
attach("/api/files/from-knowledge", body);
}
}
function attach(url, body) {
var target = document.getElementById("attachments");
if (!target) return;
fetch(url, { method: "POST", body: body, credentials: "same-origin" })
.then(function (response) { return response.text(); })
.then(function (html) {
target.insertAdjacentHTML("beforeend", html);
// The chip's remove button is htmx-driven and inert until announced.
if (window.htmx) window.htmx.process(target.lastElementChild);
})
.catch(function () {
if (window.lembas) window.lembas.notify("Could not attach that.", { kind: "error" });
});
}
/* --- Marking the tokens as they are typed --------------------------------
Backgrounds only. The mirror's text is transparent and exists purely to
put a rectangle in the right place; the visible glyphs are still the
textarea's own. Everything here is set as textContent or built with
createElement -- what is in the box is the one string a person controls
exactly, and it must never become markup. */
function mirrorEl() {
return document.querySelector("[data-composer-mirror]");
}
/* The same rule the server applies to a sent message: `@` at the start or
after whitespace, so an email address is not a file reference. */
var MENTION = /(^|\s)(@[^\s@]+)/g;
function paint() {
var mirror = mirrorEl();
var input = composer();
if (!mirror || !input) return;
var value = input.value;
mirror.replaceChildren();
/* A command is marked only when it resolves. `/thoughts on this` is not a
command and must not look like one before it is sent -- that ambiguity
is the whole reason `//` exists. */
var rest = value;
var command = commandIn(value);
if (command) {
var head = "/" + command.name;
mirror.appendChild(token(head, "tok-command"));
rest = value.slice(head.length);
}
var last = 0;
var match;
MENTION.lastIndex = 0;
while ((match = MENTION.exec(rest)) !== null) {
mirror.appendChild(document.createTextNode(rest.slice(last, match.index) + match[1]));
mirror.appendChild(token(match[2], "tok-mention"));
last = match.index + match[0].length;
}
// A trailing newline collapses in a div; the space keeps the last line.
mirror.appendChild(document.createTextNode(rest.slice(last) + "\n"));
mirror.scrollTop = input.scrollTop;
}
function token(text, cls) {
var span = document.createElement("span");
span.className = cls;
span.textContent = text;
return span;
}
/* --- Keys --------------------------------------------------------------- */
document.addEventListener(
"keydown",
function (event) {
var input = event.target.closest && event.target.closest("[data-composer-input]");
if (!input) return;
if (open) {
if (event.key === "Escape") {
event.stopPropagation();
event.preventDefault();
return hide();
}
if (event.key === "ArrowDown") {
event.preventDefault();
return highlight(active + 1);
}
if (event.key === "ArrowUp") {
event.preventDefault();
return highlight(active - 1);
}
if (event.key === "Enter" || event.key === "Tab") {
var all = options();
if (all.length && active >= 0) {
/* Capture phase, so app.js's Enter-to-send never sees this one.
Without stopping it the message would be sent *and* the menu
item chosen. */
event.preventDefault();
event.stopPropagation();
choose(all[active], event.key === "Tab");
return;
}
}
}
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
/* Not a menu key: a command typed in full and submitted. Handled here
rather than on submit so the form is never posted at all -- a
command is not a message and must not become one if it is unknown. */
var command = commandIn(input.value);
if (command) {
event.preventDefault();
event.stopPropagation();
input.value = "";
if (window.lembas && window.lembas.autosize) window.lembas.autosize(input);
runCommand(command.name, command.rest);
}
}
},
true
);
document.addEventListener("input", function (event) {
if (!event.target.closest("[data-composer-input]")) return;
refresh();
paint();
});
/* The textarea scrolls once it hits data-max-height, and the mirror has to
go with it or the rectangles drift off the words. */
document.addEventListener(
"scroll",
function (event) {
var input = event.target;
if (!input.closest || !input.closest("[data-composer-input]")) return;
var mirror = mirrorEl();
if (mirror) mirror.scrollTop = input.scrollTop;
},
true
);
/* Sending, resetting, dictating and pasting all change the value without an
`input` event that reaches here, so the mirror is repainted after any htmx
swap and once at load. */
document.addEventListener("DOMContentLoaded", paint);
document.addEventListener("htmx:afterSwap", paint);
document.addEventListener("htmx:afterSettle", paint);
document.addEventListener("click", function (event) {
if (!event.target.closest(".composer-menu") &&
!event.target.closest("[data-composer-input]")) hide();
});
/* Opening the menu from a button, for anyone who would rather press than
type. Inserts the character and lets the normal path take over. */
document.addEventListener("click", function (event) {
var button = event.target.closest("[data-mention-open]");
if (!button) return;
event.preventDefault();
var input = composer();
if (!input) return;
input.focus();
var caret = input.selectionStart;
var lead = caret && !/\s/.test(input.value[caret - 1]) ? " @" : "@";
input.setRangeText(lead, caret, caret, "end");
refresh();
});
window.lembas = window.lembas || {};
window.lembas.closeComposerMenu = hide;
/* Anything that writes into the composer from outside -- dictation, the
terminal's Send, a slash command that clears it -- calls this so the
rectangles keep up. */
window.lembas.paintComposer = paint;
})();