A form's handler answers its own request, and a finished reply is finished

Two regressions, one of them much older than it looked.

htmx events bubble, and the composer's form declares `hx-on::after-request` so
it can clear itself after sending. Six things inside that form make requests --
the two scope switches, "ask me about these again", the agent mode select, the
effort select, and the jobs chip -- and every one of their afterRequest events
was reaching that handler. So changing the mode, or the effort, or toggling a
tool called `this.reset()` on a composer somebody was typing in and dragged the
view to the bottom. That has been true for as long as those controls have
existed. The jobs chip did not introduce it; it polls, so it made it happen
every five seconds, and that is the only reason it was ever noticed.

`event.target === this` is the whole fix, and it is what the attribute always
meant. Moving the chip out of the form would have left the other five.

The second: `steps.for_message` marked its trailing prose step as still being
written, so every finished reply ending in prose carried `msg__body--live` and
blinked a caret at the reader for ever. One flag was doing two jobs -- emit the
tail, and mark it live -- and a stored reply wants the first without the second.
They are separate arguments now.

Note what the existing test for that did: it asserted the caret was on the
*right* step, through `for_message`, and passed. It never asked whether a
finished reply should have one at all. It is driven through the live path now,
and the stored path has its own assertion.

The composer handler is driven under a DOM stub -- extract the body from the
template, fire the event from a descendant and from the form -- because a source
assertion can only say the guard is present, not what it does. Checked against
the bug before being kept: without the guard the stub reports the text wiped and
the thread scrolled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-04 20:54:03 +02:00
parent df2d5cc877
commit e107b5069d
4 changed files with 118 additions and 13 deletions
+28
View File
@@ -1016,3 +1016,31 @@ def test_nothing_inside_the_composer_form_fetches_without_saying_where_it_lands(
"this fetches from inside a form targeting #thread and does not say "
f"where its answer goes:\n{markup}"
)
def test_the_composer_form_answers_only_its_own_request():
"""The other half of the bug that blanked agent chats, and the one that had
been quietly costing typed messages for far longer.
htmx events bubble. This form contains six things that fetch -- two scope
switches, "ask me about these again", the agent mode select, the effort
select and the jobs chip -- and every one of their `htmx:afterRequest`
events reaches the form's own `hx-on::after-request`. Without the guard,
changing the mode or the effort called `this.reset()` on a composer somebody
was typing in, and dragged the view to the bottom. The chip polls, so it did
it every five seconds; that is the only reason it was ever noticed.
"""
import re
from pathlib import Path
import lembas
source = (
Path(lembas.__file__).parent / "web/templates/chat/_composer.html"
).read_text()
handler = re.search(r'hx-on::after-request="([^"]*)"', source)
assert handler, "the composer no longer clears itself after sending"
assert "event.target === this" in handler.group(1), (
"a descendant's request will run this handler without the guard"
)