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}" + )