diff --git a/pyproject.toml b/pyproject.toml index 76dd452..939900a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,11 @@ build-backend = "hatchling.build" [project] 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" readme = "README.md" requires-python = ">=3.11" @@ -60,7 +64,10 @@ ssh = ["asyncssh[bcrypt]>=2.14"] lembas = "lembas.cli:app" [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] packages = ["src/lembas"] diff --git a/src/lembas/__init__.py b/src/lembas/__init__.py index 076d707..bb7289c 100644 --- a/src/lembas/__init__.py +++ b/src/lembas/__init__.py @@ -1,3 +1,3 @@ """LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints.""" -__version__ = "0.8.1" +__version__ = "0.8.2" diff --git a/src/lembas/api/pages.py b/src/lembas/api/pages.py index 69461ac..09296ba 100644 --- a/src/lembas/api/pages.py +++ b/src/lembas/api/pages.py @@ -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 # exist. They are pointed at a target by `lembas:agent-target`, and show # 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] return { diff --git a/src/lembas/web/static/css/admin.css b/src/lembas/web/static/css/admin.css index bab1f55..2634636 100644 --- a/src/lembas/web/static/css/admin.css +++ b/src/lembas/web/static/css/admin.css @@ -1,8 +1,22 @@ /* 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, -.tabs__body { +.main > .tabs > .tabs__body { flex: 1; min-height: 0; overflow-y: auto; diff --git a/src/lembas/web/static/css/app.css b/src/lembas/web/static/css/app.css index cdf4952..d1f2e21 100644 --- a/src/lembas/web/static/css/app.css +++ b/src/lembas/web/static/css/app.css @@ -416,6 +416,11 @@ button, input, textarea, select { flex: 1; min-height: 0; 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); scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; @@ -423,14 +428,15 @@ button, input, textarea, select { .sidebar__footer { flex: none; - border-top: 1px solid var(--border); padding: var(--sp-2); display: flex; flex-direction: column; gap: var(--sp-1); - /* Meets the composer's top border on the other side of the sidebar edge. - See `--footer-height`; `justify-content` keeps the rows at the bottom when - the reader's permissions leave fewer of them than the token allows for. */ + /* Ends level with the composer on the other side of the sidebar edge. See + `--footer-height`; `justify-content` keeps the rows at the bottom when the + 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); justify-content: flex-end; } diff --git a/src/lembas/web/static/css/chat.css b/src/lembas/web/static/css/chat.css index a3e30e5..56a15a0 100644 --- a/src/lembas/web/static/css/chat.css +++ b/src/lembas/web/static/css/chat.css @@ -918,12 +918,11 @@ .composer { flex: none; padding: var(--sp-3) var(--sp-5) var(--sp-4); - border-top: 1px solid var(--border); background: var(--bg); - /* Lifted to meet the sidebar footer's top border, so the two read as one - line across the shell rather than as one that has been broken at the - sidebar's edge. See `--footer-height`. A column ending at `flex-end` so the - extra height opens above the box and the input stays where the hand is. */ + /* Lifted to end level with the sidebar's footer across the shell. See + `--footer-height`. A column ending at `flex-end` so the extra height opens + above the box and the input stays where the hand is. No top border: the + transcript scrolls under this, and the edge reads better undrawn. */ min-height: var(--footer-height); display: flex; flex-direction: column; diff --git a/src/lembas/web/static/css/tokens.css b/src/lembas/web/static/css/tokens.css index daf5698..ca51a41 100644 --- a/src/lembas/web/static/css/tokens.css +++ b/src/lembas/web/static/css/tokens.css @@ -84,11 +84,16 @@ /* 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 - vertical line, and both were content-sized -- so the two top borders met - the sidebar's edge at different heights and read as one line that had been - broken. Neither could be made to match the other by accident: the footer's - height depends on which entries the reader's permissions allow, and the - composer's on how much they have typed. + vertical line and both are content-sized, so without this they end at + different heights -- and neither can be made to match the other by + accident: the footer's height depends on which entries the reader's + permissions allow, and the 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 `--control-h`, the gaps between them, and its own padding -- so it stays diff --git a/src/lembas/web/static/js/terminal.js b/src/lembas/web/static/js/terminal.js index 349dae1..a849e51 100644 --- a/src/lembas/web/static/js/terminal.js +++ b/src/lembas/web/static/js/terminal.js @@ -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://"; diff --git a/src/lembas/web/static/js/ui.js b/src/lembas/web/static/js/ui.js index 7fae217..5b5bdfc 100644 --- a/src/lembas/web/static/js/ui.js +++ b/src/lembas/web/static/js/ui.js @@ -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" }); }); })(); diff --git a/src/lembas/web/templates/chat/_canvas.html b/src/lembas/web/templates/chat/_canvas.html index 1e5a6e7..6ac1b49 100644 --- a/src/lembas/web/templates/chat/_canvas.html +++ b/src/lembas/web/templates/chat/_canvas.html @@ -30,8 +30,15 @@