An sse-swap element must never contain another

An agent reply rendered nothing from its first tool call onwards. An ordinary
chat was fine, and that difference is the whole diagnosis: `#steps-{id}` is
itself an `sse-swap` target, so its innerHTML is replaced every time a round
closes -- and I had put the live `reasoning` and `render` containers *inside*
it. Every round boundary tore out the two elements the next frames were aimed
at, in the same pass that aimed them. An ordinary chat closes no steps, so the
swap never happened and nothing was ever torn out.

The tail moves back out to `_message.html`, as siblings of the steps container.
That removes the trick where the `steps` frame re-emitted the tail empty in
order to clear it, and replaces it with something simpler: `reasoning` and
`render` are now sent on every pass including empty, which is what clears them
when a round closes. Safe here and not before -- they carry the open tail only,
so an empty one means the tail is empty, where the version that carried the
whole reply would have wiped the answer. `steps` is the frame that must never
blank now.

`tests/test_chat.py` walks every template and refuses any `sse-swap` element
inside another; checked against the bug before being kept.

Two things I had left undone and should not have. `.msg__steps` had no styling
at all, so the sequence ran together with nothing separating a paragraph from
the command it led to. And `.msg__body--live:not(:empty) + .msg__waiting .dots`
stopped matching when those two stopped being siblings, so the dots pulsed
beside a finished answer for ever; it is a `:has()` on the bubble now.

The version bump is not cosmetic either: the service worker keys its cache on
it, so without one every browser kept serving the previous release's CSS and JS
against the new markup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-04 19:25:10 +02:00
parent 7df68eb44c
commit 74a3c0f9d3
8 changed files with 366 additions and 93 deletions
+8 -20
View File
@@ -855,19 +855,6 @@ def _step_html(message_id: str, step) -> str:
)
def _steps_tail_html(message_id: str) -> str:
"""The two containers the live thinking and prose are swapped into.
Sent as part of the `steps` frame rather than as a frame of its own, which
is how the tail clears when a round closes: what was being written is now a
step above, and re-emitting these empty is what stops it also showing below.
It means `reasoning` and `render` can keep their never-blank guard.
"""
return templates.get_template("chat/_steps_tail.html").render(
{"message": SimpleNamespace(id=message_id, reasoning_ms=0)}
)
def _ask_html(chat_id: str, pending) -> str:
"""The card asking the reader something, or nothing at all.
@@ -948,14 +935,15 @@ async def _follow(chat_id: str, message_id: str) -> AsyncIterator[str]:
for step in steps_service.closed_from(generation, since=marks_done):
rendered.append(_step_html(message_id, step))
marks_done = len(generation.steps)
yield sse.event(
"steps", "".join(rendered) + _steps_tail_html(message_id)
)
yield sse.event("steps", "".join(rendered))
# Sent every pass, empty included. That is what clears the tail
# when a round closes and its contents become a step above --
# and it is safe precisely because these carry the open tail
# only. The version that carried the whole reply had to be
# guarded, or a frame could wipe the answer.
thinking_tail, text_tail = steps_service.tail(generation)
if thinking_tail:
yield sse.event("reasoning", escape_text(thinking_tail))
if text_tail:
yield sse.event("render", render_markdown(text_tail))
yield sse.event("reasoning", escape_text(thinking_tail))
yield sse.event("render", render_markdown(text_tail) if text_tail else "")
if generation.canvas.get("tabs"):
# Guarded on truthiness, which puts this in the
# reasoning/tools/render group and not the