4 Commits
Author SHA1 Message Date
HomerandClaude Opus 5.5 cb8a223fa4 A dot on the loaded model, and a new chat that matches its model
The model menu asks each connection's /v1/models for the load state
llama-swap reports there and marks the loaded model; endpoints that state
nothing (a hosted API) get no dot. The new-chat screen offered the generic
three efforts whatever the model took, so Bonsai's xhigh default showed as
off. The composer was as wide as its widest hint. And the Doors of Durin are
a riddle: Speak friend and enter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:48:58 +00:00
HomerandClaude Opus 5.5 dfd8418d95 A tab bar's edge fade that stays hidden until something is off the edge
The local-attached covers were as wide as the scroll shadows and solid for
only 40% of that, so most of each shadow showed at rest -- invisible on Moria,
a grey sliver at both ends of every tab bar on Shire. The covers are now twice
the shadow's width and solid across all of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 06:53:45 +00:00
HomerandClaude Opus 5.5 db22962164 A reload offer only for a page that is older, and a tab switch that moves one scroller
The update toast fired whenever a service worker was waiting, so after every
release it appeared on pages that were already the release -- including one
fetched with Ctrl+Shift+R. It now compares the waiting worker's release with
the page's own, and so does the reload that follows another tab accepting it.

On Admin -> Prompts, visually hidden labels were positioned against the page
and made the document 6771px tall behind an overflow-hidden root; the tab
handler's scrollIntoView then scrolled that root and lifted the shell 56px.
Every scroll region is now a containing block, and the handler moves only the
container that scrolls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 06:17:18 +00:00
HomerandClaude Opus 5.5 793f9c8cad A model menu that stays on the phone
Inside a chat the picker sits mid-bar with the panel buttons to its right,
and its menu opened from the picker's right edge -- at 390px it spanned
x = -132..226, cutting every model's name off. Below 48rem the bar is now
the containing block and the menu is pinned between its edges (capped at
24rem). Opening it on a touchscreen no longer focuses the filter, which
raised the keyboard over half the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:36:45 +00:00
24 changed files with 774 additions and 33 deletions
+86
View File
@@ -16,6 +16,92 @@ for 1.0.0 have something to be assembled from.
## Unreleased ## Unreleased
## 1.9.0
The model menu says which model is loaded, and a new chat now matches the
model it is about to talk to.
- **A dot on the model that is loaded.** Opening the model menu asks each
connection which of its models is in memory. llama-swap says so in its
ordinary model list, so the one it is holding gets a green dot, and one being
loaded gets a pulsing amber one. Picking a model without a dot means waiting
for it to load first. A hosted API such as DeepSeek never unloads anything and
does not report it, so its models show no dot, not a false "not loaded". Each
connection is asked once per menu opening, at most every five seconds. One
that reports nothing is asked again only after ten minutes, and one that is
slow or down just leaves the menu without dots.
- **A new chat offers the model's own effort levels.** The new-chat screen
offered low, medium and high whatever the model took. On a model like Bonsai,
which takes low, medium and xhigh with xhigh as its default, the menu offered a
`high` it rejects. It had no xhigh, so it showed "off" while the chat it
created used xhigh. It now shows the same levels, and the same default, as the
chat will have.
- **The message box is the same width for every model.** It was sized by its
widest content, so the "has no vision, so images will not be sent" line made
it wider for models without vision than for models with it. It is now always
the width of the conversation column.
- **"Speak friend and enter."** The line under an empty chat (and on the "not
yours" error page) lost its commas. On the Doors of Durin it is a riddle: the
answer is to say *friend*, not to be greeted as one. Only the shipped wording
changed. An instance that has overridden the line keeps its own.
## 1.8.5
- **No more grey slivers at the ends of the tab bars.** Tab bars fade at an edge
to show there are more tabs to scroll to. The fade was only partly hidden
when there was nothing to scroll, so a shadow always showed at both ends. It
was invisible on the dark theme and a grey sliver on Shire, on Administration
→ Prompts, Settings and every other tabbed page. The fade now appears only
on the side where tabs are actually hidden.
## 1.8.4
Two things that kept showing up after they should have gone away.
- **"A new version is ready" no longer appears on a page that is already the
new version.** After an update the toast showed up on every page, even one just
fetched with Ctrl+Shift+R, and reloading never made it go away. It fired
whenever a new service worker was waiting. But a page loaded after the update
already *is* the update: pages always come from the server, and every
stylesheet and script they name carries the release in its address. The
worker that waits is nearly always the one from the previous release, still
holding the tab, because a reload opens the new page before the old one goes
away. The toast now compares the waiting worker's release with the page's
own, so it only appears in a tab that was opened before the update. For the
same reason, pressing Reload in one tab no longer reloads the other tabs that
are already up to date. That matters when one of them has a reply streaming
into it.
- **Switching tabs on Administration → Prompts no longer lifts the page.**
Choosing any tab but the first pushed the whole window up by the height of
the title bar and left a blank strip along the bottom, under the sidebar too.
The prompt cards' hidden labels were positioned against the page instead of
the panel. That made the page 6,771px tall behind a window that cannot scroll
by hand, and the tab switch then scrolled it anyway. Every scrolling area now
contains what is inside it, and a tab switch moves only the panel that
scrolls. Administration → General on a small phone had the same leak and is
fixed with it.
## 1.8.3
The model picker on a phone, which could not be read once a chat was open.
- **The model menu no longer runs off the left of the screen.** Inside a chat
the picker sits in the middle of the top bar, with the panel buttons to its
right, and its menu opened from the picker's right edge — so on a phone most
of it was off the screen and every model's name was cut off. On a narrow
screen the menu now hangs from the bar itself, edge to edge, and every name is
whole. Wider screens are unchanged.
- **Opening it on a touchscreen no longer raises the keyboard.** With more than
eight models the menu has a filter box, and it took the focus on opening — so
the keyboard came up and covered half the list you had opened it to choose
from. On a touchscreen the chosen model takes the focus instead; the filter is
one tap away. With a mouse, typing straight into the filter works as before.
## 1.8.2 ## 1.8.2
The model lists, made readable. Both printed every capability switch as a tag — The model lists, made readable. Both printed every capability switch as a tag —
+1 -1
View File
@@ -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__ = "1.8.2" __version__ = "1.9.0"
+1 -1
View File
@@ -19,7 +19,7 @@ log = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/audio", tags=["admin-audio"]) router = APIRouter(prefix="/admin/audio", tags=["admin-audio"])
# Read out by the speech test. Short, and the one line this project would pick. # Read out by the speech test. Short, and the one line this project would pick.
TEST_PHRASE = "Speak, friend, and enter." TEST_PHRASE = "Speak friend and enter."
def _page_context(db: Db) -> dict: def _page_context(db: Db) -> dict:
+23
View File
@@ -0,0 +1,23 @@
"""What the model menu asks for when it opens."""
from __future__ import annotations
from fastapi import APIRouter
from lembas.api.deps import Db, RequiredUser
from lembas.services import chat as chat_service
from lembas.services import model_state
router = APIRouter(prefix="/api/models", tags=["models"])
@router.get("/state")
async def model_states(db: Db, user: RequiredUser) -> dict:
"""`{"states": {model_id: "loaded" | "loading" | "unloaded"}}`.
Only models this reader may use, so the answer never names a model the
menu would not show. Only those whose endpoint reports a state, so a hosted
API's models are simply absent. See `services/model_state.py`.
"""
models = chat_service.available_models(db, user)
return {"states": await model_state.states_for(models)}
+12
View File
@@ -736,6 +736,18 @@ async def chat_index(
"bodies": {}, "bodies": {},
**context, **context,
"current_model": preselected, "current_model": preselected,
# `_chat_context` reads the efforts off the *chat's* model, and there
# is no chat here -- so every new chat was offered the generic three
# whatever it was about to talk to. On Bonsai (low, medium, xhigh)
# the configured `xhigh` was not among them, and the picker fell
# through to "off". The chat created from this screen then got
# `xhigh` anyway, so the control said one thing and the first reply
# did another.
"efforts": (
chat_service.efforts_for(preselected)
if preselected
else chat_service.DEFAULT_EFFORTS
),
"starting_temporary": temporary, "starting_temporary": temporary,
"starting_kind": kind if kind in KINDS else KIND_CHAT, "starting_kind": kind if kind in KINDS else KIND_CHAT,
"starting_folder": starting_folder, "starting_folder": starting_folder,
+2
View File
@@ -38,6 +38,7 @@ from lembas.api import (
folders, folders,
library, library,
messages, messages,
models,
pages, pages,
preferences, preferences,
push, push,
@@ -204,6 +205,7 @@ def create_app() -> FastAPI:
app.include_router(folders.router) app.include_router(folders.router)
app.include_router(library.router) app.include_router(library.router)
app.include_router(messages.router) app.include_router(messages.router)
app.include_router(models.router)
app.include_router(reports.router) app.include_router(reports.router)
app.include_router(schedules.router) app.include_router(schedules.router)
app.include_router(agents.router) app.include_router(agents.router)
+6 -2
View File
@@ -71,7 +71,11 @@ FLAVOUR: dict[str, tuple[str, str, str]] = {
"chat_empty": ( "chat_empty": (
"Empty chat", "Empty chat",
"Above the composer on a chat with nothing in it yet.", "Above the composer on a chat with nothing in it yet.",
"Speak, friend, and enter.", # No commas, on purpose. It is the riddle on the Doors of Durin, and
# its answer is to *say* "friend" -- the password is the word itself.
# With commas it is an invitation to a friend, which is the misreading
# that kept the Fellowship outside the door.
"Speak friend and enter.",
), ),
"offline_title": ( "offline_title": (
"Offline heading", "Offline heading",
@@ -87,7 +91,7 @@ FLAVOUR: dict[str, tuple[str, str, str]] = {
"error_403": ( "error_403": (
"403 — not yours", "403 — not yours",
"Shown on a page somebody is not allowed to see.", "Shown on a page somebody is not allowed to see.",
"Speak, friend, and enter. This door is not yours to open.", "Speak friend and enter. This door is not yours to open.",
), ),
"error_404": ( "error_404": (
"404 — not found", "404 — not found",
+114
View File
@@ -0,0 +1,114 @@
"""Which models are loaded right now, where the endpoint is able to say.
llama-swap holds one model at a time and reports which, inside the ordinary
`GET /v1/models` answer: every entry carries `"status": {"value": "loaded"}`
or `"unloaded"`. Choosing a model that is not loaded costs a load (seconds for
a small one, most of a minute for the 26B), so the model menu shows a dot on
the one that is ready.
**Only what an endpoint states, and nothing inferred.** The OpenAI spec has
no such field. A hosted API such as DeepSeek leaves it out because nothing is
ever unloaded there, so its models get no state and no dot, rather than a
guess dressed up as a reading. The same shape covers the next runner that
reports it: `status` as an object with `value`, or as a bare string.
**Cheap by construction**, because the menu asks every time it opens:
- one `/v1/models` per *connection*, not per model, all at once;
- a short timeout, because a slow endpoint must never hold up a menu;
- five seconds of cache per connection, so opening the menu repeatedly costs
one request;
- and ten minutes for a connection that said nothing about state, so a hosted
API is not asked for its model list on every click only to answer nothing
again.
Process-level, like the branding cache. With several workers each keeps its
own, which costs at most one extra request each and cannot be wrong for longer
than the TTL.
"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import Any
from lembas.services.llm.openai_client import Endpoint, list_models
log = logging.getLogger(__name__)
TIMEOUT = 3.0
TTL = 5.0
TTL_SILENT = 600.0
LOADED = "loaded"
LOADING = "loading"
UNLOADED = "unloaded"
_LOADED_WORDS = frozenset({"loaded", "ready", "running"})
_LOADING_WORDS = frozenset({"loading", "starting"})
# connection id -> (monotonic time read, TTL, {model_id: state})
_CACHE: dict[str, tuple[float, float, dict[str, str]]] = {}
def state_of(entry: dict[str, Any]) -> str:
"""One `/v1/models` entry's state, or "" when it states none."""
status = entry.get("status")
value = status.get("value") if isinstance(status, dict) else status
if not isinstance(value, str) or not value.strip():
return ""
word = value.strip().lower()
if word in _LOADED_WORDS:
return LOADED
if word in _LOADING_WORDS:
return LOADING
return UNLOADED
async def _read(connection) -> dict[str, str]:
now = time.monotonic()
cached = _CACHE.get(connection.id)
if cached and now - cached[0] < cached[1]:
return cached[2]
try:
entries = await asyncio.wait_for(
list_models(Endpoint.from_connection(connection)), TIMEOUT
)
except Exception: # noqa: BLE001 - an unreachable endpoint has no state, not an error page
log.debug("model state unavailable for %s", connection.name, exc_info=True)
# Not cached: the next open asks again, which is right for an endpoint
# that is merely starting up.
return {}
states = {entry["id"]: state for entry in entries if (state := state_of(entry))}
_CACHE[connection.id] = (now, TTL if states else TTL_SILENT, states)
return states
async def states_for(models) -> dict[str, str]:
"""`{model_id: state}` for the models whose endpoint reports one.
Models without a stated state are absent, not `""`, so the page can treat
"no key" as "draw nothing".
"""
connections = {}
for model in models:
connection = getattr(model, "connection", None)
if connection is not None and connection.enabled:
connections[connection.id] = connection
if not connections:
return {}
results = await asyncio.gather(*(_read(c) for c in connections.values()))
by_connection = dict(zip(connections, results, strict=True))
out: dict[str, str] = {}
for model in models:
state = by_connection.get(model.connection_id, {}).get(model.model_id)
if state:
out[model.model_id] = state
return out
def forget() -> None:
"""Drop the cache. For tests."""
_CACHE.clear()
+2
View File
@@ -681,6 +681,8 @@ MESSAGES.update(
"Model": "Model", "Model": "Model",
"Context window": "Kontextové okno", "Context window": "Kontextové okno",
"Sees images": "Vidí obrázky", "Sees images": "Vidí obrázky",
"Loaded": "Načítaný",
"Loading": "Načítava sa",
"Groups": "Skupiny", "Groups": "Skupiny",
"Members": "Členovia", "Members": "Členovia",
"Account": "Účet", "Account": "Účet",
+11 -3
View File
@@ -167,16 +167,24 @@ a.tabs__tab { text-decoration: none; }
pinned to the scrollport with `background-attachment: local`, which is the old pinned to the scrollport with `background-attachment: local`, which is the old
trick and works everywhere -- the `local` layers scroll with the content and trick and works everywhere -- the `local` layers scroll with the content and
cover the `scroll` ones exactly when there is nothing more to see. cover the `scroll` ones exactly when there is nothing more to see.
⚠ "Cover" has to mean all of it. The covers used to be as wide as the
shadows and solid for only 40% of that width, so the other 60% of every
shadow always showed through, with nothing to scroll to. On Moria that is
near-black on near-black and nobody saw it. On Shire it was a grey sliver at
both ends of every tab bar. Each cover is now twice the shadow's width and
solid across the first half, which is the whole shadow. It fades only past
the shadow's end, so once content is scrolled the shadow shows as before.
*/ */
.tabs__bar { .tabs__bar {
background-image: background-image:
linear-gradient(to right, var(--bg) 40%, transparent), linear-gradient(to right, var(--bg) 50%, transparent),
linear-gradient(to left, var(--bg) 40%, transparent), linear-gradient(to left, var(--bg) 50%, transparent),
linear-gradient(to right, var(--scrim), transparent 1.5rem), linear-gradient(to right, var(--scrim), transparent 1.5rem),
linear-gradient(to left, var(--scrim), transparent 1.5rem); linear-gradient(to left, var(--scrim), transparent 1.5rem);
background-position: left center, right center, left center, right center; background-position: left center, right center, left center, right center;
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: 1.5rem 100%; background-size: 3rem 100%, 3rem 100%, 1.5rem 100%, 1.5rem 100%;
background-attachment: local, local, scroll, scroll; background-attachment: local, local, scroll, scroll;
/* A tab is a destination, so a flick should land on one rather than between /* A tab is a destination, so a flick should land on one rather than between
two. */ two. */
+56 -1
View File
@@ -452,7 +452,17 @@ input.visually-hidden[type="checkbox"] {
child will not shrink below its content without it, so a scroller missing it child will not shrink below its content without it, so a scroller missing it
grows its parent instead of scrolling inside it. `.thread-scroll` relied on a grows its parent instead of scrolling inside it. `.thread-scroll` relied on a
scroll container's automatic minimum size to get away with omitting it, which scroll container's automatic minimum size to get away with omitting it, which
is true and is not something the next person should have to know. */ is true and is not something the next person should have to know.
`position: relative` makes each scroller the containing block for what is in
it, and without it a `.visually-hidden` label is not in it at all. That class
is `position: absolute`, so with no positioned ancestor it is placed against
the *page*, at its static position -- six thousand pixels down the Tools
panel on /admin/prompts -- and the document grew to 6771px behind a root
that is `overflow: hidden`. Nobody can scroll that by hand, but
`scrollIntoView()` and `focus()` scroll every ancestor that can scroll,
and the root can. Switching a tab there lifted the whole shell 56px: the
topbar gone off the top and a strip of bare background under everything. */
.scroll-region, .scroll-region,
.sidebar__scroll, .sidebar__scroll,
.inspector__body, .inspector__body,
@@ -460,6 +470,7 @@ input.visually-hidden[type="checkbox"] {
.thread-scroll, .thread-scroll,
.admin-scroll, .admin-scroll,
.main > .tabs > .tabs__body { .main > .tabs > .tabs__body {
position: relative;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
@@ -1377,6 +1388,23 @@ body.is-resizing .canvas__body { pointer-events: none; }
which nothing else on the screen tells you -- gets the room back. */ which nothing else on the screen tells you -- gets the room back. */
.topbar__actions .picker__label { display: none; } .topbar__actions .picker__label { display: none; }
/* And the menu is the bar's, not the picker's. Anchored to the picker it
opens `right: 0` of a button that sits mid-bar with the panel buttons to
its right, so a 24rem menu ran off the left edge of a 390px phone and cut
every name in half. Taking `position` off the picker makes the bar the
containing block: the menu spans the bar under it, whatever sits where --
up to its usual 24rem, held at the bar's right edge by the auto margin. */
.topbar { position: relative; }
.topbar__actions .picker { position: static; }
.topbar__actions .picker__menu {
left: max(var(--sp-2), var(--safe-left));
right: max(var(--sp-2), var(--safe-right));
width: auto;
max-width: 24rem;
margin-left: auto;
}
.topbar__actions .picker__list { max-height: min(22rem, 60dvh); }
.sidebar { .sidebar {
position: fixed; position: fixed;
inset: 0 auto 0 0; inset: 0 auto 0 0;
@@ -1747,6 +1775,33 @@ body.is-resizing .canvas__body { pointer-events: none; }
gap: inherit; gap: inherit;
} }
.picker__list--models .picker__option .picker__avatar { margin-top: 0; } .picker__list--models .picker__option .picker__avatar { margin-top: 0; }
/* Whether a model is loaded, where its endpoint says so (llama-swap does; a
hosted API does not, and gets nothing). A dot on the avatar's corner, ringed
in the menu's own surface so it reads against any avatar colour. Nothing is
drawn until ui.js has an answer -- an empty `data-model-state` is "unknown",
which is not the same claim as "unloaded". */
.model-slot { position: relative; display: flex; flex: none; }
.model-state {
position: absolute;
right: calc(var(--model-state-size) / -3);
bottom: calc(var(--model-state-size) / -3);
width: var(--model-state-size);
height: var(--model-state-size);
border-radius: var(--radius-full);
box-shadow: 0 0 0 var(--outline-w) var(--surface);
display: none;
}
.model-slot[data-model-state="loaded"] .model-state { display: block; background: var(--model-state-loaded); }
.model-slot[data-model-state="loading"] .model-state {
display: block;
background: var(--model-state-loading);
animation: model-state-pulse var(--dur-slow) var(--ease-in-out) infinite;
}
@keyframes model-state-pulse { 50% { opacity: 0.35; } }
@media (prefers-reduced-motion: reduce) {
.model-slot[data-model-state="loading"] .model-state { animation: none; }
}
.picker__list--models .picker__option-name { min-width: 0; } .picker__list--models .picker__option-name { min-width: 0; }
.model-ctx { .model-ctx {
font-size: var(--text-xs); font-size: var(--text-xs);
+9 -1
View File
@@ -1003,9 +1003,17 @@
flex-direction: column; flex-direction: column;
justify-content: flex-end; justify-content: flex-end;
} }
/* position: relative anchors the `@` and `/` menu to the box. */ /* position: relative anchors the `@` and `/` menu to the box.
`width: 100%` is the width; `max-width` only caps it. Without it the box was
as wide as its widest content: `.composer` is a flex column, and auto margins
on a flex item switch off the stretch it would otherwise get. So the hint
under it decided. "GPT-OSS has no vision, so images will not be sent" made
the box 768px, and the same screen with a model that sees images made it
538px. */
.composer__inner { .composer__inner {
position: relative; position: relative;
width: 100%;
max-width: var(--thread-max-width); max-width: var(--thread-max-width);
margin: 0 auto; margin: 0 auto;
} }
+8
View File
@@ -50,6 +50,14 @@
--radius-xl: 18px; --radius-xl: 18px;
--radius-full: 999px; --radius-full: 999px;
/* The load-state dot on a model's avatar in the model menu. Its colours are
tokens of their own, defaulting to the theme's success and warning, so
an instance whose success colour is not green can still say "loaded" in
green -- that is what people read a dot beside a name as. */
--model-state-size: 0.625rem;
--model-state-loaded: var(--success);
--model-state-loading: var(--warning);
/* /*
--- Controls ---------------------------------------------------------- --- Controls ----------------------------------------------------------
Every button, input and select resolves its height from these. That is the Every button, input and select resolves its height from these. That is the
+59 -20
View File
@@ -1099,27 +1099,59 @@
The worker no longer takes over open pages on its own -- see sw.js -- so The worker no longer takes over open pages on its own -- see sw.js -- so
something has to say that one is waiting, and the reader decides. A toast something has to say that one is waiting, and the reader decides. A toast
rather than a reload: an application with a reply streaming into it must rather than a reload: an application with a reply streaming into it must
not be navigated out from under somebody. */ not be navigated out from under somebody.
🚨 "A worker is waiting" is not the same as "this page is out of date",
and the toast used to treat them as one. After a release it offered a
reload on every page, including one just fetched with Ctrl+Shift+R, and
reloading could not make it stop. A page is always fetched from the network
and every asset it names carries `?v=<release>`, so a page loaded after
the update IS the update, whichever worker happens to control it. And the
worker that controls it is nearly always the previous one: a reload
creates the new page before the old one goes away, so the old worker
never runs out of pages and the new one never stops waiting.
So the question is asked of the page. The worker's release is in its own
script URL (`/sw.js?v=`), and the page's is `window.lembasRelease` from
base.html. When the two match there is nothing newer to reload into, and
the worker is left to take over once the old tabs are closed. */
var PAGE_RELEASE = window.lembasRelease || "";
function releaseOf(worker) {
try {
return new URL(worker.scriptURL).searchParams.get("v") || "";
} catch (error) {
return "";
}
}
/* Unknown on either side counts as newer: better an extra offer than a
release nobody is told about. */
function isNewerThanThisPage(worker) {
var release = releaseOf(worker);
return !PAGE_RELEASE || !release || release !== PAGE_RELEASE;
}
function offerReload(worker) {
window.lembas.notify(
"A new version is ready. Reload to use it.",
{ kind: "info", action: { label: "Reload", run: function () {
worker.postMessage({ type: "SKIP_WAITING" });
} } }
);
}
function watchForUpdate(registration) { function watchForUpdate(registration) {
function offer(worker) { function offer(worker) {
if (!worker || !navigator.serviceWorker.controller) return; if (!worker || !navigator.serviceWorker.controller) return;
worker.addEventListener("statechange", function () { worker.addEventListener("statechange", function () {
if (worker.state !== "installed") return; if (worker.state !== "installed") return;
window.lembas.notify( if (isNewerThanThisPage(worker)) offerReload(worker);
"A new version is ready. Reload to use it.",
{ kind: "info", action: { label: "Reload", run: function () {
worker.postMessage({ type: "SKIP_WAITING" });
} } }
);
}); });
} }
if (registration.waiting && navigator.serviceWorker.controller) { if (registration.waiting && navigator.serviceWorker.controller &&
window.lembas.notify( isNewerThanThisPage(registration.waiting)) {
"A new version is ready. Reload to use it.", offerReload(registration.waiting);
{ kind: "info", action: { label: "Reload", run: function () {
registration.waiting.postMessage({ type: "SKIP_WAITING" });
} } }
);
} }
registration.addEventListener("updatefound", function () { registration.addEventListener("updatefound", function () {
offer(registration.installing); offer(registration.installing);
@@ -1130,17 +1162,24 @@
the right answer to it -- the page is now being served by a worker whose the right answer to it -- the page is now being served by a worker whose
cache it did not start from. cache it did not start from.
Two guards, and the second is the one that is easy to miss. A flag, because Three guards, and the second is the one that is easy to miss. A flag,
`controllerchange` can fire more than once. And `hadController`, because on because `controllerchange` can fire more than once. And `hadController`,
a *first* visit there is no worker at all: the one that installs then calls because on a *first* visit there is no worker at all: the one that
`clients.claim()`, which fires this event for the first time -- so without installs then calls `clients.claim()`, which fires this event for the
it, the very first page anybody loads reloads itself in front of them for first time -- so without it, the very first page anybody loads reloads
no reason they could possibly work out. */ itself in front of them for no reason they could possibly work out.
The third is the same question as the toast's. Somebody pressing Reload in
one tab activates the worker for all of them, and a tab that was already
rendered by that release has nothing to gain from a reload -- and may have
a reply streaming into it. */
var reloading = false; var reloading = false;
if ("serviceWorker" in navigator) { if ("serviceWorker" in navigator) {
var hadController = !!navigator.serviceWorker.controller; var hadController = !!navigator.serviceWorker.controller;
navigator.serviceWorker.addEventListener("controllerchange", function () { navigator.serviceWorker.addEventListener("controllerchange", function () {
if (reloading || !hadController) return; if (reloading || !hadController) return;
var controller = navigator.serviceWorker.controller;
if (controller && !isNewerThanThisPage(controller)) return;
reloading = true; reloading = true;
window.location.reload(); window.location.reload();
}); });
+57 -1
View File
@@ -320,6 +320,11 @@
if (filter) { if (filter) {
filter.value = ""; filter.value = "";
applyFilter(menu, ""); applyFilter(menu, "");
}
// Not on a touchscreen: focusing a text field there raises the keyboard,
// which covers half the list the finger came to choose from. The filter
// is one tap away for whoever wants it.
if (filter && !window.matchMedia("(hover: none)").matches) {
filter.focus(); filter.focus();
} else { } else {
var selected = menu.querySelector(".picker__option.is-selected") || var selected = menu.querySelector(".picker__option.is-selected") ||
@@ -329,6 +334,45 @@
// Keep the chosen model in view when the list is long. // Keep the chosen model in view when the list is long.
var current = menu.querySelector(".picker__option.is-selected"); var current = menu.querySelector(".picker__option.is-selected");
if (current) current.scrollIntoView({ block: "nearest" }); if (current) current.scrollIntoView({ block: "nearest" });
refreshStates(menu);
}
/* Which models are loaded, asked for each time the model menu opens --
llama-swap holds one at a time and it changes by the minute, so a value
rendered with the page would be stale by the time anybody looked. Only
models whose endpoint reports a state come back; everything else keeps an
empty `data-model-state`, which draws nothing. While one is loading the
menu asks again every two seconds, and stops when it closes. */
function refreshStates(menu) {
var list = menu.querySelector(".picker__list--models");
if (!list || !window.fetch) return;
clearTimeout(menu._stateTimer);
fetch("/api/models/state", {
credentials: "same-origin",
headers: { Accept: "application/json" }
}).then(function (response) {
return response.ok ? response.json() : null;
}).then(function (data) {
var states = (data && data.states) || {};
var loading = false;
list.querySelectorAll(".picker__option[data-model-id]").forEach(function (option) {
var slot = option.querySelector("[data-model-state]");
if (!slot) return;
var state = states[option.dataset.modelId] || "";
slot.dataset.modelState = state;
if (state === "loading") loading = true;
var label = slot.querySelector("[data-model-state-label]");
if (label) {
label.textContent = state === "loaded" ? list.dataset.labelLoaded
: state === "loading" ? list.dataset.labelLoading : "";
}
});
if (loading && !menu.hidden) {
menu._stateTimer = setTimeout(function () {
if (!menu.hidden) refreshStates(menu);
}, 2000);
}
}).catch(function () { /* No state is a menu without dots, not an error. */ });
} }
function applyFilter(menu, needle) { function applyFilter(menu, needle) {
@@ -1128,7 +1172,19 @@ document.addEventListener("lembas:notify", function (event) {
shrinks the document and scrollTop is clamped to the new maximum, which shrinks the document and scrollTop is clamped to the new maximum, which
for a short panel is somewhere below everything. */ for a short panel is somewhere below everything. */
var outer = scroller(bar); var outer = scroller(bar);
if (outer && outer !== body) bar.scrollIntoView({ block: "start" }); if (!outer || outer === body) return;
/* Moved by hand, and only `outer`. `scrollIntoView` scrolls *every*
ancestor that can scroll, the document included -- and the document
could, by the height of whatever leaked out of the scroller, so a tab
switch lifted the whole shell and left a strip of background under it.
The containing block in app.css stops the leak; this stops a leak
anyone adds later from being turned into a visible one.
Measured from `.tabs`, not the bar: the bar is sticky, so once the page
is scrolled past the lede it reports the scroller's own top and the
sum below would come out as nothing to do. */
var tabs = bar.parentElement;
outer.scrollTop += tabs.getBoundingClientRect().top - outer.getBoundingClientRect().top;
}); });
})(); })();
+3
View File
@@ -152,6 +152,9 @@
what `app.js` turns into a sentence on the settings page. what `app.js` turns into a sentence on the settings page.
#} #}
<script> <script>
/* The release this page was rendered by, for `app.js` to hold a waiting
worker up against -- see "A release that arrived while you were reading". */
window.lembasRelease = {{ version | tojson }};
window.lembasWorker = { state: "unsupported" }; window.lembasWorker = { state: "unsupported" };
if (!window.isSecureContext) { if (!window.isSecureContext) {
/* Reported separately from an outright failure: the fix is different. */ /* Reported separately from an outright failure: the fix is different. */
@@ -38,14 +38,28 @@
</div> </div>
{% endif %} {% endif %}
<div class="picker__list picker__list--models"> <div class="picker__list picker__list--models"
data-label-loaded="{{ t('Loaded') }}" data-label-loading="{{ t('Loading') }}">
{% for model in models %} {% for model in models %}
<button class="picker__option {{ 'is-selected' if current_model and model.model_id == current_model.model_id }}" <button class="picker__option {{ 'is-selected' if current_model and model.model_id == current_model.model_id }}"
type="button" role="option" type="button" role="option"
aria-selected="{{ 'true' if current_model and model.model_id == current_model.model_id else 'false' }}" aria-selected="{{ 'true' if current_model and model.model_id == current_model.model_id else 'false' }}"
data-picker-value="{{ model.model_id }}" data-picker-value="{{ model.model_id }}"
data-picker-search="{{ model.label|lower }} {{ model.model_id|lower }}"> data-picker-search="{{ model.label|lower }} {{ model.model_id|lower }}"
{{ model_avatar(model, cls="picker__avatar") }} data-model-id="{{ model.model_id }}">
{#
The avatar in a slot of its own size, carrying the load-state dot on
its corner. A dot there takes no track, so the columns the context
sizes and the eyes line up on are the ones they had. The state is
fetched when the menu opens (ui.js, /api/models/state) rather than
rendered here: it changes by the minute, and asking every endpoint on
every page render would put a network call in front of each page.
#}
<span class="model-slot" data-model-state="">
{{ model_avatar(model, cls="picker__avatar") }}
<span class="model-state" aria-hidden="true"></span>
<span class="visually-hidden" data-model-state-label></span>
</span>
<span class="picker__option-name"> <span class="picker__option-name">
<span class="truncate">{{ model.label }}</span> <span class="truncate">{{ model.label }}</span>
{% if model.pinned %}{{ icon("pin", "icon--sm picker__pin") }}{% endif %} {% if model.pinned %}{{ icon("pin", "icon--sm picker__pin") }}{% endif %}
+13
View File
@@ -448,3 +448,16 @@ def test_only_an_administrator_may_customise(db, client, registered):
for path in ("identity", "flavour", "css", "themes"): for path in ("identity", "flavour", "css", "themes"):
assert client.post(f"/admin/customization/{path}", data={}).status_code == 403 assert client.post(f"/admin/customization/{path}", data={}).status_code == 403
def test_the_doors_of_durin_are_a_riddle_not_a_greeting():
""""Speak, friend, and enter" invites a friend to speak. The inscription is a
riddle whose answer is to say the word *friend*, so the shipped line has no
commas. The owner caught it, and the commas must not come back in a
tidy-up."""
from lembas.services.branding import FLAVOUR
for key in ("chat_empty", "error_403"):
line = FLAVOUR[key][2]
assert line.startswith("Speak friend and enter.")
assert "Speak, friend" not in line
+22
View File
@@ -582,3 +582,25 @@ def test_an_endpoint_with_no_props_leaves_the_list_alone(client, db, registered,
db.expire_all() db.expire_all()
assert db.get(Model, model.id).reasoning_efforts == ["low", "high"] assert db.get(Model, model.id).reasoning_efforts == ["low", "high"]
def test_a_new_chat_offers_the_models_own_efforts(client: TestClient, db, registered):
"""`/chat?model=` offered the generic three whatever the model took.
On Bonsai (low, medium, xhigh, default xhigh) that drew `high`, which it
rejects, and no `xhigh`, so the configured default was not an option and
the picker fell through to "off". The chat created from that screen got
`xhigh` anyway, so the control said one thing and the first reply did
another. Reported from the live instance.
"""
model = _model(db)
model.model_id = "bonsai"
model.reasoning_efforts = ["low", "medium", "xhigh"]
model.params_json = {"reasoning_effort": "xhigh"}
db.commit()
html = client.get("/chat?model=bonsai").text.replace("\n", "").replace(" ", "")
assert '<option value="xhigh" selected>' in html
assert '<option value="high"' not in html
assert '<option value="off" selected' not in html
+16
View File
@@ -208,3 +208,19 @@ def test_the_desktop_minimum_is_still_declared():
for token in ("--terminal-width-min", "--canvas-width-min"): for token in ("--terminal-width-min", "--canvas-width-min"):
assert f"{token}:" in TOKENS assert f"{token}:" in TOKENS
assert f"var({token})" in APP_CSS assert f"var({token})" in APP_CSS
def test_the_topbar_model_menu_belongs_to_the_bar_on_a_phone():
"""1.8.3. Anchored to the picker, the menu opened `right: 0` of a button that
sits mid-bar with the panel buttons to its right, so on a 390px phone a 24rem
menu started 132px left of the screen and every model's name was cut off.
Below the phone breakpoint the picker gives up `position`, which makes the bar
the containing block, and the menu is pinned between the bar's two edges."""
body = _media_body(APP_CSS, "48rem")
assert re.search(r"\.topbar\s*\{\s*position:\s*relative", body)
assert re.search(r"\.topbar__actions \.picker\s*\{\s*position:\s*static", body)
menu = re.search(r"\.topbar__actions \.picker__menu\s*\{([^}]*)\}", body)
assert menu, "the topbar's menu is not placed on a phone"
for declaration in ("left:", "right:", "width: auto"):
assert declaration in menu.group(1), f"{declaration} missing from the phone menu"
+19
View File
@@ -111,3 +111,22 @@ def test_settings_lists_the_models_with_their_tags_below_the_name(client, models
assert listing.count("#i-eye") == 1 assert listing.count("#i-eye") == 1
assert listing.count('class="model-list__more model-list__tags"') == 2 assert listing.count('class="model-list__more model-list__tags"') == 2
assert "tool_fetch" in listing assert "tool_fetch" in listing
def test_opening_the_picker_on_a_touchscreen_does_not_raise_the_keyboard():
"""1.8.3. With more than eight models the menu has a filter, and `open()`
focused it -- which on a phone raises the keyboard over half the list the
finger came to choose from. The focus is gated on `(hover: none)`, the same
query the stylesheet uses for touch."""
from pathlib import Path
import lembas
js = (Path(lembas.__file__).parent / "web/static/js/ui.js").read_text(encoding="utf-8")
start = js.index("function open(picker)")
body = js[start : js.index("function applyFilter", start)]
assert "filter.focus()" in body, "the filter is no longer focused anywhere -- test is blind"
gated = r'if \(filter && !window\.matchMedia\("\(hover: none\)"\)\.matches\)\s*\{'
assert re.search(gated + r"\s*filter\.focus\(\)", body), (
"the filter is focused on open without asking whether this is a touchscreen"
)
+152
View File
@@ -0,0 +1,152 @@
"""The load-state dot in the model menu: only what an endpoint states.
llama-swap reports `"status": {"value": "loaded" | "unloaded"}` on every entry
of `GET /v1/models`, verified against the live one on 2026-09-28. A hosted API
such as DeepSeek has no such field, so its models must get no state at all --
not "unloaded", which would be a claim nobody made.
"""
from __future__ import annotations
import asyncio
import pytest
from fastapi.testclient import TestClient
from lembas.db.models import Connection, Model
from lembas.services import model_state
LLAMA_SWAP = [
{"id": "bonsai", "status": {"value": "loaded"}},
{"id": "gpt-oss", "status": {"value": "unloaded"}},
{"id": "qwen36", "status": {"value": "starting"}},
]
HOSTED = [{"id": "deepseek-flash", "object": "model"}]
@pytest.fixture(autouse=True)
def _fresh_cache():
model_state.forget()
yield
model_state.forget()
@pytest.mark.parametrize(
("entry", "state"),
[
({"status": {"value": "loaded"}}, "loaded"),
({"status": {"value": "ready"}}, "loaded"),
({"status": "loaded"}, "loaded"),
({"status": {"value": "starting"}}, "loading"),
({"status": {"value": "unloaded"}}, "unloaded"),
({"status": {"value": "stopped"}}, "unloaded"),
({}, ""),
({"status": {}}, ""),
({"status": 3}, ""),
],
)
def test_state_is_read_from_the_entry_or_not_at_all(entry, state):
assert model_state.state_of({"id": "x", **entry}) == state
def _two_connections(db):
local = Connection(name="llama", base_url="http://llama.test/v1", api_key_encrypted="")
hosted = Connection(name="deepseek", base_url="http://hosted.test/v1", api_key_encrypted="")
db.add_all([local, hosted])
db.commit()
served = ((local, ("bonsai", "gpt-oss", "qwen36")), (hosted, ("deepseek-flash",)))
for connection, ids in served:
for model_id in ids:
db.add(Model(connection_id=connection.id, model_id=model_id))
db.commit()
return local, hosted
def _fake_endpoints(monkeypatch, calls):
async def fake(endpoint):
calls.append(endpoint.base_url)
return LLAMA_SWAP if "llama" in endpoint.base_url else HOSTED
monkeypatch.setattr(model_state, "list_models", fake)
def test_only_models_whose_endpoint_states_one_get_a_state(
client: TestClient, db, registered, monkeypatch
):
_two_connections(db)
calls: list[str] = []
_fake_endpoints(monkeypatch, calls)
states = client.get("/api/models/state").json()["states"]
assert states == {"bonsai": "loaded", "gpt-oss": "unloaded", "qwen36": "loading"}
assert "deepseek-flash" not in states
# One request per connection, not per model.
assert sorted(calls) == ["http://hosted.test/v1", "http://llama.test/v1"]
def test_a_silent_endpoint_is_not_asked_again_on_every_open(
client: TestClient, db, registered, monkeypatch
):
"""A hosted API answers with no state every time. Asking it on each click
only to hear nothing again is a request to a third party for no reason."""
_two_connections(db)
calls: list[str] = []
_fake_endpoints(monkeypatch, calls)
client.get("/api/models/state")
client.get("/api/models/state")
assert calls.count("http://hosted.test/v1") == 1
def test_an_unreachable_endpoint_is_a_menu_without_dots(
client: TestClient, db, registered, monkeypatch
):
_two_connections(db)
async def broken(endpoint):
raise OSError("connection refused")
monkeypatch.setattr(model_state, "list_models", broken)
response = client.get("/api/models/state")
assert response.status_code == 200
assert response.json() == {"states": {}}
def test_a_slow_endpoint_cannot_hold_the_menu(db, monkeypatch):
connection = Connection(name="slow", base_url="http://slow.test/v1", api_key_encrypted="")
db.add(connection)
db.commit()
db.add(Model(connection_id=connection.id, model_id="m"))
db.commit()
async def slow(endpoint):
await asyncio.sleep(10)
return LLAMA_SWAP
monkeypatch.setattr(model_state, "list_models", slow)
monkeypatch.setattr(model_state, "TIMEOUT", 0.05)
models = db.query(Model).all()
assert asyncio.run(model_state.states_for(models)) == {}
def test_the_menu_has_a_slot_for_every_model(client: TestClient, db, registered):
"""Every option emits the slot, whatever its endpoint says: the dot is
placed by ui.js after the menu opens, so a model with no slot could never
show one."""
_two_connections(db)
html = client.get("/chat").text
for model_id in ("bonsai", "gpt-oss", "qwen36", "deepseek-flash"):
start = html.index(f'data-model-id="{model_id}"')
option = html[start : html.index("</button>", start)]
assert 'data-model-state=""' in option
assert "data-label-loaded=" in html
def test_the_state_needs_a_signed_in_reader(client: TestClient):
response = client.get("/api/models/state", follow_redirects=False)
assert response.status_code in (401, 303, 307)
+30
View File
@@ -311,3 +311,33 @@ def test_the_worker_precaches_what_a_page_will_ask_for():
source = (STATIC_DIR / "js" / "sw.js").read_text() source = (STATIC_DIR / "js" / "sw.js").read_text()
assert 'path + "?v=" + VERSION' in source assert 'path + "?v=" + VERSION' in source
assert "versioned(path)" in source assert "versioned(path)" in source
def test_the_update_offer_asks_whether_this_page_is_older(client: TestClient, registered):
"""After a release the toast offered a reload on every page, including one
just fetched with Ctrl+Shift+R, and reloading never made it go away. It
fired whenever a worker was waiting. But a page loaded after the update
already IS the update: it comes from the network, and every asset it names
carries `?v=`. The worker that waits is nearly always the previous one,
still holding the tab, because a reload never lets it run out of pages.
So the offer, and the automatic reload when another tab accepts it, compare
the worker's release with the page's own. Driven under Node against stubs
before committing: 1.8.3 offered a reload to a current page and to a page
that another tab's Reload had just made current. This does neither, and
still offers it to a page from an older release.
"""
import re
import lembas
page = client.get("/chat").text
assert f"window.lembasRelease = \"{lembas.__version__}\";" in page
source = (STATIC_DIR / "js" / "app.js").read_text()
code = re.sub(r"/\*.*?\*/", "", source, flags=re.S)
start = code.index("function watchForUpdate")
block = code[start : code.index("navigator.serviceWorker.ready.then(watchForUpdate)")]
# Both ways a waiting worker is found, and the controllerchange reload.
assert block.count("isNewerThanThisPage(") == 3
assert 'searchParams.get("v")' in code
+55
View File
@@ -177,6 +177,61 @@ def test_the_tab_reset_finds_the_container_that_actually_scrolls():
assert "scrollHeight > " in SOURCE assert "scrollHeight > " in SOURCE
def test_a_tab_switch_moves_only_the_container_that_scrolls():
"""Switching a tab on /admin/prompts lifted the whole shell 56px, with the
topbar gone off the top and a strip of bare background under everything.
Two halves, and either one alone is enough to bring it back. The prompt
cards' `.visually-hidden` labels are `position: absolute`. With no
positioned ancestor they were placed against the page and stretched the
document to 6771px behind an `overflow: hidden` root. And the handler used
`scrollIntoView`, which scrolls every ancestor that can scroll, the root
included. Measured in headless Chromium at 1640x930 before and after.
"""
app = (ROOT / "web/static/css/app.css").read_text(encoding="utf-8")
start = app.index(".scroll-region,")
rule = app[start : app.index("}", start)]
assert ".admin-scroll" in rule
assert "position: relative" in rule
code = re.sub(r"/\*.*?\*/", "", SOURCE, flags=re.S)
start = code.index('closest(".tabs__bar")')
handler = code[start : code.index("})();", start)]
assert "scrollIntoView" not in handler
assert "outer.scrollTop" in handler
def test_the_tab_bar_edge_fade_is_covered_when_nothing_overflows():
"""The covers were as wide as the shadows and solid for only 40% of that,
so 60% of each shadow showed through with nothing to scroll to. On Shire
that was a grey sliver at both ends of every tab bar. A cover has to be
solid across the whole shadow. Measured in headless Chromium on
/admin/prompts and /settings in both themes before and after.
"""
admin = (ROOT / "web/static/css/admin.css").read_text(encoding="utf-8")
start = admin.index("background-attachment: local, local, scroll, scroll")
block = admin[admin.rindex(".tabs__bar {", 0, start) : start]
cover = re.search(r"linear-gradient\(to right, var\(--bg\) (\d+)%, transparent\)", block)
shadow = re.search(r"var\(--scrim\), transparent ([\d.]+)rem", block)
sizes = re.search(r"background-size: ([\d.]+)rem 100%, [\d.]+rem 100%, ([\d.]+)rem 100%", block)
assert cover and shadow and sizes, "the edge-fade rule changed shape; re-check it by eye"
solid = float(sizes.group(1)) * int(cover.group(1)) / 100
assert solid >= float(shadow.group(1)) == float(sizes.group(2))
def test_the_composer_is_as_wide_as_its_column_not_its_hint():
"""With `max-width` and auto margins alone, the box was as wide as its
widest content inside a flex column, so the vision hint under it decided:
768px for GPT-OSS ("has no vision, so images will not be sent") and 538px
for a model that sees images. Reported from the live instance with four
screenshots."""
chat = (ROOT / "web/static/css/chat.css").read_text(encoding="utf-8")
start = chat.index(".composer__inner {")
rule = chat[start : chat.index("}", start)]
assert "width: 100%" in rule
assert "max-width: var(--thread-max-width)" in rule
def test_the_two_ends_of_the_shell_stay_level(): def test_the_two_ends_of_the_shell_stay_level():
"""The sidebar footer and the composer sit either side of the same vertical """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 edge and are both content-sized, so without a common floor they end at