diff --git a/CLAUDE.md b/CLAUDE.md index 25e943e..f16ed0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ lembas info # paths + counts, useful when confused lembas secret-key # generate LEMBAS_SECRET_KEY lembas create-admin # create or promote an admin -pytest # 1499 tests, ~92s +pytest # 1500 tests, ~92s # PLAN.md tracks what is and is not built ruff check . # lint (line length 100) python scripts/build_artwork.py # regenerate artwork (SVG + PWA icons; @@ -953,6 +953,24 @@ a menuitem is clicked, which is right for an action menu and wrong for a list of switches you want to set several of. That is the whole reason the menu needs no JavaScript at all. The verb is on the checkbox, per the usual rule. +**`hx-target` is inherited, and the composer's form targets the transcript.** +The chip below sits inside `
` — that target is what makes a sent message append a +bubble. htmx resolves `hx-target` by walking up the DOM, so an element in there +that fetches and does not name its own target aims at `#thread` too. The jobs +chip declared `hx-swap="outerHTML"` and nothing else, which reads as "replace +yourself" and meant "replace the whole transcript with yourself" — on load, and +then again every five seconds. Every agent chat rendered its reply and then went +blank, the reader's own prompt with it, while the server logged nothing at all +because nothing had gone wrong there. + +Anything inside that form that fetches must carry `hx-target` (or +`hx-swap="none"`). `tests/test_chat.py` walks the form and refuses the rest. Note +what the failing version looked like: correct, idiomatic markup whose meaning +came from an ancestor — the same family as the trigger bound where the event does +not go, and the reason that test asserts the resolved property rather than the +attributes. + **Background jobs have a chip in the composer row and a panel behind it.** A job runs detached for as long as it takes and the only way to see one used to be asking the model to call `job_list` — something that outlives the reply that diff --git a/PLAN.md b/PLAN.md index d2f1978..f6b4b75 100644 --- a/PLAN.md +++ b/PLAN.md @@ -7,7 +7,7 @@ that would be expensive to revisit. Kept current as work lands; the detail of **Status:** usable daily. Streaming chat, attachments, reasoning, tool calling with web search, custom HTTP tools and MCP servers, agent chats that work on a machine over SSH, a knowledge library, notes, memory and skills, speech in and -out, users and groups, model administration, installable as an app. 1499 tests, +out, users and groups, model administration, installable as an app. 1500 tests, `ruff` clean. --- diff --git a/src/lembas/web/templates/chat/_composer.html b/src/lembas/web/templates/chat/_composer.html index c535df8..71490b9 100644 --- a/src/lembas/web/templates/chat/_composer.html +++ b/src/lembas/web/templates/chat/_composer.html @@ -354,9 +354,19 @@ `agent_jobs` table, and the composer is rendered on every page load and after every reply. One request five seconds later costs nothing; a query on the render path costs it every time. #} + {# `hx-target="this"` is load-bearing and is the whole of a bug that + blanked every agent chat. htmx INHERITS `hx-target` from ancestors, + and this sits inside the composer's form, which carries + `hx-target="#thread"` so that a sent message appends a bubble. Without + a target of its own, this element's request resolved to `#thread` -- + and with `outerHTML` it replaced the entire transcript with the chip. + The reply rendered and then vanished, prompt and all. + + Anything in here that fetches must say where the answer goes. There + is a test. #} {% if jobs_enabled %}
+ hx-target="this" hx-swap="outerHTML"> {% endif %} {% endif %} diff --git a/src/lembas/web/templates/chat/_jobs_chip.html b/src/lembas/web/templates/chat/_jobs_chip.html index 1b48f65..cdecbec 100644 --- a/src/lembas/web/templates/chat/_jobs_chip.html +++ b/src/lembas/web/templates/chat/_jobs_chip.html @@ -10,10 +10,17 @@ Swapped `outerHTML` onto itself, so the reply is the whole element including the trigger. Every response has to be a complete chip for the same reason. + + `hx-target="this"`, explicitly, because this element lives inside the + composer's form and htmx INHERITS `hx-target` from ancestors -- that form + carries `hx-target="#thread"` so a sent message appends a bubble, and without + a target of its own this poll resolved there and replaced the whole transcript + with itself every five seconds. It blanked every agent chat. #}
{% if running %}
diff --git a/tests/test_chat.py b/tests/test_chat.py index 314c149..66dd1f3 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -975,3 +975,44 @@ def test_the_finished_bubble_carries_no_live_containers(): ) assert "sse-swap" not in html + + +def test_nothing_inside_the_composer_form_fetches_without_saying_where_it_lands(): + """The bug that blanked every agent chat, and the reason it was invisible. + + htmx INHERITS `hx-target` from ancestors. The composer's form carries + `hx-target="#thread"` so that sending a message appends a bubble to the + transcript -- so anything inside that form which fetches, and does not name + its own target, aims at `#thread` too. The background-jobs chip did exactly + that with `hx-swap="outerHTML"`: on load it replaced the entire transcript + with itself, and the reply appeared and then vanished, the reader's own + prompt with it. + + Asserted as the property rather than by rendering, because the markup was + never wrong -- `hx-swap="outerHTML"` on an element with no target reads as + "swap yourself", and it means that only when nothing above it disagrees. + Same family as the trigger bound where the event does not go. + """ + import re + from pathlib import Path + + import lembas + + source = ( + Path(lembas.__file__).parent / "web/templates/chat/_composer.html" + ).read_text() + + opened = source.index('", opened) + inside = source[opened:closed] + # The form's own attributes are the ones being inherited; skip its tag. + inside = inside[inside.index(">") + 1 :] + + for tag in re.finditer(r"<[a-z]+\s[^>]*>", inside): + markup = tag.group() + if not re.search(r'hx-(get|post|put|patch|delete)=', markup): + continue + assert "hx-target=" in markup or 'hx-swap="none"' in markup, ( + "this fetches from inside a form targeting #thread and does not say " + f"where its answer goes:\n{markup}" + )