A thinking block that says how long and how much

Each block reports its own round now. `reasoning_ms` was the reply's first
burst, written once, so on the fifteen-block reply GPT-OSS actually produces
only the first could claim a duration and the other fourteen said "Thought" and
nothing at all. `Generation.thinking_ms` accumulates per round and `close_step`
stamps it cumulatively, so steps.py diffs it exactly as it already diffs the
three lengths beside it.

The interval between a round's first and last reasoning delta, deliberately, not
a sum of gaps between deltas -- that would count the network's latency as the
model's thinking.

While it runs: "Thinking" with an ellipsis that types itself, and the seconds
and tokens climbing beside it. The ellipsis is a `content` keyframe, so there is
no timer to start, stop or clean up when the block is swapped away -- it stops
existing when the element does. The numbers come from a `think` frame, and
`round_thinking_ms` is written by the producer rather than computed by the
follower from a start time: a model that has stopped thinking and moved on to a
tool should show a settled number, not a clock that keeps running.

Tokens read exactly up to 200 and as `0.4k` above it, from one helper shared by
the live label and the stored one, so the two cannot drift into two conventions.
The live duration is terser than the finished one -- `6s` against `6 seconds` --
because it sits beside an animating word and changes every second, where "less
than a second" flickering into "1 second" reads as a glitch.

Checked against the real endpoint: fourteen marks carrying 919ms through
14223ms, per-block labels from "less than a second · 111" to "4 seconds · 0.5k",
and the live frames resetting each round rather than accumulating.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-04 21:03:25 +02:00
parent e107b5069d
commit fe43e95b79
11 changed files with 427 additions and 12 deletions
+11 -1
View File
@@ -112,10 +112,20 @@
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. #}
{# The word animates its own ellipsis in CSS -- `.` `..` `...` -- so there
is no timer to start or clean up, and it stops when the element does.
The numbers beside it come from the `think` frame.
That span is a SIBLING of the body, inside a `<details>` that is never
itself swapped. Two swap targets in one static container is fine; a
swap target inside another is what blanked every agent chat. #}
<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>
<span class="reasoning__label">
<span class="reasoning__working">Thinking</span>
<span class="reasoning__stats" sse-swap="think" hx-swap="innerHTML"></span>
</span>
{{ icon("chevron-down", "icon--sm reasoning__chevron") }}
</summary>
<div class="reasoning__body" sse-swap="reasoning" hx-swap="innerHTML"></div>
+7 -5
View File
@@ -19,12 +19,14 @@
<details class="reasoning" id="think-{{ message.id }}-{{ step.index }}">
<summary class="reasoning__summary">
{{ icon("sparkle", "icon--sm reasoning__icon") }}
{# Each block reports its own round: `step.label` is built from that
block's slice of the thinking and the interval between its mark and the
one before it. It used to read the reply's total, which meant only the
first block could honestly claim it and the other eleven said "Thought"
and nothing else. #}
<span class="reasoning__label">
{% if step.index == 0 and message.reasoning_ms %}
{# The duration is for the whole reply, so only the first block may
claim it. Repeating it on each would be four blocks each saying
they took ninety seconds. #}
Thought for {{ message.reasoning_ms | duration }}
{% if step.label %}
Thought for {{ step.label }}
{% else %}
Thought
{% endif %}