An edge that is not drawn, and a panel that stopped eating the site

`hx-get=""` is not "fetch nothing". htmx looks for the attribute, not for a
value, so the empty one the canvas rendered before a chat existed was a real
request for the empty path -- which the browser resolves against the current
document. Opening the canvas on the new-chat screen fetched the new-chat screen
and swapped the whole site into the panel. The attribute is omitted now, and a
test refuses an empty verb anywhere on the page.

Which panels can exist is the server's answer; which are offered is the
browser's. Both need an agent chat on a chosen connection, and before a chat
exists those are controls in the composer -- so answering with the first profile
offered a terminal on an ordinary chat with nothing selected. They follow
`lembas:agent-target` now, and an open panel whose target goes away is closed
rather than left showing one machine under another's name.

`.tabs__body` is only sometimes the scroller: true where the tabs are a bounded
flex child, false under the admin layout, where the page scrolls instead. So
setting its scrollTop on every tab change had never once run on /admin/prompts,
silently, while the reader was dragged to the bottom of a document that had just
got shorter. The rule names the position now, and the handler finds the
container that actually scrolls.

The two top borders come off. They were what made the misalignment at the bottom
of the shell visible; `--footer-height` stays, because two ends at different
heights are visible without a line to prove it. The top of the shell keeps its
line -- there, everything is `--header-height` and aligns by construction.

And one version. pyproject carried its own copy and had drifted three minors
from the one everything actually reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-06 09:42:06 +02:00
co-authored by Claude Opus 5
parent 4c78215e31
commit bdd7e09753
13 changed files with 281 additions and 39 deletions
+9
View File
@@ -108,6 +108,15 @@
function connect() {
if (socket) return;
/* No target yet. The same guard `repointTerminal` has, and it belongs here
too: with no connection chosen `dataset.url` is "", so the URL below
becomes `ws://host?cols=80&rows=24` -- a handshake against the app root,
which fails into onerror and blames the proxy for something the reader
simply has not chosen yet. */
if (!panel.dataset.url) {
say("Choose a connection above, and this panel will open a shell on it.");
return;
}
closedOnPurpose = false;
var base = location.protocol === "https:" ? "wss://" : "ws://";
+71 -6
View File
@@ -680,6 +680,36 @@ document.addEventListener("lembas:notify", function (event) {
});
}
/* The two panel buttons, on the screen where the server cannot answer.
Both the canvas and the terminal need an agent chat on a chosen connection,
and before a chat exists both of those are radio buttons and a select in the
composer -- nothing the server has seen. It used to answer with
`profiles[0]`, so the buttons were offered on the new-chat screen whatever
the toggle said and whatever was selected, and pressing either opened a
panel that could not work.
So the markup renders them `hidden` carrying `data-agent-only`, and this
follows the event `wire()` already dispatches. Anything without that
attribute is left alone: on a chat that exists the server's answer is
complete and this must not second-guess it.
Closing a panel whose target has just gone is not tidiness. The panel is
still pointed at the old connection, and leaving it open would show one
machine's files under a heading naming another. */
document.addEventListener("lembas:agent-target", function (event) {
var ready = !!(event.detail && event.detail.profileId);
document.querySelectorAll("[data-agent-only]").forEach(function (button) {
button.hidden = !ready;
if (ready) return;
var selector = button.dataset.toggle || "";
var panel = selector && document.querySelector(selector);
if (panel && !panel.hidden && window.lembas && window.lembas.setPanel) {
window.lembas.setPanel(selector, false);
}
});
});
document.addEventListener("DOMContentLoaded", scan);
document.body && scan();
document.addEventListener("htmx:afterSettle", scan);
@@ -697,21 +727,56 @@ document.addEventListener("lembas:notify", function (event) {
scroll up before scrolling down.
Nothing in CSS can reset a scroll position, so this is the smallest amount of
JavaScript that fixes it: on a tab change, put the body it belongs to back at
the top. Delegated and keyed on the class rather than on any one page, because
every tabbed screen here has the same container and the same problem.
JavaScript that fixes it: on a tab change, put the container that actually
scrolls back to the top. Delegated and keyed on the class rather than on any
one page, because every tabbed screen here has the same problem.
Which container that is depends on where the tabs are, and assuming it was
always `.tabs__body` is why this did nothing at all on /admin/prompts for the
whole life of the fix. `.tabs__body` scrolls only when `.tabs` is a flex child
of something bounded -- true on the settings page, false under the admin
layout, where the scroller is the `.admin-scroll` above it and `.tabs__body`
has `height: auto`. Setting `scrollTop = 0` on an element that does not scroll
is a silent no-op, which is exactly the kind of failure that survives review.
So: walk up from the bar and reset the first ancestor that can scroll. That is
correct on both shapes without knowing which one it is looking at.
*/
(function () {
function scroller(node) {
for (var el = node; el && el !== document.body; el = el.parentElement) {
var overflow = getComputedStyle(el).overflowY;
if ((overflow === "auto" || overflow === "scroll") && el.scrollHeight > el.clientHeight) {
return el;
}
}
return null;
}
document.addEventListener("change", function (event) {
var radio = event.target;
if (!radio || radio.type !== "radio") return;
var bar = radio.closest && radio.closest(".tabs__bar");
if (!bar) return;
/* The body is the bar's sibling, which is also what the panel-matching
selectors in admin.css rely on -- so if this ever stops finding it, those
will have stopped working too. */
/* The body first, because on the settings page it is the scroller and is
also the thing whose *content* changed; then whatever encloses the tabs.
Both, not either: on the admin layout the body may still hold a scrolled
inner panel while the page itself is what the reader is lost in. */
var body = bar.parentElement && bar.parentElement.querySelector(".tabs__body");
if (body) body.scrollTop = 0;
/* And where the page itself is the scroller, put the bar back at the top of
it -- not the page at zero. There is content above the tabs on
/admin/prompts and the reader has just asked to look at a tab, so the tab
bar is where they want to be.
This has to happen *after* the panel has swapped, which it has: :checked
applies before `change` fires. That order is the whole failure -- the
browser scrolls the focused radio into view first, then the shorter panel
shrinks the document and scrollTop is clamped to the new maximum, which
for a short panel is somewhere below everything. */
var outer = scroller(bar);
if (outer && outer !== body) bar.scrollIntoView({ block: "start" });
});
})();