3 Commits
Author SHA1 Message Date
HomerandClaude Opus 5 92070d7879 Six things that looked like they worked
None of these fails loudly and two of them correct themselves if you reload,
which is why five were found by reading rather than by anybody reporting them.

The reply that lost its author is the one worth knowing about: the frame that
replaces a bubble when a reply lands was looking the models up as nobody, and
"no user" answers "no models" rather than "all models" -- so every finished
reply swapped the model's avatar for the plain mark and put the instance's name
where the model's should be, until the next page load put it back.

Beside it: a concurrency quota enforced on two of the six paths that start a
reply, including neither of the two most used; a custom theme whose success and
warning colours moved the text and left the background behind; a phone shell
sized to one viewport inside a document sized to another, which is the reported
scroll past the bottom of Settings; a whole conversation's Markdown rendered on
every page load and read by nothing; a skip guard inert since it was written;
and an endpoint nothing has ever called.

The scroll fix folded five near-identical scroller rules into one, which is
also where the containment they were all missing now lives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 12:49:26 +00:00
HomerandClaude Opus 5 d73a791c86 An installer that had only ever met Arch
`deploy/lxc-install.sh` had never been executed -- there was no Proxmox host
to run it on, and PLAN.md said so rather than letting it read as tested. It
was reviewed and `bash -n` checked, which is not the same claim. Running it
for the first time found two Arch-isms in `install.sh`, the script it wraps,
and only a Debian machine could have found either.

`python -m venv` is the one that mattered. On Arch `python` is Python 3, so
the bare name had worked on the only machine this had ever run on. Debian has
no `python` at all unless somebody installed `python-is-python3`, and the LXC
bootstrap installs `python3` -- so the install aborted at the virtualenv step,
with the service user, the bind mount and the clone already in place. It is
`python3` now, which is right on both.

`--shell /usr/bin/nologin` is the one that did not. That is where Arch keeps
nologin and not where Debian does, but nothing ever invoked it: `sudo -u`
execs the command directly and systemd's `User=` never reads a shell. The
account worked while pointing at a file that was not there. `/usr/sbin/nologin`
is correct on Debian and resolves on Arch too, whose `/usr/sbin` is a symlink
to `bin`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 19:42:28 +02:00
HomerandClaude Opus 5 25aa208d04 Documentation that points where the documentation is
The working notes, the roadmap and the eight topic notes now live on the wiki,
so the twelve places in the source that said "see CLAUDE.md" were pointing at
a file this repository no longer has. They say "see the working notes" now,
and the README opens onto the wiki rather than onto two files beside it.

