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:
+9
-2
@@ -4,7 +4,11 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "lembas"
|
name = "lembas"
|
||||||
version = "0.6.2"
|
# Read from lembas.__version__ rather than written here. Two copies drifted
|
||||||
|
# three minor versions apart without anything noticing, because nothing reads
|
||||||
|
# this one: the app, the service worker cache key and the page footer all read
|
||||||
|
# the module. See [tool.hatch.version] below.
|
||||||
|
dynamic = ["version"]
|
||||||
description = "LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints"
|
description = "LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
@@ -60,7 +64,10 @@ ssh = ["asyncssh[bcrypt]>=2.14"]
|
|||||||
lembas = "lembas.cli:app"
|
lembas = "lembas.cli:app"
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://github.com/homer/LLeMbas"
|
Homepage = "https://git.houmeres.sk/Houmeres/LLeMbas"
|
||||||
|
|
||||||
|
[tool.hatch.version]
|
||||||
|
path = "src/lembas/__init__.py"
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["src/lembas"]
|
packages = ["src/lembas"]
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
||||||
|
|
||||||
__version__ = "0.8.1"
|
__version__ = "0.8.2"
|
||||||
|
|||||||
@@ -229,6 +229,13 @@ def _agent_context(db: DBSession, user: User, chat: Chat | None) -> dict:
|
|||||||
# that there is one to choose, which is all the panels need in order to
|
# that there is one to choose, which is all the panels need in order to
|
||||||
# exist. They are pointed at a target by `lembas:agent-target`, and show
|
# exist. They are pointed at a target by `lembas:agent-target`, and show
|
||||||
# nothing until they are.
|
# nothing until they are.
|
||||||
|
#
|
||||||
|
# This says the panels may *exist*, never that they should be *offered*.
|
||||||
|
# The two buttons render `hidden` here and are shown by the same event,
|
||||||
|
# because the kind toggle and the connection select are both in the
|
||||||
|
# browser: answering with `profiles[0]` and leaving it at that offered a
|
||||||
|
# terminal on an ordinary chat with nothing selected, and pressing it
|
||||||
|
# opened a panel that could not work.
|
||||||
current = profiles[0]
|
current = profiles[0]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
/* Settings and administration screens. */
|
/* Settings and administration screens. */
|
||||||
|
|
||||||
/* --- Page scaffolding ------------------------------------------------------ */
|
/* --- Page scaffolding ------------------------------------------------------
|
||||||
|
One scroll container per screen, and this is the rule that decides which.
|
||||||
|
|
||||||
|
`.admin-scroll` is it on every admin page. `.tabs__body` is it only where the
|
||||||
|
tabs are a *bounded* flex child -- `.main > .tabs` on the settings page --
|
||||||
|
which is why the selector below says so rather than naming the class alone.
|
||||||
|
|
||||||
|
It used to name the class alone, and the result was two scrollers stacked on
|
||||||
|
/admin/prompts, where `.tabs` sits inside `.admin-scroll > .admin-page`, a
|
||||||
|
plain block. `flex: 1` and `min-height: 0` mean nothing there, so
|
||||||
|
`.tabs__body` had `height: auto` and never scrolled while still declaring
|
||||||
|
`overflow-y: auto` -- and everything written to reset "the scroller" reset
|
||||||
|
that one, silently, while the reader was lost in the other. Under
|
||||||
|
`.admin-scroll` the body is now an ordinary block and the page scrolls as one.
|
||||||
|
*/
|
||||||
.admin-scroll,
|
.admin-scroll,
|
||||||
.tabs__body {
|
.main > .tabs > .tabs__body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
@@ -416,6 +416,11 @@ button, input, textarea, select {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
/* A flick past the end of the list stops there rather than chaining to
|
||||||
|
whatever is behind it. The shell is `overflow: hidden`, so what chaining
|
||||||
|
produced was not a scrolled page but a rubber-band into blank background --
|
||||||
|
which reads as the sidebar having come loose from the layout. */
|
||||||
|
overscroll-behavior: contain;
|
||||||
padding: 0 var(--sp-2) var(--sp-3);
|
padding: 0 var(--sp-2) var(--sp-3);
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: var(--border-strong) transparent;
|
scrollbar-color: var(--border-strong) transparent;
|
||||||
@@ -423,14 +428,15 @@ button, input, textarea, select {
|
|||||||
|
|
||||||
.sidebar__footer {
|
.sidebar__footer {
|
||||||
flex: none;
|
flex: none;
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
padding: var(--sp-2);
|
padding: var(--sp-2);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--sp-1);
|
gap: var(--sp-1);
|
||||||
/* Meets the composer's top border on the other side of the sidebar edge.
|
/* Ends level with the composer on the other side of the sidebar edge. See
|
||||||
See `--footer-height`; `justify-content` keeps the rows at the bottom when
|
`--footer-height`; `justify-content` keeps the rows at the bottom when the
|
||||||
the reader's permissions leave fewer of them than the token allows for. */
|
reader's permissions leave fewer of them than the token allows for. No top
|
||||||
|
border: the nav above scrolls, and a drawn edge over scrolling content is
|
||||||
|
a line the content stops dead at rather than passes under. */
|
||||||
min-height: var(--footer-height);
|
min-height: var(--footer-height);
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -918,12 +918,11 @@
|
|||||||
.composer {
|
.composer {
|
||||||
flex: none;
|
flex: none;
|
||||||
padding: var(--sp-3) var(--sp-5) var(--sp-4);
|
padding: var(--sp-3) var(--sp-5) var(--sp-4);
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
/* Lifted to meet the sidebar footer's top border, so the two read as one
|
/* Lifted to end level with the sidebar's footer across the shell. See
|
||||||
line across the shell rather than as one that has been broken at the
|
`--footer-height`. A column ending at `flex-end` so the extra height opens
|
||||||
sidebar's edge. See `--footer-height`. A column ending at `flex-end` so the
|
above the box and the input stays where the hand is. No top border: the
|
||||||
extra height opens above the box and the input stays where the hand is. */
|
transcript scrolls under this, and the edge reads better undrawn. */
|
||||||
min-height: var(--footer-height);
|
min-height: var(--footer-height);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -84,11 +84,16 @@
|
|||||||
|
|
||||||
/* What `--header-height` does at the top of the shell, this does at the
|
/* What `--header-height` does at the top of the shell, this does at the
|
||||||
bottom. The sidebar's footer and the composer sit either side of the same
|
bottom. The sidebar's footer and the composer sit either side of the same
|
||||||
vertical line, and both were content-sized -- so the two top borders met
|
vertical line and both are content-sized, so without this they end at
|
||||||
the sidebar's edge at different heights and read as one line that had been
|
different heights -- and neither can be made to match the other by
|
||||||
broken. Neither could be made to match the other by accident: the footer's
|
accident: the footer's height depends on which entries the reader's
|
||||||
height depends on which entries the reader's permissions allow, and the
|
permissions allow, and the composer's on how much they have typed.
|
||||||
composer's on how much they have typed.
|
|
||||||
|
This began as the fix for a broken *line*: both carried a top border, and
|
||||||
|
the two met the sidebar's edge at different heights. The borders are gone
|
||||||
|
now -- an edge that content scrolls under reads better undrawn than drawn
|
||||||
|
and aligned -- and the token stays, because the two ends of the shell
|
||||||
|
sitting at different heights is visible without any border to prove it.
|
||||||
|
|
||||||
A calc of the pieces the footer is actually built from -- four rows at
|
A calc of the pieces the footer is actually built from -- four rows at
|
||||||
`--control-h`, the gaps between them, and its own padding -- so it stays
|
`--control-h`, the gaps between them, and its own padding -- so it stays
|
||||||
|
|||||||
@@ -108,6 +108,15 @@
|
|||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
if (socket) return;
|
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;
|
closedOnPurpose = false;
|
||||||
|
|
||||||
var base = location.protocol === "https:" ? "wss://" : "ws://";
|
var base = location.protocol === "https:" ? "wss://" : "ws://";
|
||||||
|
|||||||
@@ -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.addEventListener("DOMContentLoaded", scan);
|
||||||
document.body && scan();
|
document.body && scan();
|
||||||
document.addEventListener("htmx:afterSettle", scan);
|
document.addEventListener("htmx:afterSettle", scan);
|
||||||
@@ -697,21 +727,56 @@ document.addEventListener("lembas:notify", function (event) {
|
|||||||
scroll up before scrolling down.
|
scroll up before scrolling down.
|
||||||
|
|
||||||
Nothing in CSS can reset a scroll position, so this is the smallest amount of
|
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
|
JavaScript that fixes it: on a tab change, put the container that actually
|
||||||
the top. Delegated and keyed on the class rather than on any one page, because
|
scrolls back to the top. Delegated and keyed on the class rather than on any
|
||||||
every tabbed screen here has the same container and the same problem.
|
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 () {
|
||||||
|
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) {
|
document.addEventListener("change", function (event) {
|
||||||
var radio = event.target;
|
var radio = event.target;
|
||||||
if (!radio || radio.type !== "radio") return;
|
if (!radio || radio.type !== "radio") return;
|
||||||
var bar = radio.closest && radio.closest(".tabs__bar");
|
var bar = radio.closest && radio.closest(".tabs__bar");
|
||||||
if (!bar) return;
|
if (!bar) return;
|
||||||
/* The body is the bar's sibling, which is also what the panel-matching
|
/* The body first, because on the settings page it is the scroller and is
|
||||||
selectors in admin.css rely on -- so if this ever stops finding it, those
|
also the thing whose *content* changed; then whatever encloses the tabs.
|
||||||
will have stopped working too. */
|
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");
|
var body = bar.parentElement && bar.parentElement.querySelector(".tabs__body");
|
||||||
if (body) body.scrollTop = 0;
|
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" });
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,15 @@
|
|||||||
<div class="canvas__inner" id="canvas-inner"
|
<div class="canvas__inner" id="canvas-inner"
|
||||||
{# Before a chat exists this fetches nothing until `draft.js` rewrites it,
|
{# Before a chat exists this fetches nothing until `draft.js` rewrites it,
|
||||||
which is what "intersect once" gives for free: the panel is hidden until
|
which is what "intersect once" gives for free: the panel is hidden until
|
||||||
it is opened, so the trigger has not fired yet. #}
|
it is opened, so the trigger has not fired yet.
|
||||||
hx-get="{{ "/api/chats/" ~ chat.id ~ "/canvas" if chat else "" }}"
|
|
||||||
|
The attribute is *omitted* rather than left empty, and that distinction is
|
||||||
|
the whole bug it fixes: htmx looks for the attribute's presence, not for a
|
||||||
|
value -- `if(s(t,"hx-"+r))` is `hasAttribute` -- so `hx-get=""` is a real
|
||||||
|
request for the empty path, which the browser resolves against the current
|
||||||
|
document. Opening the canvas on the new-chat screen therefore fetched the
|
||||||
|
new-chat screen and swapped the entire site into this panel. #}
|
||||||
|
{% if chat %}hx-get="/api/chats/{{ chat.id }}/canvas"{% endif %}
|
||||||
hx-trigger="intersect once"
|
hx-trigger="intersect once"
|
||||||
hx-target="this" hx-swap="innerHTML">
|
hx-target="this" hx-swap="innerHTML">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
|
|||||||
@@ -89,6 +89,12 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# Before a chat exists, both of these depend on a decision being made in
|
||||||
|
the browser -- the kind toggle and the connection select -- which the
|
||||||
|
server cannot see. So they render carrying `data-agent-only` and start
|
||||||
|
`hidden`; `ui.js` shows them when `lembas:agent-target` names a
|
||||||
|
connection and hides them again when it stops. On a chat that exists
|
||||||
|
the server's answer is complete and neither attribute appears. #}
|
||||||
{% if canvas_enabled %}
|
{% if canvas_enabled %}
|
||||||
{# Nearest the conversation of the three, being the widest and the one
|
{# Nearest the conversation of the three, being the widest and the one
|
||||||
most likely to be open beside it. All three share one slot: at
|
most likely to be open beside it. All three share one slot: at
|
||||||
@@ -96,6 +102,7 @@
|
|||||||
chat. #}
|
chat. #}
|
||||||
<button class="btn btn--icon" type="button" aria-label="Canvas"
|
<button class="btn btn--icon" type="button" aria-label="Canvas"
|
||||||
title="Open a file beside the conversation"
|
title="Open a file beside the conversation"
|
||||||
|
{% if not chat %}data-agent-only hidden{% endif %}
|
||||||
aria-expanded="false" data-toggle="#canvas" data-toggle-group="side">
|
aria-expanded="false" data-toggle="#canvas" data-toggle-group="side">
|
||||||
{{ icon("file-text") }}
|
{{ icon("file-text") }}
|
||||||
</button>
|
</button>
|
||||||
@@ -106,6 +113,7 @@
|
|||||||
toggle group in app.js. #}
|
toggle group in app.js. #}
|
||||||
<button class="btn btn--icon" type="button" aria-label="Terminal"
|
<button class="btn btn--icon" type="button" aria-label="Terminal"
|
||||||
title="Open a shell on {{ agent_profile.name if agent_profile else 'this connection' }}"
|
title="Open a shell on {{ agent_profile.name if agent_profile else 'this connection' }}"
|
||||||
|
{% if not chat %}data-agent-only hidden{% endif %}
|
||||||
aria-expanded="false" data-toggle="#terminal" data-toggle-group="side">
|
aria-expanded="false" data-toggle="#terminal" data-toggle-group="side">
|
||||||
{{ icon("terminal") }}
|
{{ icon("terminal") }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from lembas.db.models import (
|
|||||||
Connection,
|
Connection,
|
||||||
Model,
|
Model,
|
||||||
ScratchDoc,
|
ScratchDoc,
|
||||||
|
SshProfile,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from lembas.services import canvas as canvas_service
|
from lembas.services import canvas as canvas_service
|
||||||
@@ -602,3 +603,65 @@ def test_a_chat_with_no_machine_is_offered_no_file_dialog(
|
|||||||
assert "data-canvas-open" not in body
|
assert "data-canvas-open" not in body
|
||||||
# Scratch is still there: it needs no machine.
|
# Scratch is still there: it needs no machine.
|
||||||
assert "scratch:" in body
|
assert "scratch:" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_new_chat_screen_leaves_no_empty_htmx_verb(
|
||||||
|
client: TestClient, db, registered
|
||||||
|
):
|
||||||
|
"""`hx-get=""` is not "fetch nothing" -- htmx looks for the attribute, not
|
||||||
|
for a value (`if(s(t,"hx-"+r))` is hasAttribute), so an empty one is a real
|
||||||
|
request for the empty path. The browser resolves that against the current
|
||||||
|
document, so opening the canvas on the new-chat screen fetched the new-chat
|
||||||
|
screen and swapped the whole site into the panel.
|
||||||
|
|
||||||
|
Asserted over the entire page rather than over the canvas markup: the same
|
||||||
|
`{{ ... if chat else "" }}` shape would do the same thing anywhere, and the
|
||||||
|
failure looks like a rendering bug rather than a request.
|
||||||
|
"""
|
||||||
|
_add_connection(db)
|
||||||
|
profile = SshProfile(
|
||||||
|
owner_id=db.scalars(select(User)).first().id,
|
||||||
|
name="box",
|
||||||
|
host="10.0.0.9",
|
||||||
|
username="deploy",
|
||||||
|
auth="password",
|
||||||
|
password_encrypted=encrypt("hunter2"),
|
||||||
|
host_key="ssh-ed25519 AAAA",
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
db.add(profile)
|
||||||
|
settings_store.update(db, {"enabled": True}, key=settings_store.AGENTS)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
body = client.get("/chat").text
|
||||||
|
|
||||||
|
for verb in ("get", "post", "patch", "put", "delete"):
|
||||||
|
assert f'hx-{verb}=""' not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_panel_buttons_start_hidden_before_a_chat_exists(
|
||||||
|
client: TestClient, db, registered
|
||||||
|
):
|
||||||
|
"""Which connection is chosen, and whether this is going to be an agent chat
|
||||||
|
at all, are decisions being made in the browser. The server answered with
|
||||||
|
the first profile regardless, so both buttons were offered on an ordinary
|
||||||
|
new chat with nothing selected."""
|
||||||
|
_add_connection(db)
|
||||||
|
db.add(
|
||||||
|
SshProfile(
|
||||||
|
owner_id=db.scalars(select(User)).first().id,
|
||||||
|
name="box",
|
||||||
|
host="10.0.0.9",
|
||||||
|
username="deploy",
|
||||||
|
auth="password",
|
||||||
|
password_encrypted=encrypt("hunter2"),
|
||||||
|
host_key="ssh-ed25519 AAAA",
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
settings_store.update(db, {"enabled": True}, key=settings_store.AGENTS)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
body = client.get("/chat").text
|
||||||
|
|
||||||
|
assert "data-agent-only hidden" in body
|
||||||
|
|||||||
+64
-12
@@ -136,10 +136,10 @@ def test_the_finished_bubble_repeats_the_live_container_s_id():
|
|||||||
|
|
||||||
def test_switching_a_tab_puts_its_body_back_at_the_top():
|
def test_switching_a_tab_puts_its_body_back_at_the_top():
|
||||||
"""A tab is a radio and a panel is shown by CSS, so switching one changes
|
"""A tab is a radio and a panel is shown by CSS, so switching one changes
|
||||||
nothing about `.tabs__body` -- the element that actually scrolls. Read half
|
nothing about the container that scrolls. Read half way down a long panel,
|
||||||
way down a long panel, switch to a short one, and the browser clamps the
|
switch to a short one, and the browser clamps the kept scrollTop to that
|
||||||
kept scrollTop to that panel's bottom: what lands on screen is the end of it
|
panel's bottom: what lands on screen is the end of it above a screen of
|
||||||
above a screen of nothing, which reads as a page that failed to load.
|
nothing, which reads as a page that failed to load.
|
||||||
|
|
||||||
Nothing in CSS can reset a scroll position. Driven under a DOM stub before
|
Nothing in CSS can reset a scroll position. Driven under a DOM stub before
|
||||||
committing; what is pinned here is that the listener is delegated and keyed
|
committing; what is pinned here is that the listener is delegated and keyed
|
||||||
@@ -151,15 +151,38 @@ def test_switching_a_tab_puts_its_body_back_at_the_top():
|
|||||||
assert "scrollTop = 0" in SOURCE
|
assert "scrollTop = 0" in SOURCE
|
||||||
|
|
||||||
|
|
||||||
def test_the_shell_has_one_line_along_its_bottom_edge():
|
def test_the_tab_reset_finds_the_container_that_actually_scrolls():
|
||||||
"""The sidebar footer and the composer sit either side of the same vertical
|
"""Resetting `.tabs__body` was a no-op on /admin/prompts for the whole life
|
||||||
edge and were both content-sized, so their top borders met it at different
|
of the fix, because there the tabs sit inside `.admin-scroll > .admin-page`
|
||||||
heights and read as one line that had been broken.
|
-- a plain block -- so the body has `height: auto` and the page is what
|
||||||
|
scrolls. Setting scrollTop on an element that does not scroll says nothing
|
||||||
|
and fails nothing.
|
||||||
|
|
||||||
Neither can match the other by accident: the footer's height depends on
|
So the handler walks up for the first ancestor that can scroll, and the CSS
|
||||||
which entries a reader's permissions allow, and the composer's on how much
|
stops claiming `.tabs__body` is one everywhere. Both halves are pinned:
|
||||||
has been typed. So both take a `min-height` from one token, the way
|
either alone leaves the bug.
|
||||||
`--header-height` already does this at the top of the shell.
|
"""
|
||||||
|
admin = (ROOT / "web/static/css/admin.css").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# The scroller rule names where the tabs must be, not just the class.
|
||||||
|
assert ".main > .tabs > .tabs__body" in admin
|
||||||
|
assert "\n.tabs__body {" not in admin
|
||||||
|
|
||||||
|
assert "overflowY" in SOURCE
|
||||||
|
assert "scrollHeight > " in SOURCE
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_two_ends_of_the_shell_stay_level():
|
||||||
|
"""The sidebar footer and the composer sit either side of the same vertical
|
||||||
|
edge and are both content-sized, so without a common floor they end at
|
||||||
|
different heights: the footer's depends on which entries a reader's
|
||||||
|
permissions allow, and the composer's on how much has been typed.
|
||||||
|
|
||||||
|
They used to carry a top border each, which is how the misalignment was
|
||||||
|
noticed. The borders are gone -- content scrolls under both edges, and an
|
||||||
|
undrawn edge reads better than one that has to be aligned -- and the shared
|
||||||
|
`min-height` stays, because two ends at different heights is visible without
|
||||||
|
a border to prove it.
|
||||||
"""
|
"""
|
||||||
tokens = (ROOT / "web/static/css/tokens.css").read_text(encoding="utf-8")
|
tokens = (ROOT / "web/static/css/tokens.css").read_text(encoding="utf-8")
|
||||||
app = (ROOT / "web/static/css/app.css").read_text(encoding="utf-8")
|
app = (ROOT / "web/static/css/app.css").read_text(encoding="utf-8")
|
||||||
@@ -172,6 +195,35 @@ def test_the_shell_has_one_line_along_its_bottom_edge():
|
|||||||
assert "min-height: var(--footer-height)" in app
|
assert "min-height: var(--footer-height)" in app
|
||||||
assert "min-height: var(--footer-height)" in chat
|
assert "min-height: var(--footer-height)" in chat
|
||||||
|
|
||||||
|
# And neither draws the line again. Scoped to each rule's own block, so an
|
||||||
|
# unrelated border elsewhere in either file does not fail this.
|
||||||
|
footer = app.split(".sidebar__footer {")[1].split("}")[0]
|
||||||
|
composer = chat.split(".composer {")[1].split("}")[0]
|
||||||
|
assert "border-top" not in footer
|
||||||
|
assert "border-top" not in composer
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_panel_buttons_wait_for_a_connection_before_they_appear():
|
||||||
|
"""The canvas and the terminal both need an agent chat on a chosen
|
||||||
|
connection. Before a chat exists both of those live in the composer -- a
|
||||||
|
radio group and a select -- so the server cannot answer, and answering with
|
||||||
|
the first profile anyway offered a terminal on an ordinary chat with nothing
|
||||||
|
selected. Pressing it opened a panel that could not work.
|
||||||
|
|
||||||
|
So the buttons render hidden and follow the event the composer already
|
||||||
|
dispatches. Driven under a DOM stub; pinned here is that they are keyed on
|
||||||
|
an attribute rather than on their own ids -- a third panel would otherwise
|
||||||
|
need this written a third time.
|
||||||
|
"""
|
||||||
|
index = (TEMPLATES / "chat/index.html").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert index.count("{% if not chat %}data-agent-only hidden{% endif %}") == 2
|
||||||
|
assert "[data-agent-only]" in SOURCE
|
||||||
|
assert "lembas:agent-target" in SOURCE
|
||||||
|
# An open panel pointed at a connection that has gone is closed, or it shows
|
||||||
|
# one machine's files under a heading naming another.
|
||||||
|
assert "setPanel" in SOURCE
|
||||||
|
|
||||||
|
|
||||||
def test_the_canvas_open_button_posts_through_htmx():
|
def test_the_canvas_open_button_posts_through_htmx():
|
||||||
"""The button cannot carry an `hx-post`: the path is not known until the
|
"""The button cannot carry an `hx-post`: the path is not known until the
|
||||||
|
|||||||
Reference in New Issue
Block a user