The jobs chip was replacing the whole transcript
This is the blank agent chat, and it was not the transcript rewrite at all.
`hx-target` is inherited. The composer's form carries `hx-target="#thread"`
with `hx-swap="beforeend"`, which is what makes a sent message append a bubble.
The background-jobs chip I added last commit sits inside that form and declared
`hx-swap="outerHTML"` and nothing else -- which reads as "replace yourself" and
resolved, through the form, to "replace #thread with yourself". On load, and
then again every five seconds.
So an agent chat rendered its reply and then went blank, the reader's own prompt
along with it, because the entire transcript had been swapped out for a chip
that renders empty when no jobs are running. Only agent chats, because that is
the only place the chip exists. The server logged nothing, because nothing there
had gone wrong: every page render, every SSE frame and every stored row was
correct throughout, which is why four rounds of looking at the server found
nothing.
Both the chip and the element that loads it now carry `hx-target="this"`, and
`tests/test_chat.py` walks the composer's form and refuses anything that fetches
without saying where its answer goes. Checked against the bug before being kept.
Worth being precise about what made it invisible: the markup was correct. There
is nothing wrong with `hx-swap="outerHTML"` on an element with no target -- it
means "swap yourself" right up until an ancestor disagrees. It is the same
family as the trigger bound where the event does not go, and the same lesson:
assert the resolved property, not the attributes.
My earlier fix in ffe4966 was a real defect -- an sse-swap container must not
hold another -- but it was not this, and I should have said "best hypothesis"
rather than "found it" when I shipped it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 %}
|
||||
<div hx-get="/api/chats/{{ chat.id }}/jobs" hx-trigger="load"
|
||||
hx-swap="outerHTML"></div>
|
||||
hx-target="this" hx-swap="outerHTML"></div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -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.
|
||||
#}
|
||||
<div class="composer__jobs" id="jobs-chip"
|
||||
hx-get="/api/chats/{{ chat.id }}/jobs"
|
||||
hx-trigger="every 5s"
|
||||
hx-target="this"
|
||||
hx-swap="outerHTML">
|
||||
{% if running %}
|
||||
<div class="picker picker--up" data-picker>
|
||||
|
||||
@@ -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('<form class="composer__form"')
|
||||
closed = source.index("</form>", 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}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user