Four references are deliberately untouched -- prompts.py, settings_store.py,
admin/agents.html and the whole of agent/instructions.py. Those name AGENTS.md
and CLAUDE.md as the file an agent chat looks for in *somebody else's* project
directory. Rewriting them would have broken the feature while looking tidy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 01:38:38 +02:00
23 changed files with 333 additions and 97 deletions
+75
View File
@@ -16,6 +16,81 @@ for 1.0.0 have something to be assembled from.
## Unreleased ## Unreleased
## 1.0.4
Six things that looked like they worked. Five of them were found by reading the
code rather than by anybody reporting them, which is what they have in common:
none of these fails loudly, and two of them correct themselves if you reload.
- Fixed: **a reply lost the model's name and picture the moment it finished.**
While a reply streams it is attributed correctly; at the instant it lands, the
frame that replaces the bubble was looking the models up as nobody, and "no
user" answers "no models" rather than "all models". So a finished reply swapped
the model's avatar for the plain leaf mark, put the instance's name where the
model's should be, and grew a raw model id beside it. Reloading the page put it
all back, which is why this survived a release: it is only ever wrong until you
look away.
- Fixed: **a limit on how many replies an account may write at once could be
stepped over by pressing New chat.** It was enforced when sending into a chat
that already existed and nowhere else — not on a new chat, not on editing an
earlier message, not on sending a queued one, and not on regenerating. Four of
the six ways to start a reply ignored it, including the commonest.
- Fixed: **a custom theme's confirmations and warnings kept the built-in
theme's colour behind them.** Setting `success` or `warning` moved the text and
left the background it sits on, because the faded companion colour was derived
for three of the five settable colours. Visible on every alert and badge of
those two kinds, on the "on" state in the permissions list, and on the added
lines of every diff in an agent chat.
- Fixed: **on a phone, every page with a sidebar could be scrolled past its own
bottom into empty background.** The shell was sized to the part of the screen
you can actually see and the document around it to the part you can see with
the browser's toolbar retracted; the difference between those is real on a
phone and nil on a desktop, which is why it was never noticed on one. Reported
on Settings and true everywhere. A flick that ran off the end of a list now
stops there as well, instead of dragging the page behind it.
- Fixed: **the conversation was rendering every assistant message twice on every
page load** — once into Markdown that nothing read, and once the way it is
actually shown. The same was true of Messages, for your own turns. Nothing
looked wrong; a long conversation was simply slower to open than it needed to
be, every time, along with every rewind and every compaction.
- Fixed: a test file meant to skip itself on a machine without `setsid` never
did, because it set its marker twice and the second one replaced the first.
- Removed: an endpoint serving a message's unrendered Markdown, which nothing
had ever called — the copy button reads the page it is already on.
## 1.0.3
Two Arch-isms in the installer, both of which only a Debian machine could find.
`deploy/lxc-install.sh` had never been executed — it was reviewed and
syntax-checked, which is not the same claim — and running it is what found them.
- Fixed: **`deploy/install.sh` could not create its virtualenv on Debian**, and
so `deploy/lxc-install.sh` could not finish. It called bare `python`, which is
Python 3 on Arch — the machine this was written and only ever run on — and
does not exist on Debian at all unless `python-is-python3` is installed. The
LXC bootstrap installs `python3`, so the install aborted at the virtualenv
step with the service user, the bind mount and the clone already made. It now
calls `python3`, which is right on both.
- Fixed: the service account was created with `--shell /usr/bin/nologin`, which
is where Arch keeps it and where Debian does not. Nothing invoked it — `sudo -u`
execs directly and systemd's `User=` never reads a shell — so the account
worked either way, but it was created pointing at a file that was not there.
Now `/usr/sbin/nologin`, which is correct on Debian and resolves on Arch too,
since Arch's `/usr/sbin` is a symlink to `bin`.
## 1.0.2
- **The documentation moved to the [wiki](https://git.houmeres.sk/Houmeres/LLeMbas/wiki).**
`CLAUDE.md`, `PLAN.md` and `docs/` are gone from the repository: they are
documentation *about* this project rather than part of it, and a clone should
carry software. Nothing was lost — the working notes, the roadmap and the eight
topic notes are all there, with every internal link rewritten, and the README
now opens onto them. Where a source comment said "see `CLAUDE.md`" it now says
"see the working notes".
- Entries below this one still name `PLAN.md` and `docs/notes/…`, and are left as
they were written. A changelog records what happened at the time; rewriting old
entries to match a later decision makes it a worse record, not a better one.
## 1.0.1 ## 1.0.1
- Fixed: the Updates page showed **"v1.0.0 (reports 1.0.0)"** — two spellings of - Fixed: the Updates page showed **"v1.0.0 (reports 1.0.0)"** — two spellings of
+19 -2
View File
@@ -144,7 +144,24 @@ runtime. Clone it, `pip install -e .`, run it.
OCR for scanned PDFs · conversation branching · chat export · archived chats. OCR for scanned PDFs · conversation branching · chat export · archived chats.
See [PLAN.md](PLAN.md) for what is built, what is not, and why. See the [Roadmap](https://git.houmeres.sk/Houmeres/LLeMbas/wiki/Roadmap) for what
is built, what is not, and why.
## Documentation
The **[wiki](https://git.houmeres.sk/Houmeres/LLeMbas/wiki)** carries everything
about how this works and why — it is documentation *about* the project rather
than part of it, so a clone stays software.
- **[Working notes](https://git.houmeres.sk/Houmeres/LLeMbas/wiki/Working-notes)**
— read this before changing anything. The hard rules the project is built
around, the layout, and a long catalogue of *things that will bite you*: bugs
that shipped looking correct, why each happened, and what stops it recurring.
- **[Roadmap](https://git.houmeres.sk/Houmeres/LLeMbas/wiki/Roadmap)** — what is
built, what is deliberately not, and the reasoning behind each.
- A page each for agent chats, schedules and reports, permissions and sharing,
search and extraction, image generation, subagents, branding, and the manual
release checklist.
## Quick start ## Quick start
@@ -479,7 +496,7 @@ python scripts/fetch_vendor.py # verify vendored JS against the lockfile
There is no Alembic. The schema is SQLite-only and synchronised at startup: There is no Alembic. The schema is SQLite-only and synchronised at startup:
missing tables and missing columns are added automatically, so adding a field to missing tables and missing columns are added automatically, so adding a field to
a model needs nothing but a restart. Renames, drops and retypes are still manual a model needs nothing but a restart. Renames, drops and retypes are still manual
— see `CLAUDE.md`. — see the [working notes](https://git.houmeres.sk/Houmeres/LLeMbas/wiki/Working-notes).
## Artwork ## Artwork
+11 -2
View File
@@ -95,9 +95,13 @@ fi
echo "== service user ==" echo "== service user =="
# --system: no ageing, no mail spool. Home under /home, not /var/lib, so the # --system: no ageing, no mail spool. Home under /home, not /var/lib, so the
# venv and database sit on the larger volume. # venv and database sit on the larger volume.
#
# `/usr/sbin/nologin` is Debian's path and works on both: Arch keeps `nologin`
# in /usr/bin, but its /usr/sbin is a symlink to bin, so the Debian spelling
# resolves there while the Arch one does not resolve on Debian at all.
if ! getent passwd "$SERVICE_USER" >/dev/null; then if ! getent passwd "$SERVICE_USER" >/dev/null; then
sudo useradd --system --create-home --home-dir "$HOME_DIR" \ sudo useradd --system --create-home --home-dir "$HOME_DIR" \
--shell /usr/bin/nologin --comment "LLeMbas" "$SERVICE_USER" --shell /usr/sbin/nologin --comment "LLeMbas" "$SERVICE_USER"
else else
echo " user $SERVICE_USER already exists" echo " user $SERVICE_USER already exists"
fi fi
@@ -120,8 +124,13 @@ else
fi fi
echo "== virtualenv ==" echo "== virtualenv =="
# `python3`, not `python`. On Arch -- the machine this was written on and the
# only one it had ever run on -- `python` is Python 3 and the bare name worked.
# On Debian it does not exist unless somebody installed `python-is-python3`, so
# the LXC bootstrap aborted here, after the service user, the bind mount and the
# clone were already in place. `python3` is correct on both.
if [[ ! -x "$VENV/bin/python" ]]; then if [[ ! -x "$VENV/bin/python" ]]; then
sudo -u "$SERVICE_USER" python -m venv "$VENV" sudo -u "$SERVICE_USER" python3 -m venv "$VENV"
fi fi
sudo -u "$SERVICE_USER" "$VENV/bin/pip" install --quiet --upgrade pip sudo -u "$SERVICE_USER" "$VENV/bin/pip" install --quiet --upgrade pip
# The extras a deployment gets. `search` because DuckDuckGo is the default web # The extras a deployment gets. `search` because DuckDuckGo is the default web
+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.0.1" __version__ = "1.0.4"
+1 -1
View File
@@ -3,7 +3,7 @@
Two shapes on one nav entry, because they are two different kinds of thing. The Two shapes on one nav entry, because they are two different kinds of thing. The
connection, the checkpoints and the switches are instance settings and get a connection, the checkpoints and the switches are instance settings and get a
settings page. A workflow is an authored document with a name, a description and settings page. A workflow is an authored document with a name, a description and
a body, so the workflows are list-plus-detail -- the shape `CLAUDE.md` requires a body, so the workflows are list-plus-detail -- the shape the working notes require
of any admin list, and for the reason it gives: a page that renders a ten-line of any admin list, and for the reason it gives: a page that renders a ten-line
JSON textarea per row is unusable at three rows. JSON textarea per row is unusable at three rows.
+1 -1
View File
@@ -238,7 +238,7 @@ async def browse_profile(
it holds for the same reason -- somebody who owns the credential could list it holds for the same reason -- somebody who owns the credential could list
the directory with an ssh client -- but it does mean Manual mode's promise the directory with an ssh client -- but it does mean Manual mode's promise
that everything is shown to you first now has a second exception. Both are that everything is shown to you first now has a second exception. Both are
written down in CLAUDE.md. written down in the working notes.
""" """
profile = _profile(db, user, profile_id) profile = _profile(db, user, profile_id)
entries: list = [] entries: list = []
+44 -22
View File
@@ -308,6 +308,12 @@ async def start_chat(
if not content and not file_ids: if not content and not file_ids:
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
# Before `_new_chat`, not after: a refusal that has already written the row
# leaves an empty chat in the sidebar as the visible result of being told
# no. There is no chat yet to exclude from the count, and none is needed --
# nothing can be running for a chat that does not exist.
_refuse_extra_reply(db, None, user)
chat = _new_chat( chat = _new_chat(
db, db,
user, user,
@@ -981,11 +987,11 @@ def _note_rewind(chat: Chat) -> None:
chat.rewound_at = datetime.now(UTC) chat.rewound_at = datetime.now(UTC)
def _too_many_replies(db: DBSession, chat: Chat, user: User) -> str: def _too_many_replies(db: DBSession, chat: Chat | None, user: User) -> str:
"""Why this account may not start another reply right now, or "". """Why this account may not start another reply right now, or "".
In-process, and that is exact rather than approximate only because this In-process, and that is exact rather than approximate only because this
application runs one worker -- see the first known limit in PLAN.md. With application runs one worker -- see the first known limit in the roadmap. With
several, this becomes a guess, and a quota that is a guess should be a several, this becomes a guess, and a quota that is a guess should be a
number in the database instead. Stated here rather than discovered. number in the database instead. Stated here rather than discovered.
""" """
@@ -998,10 +1004,13 @@ def _too_many_replies(db: DBSession, chat: Chat, user: User) -> str:
row[0] row[0]
for row in db.execute(select(Chat.id).where(Chat.user_id == user.id)).all() for row in db.execute(select(Chat.id).where(Chat.user_id == user.id)).all()
} }
# `chat` is None on the new-chat path, where there is no row yet and so
# nothing to exclude -- every running reply of theirs counts.
here = chat.id if chat is not None else None
running = sum( running = sum(
1 1
for chat_id in mine for chat_id in mine
if chat_id != chat.id and generation_service.running_for(chat_id) is not None if chat_id != here and generation_service.running_for(chat_id) is not None
) )
if running < ceiling: if running < ceiling:
return "" return ""
@@ -1011,6 +1020,22 @@ def _too_many_replies(db: DBSession, chat: Chat, user: User) -> str:
) )
def _refuse_extra_reply(db: DBSession, chat: Chat | None, user: User) -> None:
"""Raise if this account is already writing as many replies as it may.
A function rather than two lines repeated, because it is repeated five
times now. It used to be called once -- from `_send`, which serves
`post_message` and `execute_plan` -- while four other routes start a
generation: `start_chat`, `edit_message`, `send_queued_now` and
`regenerate`. So a group's `concurrent_replies` was reached by sending into
a chat that already existed and walked straight past by pressing New chat,
which is the commonest way to start a reply there is. A quota you can step
over by using the obvious button is not a quota.
"""
if busy := _too_many_replies(db, chat, user):
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, busy)
def _send( def _send(
request: Request, request: Request,
db: Db, db: Db,
@@ -1042,8 +1067,7 @@ def _send(
# This chat's own reply does not count against it -- a second message here # This chat's own reply does not count against it -- a second message here
# is queued rather than sent, a few lines down, and that path is what the # is queued rather than sent, a few lines down, and that path is what the
# queue is for. # queue is for.
if busy := _too_many_replies(db, chat, user): _refuse_extra_reply(db, chat, user)
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, busy)
if queued := _reply_in_flight(db, chat): if queued := _reply_in_flight(db, chat):
waiting = db.scalar( waiting = db.scalar(
@@ -1328,8 +1352,16 @@ async def _follow(chat_id: str, message_id: str) -> AsyncIterator[str]:
# template shares both roles, and a missing `user` would only # template shares both roles, and a missing `user` would only
# blow up on whichever branch is not being exercised here. # blow up on whichever branch is not being exercised here.
"user": owner, "user": owner,
# `owner`, never None. `models_visible_to` answers an absent
# user with [], so a None here is not "every model" but *no*
# model -- and this frame replaces the whole bubble at the
# moment a reply finishes. The template then finds no
# `speaking_model` and the finished reply swaps its avatar for
# the LLeMbas mark, its author for the instance name, and grows
# a raw model_id chip, all of which a reload silently corrects.
# That is why it went unreported for so long.
"models_by_id": { "models_by_id": {
m.model_id: m for m in chat_service.available_models(db, None) m.model_id: m for m in chat_service.available_models(db, owner)
}, },
# This frame replaces the whole bubble, so it has to carry the # This frame replaces the whole bubble, so it has to carry the
# speaker button's conditions too -- and the owner's, not the # speaker button's conditions too -- and the owner's, not the
@@ -1360,7 +1392,7 @@ def _render_bubble(db: DBSession, chat: Chat, owner: User | None, message: Messa
"message": message, "message": message,
"chat": chat, "chat": chat,
"user": owner, "user": owner,
"models_by_id": {m.model_id: m for m in chat_service.available_models(db, None)}, "models_by_id": {m.model_id: m for m in chat_service.available_models(db, owner)},
**audio_service.template_flags(db, owner), **audio_service.template_flags(db, owner),
} }
) )
@@ -1447,11 +1479,6 @@ def _thread_context(db: DBSession, chat: Chat, user: User) -> dict:
"user": user, "user": user,
"messages": messages, "messages": messages,
"compacted": compacted, "compacted": compacted,
"bodies": {
m.id: render_markdown(m.content)
for m in everything
if m.role == ROLE_ASSISTANT and m.content
},
"models_by_id": {m.model_id: m for m in chat_service.available_models(db, user)}, "models_by_id": {m.model_id: m for m in chat_service.available_models(db, user)},
**audio_service.template_flags(db, user), **audio_service.template_flags(db, user),
} }
@@ -1560,6 +1587,8 @@ async def edit_message(
raise HTTPException( raise HTTPException(
status.HTTP_409_CONFLICT, "Wait for the current reply to finish, or stop it." status.HTTP_409_CONFLICT, "Wait for the current reply to finish, or stop it."
) )
# `_reply_in_flight` is about *this* chat; the quota is about the account.
_refuse_extra_reply(db, chat, user)
message.content = content message.content = content
@@ -1703,6 +1732,7 @@ async def send_queued_now(
raise HTTPException( raise HTTPException(
status.HTTP_409_CONFLICT, "Wait for the current reply to finish, or stop it." status.HTTP_409_CONFLICT, "Wait for the current reply to finish, or stop it."
) )
_refuse_extra_reply(db, chat, user)
message.queued = False message.queued = False
db.commit() db.commit()
@@ -2123,16 +2153,6 @@ async def delete_chat(db: Db, user: RequiredUser, chat_id: str) -> Response:
return response return response
@router.get("/{chat_id}/messages/{message_id}/raw")
async def raw_message(db: Db, user: RequiredUser, chat_id: str, message_id: str) -> HTMLResponse:
"""The unrendered Markdown of a message, for the copy button."""
_owned_chat(db, chat_id, user.id)
message = db.get(Message, message_id)
if message is None or message.chat_id != chat_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That message no longer exists.")
return HTMLResponse(escape_text(message.content))
@router.post("/{chat_id}/messages/{message_id}/regenerate") @router.post("/{chat_id}/messages/{message_id}/regenerate")
async def regenerate( async def regenerate(
request: Request, request: Request,
@@ -2147,6 +2167,8 @@ async def regenerate(
if message is None or message.chat_id != chat.id or message.role != ROLE_ASSISTANT: if message is None or message.chat_id != chat.id or message.role != ROLE_ASSISTANT:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That reply no longer exists.") raise HTTPException(status.HTTP_404_NOT_FOUND, "That reply no longer exists.")
_refuse_extra_reply(db, chat, user)
message.content = "" message.content = ""
message.error = "" message.error = ""
message.complete = False message.complete = False
-8
View File
@@ -21,7 +21,6 @@ from lembas.api.pages import _chat_context, sidebar_context
from lembas.db.models import Message, Schedule from lembas.db.models import Message, Schedule
from lembas.services import messages as messages_service from lembas.services import messages as messages_service
from lembas.services import schedules as schedules_service from lembas.services import schedules as schedules_service
from lembas.services.markdown import render_markdown
from lembas.services.schedule import clock from lembas.services.schedule import clock
from lembas.services.schedule import rule as rule_service from lembas.services.schedule import rule as rule_service
from lembas.web.templating import render from lembas.web.templating import render
@@ -31,11 +30,6 @@ log = logging.getLogger(__name__)
router = APIRouter(tags=["messages"]) router = APIRouter(tags=["messages"])
def _bodies(messages: list[Message]) -> dict[str, str]:
"""Markdown rendered server-side, keyed by id, as `chat_detail` does."""
return {m.id: render_markdown(m.content) for m in messages if m.role == "user"}
@router.get("/messages") @router.get("/messages")
async def messages_page(request: Request, db: Db, user: RequiredUser): async def messages_page(request: Request, db: Db, user: RequiredUser):
conversation = messages_service.for_user(db, user) conversation = messages_service.for_user(db, user)
@@ -61,7 +55,6 @@ async def messages_page(request: Request, db: Db, user: RequiredUser):
"chat": conversation, "chat": conversation,
"messages": live, "messages": live,
"compacted": [], "compacted": [],
"bodies": _bodies(live),
"inherited_prompt": "", "inherited_prompt": "",
"inherited_from": "", "inherited_from": "",
"more_before": bool(live) and messages_service.has_more_before( "more_before": bool(live) and messages_service.has_more_before(
@@ -109,7 +102,6 @@ async def messages_history(
"messages/_history.html", "messages/_history.html",
{ {
"messages": page, "messages": page,
"bodies": _bodies(page),
"more_before": messages_service.has_more_before(db, conversation, page[0]), "more_before": messages_service.has_more_before(db, conversation, page[0]),
"oldest_id": page[0].id, "oldest_id": page[0].id,
# `render()` injects `user` and friends; `TemplateResponse` does # `render()` injects `user` and friends; `TemplateResponse` does
+1 -1
View File
@@ -263,7 +263,7 @@ def register_error_handlers(app: FastAPI) -> None:
# Flavour lives in error pages, empty states and theme names -- never in the # Flavour lives in error pages, empty states and theme names -- never in the
# functional UI. See CLAUDE.md. # functional UI. See the working notes.
# #
# The three lines themselves moved into `services/branding.py` with the rest of # The three lines themselves moved into `services/branding.py` with the rest of
# what an administrator can replace. What is left here is the mapping from a # what an administrator can replace. What is left here is the mapping from a
+1 -1
View File
@@ -18,7 +18,7 @@ a model choosing to run something. Both are read-only, both are built here
rather than assembled from anything a model said, and the project directory is rather than assembled from anything a model said, and the project directory is
configuration rather than input. It is still an exception to Manual mode's configuration rather than input. It is still an exception to Manual mode's
"everything is shown to you before it happens", and it is written down in "everything is shown to you before it happens", and it is written down in
CLAUDE.md next to the others. the working notes, next to the others.
**Nothing here is trusted.** Filenames come off somebody else's machine and end **Nothing here is trusted.** Filenames come off somebody else's machine and end
up inside a system prompt, so they are stripped of control characters, capped up inside a system prompt, so they are stripped of control characters, capped
+1 -1
View File
@@ -184,7 +184,7 @@ def launch_and_wait_command(chat_id: str, job_id: str, command: str, max_bytes:
# operand and formats to "<Logger … (WARNING)>", whose angle brackets and # operand and formats to "<Logger … (WARNING)>", whose angle brackets and
# parentheses are shell syntax -- so this line died with a syntax error, # parentheses are shell syntax -- so this line died with a syntax error,
# after the sentinel where nothing reads it, and every job's four files # after the sentinel where nothing reads it, and every job's four files
# were left on the far side forever. See the note in CLAUDE.md. # were left on the far side forever. See the note in the working notes.
f"rm -f {_file(chat_id, job_id, 'sh')} {pid} {logf} {exit_}\n" f"rm -f {_file(chat_id, job_id, 'sh')} {pid} {logf} {exit_}\n"
) )
+16 -3
View File
@@ -7,7 +7,7 @@ are separate because they fail differently:
shows; shows;
- **flavour text** — the Middle-earth lines, which live in the artwork, the - **flavour text** — the Middle-earth lines, which live in the artwork, the
empty states, the loading lines and the error pages and nowhere else (see the empty states, the loading lines and the error pages and nowhere else (see the
flavour rule in CLAUDE.md), and which somebody rebranding needs to be able to flavour rule in the working notes), and which somebody rebranding needs to be able to
replace without editing templates; replace without editing templates;
- **themes**, which are token sets rather than stylesheets, because the - **themes**, which are token sets rather than stylesheets, because the
invariant that no component hard-codes a colour is what makes a third one invariant that no component hard-codes a colour is what makes a third one
@@ -36,7 +36,7 @@ page and by nothing else.
The cost of being a cache is stated rather than discovered: with several The cost of being a cache is stated rather than discovered: with several
workers, a save in one is not seen by the others until each next reads. That is workers, a save in one is not seen by the others until each next reads. That is
already true of this application for other reasons -- see the "one worker" note already true of this application for other reasons -- see the "one worker" note
in PLAN.md -- and this does not make it worse. in the roadmap -- and this does not make it worse.
""" """
from __future__ import annotations from __future__ import annotations
@@ -462,7 +462,20 @@ def theme_css(theme: Theme) -> str:
if not theme.tokens: if not theme.tokens:
return "" return ""
lines = [f" --{name}: {value};" for name, value in theme.tokens.items()] lines = [f" --{name}: {value};" for name, value in theme.tokens.items()]
for name, alpha in (("accent", "0.14"), ("leaf", "0.14"), ("danger", "0.14")): # Every settable colour that has a `-soft` companion in tokens.css, not the
# three somebody stopped at. `success` and `warning` were settable and their
# softs were not derived, so a custom theme moved the text and left the
# background behind it in the base theme's hue -- an alert, a badge, a
# permission's "on" state and the `+` lines of every agent diff, each in two
# colours that were never meant to meet. Precisely the half-working failure
# this function's own docstring says it exists to prevent.
for name, alpha in (
("accent", "0.14"),
("leaf", "0.14"),
("danger", "0.14"),
("success", "0.14"),
("warning", "0.14"),
):
soft = _soft(theme.tokens.get(name, ""), alpha) soft = _soft(theme.tokens.get(name, ""), alpha)
if soft: if soft:
lines.append(f" --{name}-soft: {soft};") lines.append(f" --{name}-soft: {soft};")
+1 -1
View File
@@ -383,7 +383,7 @@ async def _run_fetch(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
Straight through `services/fetch.py`, which owns the SSRF guard, the Straight through `services/fetch.py`, which owns the SSRF guard, the
hand-rolled redirect loop that re-checks every hop, and the content-type hand-rolled redirect loop that re-checks every hop, and the content-type
sniff. Deliberately not a second HTTP client: CLAUDE.md already names three sniff. Deliberately not a second HTTP client: the working notes already name three
places that follow redirects by hand as the ceiling, and a fourth is how one places that follow redirects by hand as the ceiling, and a fourth is how one
of them loses its check. of them loses its check.
""" """
+4 -8
View File
@@ -15,14 +15,10 @@
that one, silently, while the reader was lost in the other. Under 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` the body is now an ordinary block and the page scrolls as one.
*/ */
.admin-scroll, /* What makes one of these scroll is `.scroll-region` in app.css, which both of
.main > .tabs > .tabs__body { these selectors are listed in. Named there so the four declarations exist
flex: 1; once; named *here* is the reasoning above, which is about which element is
min-height: 0; the scroller on which screen rather than about how a scroller behaves. */
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
}
.page, .page,
.admin-page { .admin-page {
+63 -17
View File
@@ -18,6 +18,33 @@ body {
height: 100%; height: 100%;
} }
/*
A page built around the shell never scrolls its own document.
`.shell` is `100dvh` -- the *dynamic* viewport, which is what you can actually
see -- while `html` and `body` above are `100%`, which resolves against the
initial containing block and is the *large* viewport, the one you get with the
browser's toolbar retracted. On a desktop those are the same number and this
rule does nothing. On a phone they differ by the height of the toolbar, and
the difference is a document taller than its own window: you scroll past the
bottom of the sidebar and the main column into bare background, and because
every gesture retracts or extends the toolbar the shell resizes underneath you
and it never settles.
Reported on /settings, true of every page with a shell. `:has()` rather than a
class because the shell is what decides this, not the route -- the auth, error
and offline pages have no shell and genuinely do scroll their document, and
they must keep doing so.
*/
html:has(body > .shell),
body:has(> .shell) {
height: 100dvh;
overflow: hidden;
/* A flick that reaches the end of an inner scroller stops there rather than
pulling the page around behind it. */
overscroll-behavior: none;
}
/* /*
The `hidden` attribute has to win. The `hidden` attribute has to win.
@@ -367,6 +394,39 @@ button, input, textarea, select {
.badge--danger { background: var(--danger-soft); color: var(--danger); } .badge--danger { background: var(--danger-soft); color: var(--danger); }
.badge--warning { background: var(--warning-soft); color: var(--warning); } .badge--warning { background: var(--warning-soft); color: var(--warning); }
/* --- Scroll regions --------------------------------------------------------
The four declarations that make an element *the* scroller, written once.
They were spelled out five times -- the sidebar's list, the thread, the
inspector, the canvas and (in admin.css) the tabs and admin pages -- and
agreed on three of them. The fourth, `overscroll-behavior`, was on the
sidebar alone, with a good comment explaining why it was needed there. It is
needed everywhere for the same reason: a flick that reaches the end of a
scroller chains to whatever is behind it, and behind these is the shell,
which does not scroll -- so what the gesture produces is not a scrolled page
but a rubber-band into blank background, which reads as the layout having
come loose.
`min-height: 0` is the half that is load-bearing rather than cosmetic: a flex
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
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. */
.scroll-region,
.sidebar__scroll,
.inspector__body,
.canvas__body,
.thread-scroll,
.admin-scroll,
.main > .tabs > .tabs__body {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
}
/* --- Application shell ----------------------------------------------------- */ /* --- Application shell ----------------------------------------------------- */
.shell { display: flex; height: 100dvh; overflow: hidden; } .shell { display: flex; height: 100dvh; overflow: hidden; }
@@ -412,18 +472,9 @@ button, input, textarea, select {
flex: none; flex: none;
} }
/* A `.scroll-region`; only the padding is its own. */
.sidebar__scroll { .sidebar__scroll {
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); padding: 0 var(--sp-2) var(--sp-3);
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
} }
.sidebar__footer { .sidebar__footer {
@@ -491,12 +542,7 @@ button, input, textarea, select {
} }
.inspector__body { .inspector__body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: var(--sp-4); padding: var(--sp-4);
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
} }
.inspector__heading { .inspector__heading {
@@ -789,8 +835,8 @@ body.is-resizing .canvas__body { pointer-events: none; }
} }
.canvas__body { .canvas__body {
flex: 1; /* Both axes, unlike every other scroll region: nothing re-wraps a source
min-height: 0; line, so it has to be reachable sideways. */
overflow: auto; overflow: auto;
padding: var(--sp-3); padding: var(--sp-3);
} }
+3 -4
View File
@@ -6,12 +6,11 @@
*/ */
/* --- Thread --------------------------------------------------------------- */ /* --- Thread --------------------------------------------------------------- */
/* A `.scroll-region` (app.css); the smooth behaviour is this one's own, because
this is the scroller something is repeatedly scrolled *to* -- the newest
message, a jump back to the bottom -- and the others are not. */
.thread-scroll { .thread-scroll {
flex: 1;
overflow-y: auto;
scroll-behavior: smooth; scroll-behavior: smooth;
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
} }
.thread { .thread {
+2 -6
View File
@@ -25,16 +25,12 @@
</p> </p>
<div class="compacted__body"> <div class="compacted__body">
{% for message in compacted %} {% for message in compacted %}
{% with body_html = bodies.get(message.id, "") %} {% include "chat/_message.html" %}
{% include "chat/_message.html" %}
{% endwith %}
{% endfor %} {% endfor %}
</div> </div>
</details> </details>
{% endif %} {% endif %}
{% for message in messages %} {% for message in messages %}
{% with body_html = bodies.get(message.id, "") %} {% include "chat/_message.html" %}
{% include "chat/_message.html" %}
{% endwith %}
{% endfor %} {% endfor %}
@@ -29,7 +29,5 @@
{% endif %} {% endif %}
{% for message in messages %} {% for message in messages %}
{% with body_html = bodies.get(message.id, "") %} {% include "chat/_message.html" %}
{% include "chat/_message.html" %}
{% endwith %}
{% endfor %} {% endfor %}
+2 -4
View File
@@ -83,9 +83,7 @@
{% endif %} {% endif %}
{% for message in messages %} {% for message in messages %}
{% with body_html = bodies.get(message.id, "") %} {% include "chat/_message.html" %}
{% include "chat/_message.html" %}
{% endwith %}
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
@@ -118,7 +116,7 @@
toggling their panel twice, which is to say doing nothing at all. toggling their panel twice, which is to say doing nothing at all.
None of it looks like a script loaded twice. Found by driving the file under a None of it looks like a script loaded twice. Found by driving the file under a
DOM stub, which is the rule `CLAUDE.md` sets out and the reason it does. DOM stub, which is the rule the working notes set out and the reason it does.
#} #}
{% block scripts %} {% block scripts %}
<script src="{{ url_for('static', path='js/steps.js') }}" defer></script> <script src="{{ url_for('static', path='js/steps.js') }}" defer></script>
+1 -1
View File
@@ -5,7 +5,7 @@
Cached at install time, so it has to stand entirely on its own: no user, no Cached at install time, so it has to stand entirely on its own: no user, no
chats, nothing that was rendered from the database. One of the few places chats, nothing that was rendered from the database. One of the few places
flavour belongs -- see the flavour rule in CLAUDE.md. flavour belongs -- see the flavour rule in the working notes.
#} #}
{% block title %}Offline - {{ brand.name }}{% endblock %} {% block title %}Offline - {{ brand.name }}{% endblock %}
+13 -8
View File
@@ -22,14 +22,19 @@ from lembas.services.agent.base import ExecRequest, ExecResult, clean_output
from lembas.services.agent.session import AgentContext from lembas.services.agent.session import AgentContext
from lembas.services.agent.tools import _run_shell from lembas.services.agent.tools import _run_shell
pytestmark = pytest.mark.skipif( # A list, because there are two of them and `pytestmark = ...` twice is not two
shutil.which("setsid") is None or shutil.which("base64") is None, # marks -- the second binding replaces the first, silently. It did, for the
reason="needs setsid and base64 (Linux)", # whole life of this file: the guard below was written, read as present, and
) # never once applied, so a host without `setsid` got a module that errored
# instead of the skip somebody had taken the trouble to write.
pytestmark = [
# Stands up something real -- see the `slow` marker in pyproject.toml. pytest.mark.skipif(
pytestmark = pytest.mark.slow shutil.which("setsid") is None or shutil.which("base64") is None,
reason="needs setsid and base64 (Linux)",
),
# Stands up something real -- see the `slow` marker in pyproject.toml.
pytest.mark.slow,
]
class LocalExecutor: class LocalExecutor:
"""`SshExecutor.run`'s contract, run against the local shell. """`SshExecutor.run`'s contract, run against the local shell.
+65
View File
@@ -1167,3 +1167,68 @@ def test_the_think_frame_lands_beside_the_reasoning_body_not_around_it():
# Neither element may open a tag that the other closes: siblings, not nested. # Neither element may open a tag that the other closes: siblings, not nested.
assert "</span>" in between or "</div>" in between assert "</span>" in between or "</div>" in between
assert between.count("<div") <= 1 assert between.count("<div") <= 1
# --- Who a finished reply says it came from ----------------------------------
def test_a_finished_bubble_names_the_model_that_wrote_it(db, client, registered, make_chat):
"""The `done` frame and the tail route render the bubble from scratch, and
both looked the models up as *nobody* -- which `models_visible_to` answers
with an empty list, not with everything. So a reply was attributed correctly
for as long as it was streaming and lost its avatar and its author line at
the instant it finished, then corrected itself on the next page load.
Asserted on the rendered HTML rather than on the argument: passing `owner`
is what the old code looked like it was doing, and an assertion on the call
would have been green throughout.
"""
from lembas.api import chats as chats_api
from lembas.db.models import Chat, Connection, Message, Model, User
connection = Connection(name="c", base_url="http://127.0.0.1:1", api_key_encrypted="")
db.add(connection)
db.commit()
db.add(Model(connection_id=connection.id, model_id="mithril-7b", display_name="Mithril 7B"))
db.commit()
chat_id = make_chat(model_id="mithril-7b")
chat = db.get(Chat, chat_id)
owner = db.get(User, chat.user_id)
message = Message(
chat_id=chat.id, role="assistant", content="Spoken.",
complete=True, model_id="mithril-7b",
)
db.add(message)
db.commit()
html = chats_api._render_bubble(db, chat, owner, message)
assert "Mithril 7B" in html
def test_the_reply_limit_covers_every_way_of_starting_one(db):
"""It was enforced in `_send` alone, so an account at its ceiling reached it
by sending into an existing chat and walked past it by pressing New chat --
and by editing, by sending a queued message, and by regenerating.
Reading the source is the honest test here: driving four routes to the point
of refusal needs four live generations, which is a fixture that would tell
you more about the fixture than about the guard.
"""
import inspect
from lembas.api import chats as chats_api
source = inspect.getsource(chats_api)
for route in ("start_chat", "edit_message", "send_queued_now", "regenerate", "_send"):
body = source.split(f"def {route}(", 1)[1].split("\n@router", 1)[0]
assert "_refuse_extra_reply" in body, route
def test_a_new_chat_is_not_written_before_the_limit_is_checked(db):
"""A refusal that has already created the row leaves an empty chat in the
sidebar as the visible result of being told no."""
import inspect
from lembas.api import chats as chats_api
body = inspect.getsource(chats_api.start_chat)
assert body.index("_refuse_extra_reply") < body.index("_new_chat(")
+7 -2
View File
@@ -163,10 +163,15 @@ def test_the_tab_reset_finds_the_container_that_actually_scrolls():
either alone leaves the bug. either alone leaves the bug.
""" """
admin = (ROOT / "web/static/css/admin.css").read_text(encoding="utf-8") admin = (ROOT / "web/static/css/admin.css").read_text(encoding="utf-8")
app = (ROOT / "web/static/css/app.css").read_text(encoding="utf-8")
# The scroller rule names where the tabs must be, not just the class. # The scroller rule names where the tabs must be, not just the class. Which
assert ".main > .tabs > .tabs__body" in admin # stylesheet it is written in is not the point and is not asserted -- the
# four declarations that make something a scroller now live once, in
# app.css, and this selector is listed there with the rest.
assert ".main > .tabs > .tabs__body" in admin + app
assert "\n.tabs__body {" not in admin assert "\n.tabs__body {" not in admin
assert "\n.tabs__body {" not in app
assert "overflowY" in SOURCE assert "overflowY" in SOURCE
assert "scrollHeight > " in SOURCE assert "scrollHeight > " in SOURCE