The composer decides what a chat is, and the topbar stops trying

The mode select in the topbar posted with hx-post against a route that only
answers PATCH, so every change returned 405 and the mode never moved. htmx
shows nothing when a request fails, so the control looked like it worked: the
select stayed where you put it and the server ignored you. It has never worked.

Two more of the same kind. A mode could not be chosen at all until the chat
existed, so reaching Plan meant sending something in Manual first and letting
the model answer under the wrong rules. And the project directory box was real
and submitted, but unlabelled and squeezed to a few characters by the select
beside it, so it read as broken -- which is how it was reported.

So the kind, the connection, the directory and the mode move out of the strip
above the text and into one toolbar row beneath it, where attach and send
already are. The directory becomes a button that opens a browser over SFTP,
because a path is something you would rather find than spell. `scan_dir` is new
beside `list_dir`: a picker has to tell a directory from a file before it can
draw the row, and `list_dir` backs a tool whose contract is a list of names and
must not change under a model mid-conversation.

Browsing is a person clicking, not a model calling, so it does not pass through
policy.py -- the same argument the terminal panel rests on. It does mean Manual
mode has a second exception now.

Also: .chip was two components with one name, and the attachment card won, so
the Chat/Agent pills silently wore its padding. --radius-md was used twice and
declared nowhere, so both fell back to 0. .btn.is-active has been set by
syncToggles since the terminal landed and styled by nothing. Enter-to-send
ignored isComposing, so committing an IME candidate sent the message. The
terminal had five colours of a sixteen-colour palette, with fallbacks from a
palette that no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-02 16:44:57 +02:00
co-authored by Claude Opus 5
parent cab8025346
commit a7e59a00f8
22 changed files with 1449 additions and 276 deletions
+99 -4
View File
@@ -144,12 +144,14 @@
.catch(function () {
target.insertAdjacentHTML(
"beforeend",
'<div class="chip chip--error"><span class="chip__body">' +
'<span class="chip__name"></span>' +
'<span class="chip__warning">Upload failed.</span></span></div>'
'<div class="attach-chip attach-chip--error">' +
'<span class="attach-chip__body">' +
'<span class="attach-chip__name"></span>' +
'<span class="attach-chip__warning">Upload failed.</span></span></div>'
);
// Set as text, never as HTML: the filename comes from the user.
target.lastElementChild.querySelector(".chip__name").textContent = file.name;
target.lastElementChild.querySelector(".attach-chip__name").textContent =
file.name;
});
});
}
@@ -262,6 +264,94 @@
search.focus();
}
/* --- Choosing a directory on the far side --------------------------------
Shaped like attachKnowledge above, with one difference: a click walks
deeper rather than finishing, and finishing is its own button. The path
that gets submitted is the directory you are *standing in*, not the last
row you pressed, so choosing the directory you are already looking at
needs no click at all. */
function chooseDirectory(profileId, current, onPick) {
var dialog = document.createElement("dialog");
dialog.className = "dialog dialog--wide";
dialog.innerHTML =
'<div class="dialog__form">' +
'<h2 class="dialog__title">Project directory</h2>' +
'<p class="dialog__note">Where this chat starts, and what a relative path ' +
"is measured from. You can walk anywhere the account can reach.</p>" +
'<div class="dialog__results"></div>' +
'<input class="input input--mono" type="text" spellcheck="false" ' +
'aria-label="Path" placeholder="/project">' +
'<div class="dialog__actions">' +
'<button class="btn" type="button" data-dir-cancel>Cancel</button>' +
'<button class="btn btn--primary" type="button" data-dir-use>Use this directory</button>' +
"</div></div>";
document.body.appendChild(dialog);
var results = dialog.querySelector(".dialog__results");
var typed = dialog.querySelector("input");
var here = current || "";
function load(path) {
results.setAttribute("aria-busy", "true");
fetch(
"/api/agents/" + encodeURIComponent(profileId) +
"/browse?path=" + encodeURIComponent(path || ""),
{ credentials: "same-origin" }
)
.then(function (response) { return response.text(); })
.then(function (html) {
results.innerHTML = html;
var box = results.querySelector("#dir-results");
/* The server decides where we ended up -- it resolved the empty
path to the profile's own default -- so the typed field follows
it rather than the other way round. */
if (box) { here = box.dataset.here || path || ""; typed.value = here; }
results.removeAttribute("aria-busy");
})
.catch(function () {
results.textContent = "Could not reach that machine.";
results.removeAttribute("aria-busy");
});
}
results.addEventListener("click", function (event) {
var row = event.target.closest("[data-dir-open]");
if (!row) return;
load(row.dataset.dirOpen);
});
/* Typing a path you already know beats clicking to it, so the field is a
first-class way in and not only a display of where you are. */
typed.addEventListener("keydown", function (event) {
if (event.key !== "Enter") return;
event.preventDefault();
load(typed.value.trim());
});
function finish(chosen) {
if (chosen !== undefined) onPick(chosen);
dialog.close();
setTimeout(function () { dialog.remove(); }, 200);
}
dialog.querySelector("[data-dir-use]").addEventListener("click", function () {
finish(typed.value.trim() || here);
});
dialog.querySelector("[data-dir-cancel]").addEventListener("click", function () {
finish();
});
dialog.addEventListener("cancel", function (event) {
event.preventDefault();
finish();
});
dialog.addEventListener("click", function (event) {
if (event.target === dialog) finish();
});
dialog.showModal();
load(current || "");
}
document.addEventListener("click", function (event) {
var choice = event.target.closest("[data-attach]");
if (!choice) return;
@@ -411,6 +501,7 @@
scrollThread: scrollThread,
autosize: autosize,
uploadFiles: uploadFiles,
chooseDirectory: chooseDirectory,
promptInstall: promptInstall
};
@@ -482,6 +573,10 @@
Shift and Enter should mean "new line". */
document.addEventListener("keydown", function (event) {
if (event.key !== "Enter" || event.shiftKey) return;
/* The Enter that commits an IME composition is not the Enter that sends.
Typing Japanese or Chinese, every accepted candidate would otherwise
post the half-written message. */
if (event.isComposing || event.keyCode === 229) return;
var composer = event.target.closest("[data-composer-input]");
if (!composer) return;
if (window.matchMedia("(pointer: coarse)").matches) return;
+31 -7
View File
@@ -40,18 +40,39 @@
}
/* --- Theme -------------------------------------------------------------- */
/* The sixteen ANSI slots, in the order xterm names them. A shell chooses
these itself -- `ls --color`, a git diff, htop's meters -- so without them
the panel rendered a foreign palette inside the application, and switching
to Shire left dark-theme colours on parchment. */
var ANSI = [
"black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
"brightBlack", "brightRed", "brightGreen", "brightYellow",
"brightBlue", "brightMagenta", "brightCyan", "brightWhite"
];
function readTheme() {
var style = getComputedStyle(document.documentElement);
function token(name, fallback) {
return (style.getPropertyValue(name) || "").trim() || fallback;
}
return {
var theme = {
background: token("--code-bg", "#0C0F13"),
foreground: token("--ink", "#E8E2D4"),
cursor: token("--accent", "#C9A227"),
foreground: token("--ink", "#E4E8EC"),
cursor: token("--accent", "#8FB3CC"),
cursorAccent: token("--code-bg", "#0C0F13"),
selectionBackground: token("--accent-soft", "rgba(201, 162, 39, 0.3)")
selectionBackground: token("--accent-soft", "rgba(143, 179, 204, 0.3)")
};
/* camelCase to --kebab-case: brightBlack -> --ansi-bright-black. A slot
with no token is left off the object entirely rather than set to
undefined, which xterm treats as a colour and renders as black. */
ANSI.forEach(function (name) {
var value = token(
"--ansi-" + name.replace(/[A-Z]/g, function (c) { return "-" + c.toLowerCase(); }),
""
);
if (value) theme[name] = value;
});
return theme;
}
/* --- Sizing ------------------------------------------------------------- */
@@ -163,13 +184,16 @@
return false;
}
var style = getComputedStyle(document.documentElement);
term = new Terminal({
allowProposedApi: true,
convertEol: false,
cursorBlink: true,
fontFamily: getComputedStyle(document.documentElement)
.getPropertyValue("--font-mono").trim() || "monospace",
fontSize: 13,
fontFamily: style.getPropertyValue("--font-mono").trim() || "monospace",
/* xterm wants a number, so the token has to be a plain pixel value and
is parsed back out here. Reading it rather than repeating 13 is what
keeps it adjustable in one place with everything else. */
fontSize: parseFloat(style.getPropertyValue("--terminal-font-size")) || 13,
scrollback: 5000,
theme: readTheme()
});
+47 -8
View File
@@ -333,6 +333,17 @@
return;
}
/* A menu item is an action, so the menu has served its purpose the moment
one is pressed. Only `choose` used to close anything, which left the
attach menu standing open over the composer after picking from it. The
item's own handler -- htmx, or the [data-attach] and [data-toggle]
delegates in app.js -- still runs; this only puts the menu away. */
var item = event.target.closest('[data-picker-menu] [role="menuitem"]');
if (item) {
close(item.closest("[data-picker]"));
return;
}
if (!event.target.closest("[data-picker-menu]")) closeAll(null);
});
@@ -489,13 +500,21 @@ document.addEventListener("lembas:notify", function (event) {
*/
(function () {
function wire(root) {
var kind = root.querySelector("#chat-kind") ||
root.parentNode.querySelector("#chat-kind");
var extra = root.querySelector(".composer__kind-agent");
var kind = root.querySelector("#chat-kind");
var extra = root.querySelector("[data-agent-extra]");
var picker = root.querySelector('select[name="ssh_profile_id"]');
var dir = root.querySelector('input[name="project_dir"]');
var dir = root.querySelector("[data-dir-value]");
var dirLabel = root.querySelector("[data-dir-label]");
if (!kind || !extra) return;
/* The directory is a hidden field plus a button, so the two have to be set
together or the button shows one path and the form submits another. */
function setDir(value) {
if (!dir) return;
dir.value = value || "";
if (dirLabel) dirLabel.textContent = value || "the login directory";
}
function sync() {
var chosen = root.querySelector('input[name="kind_choice"]:checked');
var agent = chosen && chosen.value === "agent";
@@ -503,17 +522,37 @@ document.addEventListener("lembas:notify", function (event) {
extra.hidden = !agent;
}
function profileDefault() {
var option = picker && picker.options[picker.selectedIndex];
return (option && option.dataset.dir) || "";
}
root.addEventListener("change", function (event) {
if (event.target.name === "kind_choice") sync();
// Following the profile's own directory is a convenience, not a rule:
// once someone has typed their own it is left alone.
// once someone has chosen their own it is left alone.
if (event.target === picker && dir && !dir.dataset.touched) {
var option = picker.options[picker.selectedIndex];
dir.value = (option && option.dataset.dir) || "";
setDir(profileDefault());
}
});
if (dir) dir.addEventListener("input", function () { dir.dataset.touched = "1"; });
root.addEventListener("click", function (event) {
if (!event.target.closest("[data-dir-browse]")) return;
event.preventDefault();
var profileId = picker ? picker.value : "";
if (!profileId) return;
window.lembas.chooseDirectory(profileId, dir.value, function (chosen) {
dir.dataset.touched = "1";
setDir(chosen);
});
});
sync();
/* Seeded from whichever profile the select is actually showing, not from
the first in the list -- an unverified first profile renders `disabled`,
so the two disagreed and the box offered a directory on a machine the
chat was not going to use. */
setDir(profileDefault());
}
function scan() {