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:
@@ -1,3 +1,3 @@
|
||||
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
||||
|
||||
__version__ = "0.6.2"
|
||||
__version__ = "0.6.3"
|
||||
|
||||
+8
-20
@@ -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
|
||||
|
||||
@@ -99,6 +99,20 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* A reply is a sequence of steps: thinking, prose, a tool call, more prose. The
|
||||
gap is what separates one from the next -- without it a paragraph and the
|
||||
command it led to run together and the ordering the whole thing exists for is
|
||||
not legible. Each child brings its own margins, so this only has to space
|
||||
them consistently. */
|
||||
.msg__steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.msg__steps:empty { display: none; }
|
||||
/* The blocks inside carry the rhythm; their own bottom margins would double it. */
|
||||
.msg__steps > .reasoning { margin-bottom: 0; }
|
||||
|
||||
.msg__error { margin: var(--sp-2) 0; align-items: flex-start; }
|
||||
|
||||
/* --- Streaming indicator -------------------------------------------------- */
|
||||
@@ -557,8 +571,13 @@
|
||||
padding: var(--sp-2) 0;
|
||||
}
|
||||
/* Once the answer has content the caret carries the "still going" signal, so
|
||||
the dots go, but Stop must stay reachable until the stream ends. */
|
||||
.msg__body--live:not(:empty) + .msg__waiting .dots { display: none; }
|
||||
the dots go, but Stop must stay reachable until the stream ends.
|
||||
|
||||
`:has()` on the bubble rather than a sibling selector: the live body and the
|
||||
waiting row stopped being siblings when the reply became a sequence of steps,
|
||||
and an adjacent-sibling rule that matches nothing fails by leaving the dots
|
||||
pulsing beside a finished-looking answer forever. */
|
||||
.msg__main:has(.msg__body--live:not(:empty)) .msg__waiting .dots { display: none; }
|
||||
|
||||
/* Send and Stop are one button. Which icon shows is decided here rather than
|
||||
in JavaScript, so the state is visible in the markup and the swap is free. */
|
||||
|
||||
@@ -95,16 +95,34 @@
|
||||
{% endif %}
|
||||
|
||||
{% if streaming %}
|
||||
{# The reply as a sequence of steps, in the order they happened. The
|
||||
closed ones are re-sent only when a round ends; the two live containers
|
||||
come with them, inside `_steps.html`, which is how the tail blanks
|
||||
itself. See services/steps.py. #}
|
||||
{# The reply as a sequence of steps, in the order they happened. Closed
|
||||
steps only, re-sent when a round ends. See services/steps.py. #}
|
||||
<div class="msg__steps" id="steps-{{ message.id }}" data-steps
|
||||
sse-swap="steps" hx-swap="innerHTML">
|
||||
{% with steps = [], live = true %}
|
||||
{% include "chat/_steps.html" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
sse-swap="steps" hx-swap="innerHTML"></div>
|
||||
|
||||
{# The step still being written. SIBLINGS of the container above, never
|
||||
inside it: that one is swapped whole every time a round closes, and an
|
||||
`sse-swap` element nested in another is torn out and rebuilt at each
|
||||
boundary with the frames aimed at it arriving in the same pass. That is
|
||||
what made an agent reply render nothing from its first tool call on.
|
||||
|
||||
Both frames are sent on every version bump *including empty*, which is
|
||||
how the tail clears when a round closes and its contents move into a
|
||||
step above. That is safe here and was not before: these carry the open
|
||||
tail only, so an empty one means the tail is genuinely empty, whereas
|
||||
the version that carried the whole reply would have wiped it. `steps`
|
||||
is the one that must never blank now. #}
|
||||
<details class="reasoning reasoning--live" id="reasoning-{{ message.id }}">
|
||||
<summary class="reasoning__summary">
|
||||
{{ icon("sparkle", "icon--sm reasoning__icon") }}
|
||||
<span class="reasoning__label">Thinking…</span>
|
||||
{{ icon("chevron-down", "icon--sm reasoning__chevron") }}
|
||||
</summary>
|
||||
<div class="reasoning__body" sse-swap="reasoning" hx-swap="innerHTML"></div>
|
||||
</details>
|
||||
|
||||
<div class="msg__body msg__body--live" id="stream-{{ message.id }}"
|
||||
sse-swap="render" hx-swap="innerHTML"></div>
|
||||
|
||||
{# Where a question from the model, or a command waiting to be allowed,
|
||||
lands. Unlike the blocks above it this frame is sent on every version
|
||||
|
||||
@@ -6,17 +6,19 @@
|
||||
the moment the stream ends. That was the point of putting the marks on the row
|
||||
rather than only on the running generation.
|
||||
|
||||
When `live`, the two containers for the step still being written come last, and
|
||||
they are part of *this* fragment rather than of `_message.html`. That is what
|
||||
lets the tail clear itself: the `steps` frame is sent whenever a round closes
|
||||
and re-emits them empty, so the prose and thinking that have just become a
|
||||
closed step above do not also linger below. `reasoning` and `render` keep their
|
||||
"never send an empty one" guard, and `metrics`, `status` and `ask` remain the
|
||||
only frames allowed to blank what is on screen.
|
||||
Closed steps ONLY. The step still being written lives in `_message.html`, in
|
||||
two containers that are **siblings of this one, never inside it**.
|
||||
|
||||
That is not tidiness, it is the whole reason this works. This container is
|
||||
itself an `sse-swap` target: every time a round closes, its `innerHTML` is
|
||||
replaced. Anything inside it carrying an `sse-swap` of its own is therefore
|
||||
torn out and rebuilt at every round boundary — and the frames aimed at it in
|
||||
the same pass have nowhere to land. Nesting them here is what made an agent
|
||||
reply show nothing at all from its first tool call onwards, while an ordinary
|
||||
chat was fine: an ordinary chat closes no steps, so the swap never happened.
|
||||
|
||||
One `sse-swap` element must never contain another. There is a test.
|
||||
#}
|
||||
{% for step in steps %}
|
||||
{% include "chat/_step.html" %}
|
||||
{% endfor %}
|
||||
{% if live %}
|
||||
{% include "chat/_steps_tail.html" %}
|
||||
{% endif %}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
{% from "_macros.html" import icon %}
|
||||
{#
|
||||
The step still being written: everything past the last mark.
|
||||
|
||||
These two are the only things that move at streaming speed. Everything above
|
||||
them is closed and is re-sent only when a round ends, which is what keeps a
|
||||
forty-round reply from re-rendering its whole transcript twelve times a second.
|
||||
|
||||
Both carry the complete block each time rather than a delta -- that is what
|
||||
makes reattaching to a reply in progress work at all, since a follower arriving
|
||||
late has no earlier fragments to append to.
|
||||
|
||||
The reasoning wrapper is static and only its body is swapped, so a reader who
|
||||
opens it keeps it open for as long as this step lasts. When the round closes it
|
||||
becomes a `think-…` block above and comes back collapsed; that is a real seam
|
||||
and it is left visible rather than papered over with a mapping from an
|
||||
ephemeral id to a permanent one.
|
||||
#}
|
||||
<details class="reasoning reasoning--live" id="reasoning-{{ message.id }}">
|
||||
<summary class="reasoning__summary">
|
||||
{{ icon("sparkle", "icon--sm reasoning__icon") }}
|
||||
<span class="reasoning__label">Thinking…</span>
|
||||
{{ icon("chevron-down", "icon--sm reasoning__chevron") }}
|
||||
</summary>
|
||||
<div class="reasoning__body" sse-swap="reasoning" hx-swap="innerHTML"></div>
|
||||
</details>
|
||||
|
||||
<div class="msg__body msg__body--live" id="stream-{{ message.id }}"
|
||||
sse-swap="render" hx-swap="innerHTML"></div>
|
||||
Reference in New Issue
Block a user